mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-01 00:26:38 +08:00
feat: add framer-motion animations (page transitions, modals, dropdowns, buttons, search bar)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -11,14 +11,14 @@ function Checkbox({
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
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,
|
||||
)}
|
||||
data-slot="checkbox"
|
||||
{...props}
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<CheckIcon className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -57,7 +57,7 @@ function DialogContent({
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
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,
|
||||
)}
|
||||
data-slot="dialog-content"
|
||||
|
||||
@@ -85,7 +85,7 @@ function DropdownMenuSubContent({
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
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,
|
||||
)}
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
@@ -103,7 +103,7 @@ function DropdownMenuContent({
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
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,
|
||||
)}
|
||||
data-slot="dropdown-menu-content"
|
||||
|
||||
@@ -90,9 +90,9 @@ function SelectContent({
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
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" &&
|
||||
"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,
|
||||
)}
|
||||
data-slot="select-content"
|
||||
@@ -104,7 +104,7 @@ function SelectContent({
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -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 (
|
||||
<li key={item.path}>
|
||||
<button
|
||||
<motion.button
|
||||
className={`
|
||||
w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left
|
||||
transition-colors duration-200 min-h-[44px]
|
||||
${
|
||||
isActive
|
||||
? "bg-primary-100 dark:bg-primary-600/20 text-primary-600 dark:text-primary-300"
|
||||
: "text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-900"
|
||||
}
|
||||
relative min-h-[44px]
|
||||
${isActive
|
||||
? "text-primary-600 dark:text-primary-300"
|
||||
: "text-gray-700 dark:text-gray-200"
|
||||
}
|
||||
`}
|
||||
transition={{ duration: 0.15 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
onClick={() => handleMenuClick(item.path)}
|
||||
>
|
||||
<div className="flex-shrink-0">{item.icon}</div>
|
||||
<span className="font-medium text-sm">{item.label}</span>
|
||||
</button>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
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>
|
||||
);
|
||||
})}
|
||||
@@ -476,9 +492,20 @@ export default function AdminLayout({
|
||||
|
||||
{/* 主内容 */}
|
||||
<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"}`}
|
||||
>
|
||||
{children}
|
||||
<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}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<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} />}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-4 mb-6 lg:mb-8">
|
||||
<MetricCard
|
||||
@@ -978,6 +979,6 @@ export default function DashboardPage() {
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</AnimatedPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<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-1 max-w-sm flex items-center gap-2">
|
||||
{!isSearchVisible ? (
|
||||
<Button
|
||||
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
|
||||
autoFocus
|
||||
classNames={{
|
||||
base: "bg-default-100",
|
||||
input: "bg-transparent",
|
||||
inputWrapper:
|
||||
"bg-default-100 border-2 border-default-200 hover:border-default-300 data-[hover=true]:border-default-300",
|
||||
}}
|
||||
placeholder="搜索转发名称、地址或用户名"
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
isIconOnly
|
||||
aria-label="关闭搜索"
|
||||
className="text-default-600 shrink-0"
|
||||
color="default"
|
||||
size="sm"
|
||||
variant="light"
|
||||
onPress={() => {
|
||||
setIsSearchVisible(false);
|
||||
setSearchKeyword("");
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
<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">
|
||||
{/* 筛选按钮 */}
|
||||
@@ -3160,6 +3114,6 @@ export default function ForwardPage() {
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</AnimatedPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<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)]">
|
||||
<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">
|
||||
<CardHeader className="pb-0 pt-6 px-6 flex-col items-center">
|
||||
<h1 className={title({ size: "sm" })}>登陆</h1>
|
||||
@@ -203,7 +209,7 @@ export default function IndexPage() {
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 版权信息 - 固定在底部,不占据布局空间 */}
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
|
||||
+105
-152
@@ -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 (
|
||||
<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-1 max-w-sm flex items-center gap-2">
|
||||
{!isSearchVisible ? (
|
||||
<Button
|
||||
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
|
||||
autoFocus
|
||||
classNames={{
|
||||
base: "bg-default-100",
|
||||
input: "bg-transparent",
|
||||
inputWrapper:
|
||||
"bg-default-100 border-2 border-default-200 hover:border-default-300 data-[hover=true]:border-default-300",
|
||||
}}
|
||||
placeholder="搜索规则名称或绑定隧道"
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
isIconOnly
|
||||
aria-label="关闭搜索"
|
||||
className="text-default-600 shrink-0"
|
||||
color="default"
|
||||
size="sm"
|
||||
variant="light"
|
||||
onPress={() => {
|
||||
setIsSearchVisible(false);
|
||||
setSearchKeyword("");
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
<SearchBar
|
||||
isVisible={isSearchVisible}
|
||||
placeholder="搜索规则名称或绑定隧道"
|
||||
value={searchKeyword}
|
||||
onChange={setSearchKeyword}
|
||||
onClose={() => setIsSearchVisible(false)}
|
||||
onOpen={() => setIsSearchVisible(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
|
||||
@@ -299,107 +253,106 @@ export default function LimitPage() {
|
||||
|
||||
{/* 统一卡片网格 */}
|
||||
{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">
|
||||
{filteredRules.map((rule) => (
|
||||
<Card
|
||||
key={rule.id}
|
||||
className="shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden"
|
||||
>
|
||||
<CardHeader className="pb-2 md:pb-2">
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">
|
||||
{rule.name}
|
||||
</h3>
|
||||
</div>
|
||||
<Chip
|
||||
color={rule.status === 1 ? "success" : "danger"}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{rule.status === 1 ? "运行" : "异常"}
|
||||
</Chip>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-small text-default-600">
|
||||
速度限制
|
||||
</span>
|
||||
<Chip color="secondary" size="sm" variant="flat">
|
||||
{rule.speed} Mbps
|
||||
<StaggerItem key={rule.id}>
|
||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden h-full">
|
||||
<CardHeader className="pb-2 md:pb-2">
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">
|
||||
{rule.name}
|
||||
</h3>
|
||||
</div>
|
||||
<Chip
|
||||
color={rule.status === 1 ? "success" : "danger"}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{rule.status === 1 ? "运行" : "异常"}
|
||||
</Chip>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-small text-default-600">
|
||||
绑定隧道
|
||||
</span>
|
||||
{rule.tunnelName ? (
|
||||
<Chip color="primary" size="sm" variant="flat">
|
||||
{rule.tunnelName}
|
||||
</Chip>
|
||||
) : (
|
||||
<span className="text-default-400 text-small">
|
||||
未绑定
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-small text-default-600">
|
||||
速度限制
|
||||
</span>
|
||||
)}
|
||||
<Chip color="secondary" size="sm" variant="flat">
|
||||
{rule.speed} Mbps
|
||||
</Chip>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-small text-default-600">
|
||||
绑定隧道
|
||||
</span>
|
||||
{rule.tunnelName ? (
|
||||
<Chip color="primary" size="sm" variant="flat">
|
||||
{rule.tunnelName}
|
||||
</Chip>
|
||||
) : (
|
||||
<span className="text-default-400 text-small">
|
||||
未绑定
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Button
|
||||
className="flex-1"
|
||||
color="primary"
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||
</svg>
|
||||
}
|
||||
variant="flat"
|
||||
onPress={() => handleEdit(rule)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
color="danger"
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
variant="flat"
|
||||
onPress={() => handleDelete(rule)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Button
|
||||
className="flex-1"
|
||||
color="primary"
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||
</svg>
|
||||
}
|
||||
variant="flat"
|
||||
onPress={() => handleEdit(rule)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
color="danger"
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
variant="flat"
|
||||
onPress={() => handleDelete(rule)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</StaggerItem>
|
||||
))}
|
||||
</div>
|
||||
</StaggerList>
|
||||
) : (
|
||||
/* 空状态 */
|
||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
|
||||
@@ -572,6 +525,6 @@ export default function LimitPage() {
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</AnimatedPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<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-1 max-w-sm flex items-center gap-2">
|
||||
{!isSearchVisible ? (
|
||||
<Button
|
||||
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
|
||||
autoFocus
|
||||
classNames={{
|
||||
base: "bg-default-100",
|
||||
input: "bg-transparent",
|
||||
inputWrapper:
|
||||
"bg-default-100 border-2 border-default-200 hover:border-default-300 data-[hover=true]:border-default-300",
|
||||
}}
|
||||
placeholder="搜索节点名称或IP"
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
isIconOnly
|
||||
aria-label="关闭搜索"
|
||||
className="text-default-600 shrink-0"
|
||||
color="default"
|
||||
size="sm"
|
||||
variant="light"
|
||||
onPress={() => {
|
||||
setIsSearchVisible(false);
|
||||
setSearchKeyword("");
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
<SearchBar
|
||||
isVisible={isSearchVisible}
|
||||
placeholder="搜索节点名称或IP"
|
||||
value={searchKeyword}
|
||||
onChange={setSearchKeyword}
|
||||
onClose={() => setIsSearchVisible(false)}
|
||||
onOpen={() => setIsSearchVisible(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
@@ -2168,6 +2122,6 @@ export default function NodePage() {
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</AnimatedPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<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-1 max-w-sm flex items-center gap-2">
|
||||
{!isSearchVisible ? (
|
||||
<Button
|
||||
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
|
||||
autoFocus
|
||||
classNames={{
|
||||
base: "bg-default-100",
|
||||
input: "bg-transparent",
|
||||
inputWrapper:
|
||||
"bg-default-100 border-2 border-default-200 hover:border-default-300 data-[hover=true]:border-default-300",
|
||||
}}
|
||||
placeholder="搜索隧道名称或IP"
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
isIconOnly
|
||||
aria-label="关闭搜索"
|
||||
className="text-default-600 shrink-0"
|
||||
color="default"
|
||||
size="sm"
|
||||
variant="light"
|
||||
onPress={() => {
|
||||
setIsSearchVisible(false);
|
||||
setSearchKeyword("");
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
<SearchBar
|
||||
isVisible={isSearchVisible}
|
||||
placeholder="搜索隧道名称或IP"
|
||||
value={searchKeyword}
|
||||
onChange={setSearchKeyword}
|
||||
onClose={() => setIsSearchVisible(false)}
|
||||
onOpen={() => setIsSearchVisible(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -2483,6 +2437,6 @@ export default function TunnelPage() {
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</AnimatedPage>
|
||||
);
|
||||
}
|
||||
|
||||
+159
-157
@@ -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 (
|
||||
<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-1 max-w-sm flex items-center gap-2">
|
||||
@@ -692,7 +693,7 @@ export default function UserPage() {
|
||||
</CardBody>
|
||||
</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) => {
|
||||
const userStatus = getUserStatus(user);
|
||||
const expStatus = user.expTime
|
||||
@@ -708,173 +709,174 @@ export default function UserPage() {
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={user.id}
|
||||
className="shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden"
|
||||
>
|
||||
<CardHeader className="pb-2 md:pb-2">
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-foreground truncate text-sm">
|
||||
{user.name || user.user}
|
||||
</h3>
|
||||
<p className="text-xs text-default-500 truncate">
|
||||
@{user.user}
|
||||
</p>
|
||||
<StaggerItem key={user.id}>
|
||||
<Card
|
||||
className="shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden h-full"
|
||||
>
|
||||
<CardHeader className="pb-2 md:pb-2">
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-foreground truncate text-sm">
|
||||
{user.name || user.user}
|
||||
</h3>
|
||||
<p className="text-xs text-default-500 truncate">
|
||||
@{user.user}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 ml-2">
|
||||
<Chip
|
||||
className="text-xs"
|
||||
color={userStatus.color}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{userStatus.text}
|
||||
</Chip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 ml-2">
|
||||
<Chip
|
||||
className="text-xs"
|
||||
color={userStatus.color}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{userStatus.text}
|
||||
</Chip>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3">
|
||||
<div className="space-y-2">
|
||||
{/* 流量信息 */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">流量限制</span>
|
||||
<span className="font-medium text-xs">
|
||||
{formatFlow(user.flow, "gb")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">已使用</span>
|
||||
<span className="font-medium text-xs text-danger">
|
||||
{formatFlow(usedFlow)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
aria-label={`流量使用 ${flowPercent.toFixed(1)}%`}
|
||||
className="mt-1"
|
||||
color={
|
||||
flowPercent > 90
|
||||
? "danger"
|
||||
: flowPercent > 70
|
||||
? "warning"
|
||||
: "success"
|
||||
}
|
||||
size="sm"
|
||||
value={flowPercent}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 其他信息 */}
|
||||
<div className="space-y-1.5 pt-2 border-t border-divider">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">转发数量</span>
|
||||
<span className="font-medium text-xs">{user.num}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">重置日期</span>
|
||||
<span className="text-xs">
|
||||
{user.flowResetTime === 0
|
||||
? "不重置"
|
||||
: `每月${user.flowResetTime}号`}
|
||||
</span>
|
||||
</div>
|
||||
{user.expTime && (
|
||||
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3">
|
||||
<div className="space-y-2">
|
||||
{/* 流量信息 */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">过期时间</span>
|
||||
<div className="text-right">
|
||||
{expStatus && expStatus.color === "success" ? (
|
||||
<div className="text-xs">
|
||||
{formatDate(user.expTime)}
|
||||
</div>
|
||||
) : (
|
||||
<Chip
|
||||
className="text-xs"
|
||||
color={expStatus?.color || "default"}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{expStatus?.text || "未知状态"}
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-default-600">流量限制</span>
|
||||
<span className="font-medium text-xs">
|
||||
{formatFlow(user.flow, "gb")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">已使用</span>
|
||||
<span className="font-medium text-xs text-danger">
|
||||
{formatFlow(usedFlow)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
aria-label={`流量使用 ${flowPercent.toFixed(1)}%`}
|
||||
className="mt-1"
|
||||
color={
|
||||
flowPercent > 90
|
||||
? "danger"
|
||||
: flowPercent > 70
|
||||
? "warning"
|
||||
: "success"
|
||||
}
|
||||
size="sm"
|
||||
value={flowPercent}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 mt-3">
|
||||
{/* 第一行:编辑和重置 */}
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
className="flex-1 min-h-8"
|
||||
color="primary"
|
||||
size="sm"
|
||||
startContent={<EditIcon className="w-3 h-3" />}
|
||||
variant="flat"
|
||||
onPress={() => handleEdit(user)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 min-h-8"
|
||||
color="warning"
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
variant="flat"
|
||||
onPress={() => handleResetFlow(user)}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
{/* 其他信息 */}
|
||||
<div className="space-y-1.5 pt-2 border-t border-divider">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">转发数量</span>
|
||||
<span className="font-medium text-xs">{user.num}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">重置日期</span>
|
||||
<span className="text-xs">
|
||||
{user.flowResetTime === 0
|
||||
? "不重置"
|
||||
: `每月${user.flowResetTime}号`}
|
||||
</span>
|
||||
</div>
|
||||
{user.expTime && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">过期时间</span>
|
||||
<div className="text-right">
|
||||
{expStatus && expStatus.color === "success" ? (
|
||||
<div className="text-xs">
|
||||
{formatDate(user.expTime)}
|
||||
</div>
|
||||
) : (
|
||||
<Chip
|
||||
className="text-xs"
|
||||
color={expStatus?.color || "default"}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{expStatus?.text || "未知状态"}
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 第二行:权限和删除 */}
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
className="flex-1 min-h-8"
|
||||
color="success"
|
||||
size="sm"
|
||||
startContent={<SettingsIcon className="w-3 h-3" />}
|
||||
variant="flat"
|
||||
onPress={() => handleManageTunnels(user)}
|
||||
>
|
||||
权限
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 min-h-8"
|
||||
color="danger"
|
||||
size="sm"
|
||||
startContent={<DeleteIcon className="w-3 h-3" />}
|
||||
variant="flat"
|
||||
onPress={() => handleDelete(user)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
<div className="space-y-1.5 mt-3">
|
||||
{/* 第一行:编辑和重置 */}
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
className="flex-1 min-h-8"
|
||||
color="primary"
|
||||
size="sm"
|
||||
startContent={<EditIcon className="w-3 h-3" />}
|
||||
variant="flat"
|
||||
onPress={() => handleEdit(user)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 min-h-8"
|
||||
color="warning"
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
variant="flat"
|
||||
onPress={() => handleResetFlow(user)}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 第二行:权限和删除 */}
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
className="flex-1 min-h-8"
|
||||
color="success"
|
||||
size="sm"
|
||||
startContent={<SettingsIcon className="w-3 h-3" />}
|
||||
variant="flat"
|
||||
onPress={() => handleManageTunnels(user)}
|
||||
>
|
||||
权限
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 min-h-8"
|
||||
color="danger"
|
||||
size="sm"
|
||||
startContent={<DeleteIcon className="w-3 h-3" />}
|
||||
variant="flat"
|
||||
onPress={() => handleDelete(user)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</StaggerItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</StaggerList>
|
||||
)}
|
||||
|
||||
{/* 用户表单模态框 */}
|
||||
<Modal
|
||||
< Modal
|
||||
backdrop="blur"
|
||||
isOpen={isUserModalOpen}
|
||||
placement="center"
|
||||
@@ -1788,6 +1790,6 @@ export default function UserPage() {
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</AnimatedPage>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user