feat: redesign forward card view completely syncing filters and sorti… (#179)

…ng logic
This commit is contained in:
sagit
2026-02-21 21:19:53 +08:00
committed by GitHub
15 changed files with 804 additions and 520 deletions
@@ -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;
}) => (
<motion.div
animate={{ opacity: 1, y: 0 }}
className={className}
exit={{ opacity: 0, y: -8 }}
initial={{ opacity: 0, y: 16 }}
transition={{ duration: 0.28, ease: [0.25, 0.46, 0.45, 0.94] }}
>
{children}
</motion.div>
);
/**
* 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 (
<Component
animate="show"
className={className}
initial="hidden"
variants={staggerContainer}
>
{children}
</Component>
);
};
/**
* 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 (
<Component className={className} variants={staggerItem}>
{children}
</Component>
);
};
/**
* 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;
}) => (
<motion.div
animate={{ opacity: 1, scale: 1 }}
className={className}
initial={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2, delay, ease: "easeOut" }}
>
{children}
</motion.div>
);
+101
View File
@@ -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
<div className="flex items-center gap-2 h-8 overflow-hidden">
<AnimatePresence mode="wait" initial={false}>
{!isVisible ? (
<motion.div
key="search-btn"
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
initial={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.12 }}
>
<Button
isIconOnly
aria-label="搜索"
className="text-default-600"
color="default"
size="sm"
variant="flat"
onPress={onOpen}
>
<SearchIcon className="w-4 h-4" />
</Button>
</motion.div>
) : (
<motion.div
key="search-input"
animate={{ opacity: 1, x: 0 }}
className="flex w-full items-center gap-2"
exit={{ opacity: 0, x: -8 }}
initial={{ opacity: 0, x: -16 }}
transition={{ duration: 0.18, ease: [0.25, 0.46, 0.45, 0.94] }}
>
<Input
autoFocus
classNames={{
base: "bg-default-100",
input: "bg-transparent text-sm focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none",
inputWrapper:
"bg-default-100 border-0 shadow-none h-8 min-h-8",
}}
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
<Button
isIconOnly
aria-label="关闭搜索"
className="text-default-600 shrink-0"
color="default"
size="sm"
variant="light"
onPress={() => {
onClose();
onChange("");
}}
>
<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>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const buttonVariants = cva( 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: { variants: {
variant: { variant: {
+2 -2
View File
@@ -11,14 +11,14 @@ function Checkbox({
return ( return (
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
className={cn( className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground", "peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow transition-transform duration-100 active:scale-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className, className,
)} )}
data-slot="checkbox" data-slot="checkbox"
{...props} {...props}
> >
<CheckboxPrimitive.Indicator <CheckboxPrimitive.Indicator
className="flex items-center justify-center text-current" className="flex items-center justify-center text-current data-[state=checked]:animate-in data-[state=checked]:zoom-in-75 data-[state=checked]:duration-150"
data-slot="checkbox-indicator" data-slot="checkbox-indicator"
> >
<CheckIcon className="h-3.5 w-3.5" /> <CheckIcon className="h-3.5 w-3.5" />
+1 -1
View File
@@ -57,7 +57,7 @@ function DialogContent({
<DialogOverlay /> <DialogOverlay />
<DialogPrimitive.Content <DialogPrimitive.Content
className={cn( className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-default-200 bg-white p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 sm:rounded-lg dark:bg-default-50", "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-default-200 bg-white p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=open]:slide-in-from-bottom-4 data-[state=closed]:slide-out-to-bottom-2 sm:rounded-lg dark:bg-default-50",
className, className,
)} )}
data-slot="dialog-content" data-slot="dialog-content"
@@ -85,7 +85,7 @@ function DropdownMenuSubContent({
return ( return (
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
className={cn( className={cn(
"z-50 min-w-32 overflow-hidden rounded-md border border-default-200 bg-white p-1 text-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out dark:bg-default-50", "z-50 min-w-32 overflow-hidden rounded-md border border-default-200 bg-white p-1 text-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=top]:slide-in-from-bottom-2 data-[side=bottom]:slide-in-from-top-2 dark:bg-default-50",
className, className,
)} )}
data-slot="dropdown-menu-sub-content" data-slot="dropdown-menu-sub-content"
@@ -103,7 +103,7 @@ function DropdownMenuContent({
<DropdownMenuPrimitive.Portal> <DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content <DropdownMenuPrimitive.Content
className={cn( className={cn(
"z-50 min-w-32 overflow-hidden rounded-md border border-default-200 bg-white p-1 text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out dark:bg-default-50", "z-50 min-w-32 overflow-hidden rounded-md border border-default-200 bg-white p-1 text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=top]:slide-in-from-bottom-2 data-[side=bottom]:slide-in-from-top-2 dark:bg-default-50",
className, className,
)} )}
data-slot="dropdown-menu-content" data-slot="dropdown-menu-content"
+1 -1
View File
@@ -90,7 +90,7 @@ function SelectContent({
<SelectPrimitive.Portal> <SelectPrimitive.Portal>
<SelectPrimitive.Content <SelectPrimitive.Content
className={cn( className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-default-200 bg-white text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out dark:bg-default-50", "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-default-200 bg-white text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=top]:slide-in-from-bottom-2 data-[side=bottom]:slide-in-from-top-2 dark:bg-default-50",
position === "popper" && position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className, className,
+37 -10
View File
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { useNavigate, useLocation } from "react-router-dom"; import { useNavigate, useLocation } from "react-router-dom";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { AnimatePresence, motion } from "framer-motion";
import { Button } from "@/shadcn-bridge/heroui/button"; import { Button } from "@/shadcn-bridge/heroui/button";
import { import {
@@ -335,21 +336,36 @@ export default function AdminLayout({
return ( return (
<li key={item.path}> <li key={item.path}>
<button <motion.button
className={` className={`
w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left
transition-colors duration-200 min-h-[44px] relative min-h-[44px]
${ ${isActive
isActive ? "text-primary-600 dark:text-primary-300"
? "bg-primary-100 dark:bg-primary-600/20 text-primary-600 dark:text-primary-300" : "text-gray-700 dark:text-gray-200"
: "text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-900"
} }
`} `}
transition={{ duration: 0.15 }}
whileTap={{ scale: 0.97 }}
onClick={() => handleMenuClick(item.path)} onClick={() => handleMenuClick(item.path)}
> >
<div className="flex-shrink-0">{item.icon}</div> {isActive && (
<span className="font-medium text-sm">{item.label}</span> <motion.div
</button> className="absolute inset-0 rounded-lg bg-primary-100 dark:bg-primary-600/20"
layoutId="sidebar-active"
transition={{ type: "spring", stiffness: 380, damping: 30 }}
/>
)}
{!isActive && (
<motion.div
className="absolute inset-0 rounded-lg bg-gray-100 dark:bg-gray-900 opacity-0"
transition={{ duration: 0.15 }}
whileHover={{ opacity: 1 }}
/>
)}
<div className="flex-shrink-0 relative z-10">{item.icon}</div>
<span className="font-medium text-sm relative z-10">{item.label}</span>
</motion.button>
</li> </li>
); );
})} })}
@@ -476,9 +492,20 @@ export default function AdminLayout({
{/* 主内容 */} {/* 主内容 */}
<main <main
className={`flex-1 bg-gray-100 dark:bg-black ${isMobile ? "" : "overflow-y-auto"}`} className={`flex-1 bg-gray-100 dark:bg-black overflow-hidden ${isMobile ? "" : "overflow-y-scroll"}`}
>
<AnimatePresence mode="wait">
<motion.div
key={location.pathname}
animate={{ opacity: 1, y: 0 }}
className="h-full"
exit={{ opacity: 0, y: -6 }}
initial={{ opacity: 0, y: 10 }}
transition={{ duration: 0.22, ease: [0.25, 0.46, 0.45, 0.94] }}
> >
{children} {children}
</motion.div>
</AnimatePresence>
</main> </main>
</div> </div>
+3 -2
View File
@@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { AnimatedPage } from "@/components/animated-page";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button"; import { Button } from "@/shadcn-bridge/heroui/button";
@@ -524,7 +525,7 @@ export default function DashboardPage() {
} }
return ( return (
<div className="px-3 lg:px-6 py-2 lg:py-4"> <AnimatedPage className="px-3 lg:px-6 py-2 lg:py-4">
{announcement && <AnnouncementBanner announcement={announcement} />} {announcement && <AnnouncementBanner announcement={announcement} />}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-4 mb-6 lg:mb-8"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-4 mb-6 lg:mb-8">
<MetricCard <MetricCard
@@ -978,6 +979,6 @@ export default function DashboardPage() {
</ModalBody> </ModalBody>
</ModalContent> </ModalContent>
</Modal> </Modal>
</div> </AnimatedPage>
); );
} }
+37 -108
View File
@@ -1,5 +1,7 @@
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { AnimatedPage } from "@/components/animated-page";
import { SearchBar } from "@/components/search-bar";
import { import {
DndContext, DndContext,
closestCenter, closestCenter,
@@ -119,24 +121,15 @@ interface ForwardForm {
strategy: string; strategy: string;
} }
// 添加分组接口
interface UserGroup {
userId: number | null;
userName: string;
tunnelGroups: TunnelGroup[];
}
interface TunnelGroup {
tunnelId: number;
tunnelName: string;
forwards: Forward[];
}
export default function ForwardPage() { export default function ForwardPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
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">(() => {
@@ -233,21 +226,6 @@ export default function ForwardPage() {
setViewMode(newMode); setViewMode(newMode);
try { try {
localStorage.setItem("forward-view-mode", newMode); 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 { } } catch { }
}; };
@@ -270,7 +248,6 @@ export default function ForwardPage() {
setForwards(forwardsData); setForwards(forwardsData);
// 初始化拖拽排序顺序 // 初始化拖拽排序顺序
if (viewMode === "direct") {
const currentUserId = JwtUtil.getUserIdFromToken(); const currentUserId = JwtUtil.getUserIdFromToken();
const { order, fromDatabase } = buildForwardOrder( const { order, fromDatabase } = buildForwardOrder(
forwardsData, forwardsData,
@@ -282,7 +259,6 @@ export default function ForwardPage() {
if (fromDatabase) { if (fromDatabase) {
saveOrder(FORWARD_ORDER_KEY, order); saveOrder(FORWARD_ORDER_KEY, order);
} }
}
} else { } else {
toast.error(forwardsRes.msg || "获取转发列表失败"); toast.error(forwardsRes.msg || "获取转发列表失败");
} }
@@ -298,52 +274,7 @@ export default function ForwardPage() {
} }
}; };
// 按用户和隧道分组转发数据
const groupForwardsByUserAndTunnel = (): UserGroup[] => {
const userMap = new Map<string, UserGroup>();
// 获取排序后的转发列表
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 => { const validateForm = (): boolean => {
@@ -720,26 +651,10 @@ export default function ForwardPage() {
setExportLoading(true); setExportLoading(true);
try { try {
// 根据当前显示模式获取要导出的转发列表 // 获取要导出的转发列表
let forwardsToExport: Forward[] = []; const forwardsToExport = sortedForwards.filter(
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, (forward) => forward.tunnelId === selectedTunnelForExport,
); );
}
if (forwardsToExport.length === 0) { if (forwardsToExport.length === 0) {
toast.error("所选隧道没有转发数据"); toast.error("所选隧道没有转发数据");
@@ -1181,16 +1096,8 @@ export default function ForwardPage() {
return []; return [];
} }
// 在平铺模式下,只显示当前用户的转发
let filteredForwards = forwards; let filteredForwards = forwards;
if (viewMode === "direct") {
if (tokenUserId !== null) {
filteredForwards = forwards.filter(
(forward) => forward.userId === tokenUserId,
);
}
} else if (viewMode === "grouped") {
if (filterUserId !== "all") { if (filterUserId !== "all") {
const targetUserId = parseInt(filterUserId); const targetUserId = parseInt(filterUserId);
filteredForwards = filteredForwards.filter(f => f.userId === targetUserId || (targetUserId === 0 && !f.userId)); filteredForwards = filteredForwards.filter(f => f.userId === targetUserId || (targetUserId === 0 && !f.userId));
@@ -1199,6 +1106,14 @@ export default function ForwardPage() {
const targetTunnelId = parseInt(filterTunnelId); const targetTunnelId = parseInt(filterTunnelId);
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))
);
} }
// 确保过滤后的转发列表有效 // 确保过滤后的转发列表有效
@@ -1242,7 +1157,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),
@@ -1288,7 +1203,7 @@ export default function ForwardPage() {
}, [forwards]); }, [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 { const {
attributes, attributes,
listeners, listeners,
@@ -1386,6 +1301,11 @@ export default function ForwardPage() {
{strategyDisplay.text} {strategyDisplay.text}
</Chip> </Chip>
</TableCell> </TableCell>
<TableCell className="whitespace-nowrap">
<span className="text-sm font-medium text-default-600 font-mono">
{formatFlow((forward.inFlow || 0) + (forward.outFlow || 0))}
</span>
</TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-2.5 whitespace-nowrap"> <div className="flex items-center gap-2.5 whitespace-nowrap">
<Switch <Switch
@@ -1700,13 +1620,21 @@ export default function ForwardPage() {
} }
return ( return (
<div className="px-3 lg:px-6 py-8"> <AnimatedPage 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">
<SearchBar
isVisible={isSearchVisible}
placeholder="搜索转发名称、地址或用户名"
value={searchKeyword}
onChange={setSearchKeyword}
onClose={() => setIsSearchVisible(false)}
onOpen={() => setIsSearchVisible(true)}
/>
</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">
{/* 筛选按钮 (仅在分组视图显示) */} {/* 筛选按钮 */}
{viewMode === "grouped" && (
<Button <Button
isIconOnly isIconOnly
aria-label="筛选条件" aria-label="筛选条件"
@@ -1735,7 +1663,6 @@ export default function ForwardPage() {
<span className="absolute top-1.5 right-1.5 flex h-1.5 w-1.5 rounded-full bg-primary" /> <span className="absolute top-1.5 right-1.5 flex h-1.5 w-1.5 rounded-full bg-primary" />
)} )}
</Button> </Button>
)}
{/* 显示模式切换按钮 */} {/* 显示模式切换按钮 */}
<Button <Button
isIconOnly isIconOnly
@@ -1905,6 +1832,7 @@ export default function ForwardPage() {
<TableColumn>入口</TableColumn> <TableColumn>入口</TableColumn>
<TableColumn>目标</TableColumn> <TableColumn>目标</TableColumn>
<TableColumn>策略</TableColumn> <TableColumn>策略</TableColumn>
<TableColumn>总流量</TableColumn>
<TableColumn>状态</TableColumn> <TableColumn>状态</TableColumn>
<TableColumn className="text-right">操作</TableColumn> <TableColumn className="text-right">操作</TableColumn>
</TableHeader> </TableHeader>
@@ -1925,6 +1853,7 @@ export default function ForwardPage() {
handleDiagnose={handleDiagnose} handleDiagnose={handleDiagnose}
showAddressModal={showAddressModal} showAddressModal={showAddressModal}
hasMultipleAddresses={hasMultipleAddresses} hasMultipleAddresses={hasMultipleAddresses}
formatFlow={formatFlow}
/> />
</SortableContext> </SortableContext>
)} )}
@@ -3185,6 +3114,6 @@ export default function ForwardPage() {
)} )}
</ModalContent> </ModalContent>
</Modal> </Modal>
</div> </AnimatedPage>
); );
} }
+8 -2
View File
@@ -2,6 +2,7 @@ import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { Turnstile } from "@marsidev/react-turnstile"; import { Turnstile } from "@marsidev/react-turnstile";
import { motion } from "framer-motion";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Input } from "@/shadcn-bridge/heroui/input"; import { Input } from "@/shadcn-bridge/heroui/input";
@@ -152,7 +153,12 @@ export default function IndexPage() {
return ( return (
<DefaultLayout> <DefaultLayout>
<section className="flex flex-col items-center justify-center gap-4 py-4 sm:py-8 md:py-10 pb-20 min-h-[calc(100dvh-120px)] sm:min-h-[calc(100dvh-200px)]"> <section className="flex flex-col items-center justify-center gap-4 py-4 sm:py-8 md:py-10 pb-20 min-h-[calc(100dvh-120px)] sm:min-h-[calc(100dvh-200px)]">
<div className="w-full max-w-md px-4 sm:px-0"> <motion.div
animate={{ opacity: 1, y: 0, scale: 1 }}
className="w-full max-w-md px-4 sm:px-0"
initial={{ opacity: 0, y: 24, scale: 0.97 }}
transition={{ duration: 0.35, ease: [0.25, 0.46, 0.45, 0.94] }}
>
<Card className="w-full"> <Card className="w-full">
<CardHeader className="pb-0 pt-6 px-6 flex-col items-center"> <CardHeader className="pb-0 pt-6 px-6 flex-col items-center">
<h1 className={title({ size: "sm" })}>登陆</h1> <h1 className={title({ size: "sm" })}>登陆</h1>
@@ -203,7 +209,7 @@ export default function IndexPage() {
</div> </div>
</CardBody> </CardBody>
</Card> </Card>
</div> </motion.div>
{/* 版权信息 - 固定在底部,不占据布局空间 */} {/* 版权信息 - 固定在底部,不占据布局空间 */}
+35 -14
View File
@@ -1,5 +1,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useMemo } from "react";
import toast from "react-hot-toast"; 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 { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button"; import { Button } from "@/shadcn-bridge/heroui/button";
@@ -51,6 +53,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);
@@ -219,10 +233,18 @@ export default function LimitPage() {
} }
return ( return (
<div className="px-3 lg:px-6 py-8"> <AnimatedPage 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" /> <SearchBar
isVisible={isSearchVisible}
placeholder="搜索规则名称或绑定隧道"
value={searchKeyword}
onChange={setSearchKeyword}
onClose={() => setIsSearchVisible(false)}
onOpen={() => setIsSearchVisible(true)}
/>
</div>
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}> <Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
新增 新增
@@ -230,13 +252,11 @@ 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"> <StaggerList 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 <StaggerItem key={rule.id}>
key={rule.id} <Card className="shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden h-full">
className="shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden"
>
<CardHeader className="pb-2 md:pb-2"> <CardHeader className="pb-2 md:pb-2">
<div className="flex justify-between items-start w-full"> <div className="flex justify-between items-start w-full">
<div> <div>
@@ -330,8 +350,9 @@ export default function LimitPage() {
</div> </div>
</CardBody> </CardBody>
</Card> </Card>
</StaggerItem>
))} ))}
</div> </StaggerList>
) : ( ) : (
/* 空状态 */ /* 空状态 */
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50"> <Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
@@ -504,6 +525,6 @@ export default function LimitPage() {
)} )}
</ModalContent> </ModalContent>
</Modal> </Modal>
</div> </AnimatedPage>
); );
} }
+33 -9
View File
@@ -1,5 +1,7 @@
import { useState, useEffect, useMemo, useCallback } from "react"; import { useState, useEffect, useMemo, useCallback } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { AnimatedPage } from "@/components/animated-page";
import { SearchBar } from "@/components/search-bar";
import { import {
DndContext, DndContext,
KeyboardSensor, KeyboardSensor,
@@ -150,6 +152,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);
@@ -994,7 +998,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 +1023,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 +1032,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 +1042,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),
@@ -1034,10 +1050,18 @@ export default function NodePage() {
); );
return ( return (
<div className="px-3 lg:px-6 py-8"> <AnimatedPage 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" /> <SearchBar
isVisible={isSearchVisible}
placeholder="搜索节点名称或IP"
value={searchKeyword}
onChange={setSearchKeyword}
onClose={() => setIsSearchVisible(false)}
onOpen={() => setIsSearchVisible(true)}
/>
</div>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<Button <Button
@@ -2098,6 +2122,6 @@ export default function NodePage() {
)} )}
</ModalContent> </ModalContent>
</Modal> </Modal>
</div> </AnimatedPage>
); );
} }
+31 -9
View File
@@ -1,5 +1,7 @@
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { AnimatedPage } from "@/components/animated-page";
import { SearchBar } from "@/components/search-bar";
import { import {
DndContext, DndContext,
KeyboardSensor, KeyboardSensor,
@@ -111,6 +113,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 +601,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 +624,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 +633,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 +643,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),
@@ -671,10 +685,18 @@ export default function TunnelPage() {
} }
return ( return (
<div className="px-3 lg:px-6 py-8"> <AnimatedPage 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" /> <SearchBar
isVisible={isSearchVisible}
placeholder="搜索隧道名称或IP"
value={searchKeyword}
onChange={setSearchKeyword}
onClose={() => setIsSearchVisible(false)}
onOpen={() => setIsSearchVisible(true)}
/>
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
@@ -2415,6 +2437,6 @@ export default function TunnelPage() {
)} )}
</ModalContent> </ModalContent>
</Modal> </Modal>
</div> </AnimatedPage>
); );
} }
+53 -21
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { AnimatedPage, StaggerList, StaggerItem } from "@/components/animated-page";
import { parseDate } from "@internationalized/date"; import { parseDate } from "@internationalized/date";
import { Button } from "@/shadcn-bridge/heroui/button"; import { Button } from "@/shadcn-bridge/heroui/button";
@@ -121,6 +122,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,
@@ -609,15 +611,26 @@ export default function UserPage() {
}; };
return ( return (
<div className="px-3 lg:px-6 py-8"> <AnimatedPage 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"> <Button
<div className="flex items-center gap-3 flex-1 max-w-md"> 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 <Input
className="flex-1" autoFocus
classNames={{ classNames={{
base: "bg-default-100", base: "bg-default-100",
input: "bg-transparent", input: "bg-transparent",
@@ -631,21 +644,39 @@ export default function UserPage() {
/> />
<Button <Button
isIconOnly isIconOnly
aria-label="搜索用户" aria-label="关闭搜索"
className="min-h-10 w-10" className="text-default-600 shrink-0"
color="primary" color="default"
variant="solid" size="sm"
onPress={handleSearch} variant="light"
onPress={() => {
setIsSearchVisible(false);
setSearchKeyword("");
}}
> >
<SearchIcon className="w-4 h-4" /> <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> </Button>
</div> </div>
)}
</div>
<Button color="primary" variant="flat" onPress={handleAdd}> <Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
新增 新增
</Button> </Button>
</div> </div>
</div>
{/* 用户列表 */} {/* 用户列表 */}
{loading ? ( {loading ? (
@@ -662,7 +693,7 @@ export default function UserPage() {
</CardBody> </CardBody>
</Card> </Card>
) : ( ) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4"> <StaggerList className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{users.map((user) => { {users.map((user) => {
const userStatus = getUserStatus(user); const userStatus = getUserStatus(user);
const expStatus = user.expTime const expStatus = user.expTime
@@ -678,9 +709,9 @@ export default function UserPage() {
: 0; : 0;
return ( return (
<StaggerItem key={user.id}>
<Card <Card
key={user.id} className="shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden h-full"
className="shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden"
> >
<CardHeader className="pb-2 md:pb-2"> <CardHeader className="pb-2 md:pb-2">
<div className="flex justify-between items-start w-full"> <div className="flex justify-between items-start w-full">
@@ -838,13 +869,14 @@ export default function UserPage() {
</div> </div>
</CardBody> </CardBody>
</Card> </Card>
</StaggerItem>
); );
})} })}
</div> </StaggerList>
)} )}
{/* 用户表单模态框 */} {/* 用户表单模态框 */}
<Modal < Modal
backdrop="blur" backdrop="blur"
isOpen={isUserModalOpen} isOpen={isUserModalOpen}
placement="center" placement="center"
@@ -1758,6 +1790,6 @@ export default function UserPage() {
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>
</Modal> </Modal>
</div> </AnimatedPage>
); );
} }