From a15be253f544c471067e0c599fc536ab39b80852 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Sat, 21 Feb 2026 21:16:42 +0800 Subject: [PATCH] feat: add framer-motion animations (page transitions, modals, dropdowns, buttons, search bar) --- .../src/components/animated-page.tsx | 121 +++++++ vite-frontend/src/components/search-bar.tsx | 101 ++++++ vite-frontend/src/components/ui/button.tsx | 2 +- vite-frontend/src/components/ui/checkbox.tsx | 4 +- vite-frontend/src/components/ui/dialog.tsx | 2 +- .../src/components/ui/dropdown-menu.tsx | 4 +- vite-frontend/src/components/ui/select.tsx | 6 +- vite-frontend/src/layouts/admin.tsx | 51 ++- vite-frontend/src/pages/dashboard.tsx | 5 +- vite-frontend/src/pages/forward.tsx | 70 +--- vite-frontend/src/pages/index.tsx | 14 +- vite-frontend/src/pages/limit.tsx | 257 ++++++-------- vite-frontend/src/pages/node.tsx | 70 +--- vite-frontend/src/pages/tunnel.tsx | 70 +--- vite-frontend/src/pages/user.tsx | 316 +++++++++--------- 15 files changed, 583 insertions(+), 510 deletions(-) create mode 100644 vite-frontend/src/components/animated-page.tsx create mode 100644 vite-frontend/src/components/search-bar.tsx 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 67bea0e..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, @@ -76,7 +78,6 @@ import { executeForwardBatchToggleService, } from "@/pages/forward/batch-actions"; import { buildForwardOrder, FORWARD_ORDER_KEY } from "@/pages/forward/order"; -import { SearchIcon } from "@/components/icons"; import { PageLoadingState } from "@/components/page-state"; import { useMobileBreakpoint } from "@/hooks/useMobileBreakpoint"; import { saveOrder } from "@/utils/order-storage"; @@ -1619,65 +1620,18 @@ export default function ForwardPage() { } return ( -
    + {/* 页面头部 */}
    - {!isSearchVisible ? ( - - ) : ( -
    - setSearchKeyword(e.target.value)} - /> - -
    - )} + setIsSearchVisible(false)} + onOpen={() => setIsSearchVisible(true)} + />
    {/* 筛选按钮 */} @@ -3160,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 9d9e819..5048b8a 100644 --- a/vite-frontend/src/pages/limit.tsx +++ b/vite-frontend/src/pages/limit.tsx @@ -1,5 +1,7 @@ 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"; @@ -20,7 +22,6 @@ import { deleteSpeedLimit, getTunnelList, } from "@/api"; -import { SearchIcon } from "@/components/icons"; import { PageLoadingState } from "@/components/page-state"; interface SpeedLimitRule { @@ -232,64 +233,17 @@ export default function LimitPage() { } return ( -
    +
    - {!isSearchVisible ? ( - - ) : ( -
    - setSearchKeyword(e.target.value)} - /> - -
    - )} + setIsSearchVisible(false)} + onOpen={() => setIsSearchVisible(true)} + />
    - -
    - - +
    + + +
    + + + ))} -
    + ) : ( /* 空状态 */ @@ -572,6 +525,6 @@ export default function LimitPage() { )} -
    + ); } diff --git a/vite-frontend/src/pages/node.tsx b/vite-frontend/src/pages/node.tsx index e554465..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, @@ -57,7 +59,6 @@ import { getRemoteSyncErrorMessage, } from "@/pages/node/display"; import { tryCopyInstallCommand } from "@/pages/node/install-command"; -import { SearchIcon } from "@/components/icons"; import { buildNodeSystemInfo } from "@/pages/node/system-info"; import { useNodeOfflineTimers } from "@/pages/node/use-node-offline-timers"; import { useNodeRealtime } from "@/pages/node/use-node-realtime"; @@ -1049,64 +1050,17 @@ export default function NodePage() { ); return ( -
    +
    - {!isSearchVisible ? ( - - ) : ( -
    - setSearchKeyword(e.target.value)} - /> - -
    - )} + setIsSearchVisible(false)} + onOpen={() => setIsSearchVisible(true)} + />
    @@ -2168,6 +2122,6 @@ export default function NodePage() { )} -
    + ); } diff --git a/vite-frontend/src/pages/tunnel.tsx b/vite-frontend/src/pages/tunnel.tsx index 322b343..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, @@ -45,7 +47,6 @@ import { batchDeleteTunnels, batchRedeployTunnels, } from "@/api"; -import { SearchIcon } from "@/components/icons"; import { PageLoadingState } from "@/components/page-state"; import { buildDiagnosisFallbackResult, @@ -684,64 +685,17 @@ export default function TunnelPage() { } return ( -
    +
    - {!isSearchVisible ? ( - - ) : ( -
    - setSearchKeyword(e.target.value)} - /> - -
    - )} + setIsSearchVisible(false)} + onOpen={() => setIsSearchVisible(true)} + />
    @@ -2483,6 +2437,6 @@ export default function TunnelPage() { )} -
    + ); } diff --git a/vite-frontend/src/pages/user.tsx b/vite-frontend/src/pages/user.tsx index 5edb9fd..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"; @@ -610,7 +611,7 @@ export default function UserPage() { }; return ( -
    + {/* 页面头部 */}
    @@ -692,7 +693,7 @@ export default function UserPage() { ) : ( -
    + {users.map((user) => { const userStatus = getUserStatus(user); const expStatus = user.expTime @@ -708,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 || "未知状态"} + + )} +
    +
    + )} +
    - {/* 第二行:权限和删除 */} -
    - - +
    + {/* 第一行:编辑和重置 */} +
    + + +
    + + {/* 第二行:权限和删除 */} +
    + + +
    -
    - - + + + ); })} -
    + )} {/* 用户表单模态框 */} - -
    + ); }