mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-07 02:06:38 +08:00
feat: redesign forward card view completely syncing filters and sorti… (#179)
…ng logic
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";
|
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: {
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -90,9 +90,9 @@ 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,
|
||||||
)}
|
)}
|
||||||
data-slot="select-content"
|
data-slot="select-content"
|
||||||
@@ -104,7 +104,7 @@ function SelectContent({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"p-1",
|
"p-1",
|
||||||
position === "popper" &&
|
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}
|
{children}
|
||||||
|
|||||||
@@ -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"}`}
|
||||||
>
|
>
|
||||||
{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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,18 +248,16 @@ 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,
|
currentUserId,
|
||||||
currentUserId,
|
);
|
||||||
);
|
|
||||||
|
|
||||||
setForwardOrder(order);
|
setForwardOrder(order);
|
||||||
|
|
||||||
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(
|
||||||
|
(forward) => forward.tunnelId === selectedTunnelForExport,
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (forwardsToExport.length === 0) {
|
if (forwardsToExport.length === 0) {
|
||||||
toast.error("所选隧道没有转发数据");
|
toast.error("所选隧道没有转发数据");
|
||||||
@@ -1181,24 +1096,24 @@ export default function ForwardPage() {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 在平铺模式下,只显示当前用户的转发
|
|
||||||
let filteredForwards = forwards;
|
let filteredForwards = forwards;
|
||||||
|
|
||||||
if (viewMode === "direct") {
|
if (filterUserId !== "all") {
|
||||||
if (tokenUserId !== null) {
|
const targetUserId = parseInt(filterUserId);
|
||||||
filteredForwards = forwards.filter(
|
filteredForwards = filteredForwards.filter(f => f.userId === targetUserId || (targetUserId === 0 && !f.userId));
|
||||||
(forward) => forward.userId === tokenUserId,
|
}
|
||||||
);
|
if (filterTunnelId !== "all") {
|
||||||
}
|
const targetTunnelId = parseInt(filterTunnelId);
|
||||||
} else if (viewMode === "grouped") {
|
filteredForwards = filteredForwards.filter(f => f.tunnelId === targetTunnelId);
|
||||||
if (filterUserId !== "all") {
|
}
|
||||||
const targetUserId = parseInt(filterUserId);
|
|
||||||
filteredForwards = filteredForwards.filter(f => f.userId === targetUserId || (targetUserId === 0 && !f.userId));
|
if (searchKeyword.trim()) {
|
||||||
}
|
const lowerKeyword = searchKeyword.toLowerCase();
|
||||||
if (filterTunnelId !== "all") {
|
filteredForwards = filteredForwards.filter(f =>
|
||||||
const targetTunnelId = parseInt(filterTunnelId);
|
(f.name && f.name.toLowerCase().includes(lowerKeyword)) ||
|
||||||
filteredForwards = filteredForwards.filter(f => f.tunnelId === targetTunnelId);
|
(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,42 +1620,49 @@ 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="筛选条件"
|
className={filterUserId !== "all" || filterTunnelId !== "all" ? "bg-primary/20 text-primary relative" : "text-default-600 relative"}
|
||||||
className={filterUserId !== "all" || filterTunnelId !== "all" ? "bg-primary/20 text-primary relative" : "text-default-600 relative"}
|
color={filterUserId !== "all" || filterTunnelId !== "all" ? "primary" : "default"}
|
||||||
color={filterUserId !== "all" || filterTunnelId !== "all" ? "primary" : "default"}
|
size="sm"
|
||||||
size="sm"
|
title="筛选条件"
|
||||||
title="筛选条件"
|
variant="flat"
|
||||||
variant="flat"
|
onPress={() => setIsFilterModalOpen(true)}
|
||||||
onPress={() => setIsFilterModalOpen(true)}
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<svg
|
<path
|
||||||
aria-hidden="true"
|
strokeLinecap="round"
|
||||||
className="w-4 h-4"
|
strokeLinejoin="round"
|
||||||
fill="none"
|
strokeWidth={2}
|
||||||
stroke="currentColor"
|
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
|
||||||
viewBox="0 0 24 24"
|
/>
|
||||||
>
|
</svg>
|
||||||
<path
|
{(filterUserId !== "all" || filterTunnelId !== "all") && (
|
||||||
strokeLinecap="round"
|
<span className="absolute top-1.5 right-1.5 flex h-1.5 w-1.5 rounded-full bg-primary" />
|
||||||
strokeLinejoin="round"
|
)}
|
||||||
strokeWidth={2}
|
</Button>
|
||||||
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{(filterUserId !== "all" || filterTunnelId !== "all") && (
|
|
||||||
<span className="absolute top-1.5 right-1.5 flex h-1.5 w-1.5 rounded-full bg-primary" />
|
|
||||||
)}
|
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
{/* 版权信息 - 固定在底部,不占据布局空间 */}
|
{/* 版权信息 - 固定在底部,不占据布局空间 */}
|
||||||
|
|
||||||
@@ -253,9 +259,9 @@ export default function IndexPage() {
|
|||||||
theme: (document.documentElement.classList.contains(
|
theme: (document.documentElement.classList.contains(
|
||||||
"dark",
|
"dark",
|
||||||
) ||
|
) ||
|
||||||
document.documentElement.getAttribute("data-theme") ===
|
document.documentElement.getAttribute("data-theme") ===
|
||||||
"dark" ||
|
"dark" ||
|
||||||
window.matchMedia("(prefers-color-scheme: dark)").matches
|
window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
? "dark"
|
? "dark"
|
||||||
: "light") as "light" | "dark" | "auto",
|
: "light") as "light" | "dark" | "auto",
|
||||||
}}
|
}}
|
||||||
|
|||||||
+123
-102
@@ -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,108 +252,107 @@ 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">
|
||||||
>
|
<div className="flex justify-between items-start w-full">
|
||||||
<CardHeader className="pb-2 md:pb-2">
|
<div>
|
||||||
<div className="flex justify-between items-start w-full">
|
<h3 className="font-semibold text-foreground">
|
||||||
<div>
|
{rule.name}
|
||||||
<h3 className="font-semibold text-foreground">
|
</h3>
|
||||||
{rule.name}
|
</div>
|
||||||
</h3>
|
<Chip
|
||||||
</div>
|
color={rule.status === 1 ? "success" : "danger"}
|
||||||
<Chip
|
size="sm"
|
||||||
color={rule.status === 1 ? "success" : "danger"}
|
variant="flat"
|
||||||
size="sm"
|
>
|
||||||
variant="flat"
|
{rule.status === 1 ? "运行" : "异常"}
|
||||||
>
|
|
||||||
{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
|
|
||||||
</Chip>
|
</Chip>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
</CardHeader>
|
||||||
<span className="text-small text-default-600">
|
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3">
|
||||||
绑定隧道
|
<div className="space-y-3">
|
||||||
</span>
|
<div className="flex justify-between items-center">
|
||||||
{rule.tunnelName ? (
|
<span className="text-small text-default-600">
|
||||||
<Chip color="primary" size="sm" variant="flat">
|
速度限制
|
||||||
{rule.tunnelName}
|
|
||||||
</Chip>
|
|
||||||
) : (
|
|
||||||
<span className="text-default-400 text-small">
|
|
||||||
未绑定
|
|
||||||
</span>
|
</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>
|
|
||||||
|
|
||||||
<div className="flex gap-2 mt-4">
|
<div className="flex gap-2 mt-4">
|
||||||
<Button
|
<Button
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
color="primary"
|
color="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
startContent={
|
startContent={
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="w-4 h-4"
|
className="w-4 h-4"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
viewBox="0 0 20 20"
|
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" />
|
<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>
|
</svg>
|
||||||
}
|
}
|
||||||
variant="flat"
|
variant="flat"
|
||||||
onPress={() => handleEdit(rule)}
|
onPress={() => handleEdit(rule)}
|
||||||
>
|
>
|
||||||
编辑
|
编辑
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
color="danger"
|
color="danger"
|
||||||
size="sm"
|
size="sm"
|
||||||
startContent={
|
startContent={
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="w-4 h-4"
|
className="w-4 h-4"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
clipRule="evenodd"
|
clipRule="evenodd"
|
||||||
d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"
|
d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"
|
||||||
fillRule="evenodd"
|
fillRule="evenodd"
|
||||||
/>
|
/>
|
||||||
<path
|
<path
|
||||||
clipRule="evenodd"
|
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"
|
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"
|
fillRule="evenodd"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
}
|
}
|
||||||
variant="flat"
|
variant="flat"
|
||||||
onPress={() => handleDelete(rule)}
|
onPress={() => handleDelete(rule)}
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
</Button>
|
</Button>
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -821,23 +825,23 @@ export default function NodePage() {
|
|||||||
prev.map((n) =>
|
prev.map((n) =>
|
||||||
n.id === form.id
|
n.id === form.id
|
||||||
? {
|
? {
|
||||||
...n,
|
...n,
|
||||||
name: form.name,
|
name: form.name,
|
||||||
serverIp:
|
serverIp:
|
||||||
form.serverIpV4?.trim() ||
|
form.serverIpV4?.trim() ||
|
||||||
form.serverIpV6?.trim() ||
|
form.serverIpV6?.trim() ||
|
||||||
form.serverHost?.trim() ||
|
form.serverHost?.trim() ||
|
||||||
"",
|
"",
|
||||||
serverIpV4: form.serverIpV4,
|
serverIpV4: form.serverIpV4,
|
||||||
serverIpV6: form.serverIpV6,
|
serverIpV6: form.serverIpV6,
|
||||||
port: form.port,
|
port: form.port,
|
||||||
tcpListenAddr: form.tcpListenAddr,
|
tcpListenAddr: form.tcpListenAddr,
|
||||||
udpListenAddr: form.udpListenAddr,
|
udpListenAddr: form.udpListenAddr,
|
||||||
interfaceName: form.interfaceName,
|
interfaceName: form.interfaceName,
|
||||||
http: form.http,
|
http: form.http,
|
||||||
tls: form.tls,
|
tls: form.tls,
|
||||||
socks: form.socks,
|
socks: form.socks,
|
||||||
}
|
}
|
||||||
: n,
|
: n,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -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
|
||||||
@@ -1208,7 +1232,7 @@ export default function NodePage() {
|
|||||||
</span>
|
</span>
|
||||||
<div className="text-right text-xs min-w-0 flex-1 ml-2 min-h-[2.125rem]">
|
<div className="text-right text-xs min-w-0 flex-1 ml-2 min-h-[2.125rem]">
|
||||||
{node.serverIpV4?.trim() ||
|
{node.serverIpV4?.trim() ||
|
||||||
node.serverIpV6?.trim() ? (
|
node.serverIpV6?.trim() ? (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{node.serverIpV4?.trim() && (
|
{node.serverIpV4?.trim() && (
|
||||||
<span
|
<span
|
||||||
@@ -1264,7 +1288,7 @@ export default function NodePage() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="text-xs">
|
<span className="text-xs">
|
||||||
{node.connectionStatus === "online" &&
|
{node.connectionStatus === "online" &&
|
||||||
node.systemInfo
|
node.systemInfo
|
||||||
? formatUptime(node.systemInfo.uptime)
|
? formatUptime(node.systemInfo.uptime)
|
||||||
: "-"}
|
: "-"}
|
||||||
</span>
|
</span>
|
||||||
@@ -1283,7 +1307,7 @@ export default function NodePage() {
|
|||||||
<span>CPU</span>
|
<span>CPU</span>
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
{node.connectionStatus === "online" &&
|
{node.connectionStatus === "online" &&
|
||||||
node.systemInfo
|
node.systemInfo
|
||||||
? `${node.systemInfo.cpuUsage.toFixed(1)}%`
|
? `${node.systemInfo.cpuUsage.toFixed(1)}%`
|
||||||
: "-"}
|
: "-"}
|
||||||
</span>
|
</span>
|
||||||
@@ -1300,7 +1324,7 @@ export default function NodePage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
value={
|
value={
|
||||||
node.connectionStatus === "online" &&
|
node.connectionStatus === "online" &&
|
||||||
node.systemInfo
|
node.systemInfo
|
||||||
? node.systemInfo.cpuUsage
|
? node.systemInfo.cpuUsage
|
||||||
: 0
|
: 0
|
||||||
}
|
}
|
||||||
@@ -1311,7 +1335,7 @@ export default function NodePage() {
|
|||||||
<span>内存</span>
|
<span>内存</span>
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
{node.connectionStatus === "online" &&
|
{node.connectionStatus === "online" &&
|
||||||
node.systemInfo
|
node.systemInfo
|
||||||
? `${node.systemInfo.memoryUsage.toFixed(1)}%`
|
? `${node.systemInfo.memoryUsage.toFixed(1)}%`
|
||||||
: "-"}
|
: "-"}
|
||||||
</span>
|
</span>
|
||||||
@@ -1328,7 +1352,7 @@ export default function NodePage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
value={
|
value={
|
||||||
node.connectionStatus === "online" &&
|
node.connectionStatus === "online" &&
|
||||||
node.systemInfo
|
node.systemInfo
|
||||||
? node.systemInfo.memoryUsage
|
? node.systemInfo.memoryUsage
|
||||||
: 0
|
: 0
|
||||||
}
|
}
|
||||||
@@ -1343,10 +1367,10 @@ export default function NodePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="font-mono">
|
<div className="font-mono">
|
||||||
{node.connectionStatus === "online" &&
|
{node.connectionStatus === "online" &&
|
||||||
node.systemInfo
|
node.systemInfo
|
||||||
? formatSpeed(
|
? formatSpeed(
|
||||||
node.systemInfo.uploadSpeed,
|
node.systemInfo.uploadSpeed,
|
||||||
)
|
)
|
||||||
: "-"}
|
: "-"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1356,10 +1380,10 @@ export default function NodePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="font-mono">
|
<div className="font-mono">
|
||||||
{node.connectionStatus === "online" &&
|
{node.connectionStatus === "online" &&
|
||||||
node.systemInfo
|
node.systemInfo
|
||||||
? formatSpeed(
|
? formatSpeed(
|
||||||
node.systemInfo.downloadSpeed,
|
node.systemInfo.downloadSpeed,
|
||||||
)
|
)
|
||||||
: "-"}
|
: "-"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1373,10 +1397,10 @@ export default function NodePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="font-mono text-primary-700 dark:text-primary-300">
|
<div className="font-mono text-primary-700 dark:text-primary-300">
|
||||||
{node.connectionStatus === "online" &&
|
{node.connectionStatus === "online" &&
|
||||||
node.systemInfo
|
node.systemInfo
|
||||||
? formatTraffic(
|
? formatTraffic(
|
||||||
node.systemInfo.uploadTraffic,
|
node.systemInfo.uploadTraffic,
|
||||||
)
|
)
|
||||||
: "-"}
|
: "-"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1386,10 +1410,10 @@ export default function NodePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="font-mono text-success-700 dark:text-success-300">
|
<div className="font-mono text-success-700 dark:text-success-300">
|
||||||
{node.connectionStatus === "online" &&
|
{node.connectionStatus === "online" &&
|
||||||
node.systemInfo
|
node.systemInfo
|
||||||
? formatTraffic(
|
? formatTraffic(
|
||||||
node.systemInfo.downloadTraffic,
|
node.systemInfo.downloadTraffic,
|
||||||
)
|
)
|
||||||
: "-"}
|
: "-"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2098,6 +2122,6 @@ export default function NodePage() {
|
|||||||
)}
|
)}
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</AnimatedPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+217
-185
@@ -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,42 +611,71 @@ 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">
|
|
||||||
<div className="flex items-center gap-3 flex-1 max-w-md">
|
|
||||||
<Input
|
|
||||||
className="flex-1"
|
|
||||||
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)}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
|
||||||
/>
|
|
||||||
<Button
|
<Button
|
||||||
isIconOnly
|
isIconOnly
|
||||||
aria-label="搜索用户"
|
aria-label="搜索"
|
||||||
className="min-h-10 w-10"
|
className="text-default-600"
|
||||||
color="primary"
|
color="default"
|
||||||
variant="solid"
|
size="sm"
|
||||||
onPress={handleSearch}
|
variant="flat"
|
||||||
|
onPress={() => setIsSearchVisible(true)}
|
||||||
>
|
>
|
||||||
<SearchIcon className="w-4 h-4" />
|
<SearchIcon className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="flex w-full items-center gap-2 animate-appearance-in">
|
||||||
<Button color="primary" variant="flat" onPress={handleAdd}>
|
<Input
|
||||||
新增
|
autoFocus
|
||||||
</Button>
|
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)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
|
||||||
|
新增
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 用户列表 */}
|
{/* 用户列表 */}
|
||||||
@@ -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,173 +709,174 @@ export default function UserPage() {
|
|||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<StaggerItem key={user.id}>
|
||||||
key={user.id}
|
<Card
|
||||||
className="shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden"
|
className="shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden h-full"
|
||||||
>
|
>
|
||||||
<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 className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-semibold text-foreground truncate text-sm">
|
<h3 className="font-semibold text-foreground truncate text-sm">
|
||||||
{user.name || user.user}
|
{user.name || user.user}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-default-500 truncate">
|
<p className="text-xs text-default-500 truncate">
|
||||||
@{user.user}
|
@{user.user}
|
||||||
</p>
|
</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>
|
||||||
<div className="flex items-center gap-1.5 ml-2">
|
</CardHeader>
|
||||||
<Chip
|
|
||||||
className="text-xs"
|
|
||||||
color={userStatus.color}
|
|
||||||
size="sm"
|
|
||||||
variant="flat"
|
|
||||||
>
|
|
||||||
{userStatus.text}
|
|
||||||
</Chip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3">
|
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* 流量信息 */}
|
{/* 流量信息 */}
|
||||||
<div className="space-y-1.5">
|
<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 && (
|
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-default-600">过期时间</span>
|
<span className="text-default-600">流量限制</span>
|
||||||
<div className="text-right">
|
<span className="font-medium text-xs">
|
||||||
{expStatus && expStatus.color === "success" ? (
|
{formatFlow(user.flow, "gb")}
|
||||||
<div className="text-xs">
|
</span>
|
||||||
{formatDate(user.expTime)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Chip
|
|
||||||
className="text-xs"
|
|
||||||
color={expStatus?.color || "default"}
|
|
||||||
size="sm"
|
|
||||||
variant="flat"
|
|
||||||
>
|
|
||||||
{expStatus?.text || "未知状态"}
|
|
||||||
</Chip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="flex justify-between text-sm">
|
||||||
</div>
|
<span className="text-default-600">已使用</span>
|
||||||
</div>
|
<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="space-y-1.5 pt-2 border-t border-divider">
|
||||||
<div className="flex gap-1.5">
|
<div className="flex justify-between text-sm">
|
||||||
<Button
|
<span className="text-default-600">转发数量</span>
|
||||||
className="flex-1 min-h-8"
|
<span className="font-medium text-xs">{user.num}</span>
|
||||||
color="primary"
|
</div>
|
||||||
size="sm"
|
<div className="flex justify-between text-sm">
|
||||||
startContent={<EditIcon className="w-3 h-3" />}
|
<span className="text-default-600">重置日期</span>
|
||||||
variant="flat"
|
<span className="text-xs">
|
||||||
onPress={() => handleEdit(user)}
|
{user.flowResetTime === 0
|
||||||
>
|
? "不重置"
|
||||||
编辑
|
: `每月${user.flowResetTime}号`}
|
||||||
</Button>
|
</span>
|
||||||
<Button
|
</div>
|
||||||
className="flex-1 min-h-8"
|
{user.expTime && (
|
||||||
color="warning"
|
<div className="flex justify-between text-sm">
|
||||||
size="sm"
|
<span className="text-default-600">过期时间</span>
|
||||||
startContent={
|
<div className="text-right">
|
||||||
<svg
|
{expStatus && expStatus.color === "success" ? (
|
||||||
aria-hidden="true"
|
<div className="text-xs">
|
||||||
className="w-3 h-3"
|
{formatDate(user.expTime)}
|
||||||
fill="currentColor"
|
</div>
|
||||||
viewBox="0 0 20 20"
|
) : (
|
||||||
>
|
<Chip
|
||||||
<path
|
className="text-xs"
|
||||||
clipRule="evenodd"
|
color={expStatus?.color || "default"}
|
||||||
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"
|
size="sm"
|
||||||
fillRule="evenodd"
|
variant="flat"
|
||||||
/>
|
>
|
||||||
</svg>
|
{expStatus?.text || "未知状态"}
|
||||||
}
|
</Chip>
|
||||||
variant="flat"
|
)}
|
||||||
onPress={() => handleResetFlow(user)}
|
</div>
|
||||||
>
|
</div>
|
||||||
重置
|
)}
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 第二行:权限和删除 */}
|
<div className="space-y-1.5 mt-3">
|
||||||
<div className="flex gap-1.5">
|
{/* 第一行:编辑和重置 */}
|
||||||
<Button
|
<div className="flex gap-1.5">
|
||||||
className="flex-1 min-h-8"
|
<Button
|
||||||
color="success"
|
className="flex-1 min-h-8"
|
||||||
size="sm"
|
color="primary"
|
||||||
startContent={<SettingsIcon className="w-3 h-3" />}
|
size="sm"
|
||||||
variant="flat"
|
startContent={<EditIcon className="w-3 h-3" />}
|
||||||
onPress={() => handleManageTunnels(user)}
|
variant="flat"
|
||||||
>
|
onPress={() => handleEdit(user)}
|
||||||
权限
|
>
|
||||||
</Button>
|
编辑
|
||||||
<Button
|
</Button>
|
||||||
className="flex-1 min-h-8"
|
<Button
|
||||||
color="danger"
|
className="flex-1 min-h-8"
|
||||||
size="sm"
|
color="warning"
|
||||||
startContent={<DeleteIcon className="w-3 h-3" />}
|
size="sm"
|
||||||
variant="flat"
|
startContent={
|
||||||
onPress={() => handleDelete(user)}
|
<svg
|
||||||
>
|
aria-hidden="true"
|
||||||
删除
|
className="w-3 h-3"
|
||||||
</Button>
|
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>
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user