diff --git a/vite-frontend/src/components/animated-page.tsx b/vite-frontend/src/components/animated-page.tsx new file mode 100644 index 0000000..ed59695 --- /dev/null +++ b/vite-frontend/src/components/animated-page.tsx @@ -0,0 +1,121 @@ +import { motion } from "framer-motion"; +import React from "react"; + +/** + * Wraps page content with a smooth fade-in + slide-up entrance animation. + * Use this as the outermost wrapper inside each page component. + */ +export const AnimatedPage = ({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) => ( + + {children} + +); + +/** + * Stagger container — apply to the parent of a list/grid of animated items. + * Children should use `staggerItem` as their `variants` prop. + */ +export const staggerContainer = { + hidden: { opacity: 0 }, + show: { + opacity: 1, + transition: { + staggerChildren: 0.05, + delayChildren: 0.05, + }, + }, +}; + +/** + * Individual stagger item variant — fade-in + slide-up. + */ +export const staggerItem = { + hidden: { opacity: 0, y: 12 }, + show: { + opacity: 1, + y: 0, + transition: { duration: 0.25, ease: [0.25, 0.46, 0.45, 0.94] }, + }, +}; + +/** + * Convenience wrapper for a stagger list. + * Renders a `motion.div` (or `motion.ul/motion.tbody`) with stagger behaviour. + */ +export const StaggerList = ({ + children, + className, + as = "div", +}: { + children: React.ReactNode; + className?: string; + as?: "div" | "ul" | "tbody"; +}) => { + const Component = motion[as] as React.ElementType; + + return ( + + {children} + + ); +}; + +/** + * Individual animated item for use inside StaggerList. + */ +export const StaggerItem = ({ + children, + className, + as = "div", +}: { + children: React.ReactNode; + className?: string; + as?: "div" | "li" | "tr"; +}) => { + const Component = motion[as] as React.ElementType; + + return ( + + {children} + + ); +}; + +/** + * Simple fade-in animation for standalone elements (chips, badges, counters, etc.) + */ +export const FadeIn = ({ + children, + delay = 0, + className, +}: { + children: React.ReactNode; + delay?: number; + className?: string; +}) => ( + + {children} + +); diff --git a/vite-frontend/src/components/search-bar.tsx b/vite-frontend/src/components/search-bar.tsx new file mode 100644 index 0000000..6f68fff --- /dev/null +++ b/vite-frontend/src/components/search-bar.tsx @@ -0,0 +1,101 @@ +import { motion, AnimatePresence } from "framer-motion"; + +import { Button } from "@/shadcn-bridge/heroui/button"; +import { Input } from "@/shadcn-bridge/heroui/input"; +import { SearchIcon } from "@/components/icons"; + +interface SearchBarProps { + isVisible: boolean; + value: string; + placeholder?: string; + onOpen: () => void; + onClose: () => void; + onChange: (value: string) => void; +} + +export function SearchBar({ + isVisible, + value, + placeholder = "搜索", + onOpen, + onClose, + onChange, +}: SearchBarProps) { + return ( + // Fixed h-8 so the container never changes height — eliminates the vertical jitter +
+ + {!isVisible ? ( + + + + ) : ( + + onChange(e.target.value)} + /> + + + )} + +
+ ); +} diff --git a/vite-frontend/src/components/ui/button.tsx b/vite-frontend/src/components/ui/button.tsx index 14fe8bb..db8d1a6 100644 --- a/vite-frontend/src/components/ui/button.tsx +++ b/vite-frontend/src/components/ui/button.tsx @@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; const buttonVariants = cva( - "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors transition-transform duration-100 active:scale-95 disabled:active:scale-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", { variants: { variant: { diff --git a/vite-frontend/src/components/ui/checkbox.tsx b/vite-frontend/src/components/ui/checkbox.tsx index 30748e6..118cbcd 100644 --- a/vite-frontend/src/components/ui/checkbox.tsx +++ b/vite-frontend/src/components/ui/checkbox.tsx @@ -11,14 +11,14 @@ function Checkbox({ return ( diff --git a/vite-frontend/src/components/ui/dialog.tsx b/vite-frontend/src/components/ui/dialog.tsx index 9c086cc..a3f059f 100644 --- a/vite-frontend/src/components/ui/dialog.tsx +++ b/vite-frontend/src/components/ui/dialog.tsx @@ -57,7 +57,7 @@ function DialogContent({ {children} diff --git a/vite-frontend/src/layouts/admin.tsx b/vite-frontend/src/layouts/admin.tsx index c8c6b4e..4b40a70 100644 --- a/vite-frontend/src/layouts/admin.tsx +++ b/vite-frontend/src/layouts/admin.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from "react"; import { useNavigate, useLocation } from "react-router-dom"; import { toast } from "react-hot-toast"; +import { AnimatePresence, motion } from "framer-motion"; import { Button } from "@/shadcn-bridge/heroui/button"; import { @@ -335,21 +336,36 @@ export default function AdminLayout({ return (
  • - + {isActive && ( + + )} + {!isActive && ( + + )} +
    {item.icon}
    + {item.label} +
  • ); })} @@ -476,9 +492,20 @@ export default function AdminLayout({ {/* 主内容 */}
    - {children} + + + {children} + +
    diff --git a/vite-frontend/src/pages/dashboard.tsx b/vite-frontend/src/pages/dashboard.tsx index 652b2f1..4bbc131 100644 --- a/vite-frontend/src/pages/dashboard.tsx +++ b/vite-frontend/src/pages/dashboard.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import toast from "react-hot-toast"; +import { AnimatedPage } from "@/components/animated-page"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card"; import { Button } from "@/shadcn-bridge/heroui/button"; @@ -524,7 +525,7 @@ export default function DashboardPage() { } return ( -
    + {announcement && }
    -
    +
    ); } diff --git a/vite-frontend/src/pages/forward.tsx b/vite-frontend/src/pages/forward.tsx index 07dc3c1..a1cd0aa 100644 --- a/vite-frontend/src/pages/forward.tsx +++ b/vite-frontend/src/pages/forward.tsx @@ -1,5 +1,7 @@ import { useState, useEffect, useMemo } from "react"; import toast from "react-hot-toast"; +import { AnimatedPage } from "@/components/animated-page"; +import { SearchBar } from "@/components/search-bar"; import { DndContext, closestCenter, @@ -119,24 +121,15 @@ interface ForwardForm { strategy: string; } -// 添加分组接口 -interface UserGroup { - userId: number | null; - userName: string; - tunnelGroups: TunnelGroup[]; -} -interface TunnelGroup { - tunnelId: number; - tunnelName: string; - forwards: Forward[]; -} export default function ForwardPage() { const [loading, setLoading] = useState(true); const [forwards, setForwards] = useState([]); const [tunnels, setTunnels] = useState([]); const isMobile = useMobileBreakpoint(); + const [searchKeyword, setSearchKeyword] = useState(""); + const [isSearchVisible, setIsSearchVisible] = useState(false); // 显示模式状态 - 从localStorage读取,默认为平铺显示 const [viewMode, setViewMode] = useState<"grouped" | "direct">(() => { @@ -233,21 +226,6 @@ export default function ForwardPage() { setViewMode(newMode); try { localStorage.setItem("forward-view-mode", newMode); - - // 切换到直接显示模式时,初始化拖拽排序顺序 - if (newMode === "direct") { - const currentUserId = JwtUtil.getUserIdFromToken(); - const { order, fromDatabase } = buildForwardOrder( - forwards, - currentUserId, - ); - - setForwardOrder(order); - - if (fromDatabase) { - saveOrder(FORWARD_ORDER_KEY, order); - } - } } catch { } }; @@ -270,18 +248,16 @@ export default function ForwardPage() { setForwards(forwardsData); // 初始化拖拽排序顺序 - if (viewMode === "direct") { - const currentUserId = JwtUtil.getUserIdFromToken(); - const { order, fromDatabase } = buildForwardOrder( - forwardsData, - currentUserId, - ); + const currentUserId = JwtUtil.getUserIdFromToken(); + const { order, fromDatabase } = buildForwardOrder( + forwardsData, + currentUserId, + ); - setForwardOrder(order); + setForwardOrder(order); - if (fromDatabase) { - saveOrder(FORWARD_ORDER_KEY, order); - } + if (fromDatabase) { + saveOrder(FORWARD_ORDER_KEY, order); } } else { toast.error(forwardsRes.msg || "获取转发列表失败"); @@ -298,52 +274,7 @@ export default function ForwardPage() { } }; - // 按用户和隧道分组转发数据 - const groupForwardsByUserAndTunnel = (): UserGroup[] => { - const userMap = new Map(); - // 获取排序后的转发列表 - sortedForwards.forEach((forward) => { - const userKey = forward.userId ? forward.userId.toString() : "unknown"; - const userName = forward.userName || "未知用户"; - - if (!userMap.has(userKey)) { - userMap.set(userKey, { - userId: forward.userId || null, - userName, - tunnelGroups: [], - }); - } - - const userGroup = userMap.get(userKey)!; - let tunnelGroup = userGroup.tunnelGroups.find( - (tg) => tg.tunnelId === forward.tunnelId, - ); - - if (!tunnelGroup) { - tunnelGroup = { - tunnelId: forward.tunnelId, - tunnelName: forward.tunnelName, - forwards: [], - }; - userGroup.tunnelGroups.push(tunnelGroup); - } - - tunnelGroup.forwards.push(forward); - }); - - // 排序:先按用户名,再按隧道名 - const result = Array.from(userMap.values()); - - result.sort((a, b) => a.userName.localeCompare(b.userName)); - result.forEach((userGroup) => { - userGroup.tunnelGroups.sort((a, b) => - a.tunnelName.localeCompare(b.tunnelName), - ); - }); - - return result; - }; // 表单验证 const validateForm = (): boolean => { @@ -720,26 +651,10 @@ export default function ForwardPage() { setExportLoading(true); try { - // 根据当前显示模式获取要导出的转发列表 - let forwardsToExport: Forward[] = []; - - if (viewMode === "grouped") { - // 分组模式下,获取指定隧道的转发 - const userGroups = groupForwardsByUserAndTunnel(); - - forwardsToExport = userGroups.flatMap((userGroup) => - userGroup.tunnelGroups - .filter( - (tunnelGroup) => tunnelGroup.tunnelId === selectedTunnelForExport, - ) - .flatMap((tunnelGroup) => tunnelGroup.forwards), - ); - } else { - // 直接显示模式下,过滤指定隧道的转发 - forwardsToExport = sortedForwards.filter( - (forward) => forward.tunnelId === selectedTunnelForExport, - ); - } + // 获取要导出的转发列表 + const forwardsToExport = sortedForwards.filter( + (forward) => forward.tunnelId === selectedTunnelForExport, + ); if (forwardsToExport.length === 0) { toast.error("所选隧道没有转发数据"); @@ -1181,24 +1096,24 @@ export default function ForwardPage() { return []; } - // 在平铺模式下,只显示当前用户的转发 let filteredForwards = forwards; - if (viewMode === "direct") { - if (tokenUserId !== null) { - filteredForwards = forwards.filter( - (forward) => forward.userId === tokenUserId, - ); - } - } else if (viewMode === "grouped") { - if (filterUserId !== "all") { - const targetUserId = parseInt(filterUserId); - filteredForwards = filteredForwards.filter(f => f.userId === targetUserId || (targetUserId === 0 && !f.userId)); - } - if (filterTunnelId !== "all") { - const targetTunnelId = parseInt(filterTunnelId); - filteredForwards = filteredForwards.filter(f => f.tunnelId === targetTunnelId); - } + if (filterUserId !== "all") { + const targetUserId = parseInt(filterUserId); + filteredForwards = filteredForwards.filter(f => f.userId === targetUserId || (targetUserId === 0 && !f.userId)); + } + if (filterTunnelId !== "all") { + const targetTunnelId = parseInt(filterTunnelId); + 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)) + ); } // 确保过滤后的转发列表有效 @@ -1242,7 +1157,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), @@ -1288,7 +1203,7 @@ export default function ForwardPage() { }, [forwards]); // 可拖拽的表格行组件 - const SortableTableRow = ({ forward, selectMode, selectedIds, toggleSelect, getStrategyDisplay, formatInAddress, formatRemoteAddress, handleServiceToggle, handleEdit, handleDelete, handleDiagnose, showAddressModal, hasMultipleAddresses }: any) => { + const SortableTableRow = ({ forward, selectMode, selectedIds, toggleSelect, getStrategyDisplay, formatInAddress, formatRemoteAddress, handleServiceToggle, handleEdit, handleDelete, handleDiagnose, showAddressModal, hasMultipleAddresses, formatFlow }: any) => { const { attributes, listeners, @@ -1386,6 +1301,11 @@ export default function ForwardPage() { {strategyDisplay.text} + + + {formatFlow((forward.inFlow || 0) + (forward.outFlow || 0))} + +
    + {/* 页面头部 */} -
    -
    +
    +
    + setIsSearchVisible(false)} + onOpen={() => setIsSearchVisible(true)} + /> +
    - {/* 筛选按钮 (仅在分组视图显示) */} - {viewMode === "grouped" && ( - - )} + + + {(filterUserId !== "all" || filterTunnelId !== "all") && ( + + )} + {/* 显示模式切换按钮 */}
    + ); } diff --git a/vite-frontend/src/pages/index.tsx b/vite-frontend/src/pages/index.tsx index 37756e1..b2a5a52 100644 --- a/vite-frontend/src/pages/index.tsx +++ b/vite-frontend/src/pages/index.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; import toast from "react-hot-toast"; import { Turnstile } from "@marsidev/react-turnstile"; +import { motion } from "framer-motion"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card"; import { Input } from "@/shadcn-bridge/heroui/input"; @@ -152,7 +153,12 @@ export default function IndexPage() { return (
    -
    +

    登陆

    @@ -203,7 +209,7 @@ export default function IndexPage() {
    -
    + {/* 版权信息 - 固定在底部,不占据布局空间 */} @@ -253,9 +259,9 @@ export default function IndexPage() { theme: (document.documentElement.classList.contains( "dark", ) || - document.documentElement.getAttribute("data-theme") === + document.documentElement.getAttribute("data-theme") === "dark" || - window.matchMedia("(prefers-color-scheme: dark)").matches + window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light") as "light" | "dark" | "auto", }} diff --git a/vite-frontend/src/pages/limit.tsx b/vite-frontend/src/pages/limit.tsx index 9ff942d..5048b8a 100644 --- a/vite-frontend/src/pages/limit.tsx +++ b/vite-frontend/src/pages/limit.tsx @@ -1,5 +1,7 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import toast from "react-hot-toast"; +import { AnimatedPage, StaggerList, StaggerItem } from "@/components/animated-page"; +import { SearchBar } from "@/components/search-bar"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card"; import { Button } from "@/shadcn-bridge/heroui/button"; @@ -51,6 +53,18 @@ export default function LimitPage() { const [loading, setLoading] = useState(true); const [rules, setRules] = useState([]); const [tunnels, setTunnels] = useState([]); + 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); @@ -219,10 +233,18 @@ export default function LimitPage() { } return ( -
    - {/* 页面头部 */} -
    -
    + +
    +
    + setIsSearchVisible(false)} + onOpen={() => setIsSearchVisible(true)} + /> +
    {/* 统一卡片网格 */} - {rules.length > 0 ? ( -
    - {rules.map((rule) => ( - - -
    -
    -

    - {rule.name} -

    -
    - - {rule.status === 1 ? "运行" : "异常"} - -
    -
    - -
    -
    - - 速度限制 - - - {rule.speed} Mbps + {filteredRules.length > 0 ? ( + + {filteredRules.map((rule) => ( + + + +
    +
    +

    + {rule.name} +

    +
    + + {rule.status === 1 ? "运行" : "异常"}
    -
    - - 绑定隧道 - - {rule.tunnelName ? ( - - {rule.tunnelName} - - ) : ( - - 未绑定 + + +
    +
    + + 速度限制 - )} + + {rule.speed} Mbps + +
    +
    + + 绑定隧道 + + {rule.tunnelName ? ( + + {rule.tunnelName} + + ) : ( + + 未绑定 + + )} +
    -
    -
    - - -
    - -
    +
    + + +
    + + +
    ))} -
    + ) : ( /* 空状态 */ @@ -504,6 +525,6 @@ export default function LimitPage() { )} -
    + ); } diff --git a/vite-frontend/src/pages/node.tsx b/vite-frontend/src/pages/node.tsx index 9b46bff..2ff5562 100644 --- a/vite-frontend/src/pages/node.tsx +++ b/vite-frontend/src/pages/node.tsx @@ -1,5 +1,7 @@ import { useState, useEffect, useMemo, useCallback } from "react"; import toast from "react-hot-toast"; +import { AnimatedPage } from "@/components/animated-page"; +import { SearchBar } from "@/components/search-bar"; import { DndContext, KeyboardSensor, @@ -150,6 +152,8 @@ export default function NodePage() { const [nodeList, setNodeList] = useState([]); const [nodeOrder, setNodeOrder] = useState([]); 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 +825,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 +998,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 +1023,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 +1032,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 +1042,7 @@ export default function NodePage() { } return sortedByDb; - }, [nodeList, nodeOrder]); + }, [nodeList, nodeOrder, searchKeyword]); const sortableNodeIds = useMemo( () => sortedNodes.map((n) => n.id), @@ -1034,10 +1050,18 @@ export default function NodePage() { ); return ( -
    - {/* 页面头部 */} -
    -
    + +
    +
    + setIsSearchVisible(false)} + onOpen={() => setIsSearchVisible(true)} + /> +
    {node.connectionStatus === "online" && - node.systemInfo + node.systemInfo ? formatSpeed( - node.systemInfo.downloadSpeed, - ) + node.systemInfo.downloadSpeed, + ) : "-"}
    @@ -1373,10 +1397,10 @@ export default function NodePage() {
    {node.connectionStatus === "online" && - node.systemInfo + node.systemInfo ? formatTraffic( - node.systemInfo.uploadTraffic, - ) + node.systemInfo.uploadTraffic, + ) : "-"}
    @@ -1386,10 +1410,10 @@ export default function NodePage() {
    {node.connectionStatus === "online" && - node.systemInfo + node.systemInfo ? formatTraffic( - node.systemInfo.downloadTraffic, - ) + node.systemInfo.downloadTraffic, + ) : "-"}
    @@ -2098,6 +2122,6 @@ export default function NodePage() { )} -
    + ); } diff --git a/vite-frontend/src/pages/tunnel.tsx b/vite-frontend/src/pages/tunnel.tsx index 557bd2c..accb43a 100644 --- a/vite-frontend/src/pages/tunnel.tsx +++ b/vite-frontend/src/pages/tunnel.tsx @@ -1,5 +1,7 @@ import { useState, useEffect, useMemo } from "react"; import toast from "react-hot-toast"; +import { AnimatedPage } from "@/components/animated-page"; +import { SearchBar } from "@/components/search-bar"; import { DndContext, KeyboardSensor, @@ -111,6 +113,8 @@ export default function TunnelPage() { const [tunnels, setTunnels] = useState([]); const [tunnelOrder, setTunnelOrder] = useState([]); const [nodes, setNodes] = useState([]); + const [searchKeyword, setSearchKeyword] = useState(""); + const [isSearchVisible, setIsSearchVisible] = useState(false); // 模态框状态 const [modalOpen, setModalOpen] = useState(false); @@ -597,7 +601,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 +624,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 +633,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 +643,7 @@ export default function TunnelPage() { } return sortedByDb; - }, [tunnels, tunnelOrder]); + }, [tunnels, tunnelOrder, searchKeyword]); const sortableTunnelIds = useMemo( () => sortedTunnels.map((t) => t.id), @@ -671,10 +685,18 @@ export default function TunnelPage() { } return ( -
    - {/* 页面头部 */} -
    -
    + +
    +
    + setIsSearchVisible(false)} + onOpen={() => setIsSearchVisible(true)} + /> +
    + ); } diff --git a/vite-frontend/src/pages/user.tsx b/vite-frontend/src/pages/user.tsx index a4b9393..166f66b 100644 --- a/vite-frontend/src/pages/user.tsx +++ b/vite-frontend/src/pages/user.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from "react"; import toast from "react-hot-toast"; +import { AnimatedPage, StaggerList, StaggerItem } from "@/components/animated-page"; import { parseDate } from "@internationalized/date"; import { Button } from "@/shadcn-bridge/heroui/button"; @@ -121,6 +122,7 @@ export default function UserPage() { const [users, setUsers] = useState([]); const [loading, setLoading] = useState(false); const [searchKeyword, setSearchKeyword] = useState(""); + const [isSearchVisible, setIsSearchVisible] = useState(false); const [pagination, setPagination] = useState({ current: 1, size: 10, @@ -609,42 +611,71 @@ export default function UserPage() { }; return ( -
    + {/* 页面头部 */} -
    -
    - -
    -
    - setSearchKeyword(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleSearch()} - /> +
    +
    + {!isSearchVisible ? ( -
    - - + ) : ( +
    + setSearchKeyword(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSearch()} + /> + +
    + )}
    + +
    {/* 用户列表 */} @@ -662,7 +693,7 @@ export default function UserPage() { ) : ( -
    + {users.map((user) => { const userStatus = getUserStatus(user); const expStatus = user.expTime @@ -678,173 +709,174 @@ export default function UserPage() { : 0; return ( - - -
    -
    -

    - {user.name || user.user} -

    -

    - @{user.user} -

    + + + +
    +
    +

    + {user.name || user.user} +

    +

    + @{user.user} +

    +
    +
    + + {userStatus.text} + +
    -
    - - {userStatus.text} - -
    -
    - + - -
    - {/* 流量信息 */} -
    -
    - 流量限制 - - {formatFlow(user.flow, "gb")} - -
    -
    - 已使用 - - {formatFlow(usedFlow)} - -
    - 90 - ? "danger" - : flowPercent > 70 - ? "warning" - : "success" - } - size="sm" - value={flowPercent} - /> -
    - - {/* 其他信息 */} -
    -
    - 转发数量 - {user.num} -
    -
    - 重置日期 - - {user.flowResetTime === 0 - ? "不重置" - : `每月${user.flowResetTime}号`} - -
    - {user.expTime && ( + +
    + {/* 流量信息 */} +
    - 过期时间 -
    - {expStatus && expStatus.color === "success" ? ( -
    - {formatDate(user.expTime)} -
    - ) : ( - - {expStatus?.text || "未知状态"} - - )} -
    + 流量限制 + + {formatFlow(user.flow, "gb")} +
    - )} -
    -
    +
    + 已使用 + + {formatFlow(usedFlow)} + +
    + 90 + ? "danger" + : flowPercent > 70 + ? "warning" + : "success" + } + size="sm" + value={flowPercent} + /> +
    -
    - {/* 第一行:编辑和重置 */} -
    - - + {/* 其他信息 */} +
    +
    + 转发数量 + {user.num} +
    +
    + 重置日期 + + {user.flowResetTime === 0 + ? "不重置" + : `每月${user.flowResetTime}号`} + +
    + {user.expTime && ( +
    + 过期时间 +
    + {expStatus && expStatus.color === "success" ? ( +
    + {formatDate(user.expTime)} +
    + ) : ( + + {expStatus?.text || "未知状态"} + + )} +
    +
    + )} +
    - {/* 第二行:权限和删除 */} -
    - - +
    + {/* 第一行:编辑和重置 */} +
    + + +
    + + {/* 第二行:权限和删除 */} +
    + + +
    -
    - - + + + ); })} -
    + )} {/* 用户表单模态框 */} - -
    + ); }