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
+
+ );
+}
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" && (
-
{/* 显示模式切换按钮 */}
入口
目标
策略
+ 总流量
状态
操作
@@ -1925,6 +1853,7 @@ export default function ForwardPage() {
handleDiagnose={handleDiagnose}
showAddressModal={showAddressModal}
hasMultipleAddresses={hasMultipleAddresses}
+ formatFlow={formatFlow}
/>
)}
@@ -3185,6 +3114,6 @@ export default function ForwardPage() {
)}
-
+
);
}
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)}
+ />
+
新增
@@ -230,108 +252,107 @@ export default function LimitPage() {
{/* 统一卡片网格 */}
- {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}
+
+ ) : (
+
+ 未绑定
+
+ )}
+
-
-
-
-
-
- }
- variant="flat"
- onPress={() => handleEdit(rule)}
- >
- 编辑
-
-
-
-
-
- }
- variant="flat"
- onPress={() => handleDelete(rule)}
- >
- 删除
-
-
-
-
+
+
+
+
+ }
+ variant="flat"
+ onPress={() => handleEdit(rule)}
+ >
+ 编辑
+
+
+
+
+
+ }
+ variant="flat"
+ onPress={() => handleDelete(rule)}
+ >
+ 删除
+
+
+
+
+
))}
-
+
) : (
/* 空状态 */
@@ -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.serverIpV4?.trim() ||
- node.serverIpV6?.trim() ? (
+ node.serverIpV6?.trim() ? (
{node.serverIpV4?.trim() && (
{node.connectionStatus === "online" &&
- node.systemInfo
+ node.systemInfo
? formatUptime(node.systemInfo.uptime)
: "-"}
@@ -1283,7 +1307,7 @@ export default function NodePage() {
CPU
{node.connectionStatus === "online" &&
- node.systemInfo
+ node.systemInfo
? `${node.systemInfo.cpuUsage.toFixed(1)}%`
: "-"}
@@ -1300,7 +1324,7 @@ export default function NodePage() {
size="sm"
value={
node.connectionStatus === "online" &&
- node.systemInfo
+ node.systemInfo
? node.systemInfo.cpuUsage
: 0
}
@@ -1311,7 +1335,7 @@ export default function NodePage() {
内存
{node.connectionStatus === "online" &&
- node.systemInfo
+ node.systemInfo
? `${node.systemInfo.memoryUsage.toFixed(1)}%`
: "-"}
@@ -1328,7 +1352,7 @@ export default function NodePage() {
size="sm"
value={
node.connectionStatus === "online" &&
- node.systemInfo
+ node.systemInfo
? node.systemInfo.memoryUsage
: 0
}
@@ -1343,10 +1367,10 @@ export default function NodePage() {
{node.connectionStatus === "online" &&
- node.systemInfo
+ node.systemInfo
? formatSpeed(
- node.systemInfo.uploadSpeed,
- )
+ node.systemInfo.uploadSpeed,
+ )
: "-"}
@@ -1356,10 +1380,10 @@ export default function NodePage() {
{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 ? (
setIsSearchVisible(true)}
>
-
-
-
- 新增
-
+ ) : (
+
+
setSearchKeyword(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && handleSearch()}
+ />
+
{
+ setIsSearchVisible(false);
+ setSearchKeyword("");
+ }}
+ >
+
+
+
+
+
+ )}
+
+
+ 新增
+
{/* 用户列表 */}
@@ -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)}
-
-
-
-
- {/* 其他信息 */}
-
-
- 转发数量
- {user.num}
-
-
- 重置日期
-
- {user.flowResetTime === 0
- ? "不重置"
- : `每月${user.flowResetTime}号`}
-
-
- {user.expTime && (
+
+
+ {/* 流量信息 */}
+
-
过期时间
-
- {expStatus && expStatus.color === "success" ? (
-
- {formatDate(user.expTime)}
-
- ) : (
-
- {expStatus?.text || "未知状态"}
-
- )}
-
+
流量限制
+
+ {formatFlow(user.flow, "gb")}
+
- )}
-
-
+
+ 已使用
+
+ {formatFlow(usedFlow)}
+
+
+
-
- {/* 第一行:编辑和重置 */}
-
-
}
- variant="flat"
- onPress={() => handleEdit(user)}
- >
- 编辑
-
-
-
-
- }
- variant="flat"
- onPress={() => handleResetFlow(user)}
- >
- 重置
-
+ {/* 其他信息 */}
+
+
+ 转发数量
+ {user.num}
+
+
+ 重置日期
+
+ {user.flowResetTime === 0
+ ? "不重置"
+ : `每月${user.flowResetTime}号`}
+
+
+ {user.expTime && (
+
+
过期时间
+
+ {expStatus && expStatus.color === "success" ? (
+
+ {formatDate(user.expTime)}
+
+ ) : (
+
+ {expStatus?.text || "未知状态"}
+
+ )}
+
+
+ )}
+
- {/* 第二行:权限和删除 */}
-
-
}
- variant="flat"
- onPress={() => handleManageTunnels(user)}
- >
- 权限
-
-
}
- variant="flat"
- onPress={() => handleDelete(user)}
- >
- 删除
-
+
+ {/* 第一行:编辑和重置 */}
+
+
}
+ variant="flat"
+ onPress={() => handleEdit(user)}
+ >
+ 编辑
+
+
+
+
+ }
+ variant="flat"
+ onPress={() => handleResetFlow(user)}
+ >
+ 重置
+
+
+
+ {/* 第二行:权限和删除 */}
+
+ }
+ variant="flat"
+ onPress={() => handleManageTunnels(user)}
+ >
+ 权限
+
+ }
+ variant="flat"
+ onPress={() => handleDelete(user)}
+ >
+ 删除
+
+
-
-
-
+
+
+
);
})}
-
+
)}
{/* 用户表单模态框 */}
-
-
+
);
}