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 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)}
+ />