mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-08 02:36:37 +08:00
fix(frontend): ship pending card-view animation and interaction refinements
This commit is contained in:
@@ -6,21 +6,21 @@ import React from "react";
|
|||||||
* Use this as the outermost wrapper inside each page component.
|
* Use this as the outermost wrapper inside each page component.
|
||||||
*/
|
*/
|
||||||
export const AnimatedPage = ({
|
export const AnimatedPage = ({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) => (
|
}) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className={className}
|
className={className}
|
||||||
exit={{ opacity: 0, y: -8 }}
|
exit={{ opacity: 0, y: -8 }}
|
||||||
initial={{ opacity: 0, y: 16 }}
|
initial={{ opacity: 0, y: 16 }}
|
||||||
transition={{ duration: 0.28, ease: [0.25, 0.46, 0.45, 0.94] }}
|
transition={{ duration: 0.28, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,26 +28,26 @@ export const AnimatedPage = ({
|
|||||||
* Children should use `staggerItem` as their `variants` prop.
|
* Children should use `staggerItem` as their `variants` prop.
|
||||||
*/
|
*/
|
||||||
export const staggerContainer = {
|
export const staggerContainer = {
|
||||||
hidden: { opacity: 0 },
|
hidden: { opacity: 0 },
|
||||||
show: {
|
show: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
transition: {
|
transition: {
|
||||||
staggerChildren: 0.05,
|
staggerChildren: 0.05,
|
||||||
delayChildren: 0.05,
|
delayChildren: 0.05,
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Individual stagger item variant — fade-in + slide-up.
|
* Individual stagger item variant — fade-in + slide-up.
|
||||||
*/
|
*/
|
||||||
export const staggerItem = {
|
export const staggerItem = {
|
||||||
hidden: { opacity: 0, y: 12 },
|
hidden: { opacity: 0, y: 12 },
|
||||||
show: {
|
show: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
y: 0,
|
y: 0,
|
||||||
transition: { duration: 0.25, ease: [0.25, 0.46, 0.45, 0.94] },
|
transition: { duration: 0.25, ease: [0.25, 0.46, 0.45, 0.94] },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,47 +55,47 @@ export const staggerItem = {
|
|||||||
* Renders a `motion.div` (or `motion.ul/motion.tbody`) with stagger behaviour.
|
* Renders a `motion.div` (or `motion.ul/motion.tbody`) with stagger behaviour.
|
||||||
*/
|
*/
|
||||||
export const StaggerList = ({
|
export const StaggerList = ({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
as = "div",
|
as = "div",
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
as?: "div" | "ul" | "tbody";
|
as?: "div" | "ul" | "tbody";
|
||||||
}) => {
|
}) => {
|
||||||
const Component = motion[as] as React.ElementType;
|
const Component = motion[as] as React.ElementType;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Component
|
<Component
|
||||||
animate="show"
|
animate="show"
|
||||||
className={className}
|
className={className}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
variants={staggerContainer}
|
variants={staggerContainer}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Component>
|
</Component>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Individual animated item for use inside StaggerList.
|
* Individual animated item for use inside StaggerList.
|
||||||
*/
|
*/
|
||||||
export const StaggerItem = ({
|
export const StaggerItem = ({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
as = "div",
|
as = "div",
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
as?: "div" | "li" | "tr";
|
as?: "div" | "li" | "tr";
|
||||||
}) => {
|
}) => {
|
||||||
const Component = motion[as] as React.ElementType;
|
const Component = motion[as] as React.ElementType;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Component className={className} variants={staggerItem}>
|
<Component className={className} variants={staggerItem}>
|
||||||
{children}
|
{children}
|
||||||
</Component>
|
</Component>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -103,20 +103,20 @@ export const StaggerItem = ({
|
|||||||
* Uses opacity + translateY to avoid font blurriness caused by scale transforms.
|
* Uses opacity + translateY to avoid font blurriness caused by scale transforms.
|
||||||
*/
|
*/
|
||||||
export const FadeIn = ({
|
export const FadeIn = ({
|
||||||
children,
|
children,
|
||||||
delay = 0,
|
delay = 0,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
delay?: number;
|
delay?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) => (
|
}) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className={className}
|
className={className}
|
||||||
initial={{ opacity: 0, y: 8 }}
|
initial={{ opacity: 0, y: 8 }}
|
||||||
transition={{ duration: 0.2, delay, ease: "easeOut" }}
|
transition={{ duration: 0.2, delay, ease: "easeOut" }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,97 +5,97 @@ import { Input } from "@/shadcn-bridge/heroui/input";
|
|||||||
import { SearchIcon } from "@/components/icons";
|
import { SearchIcon } from "@/components/icons";
|
||||||
|
|
||||||
interface SearchBarProps {
|
interface SearchBarProps {
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
value: string;
|
value: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
onOpen: () => void;
|
onOpen: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SearchBar({
|
export function SearchBar({
|
||||||
isVisible,
|
isVisible,
|
||||||
value,
|
value,
|
||||||
placeholder = "搜索",
|
placeholder = "搜索",
|
||||||
onOpen,
|
onOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onChange,
|
onChange,
|
||||||
}: SearchBarProps) {
|
}: SearchBarProps) {
|
||||||
return (
|
return (
|
||||||
// Fixed h-8 so the container never changes height — eliminates the vertical jitter
|
// Fixed h-8 so the container never changes height — eliminates the vertical jitter
|
||||||
<div className="flex items-center gap-2 h-8 overflow-hidden">
|
<div className="flex items-center gap-2 h-8 overflow-hidden">
|
||||||
<AnimatePresence mode="wait" initial={false}>
|
<AnimatePresence initial={false} mode="wait">
|
||||||
{!isVisible ? (
|
{!isVisible ? (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="search-btn"
|
key="search-btn"
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.12 }}
|
transition={{ duration: 0.12 }}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
isIconOnly
|
isIconOnly
|
||||||
aria-label="搜索"
|
aria-label="搜索"
|
||||||
className="text-default-600"
|
className="text-default-600"
|
||||||
color="default"
|
color="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
onPress={onOpen}
|
onPress={onOpen}
|
||||||
>
|
>
|
||||||
<SearchIcon className="w-4 h-4" />
|
<SearchIcon className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : (
|
) : (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="search-input"
|
key="search-input"
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
className="flex w-full items-center gap-2"
|
className="flex w-full items-center gap-2"
|
||||||
exit={{ opacity: 0, x: -8 }}
|
exit={{ opacity: 0, x: -8 }}
|
||||||
initial={{ opacity: 0, x: -16 }}
|
initial={{ opacity: 0, x: -16 }}
|
||||||
transition={{ duration: 0.18, ease: [0.25, 0.46, 0.45, 0.94] }}
|
transition={{ duration: 0.18, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
autoFocus
|
autoFocus
|
||||||
classNames={{
|
classNames={{
|
||||||
base: "bg-default-100",
|
base: "bg-default-100",
|
||||||
input: "bg-transparent text-sm focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none",
|
input:
|
||||||
inputWrapper:
|
"bg-transparent text-sm focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none",
|
||||||
"bg-default-100 border-0 shadow-none h-8 min-h-8",
|
inputWrapper: "bg-default-100 border-0 shadow-none h-8 min-h-8",
|
||||||
}}
|
}}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
isIconOnly
|
isIconOnly
|
||||||
aria-label="关闭搜索"
|
aria-label="关闭搜索"
|
||||||
className="text-default-600 shrink-0"
|
className="text-default-600 shrink-0"
|
||||||
color="default"
|
color="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="light"
|
variant="light"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
onClose();
|
onClose();
|
||||||
onChange("");
|
onChange("");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="w-4 h-4"
|
className="w-4 h-4"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M6 18L18 6M6 6l12 12"
|
d="M6 18L18 6M6 6l12 12"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ function SelectContent({
|
|||||||
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 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",
|
"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}
|
||||||
|
|||||||
@@ -340,10 +340,11 @@ export default function AdminLayout({
|
|||||||
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
|
||||||
relative min-h-[44px]
|
relative min-h-[44px]
|
||||||
${isActive
|
${
|
||||||
? "text-primary-600 dark:text-primary-300"
|
isActive
|
||||||
: "text-gray-700 dark:text-gray-200"
|
? "text-primary-600 dark:text-primary-300"
|
||||||
}
|
: "text-gray-700 dark:text-gray-200"
|
||||||
|
}
|
||||||
`}
|
`}
|
||||||
transition={{ duration: 0.15 }}
|
transition={{ duration: 0.15 }}
|
||||||
onClick={() => handleMenuClick(item.path)}
|
onClick={() => handleMenuClick(item.path)}
|
||||||
@@ -352,7 +353,11 @@ export default function AdminLayout({
|
|||||||
<motion.div
|
<motion.div
|
||||||
className="absolute inset-0 rounded-lg bg-primary-100 dark:bg-primary-600/20"
|
className="absolute inset-0 rounded-lg bg-primary-100 dark:bg-primary-600/20"
|
||||||
layoutId="sidebar-active"
|
layoutId="sidebar-active"
|
||||||
transition={{ type: "spring", stiffness: 380, damping: 30 }}
|
transition={{
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 380,
|
||||||
|
damping: 30,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!isActive && (
|
{!isActive && (
|
||||||
@@ -362,8 +367,12 @@ export default function AdminLayout({
|
|||||||
whileHover={{ opacity: 1 }}
|
whileHover={{ opacity: 1 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex-shrink-0 relative z-10">{item.icon}</div>
|
<div className="flex-shrink-0 relative z-10">
|
||||||
<span className="font-medium text-sm relative z-10">{item.label}</span>
|
{item.icon}
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-sm relative z-10">
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
</motion.button>
|
</motion.button>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||||
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";
|
||||||
@@ -16,8 +18,6 @@ import {
|
|||||||
ModalFooter,
|
ModalFooter,
|
||||||
ModalHeader,
|
ModalHeader,
|
||||||
} from "@/shadcn-bridge/heroui/modal";
|
} from "@/shadcn-bridge/heroui/modal";
|
||||||
import toast from "react-hot-toast";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
updateConfigs,
|
updateConfigs,
|
||||||
exportBackup,
|
exportBackup,
|
||||||
@@ -149,7 +149,7 @@ const getInitialConfigs = (): Record<string, string> => {
|
|||||||
initialConfigs[key] = cachedValue;
|
initialConfigs[key] = cachedValue;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch { }
|
} catch {}
|
||||||
|
|
||||||
return initialConfigs;
|
return initialConfigs;
|
||||||
};
|
};
|
||||||
@@ -528,10 +528,11 @@ export default function ConfigPage() {
|
|||||||
<button
|
<button
|
||||||
key={option.value}
|
key={option.value}
|
||||||
aria-pressed={isSelected}
|
aria-pressed={isSelected}
|
||||||
className={`w-full px-4 py-3 rounded-lg border transition-all duration-200 cursor-pointer text-left ${isSelected
|
className={`w-full px-4 py-3 rounded-lg border transition-all duration-200 cursor-pointer text-left ${
|
||||||
? "bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-500/50 shadow-sm"
|
isSelected
|
||||||
: "bg-white dark:bg-default-50 border-default-200 dark:border-default-100/30 hover:border-primary-200 dark:hover:border-primary-500/30 hover:shadow-sm"
|
? "bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-500/50 shadow-sm"
|
||||||
}`}
|
: "bg-white dark:bg-default-50 border-default-200 dark:border-default-100/30 hover:border-primary-200 dark:hover:border-primary-500/30 hover:shadow-sm"
|
||||||
|
}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleTypeSelection(option.value, setTypes)}
|
onClick={() => toggleTypeSelection(option.value, setTypes)}
|
||||||
>
|
>
|
||||||
@@ -545,10 +546,11 @@ export default function ConfigPage() {
|
|||||||
size="md"
|
size="md"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={`font-medium ${isSelected
|
className={`font-medium ${
|
||||||
? "text-default-900 dark:text-default-100"
|
isSelected
|
||||||
: "text-default-700 dark:text-default-500"
|
? "text-default-900 dark:text-default-100"
|
||||||
}`}
|
: "text-default-700 dark:text-default-500"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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 { 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";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { AnnouncementData } from "@/api";
|
import type { AnnouncementData } from "@/api";
|
||||||
|
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import rehypeSanitize from "rehype-sanitize";
|
import rehypeSanitize from "rehype-sanitize";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
@@ -40,10 +41,10 @@ export const AnnouncementBanner = ({
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="text-sm text-blue-800/90 dark:text-blue-100/90 break-words leading-relaxed">
|
<div className="text-sm text-blue-800/90 dark:text-blue-100/90 break-words leading-relaxed">
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
rehypePlugins={[rehypeSanitize]}
|
|
||||||
remarkPlugins={[remarkGfm]}
|
|
||||||
components={{
|
components={{
|
||||||
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
|
p: ({ children }) => (
|
||||||
|
<p className="mb-2 last:mb-0">{children}</p>
|
||||||
|
),
|
||||||
a: ({ children, href }) => (
|
a: ({ children, href }) => (
|
||||||
<a
|
<a
|
||||||
className="underline decoration-blue-500/70 underline-offset-2 hover:text-blue-700 dark:hover:text-blue-100"
|
className="underline decoration-blue-500/70 underline-offset-2 hover:text-blue-700 dark:hover:text-blue-100"
|
||||||
@@ -55,10 +56,14 @@ export const AnnouncementBanner = ({
|
|||||||
</a>
|
</a>
|
||||||
),
|
),
|
||||||
ul: ({ children }) => (
|
ul: ({ children }) => (
|
||||||
<ul className="list-disc pl-5 space-y-1 mb-2 last:mb-0">{children}</ul>
|
<ul className="list-disc pl-5 space-y-1 mb-2 last:mb-0">
|
||||||
|
{children}
|
||||||
|
</ul>
|
||||||
),
|
),
|
||||||
ol: ({ children }) => (
|
ol: ({ children }) => (
|
||||||
<ol className="list-decimal pl-5 space-y-1 mb-2 last:mb-0">{children}</ol>
|
<ol className="list-decimal pl-5 space-y-1 mb-2 last:mb-0">
|
||||||
|
{children}
|
||||||
|
</ol>
|
||||||
),
|
),
|
||||||
code: ({ children }) => (
|
code: ({ children }) => (
|
||||||
<code className="rounded bg-blue-100/80 dark:bg-blue-900/40 px-1 py-0.5 text-[0.92em]">
|
<code className="rounded bg-blue-100/80 dark:bg-blue-900/40 px-1 py-0.5 text-[0.92em]">
|
||||||
@@ -76,6 +81,8 @@ export const AnnouncementBanner = ({
|
|||||||
</blockquote>
|
</blockquote>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
|
rehypePlugins={[rehypeSanitize]}
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
>
|
>
|
||||||
{announcement.content}
|
{announcement.content}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
|
|||||||
+248
-142
@@ -1,7 +1,5 @@
|
|||||||
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,
|
||||||
@@ -22,6 +20,8 @@ import {
|
|||||||
import { useSortable } from "@dnd-kit/sortable";
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
|
||||||
|
import { SearchBar } from "@/components/search-bar";
|
||||||
|
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";
|
||||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||||
@@ -121,8 +121,6 @@ interface ForwardForm {
|
|||||||
strategy: string;
|
strategy: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
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[]>([]);
|
||||||
@@ -226,7 +224,7 @@ export default function ForwardPage() {
|
|||||||
setViewMode(newMode);
|
setViewMode(newMode);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem("forward-view-mode", newMode);
|
localStorage.setItem("forward-view-mode", newMode);
|
||||||
} catch { }
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 加载所有数据
|
// 加载所有数据
|
||||||
@@ -274,8 +272,6 @@ export default function ForwardPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 表单验证
|
// 表单验证
|
||||||
const validateForm = (): boolean => {
|
const validateForm = (): boolean => {
|
||||||
const newErrors: { [key: string]: string } = {};
|
const newErrors: { [key: string]: string } = {};
|
||||||
@@ -1100,19 +1096,27 @@ export default function ForwardPage() {
|
|||||||
|
|
||||||
if (filterUserId !== "all") {
|
if (filterUserId !== "all") {
|
||||||
const targetUserId = parseInt(filterUserId);
|
const targetUserId = parseInt(filterUserId);
|
||||||
filteredForwards = filteredForwards.filter(f => f.userId === targetUserId || (targetUserId === 0 && !f.userId));
|
|
||||||
|
filteredForwards = filteredForwards.filter(
|
||||||
|
(f) => f.userId === targetUserId || (targetUserId === 0 && !f.userId),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (filterTunnelId !== "all") {
|
if (filterTunnelId !== "all") {
|
||||||
const targetTunnelId = parseInt(filterTunnelId);
|
const targetTunnelId = parseInt(filterTunnelId);
|
||||||
filteredForwards = filteredForwards.filter(f => f.tunnelId === targetTunnelId);
|
|
||||||
|
filteredForwards = filteredForwards.filter(
|
||||||
|
(f) => f.tunnelId === targetTunnelId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchKeyword.trim()) {
|
if (searchKeyword.trim()) {
|
||||||
const lowerKeyword = searchKeyword.toLowerCase();
|
const lowerKeyword = searchKeyword.toLowerCase();
|
||||||
filteredForwards = filteredForwards.filter(f =>
|
|
||||||
(f.name && f.name.toLowerCase().includes(lowerKeyword)) ||
|
filteredForwards = filteredForwards.filter(
|
||||||
(f.remoteAddr && f.remoteAddr.toLowerCase().includes(lowerKeyword)) ||
|
(f) =>
|
||||||
(f.userName && f.userName.toLowerCase().includes(lowerKeyword))
|
(f.name && f.name.toLowerCase().includes(lowerKeyword)) ||
|
||||||
|
(f.remoteAddr && f.remoteAddr.toLowerCase().includes(lowerKeyword)) ||
|
||||||
|
(f.userName && f.userName.toLowerCase().includes(lowerKeyword)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1157,7 +1161,15 @@ export default function ForwardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return sortedByDb;
|
return sortedByDb;
|
||||||
}, [forwards, forwardOrder, viewMode, tokenUserId, filterUserId, filterTunnelId, searchKeyword]);
|
}, [
|
||||||
|
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),
|
||||||
@@ -1195,8 +1207,10 @@ export default function ForwardPage() {
|
|||||||
// 生成用作筛选项的用户和隧道列表
|
// 生成用作筛选项的用户和隧道列表
|
||||||
const uniqueUsers = useMemo(() => {
|
const uniqueUsers = useMemo(() => {
|
||||||
const userMap = new Map<number, { id: number; name: string }>();
|
const userMap = new Map<number, { id: number; name: string }>();
|
||||||
|
|
||||||
forwards.forEach((f) => {
|
forwards.forEach((f) => {
|
||||||
const uId = f.userId || 0;
|
const uId = f.userId || 0;
|
||||||
|
|
||||||
if (!userMap.has(uId)) {
|
if (!userMap.has(uId)) {
|
||||||
userMap.set(uId, { id: uId, name: f.userName || "未知用户" });
|
userMap.set(uId, { id: uId, name: f.userName || "未知用户" });
|
||||||
}
|
}
|
||||||
@@ -1206,7 +1220,22 @@ export default function ForwardPage() {
|
|||||||
}, [forwards]);
|
}, [forwards]);
|
||||||
|
|
||||||
// 可拖拽的表格行组件
|
// 可拖拽的表格行组件
|
||||||
const SortableTableRow = ({ forward, selectMode, selectedIds, toggleSelect, getStrategyDisplay, formatInAddress, formatRemoteAddress, handleServiceToggle, handleEdit, handleDelete, handleDiagnose, showAddressModal, hasMultipleAddresses, formatFlow }: any) => {
|
const SortableTableRow = ({
|
||||||
|
forward,
|
||||||
|
selectMode,
|
||||||
|
selectedIds,
|
||||||
|
toggleSelect,
|
||||||
|
getStrategyDisplay,
|
||||||
|
formatInAddress,
|
||||||
|
formatRemoteAddress,
|
||||||
|
handleServiceToggle,
|
||||||
|
handleEdit,
|
||||||
|
handleDelete,
|
||||||
|
handleDiagnose,
|
||||||
|
showAddressModal,
|
||||||
|
hasMultipleAddresses,
|
||||||
|
formatFlow,
|
||||||
|
}: any) => {
|
||||||
const {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
listeners,
|
listeners,
|
||||||
@@ -1226,7 +1255,7 @@ export default function ForwardPage() {
|
|||||||
const strategyDisplay = getStrategyDisplay(forward.strategy);
|
const strategyDisplay = getStrategyDisplay(forward.strategy);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow ref={setNodeRef} style={style} key={forward.id}>
|
<TableRow key={forward.id} ref={setNodeRef} style={style}>
|
||||||
{selectMode && (
|
{selectMode && (
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -1242,7 +1271,12 @@ export default function ForwardPage() {
|
|||||||
{...listeners}
|
{...listeners}
|
||||||
title="拖拽排序"
|
title="拖拽排序"
|
||||||
>
|
>
|
||||||
<svg aria-hidden="true" className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
<path d="M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z" />
|
<path d="M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -1254,11 +1288,13 @@ export default function ForwardPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="whitespace-nowrap">
|
<TableCell className="whitespace-nowrap">
|
||||||
<Chip
|
<Chip
|
||||||
size="sm"
|
|
||||||
color="secondary"
|
|
||||||
className="border-none bg-secondary/10 px-2"
|
className="border-none bg-secondary/10 px-2"
|
||||||
|
color="secondary"
|
||||||
|
size="sm"
|
||||||
>
|
>
|
||||||
<span className="font-medium text-secondary-700">{forward.tunnelName}</span>
|
<span className="font-medium text-secondary-700">
|
||||||
|
{forward.tunnelName}
|
||||||
|
</span>
|
||||||
</Chip>
|
</Chip>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="whitespace-nowrap font-semibold text-foreground">
|
<TableCell className="whitespace-nowrap font-semibold text-foreground">
|
||||||
@@ -1266,10 +1302,11 @@ export default function ForwardPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="max-w-[220px]">
|
<TableCell className="max-w-[220px]">
|
||||||
<button
|
<button
|
||||||
className={`w-full truncate rounded-md bg-default-100/50 px-2.5 py-1.5 text-left font-mono text-xs font-medium text-default-700 transition-all ${hasMultipleAddresses(forward.inIp)
|
className={`w-full truncate rounded-md bg-default-100/50 px-2.5 py-1.5 text-left font-mono text-xs font-medium text-default-700 transition-all ${
|
||||||
? "hover:bg-default-200 hover:shadow-sm"
|
hasMultipleAddresses(forward.inIp)
|
||||||
: ""
|
? "hover:bg-default-200 hover:shadow-sm"
|
||||||
}`}
|
: ""
|
||||||
|
}`}
|
||||||
title={formatInAddress(forward.inIp, forward.inPort)}
|
title={formatInAddress(forward.inIp, forward.inPort)}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -1281,10 +1318,11 @@ export default function ForwardPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="max-w-[240px]">
|
<TableCell className="max-w-[240px]">
|
||||||
<button
|
<button
|
||||||
className={`w-full truncate rounded-md bg-default-100/50 px-2.5 py-1.5 text-left font-mono text-xs font-medium text-default-700 transition-all ${hasMultipleAddresses(forward.remoteAddr)
|
className={`w-full truncate rounded-md bg-default-100/50 px-2.5 py-1.5 text-left font-mono text-xs font-medium text-default-700 transition-all ${
|
||||||
? "hover:bg-default-200 hover:shadow-sm"
|
hasMultipleAddresses(forward.remoteAddr)
|
||||||
: ""
|
? "hover:bg-default-200 hover:shadow-sm"
|
||||||
}`}
|
: ""
|
||||||
|
}`}
|
||||||
title={formatRemoteAddress(forward.remoteAddr)}
|
title={formatRemoteAddress(forward.remoteAddr)}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -1312,10 +1350,10 @@ export default function ForwardPage() {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2.5 whitespace-nowrap">
|
<div className="flex items-center gap-2.5 whitespace-nowrap">
|
||||||
<Switch
|
<Switch
|
||||||
|
color="success"
|
||||||
isDisabled={forward.status !== 1 && forward.status !== 0}
|
isDisabled={forward.status !== 1 && forward.status !== 0}
|
||||||
isSelected={forward.serviceRunning}
|
isSelected={forward.serviceRunning}
|
||||||
size="sm"
|
size="sm"
|
||||||
color="success"
|
|
||||||
onValueChange={() => handleServiceToggle(forward)}
|
onValueChange={() => handleServiceToggle(forward)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1329,8 +1367,18 @@ export default function ForwardPage() {
|
|||||||
title="编辑"
|
title="编辑"
|
||||||
onPress={() => handleEdit(forward)}
|
onPress={() => handleEdit(forward)}
|
||||||
>
|
>
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -1340,8 +1388,18 @@ export default function ForwardPage() {
|
|||||||
title="诊断"
|
title="诊断"
|
||||||
onPress={() => handleDiagnose(forward)}
|
onPress={() => handleDiagnose(forward)}
|
||||||
>
|
>
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -1351,8 +1409,18 @@ export default function ForwardPage() {
|
|||||||
title="删除"
|
title="删除"
|
||||||
onPress={() => handleDelete(forward)}
|
onPress={() => handleDelete(forward)}
|
||||||
>
|
>
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1391,10 +1459,11 @@ export default function ForwardPage() {
|
|||||||
<div className="flex items-center gap-1.5 ml-2">
|
<div className="flex items-center gap-1.5 ml-2">
|
||||||
{viewMode === "direct" && (
|
{viewMode === "direct" && (
|
||||||
<div
|
<div
|
||||||
className={`cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation ${isMobile
|
className={`cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation ${
|
||||||
? "opacity-100" // 移动端始终显示
|
isMobile
|
||||||
: "opacity-0 group-hover:opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
|
? "opacity-100" // 移动端始终显示
|
||||||
}`}
|
: "opacity-0 group-hover:opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
|
||||||
|
}`}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
style={{ touchAction: "none" }}
|
style={{ touchAction: "none" }}
|
||||||
title={isMobile ? "长按拖拽排序" : "拖拽排序"}
|
title={isMobile ? "长按拖拽排序" : "拖拽排序"}
|
||||||
@@ -1432,10 +1501,11 @@ export default function ForwardPage() {
|
|||||||
{/* 地址信息 */}
|
{/* 地址信息 */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<button
|
<button
|
||||||
className={`cursor-pointer px-2 py-1 bg-default-50 dark:bg-default-100/50 rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${hasMultipleAddresses(forward.inIp)
|
className={`cursor-pointer px-2 py-1 bg-default-50 dark:bg-default-100/50 rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${
|
||||||
? "hover:bg-default-100 dark:hover:bg-default-200/50"
|
hasMultipleAddresses(forward.inIp)
|
||||||
: ""
|
? "hover:bg-default-100 dark:hover:bg-default-200/50"
|
||||||
}`}
|
: ""
|
||||||
|
}`}
|
||||||
title={formatInAddress(forward.inIp, forward.inPort)}
|
title={formatInAddress(forward.inIp, forward.inPort)}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -1471,10 +1541,11 @@ export default function ForwardPage() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className={`cursor-pointer px-2 py-1 bg-default-50 dark:bg-default-100/50 rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${hasMultipleAddresses(forward.remoteAddr)
|
className={`cursor-pointer px-2 py-1 bg-default-50 dark:bg-default-100/50 rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${
|
||||||
? "hover:bg-default-100 dark:hover:bg-default-200/50"
|
hasMultipleAddresses(forward.remoteAddr)
|
||||||
: ""
|
? "hover:bg-default-100 dark:hover:bg-default-200/50"
|
||||||
}`}
|
: ""
|
||||||
|
}`}
|
||||||
title={formatRemoteAddress(forward.remoteAddr)}
|
title={formatRemoteAddress(forward.remoteAddr)}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -1641,8 +1712,16 @@ export default function ForwardPage() {
|
|||||||
<Button
|
<Button
|
||||||
isIconOnly
|
isIconOnly
|
||||||
aria-label="筛选条件"
|
aria-label="筛选条件"
|
||||||
className={filterUserId !== "all" || filterTunnelId !== "all" ? "bg-primary/20 text-primary relative" : "text-default-600 relative"}
|
className={
|
||||||
color={filterUserId !== "all" || filterTunnelId !== "all" ? "primary" : "default"}
|
filterUserId !== "all" || filterTunnelId !== "all"
|
||||||
|
? "bg-primary/20 text-primary relative"
|
||||||
|
: "text-default-600 relative"
|
||||||
|
}
|
||||||
|
color={
|
||||||
|
filterUserId !== "all" || filterTunnelId !== "all"
|
||||||
|
? "primary"
|
||||||
|
: "default"
|
||||||
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
title="筛选条件"
|
title="筛选条件"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
@@ -1656,10 +1735,10 @@ export default function ForwardPage() {
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
|
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"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
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>
|
</svg>
|
||||||
{(filterUserId !== "all" || filterTunnelId !== "all") && (
|
{(filterUserId !== "all" || filterTunnelId !== "all") && (
|
||||||
@@ -1827,8 +1906,10 @@ export default function ForwardPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
{selectMode && <TableColumn className="w-14">选择</TableColumn>}
|
{selectMode && (
|
||||||
<TableColumn className="w-10 pl-4"></TableColumn>
|
<TableColumn className="w-14">选择</TableColumn>
|
||||||
|
)}
|
||||||
|
<TableColumn className="w-10 pl-4" />
|
||||||
<TableColumn>用户</TableColumn>
|
<TableColumn>用户</TableColumn>
|
||||||
<TableColumn>隧道</TableColumn>
|
<TableColumn>隧道</TableColumn>
|
||||||
<TableColumn>名称</TableColumn>
|
<TableColumn>名称</TableColumn>
|
||||||
@@ -1841,22 +1922,26 @@ export default function ForwardPage() {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody emptyContent="暂无转发配置" items={sortedForwards}>
|
<TableBody emptyContent="暂无转发配置" items={sortedForwards}>
|
||||||
{(forward) => (
|
{(forward) => (
|
||||||
<SortableContext items={sortableForwardIds} strategy={verticalListSortingStrategy} key={forward.id}>
|
<SortableContext
|
||||||
|
key={forward.id}
|
||||||
|
items={sortableForwardIds}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
<SortableTableRow
|
<SortableTableRow
|
||||||
forward={forward}
|
formatFlow={formatFlow}
|
||||||
selectMode={selectMode}
|
|
||||||
selectedIds={selectedIds}
|
|
||||||
toggleSelect={toggleSelect}
|
|
||||||
getStrategyDisplay={getStrategyDisplay}
|
|
||||||
formatInAddress={formatInAddress}
|
formatInAddress={formatInAddress}
|
||||||
formatRemoteAddress={formatRemoteAddress}
|
formatRemoteAddress={formatRemoteAddress}
|
||||||
handleServiceToggle={handleServiceToggle}
|
forward={forward}
|
||||||
handleEdit={handleEdit}
|
getStrategyDisplay={getStrategyDisplay}
|
||||||
handleDelete={handleDelete}
|
handleDelete={handleDelete}
|
||||||
handleDiagnose={handleDiagnose}
|
handleDiagnose={handleDiagnose}
|
||||||
showAddressModal={showAddressModal}
|
handleEdit={handleEdit}
|
||||||
|
handleServiceToggle={handleServiceToggle}
|
||||||
hasMultipleAddresses={hasMultipleAddresses}
|
hasMultipleAddresses={hasMultipleAddresses}
|
||||||
formatFlow={formatFlow}
|
selectMode={selectMode}
|
||||||
|
selectedIds={selectedIds}
|
||||||
|
showAddressModal={showAddressModal}
|
||||||
|
toggleSelect={toggleSelect}
|
||||||
/>
|
/>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
)}
|
)}
|
||||||
@@ -1878,39 +1963,39 @@ export default function ForwardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
) : /* 直接显示模式 */
|
) : /* 直接显示模式 */
|
||||||
forwards.length > 0 ? (
|
forwards.length > 0 ? (
|
||||||
<DndContext
|
<DndContext
|
||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
onDragStart={() => { }} // 添加空的 onDragStart 处理器
|
onDragStart={() => {}} // 添加空的 onDragStart 处理器
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={sortableForwardIds}
|
||||||
|
strategy={rectSortingStrategy}
|
||||||
>
|
>
|
||||||
<SortableContext
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||||
items={sortableForwardIds}
|
{sortedForwards.map((forward) =>
|
||||||
strategy={rectSortingStrategy}
|
forward && forward.id ? (
|
||||||
>
|
<SortableForwardCard key={forward.id} forward={forward} />
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
) : null,
|
||||||
{sortedForwards.map((forward) =>
|
)}
|
||||||
forward && forward.id ? (
|
</div>
|
||||||
<SortableForwardCard key={forward.id} forward={forward} />
|
</SortableContext>
|
||||||
) : null,
|
</DndContext>
|
||||||
)}
|
) : (
|
||||||
</div>
|
/* 空状态 */
|
||||||
</SortableContext>
|
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
|
||||||
</DndContext>
|
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
|
||||||
) : (
|
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
|
||||||
/* 空状态 */
|
暂无转发配置
|
||||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
|
</h3>
|
||||||
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
|
<p className="text-default-500 text-sm max-w-xs mx-auto leading-relaxed">
|
||||||
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
|
还没有创建任何转发配置,点击上方按钮开始创建
|
||||||
暂无转发配置
|
</p>
|
||||||
</h3>
|
</CardBody>
|
||||||
<p className="text-default-500 text-sm max-w-xs mx-auto leading-relaxed">
|
</Card>
|
||||||
还没有创建任何转发配置,点击上方按钮开始创建
|
)}
|
||||||
</p>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 新增/编辑模态框 */}
|
{/* 新增/编辑模态框 */}
|
||||||
<Modal
|
<Modal
|
||||||
@@ -2379,10 +2464,11 @@ export default function ForwardPage() {
|
|||||||
{importResults.map((result, index) => (
|
{importResults.map((result, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className={`p-2 rounded border ${result.success
|
className={`p-2 rounded border ${
|
||||||
? "bg-success-50 dark:bg-success-100/10 border-success-200 dark:border-success-300/20"
|
result.success
|
||||||
: "bg-danger-50 dark:bg-danger-100/10 border-danger-200 dark:border-danger-300/20"
|
? "bg-success-50 dark:bg-success-100/10 border-success-200 dark:border-success-300/20"
|
||||||
}`}
|
: "bg-danger-50 dark:bg-danger-100/10 border-danger-200 dark:border-danger-300/20"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{result.success ? (
|
{result.success ? (
|
||||||
@@ -2415,10 +2501,11 @@ export default function ForwardPage() {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-0.5">
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
<span
|
<span
|
||||||
className={`text-xs font-medium ${result.success
|
className={`text-xs font-medium ${
|
||||||
? "text-success-700 dark:text-success-300"
|
result.success
|
||||||
: "text-danger-700 dark:text-danger-300"
|
? "text-success-700 dark:text-success-300"
|
||||||
}`}
|
: "text-danger-700 dark:text-danger-300"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{result.success ? "成功" : "失败"}
|
{result.success ? "成功" : "失败"}
|
||||||
</span>
|
</span>
|
||||||
@@ -2430,10 +2517,11 @@ export default function ForwardPage() {
|
|||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`text-xs ${result.success
|
className={`text-xs ${
|
||||||
? "text-success-600 dark:text-success-400"
|
result.success
|
||||||
: "text-danger-600 dark:text-danger-400"
|
? "text-success-600 dark:text-success-400"
|
||||||
}`}
|
: "text-danger-600 dark:text-danger-400"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{result.message}
|
{result.message}
|
||||||
</div>
|
</div>
|
||||||
@@ -2616,18 +2704,20 @@ export default function ForwardPage() {
|
|||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={index}
|
key={index}
|
||||||
className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${result.success
|
className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${
|
||||||
? "bg-white dark:bg-gray-800"
|
result.success
|
||||||
: "bg-danger-50 dark:bg-danger-900/30"
|
? "bg-white dark:bg-gray-800"
|
||||||
}`}
|
: "bg-danger-50 dark:bg-danger-900/30"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${result.success
|
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
|
||||||
? "bg-success text-white"
|
result.success
|
||||||
: "bg-danger text-white"
|
? "bg-success text-white"
|
||||||
}`}
|
: "bg-danger text-white"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{result.success ? "✓" : "✗"}
|
{result.success ? "✓" : "✗"}
|
||||||
</span>
|
</span>
|
||||||
@@ -2669,10 +2759,11 @@ export default function ForwardPage() {
|
|||||||
<td className="px-3 py-2 text-center">
|
<td className="px-3 py-2 text-center">
|
||||||
{result.success ? (
|
{result.success ? (
|
||||||
<span
|
<span
|
||||||
className={`font-semibold ${(result.packetLoss || 0) > 0
|
className={`font-semibold ${
|
||||||
? "text-warning"
|
(result.packetLoss || 0) > 0
|
||||||
: "text-success"
|
? "text-warning"
|
||||||
}`}
|
: "text-success"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{result.packetLoss?.toFixed(1)}%
|
{result.packetLoss?.toFixed(1)}%
|
||||||
</span>
|
</span>
|
||||||
@@ -2786,17 +2877,19 @@ export default function ForwardPage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className={`border rounded-lg p-3 ${result.success
|
className={`border rounded-lg p-3 ${
|
||||||
? "border-divider bg-white dark:bg-gray-800"
|
result.success
|
||||||
: "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30"
|
? "border-divider bg-white dark:bg-gray-800"
|
||||||
}`}
|
: "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-2 mb-2">
|
<div className="flex items-start gap-2 mb-2">
|
||||||
<span
|
<span
|
||||||
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${result.success
|
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${
|
||||||
? "bg-success text-white"
|
result.success
|
||||||
: "bg-danger text-white"
|
? "bg-success text-white"
|
||||||
}`}
|
: "bg-danger text-white"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{result.success ? "✓" : "✗"}
|
{result.success ? "✓" : "✗"}
|
||||||
</span>
|
</span>
|
||||||
@@ -2832,10 +2925,11 @@ export default function ForwardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div
|
<div
|
||||||
className={`text-lg font-bold ${(result.packetLoss || 0) > 0
|
className={`text-lg font-bold ${
|
||||||
? "text-warning"
|
(result.packetLoss || 0) > 0
|
||||||
: "text-success"
|
? "text-warning"
|
||||||
}`}
|
: "text-success"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{result.packetLoss?.toFixed(1)}%
|
{result.packetLoss?.toFixed(1)}%
|
||||||
</div>
|
</div>
|
||||||
@@ -3054,14 +3148,16 @@ export default function ForwardPage() {
|
|||||||
{/* 筛选模态框 */}
|
{/* 筛选模态框 */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={isFilterModalOpen}
|
isOpen={isFilterModalOpen}
|
||||||
onOpenChange={setIsFilterModalOpen}
|
|
||||||
placement="center"
|
placement="center"
|
||||||
size="md"
|
size="md"
|
||||||
|
onOpenChange={setIsFilterModalOpen}
|
||||||
>
|
>
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
{(onClose) => (
|
{(onClose) => (
|
||||||
<>
|
<>
|
||||||
<ModalHeader className="flex flex-col gap-1">筛选条件</ModalHeader>
|
<ModalHeader className="flex flex-col gap-1">
|
||||||
|
筛选条件
|
||||||
|
</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<div className="flex flex-col gap-4 py-2">
|
<div className="flex flex-col gap-4 py-2">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
@@ -3069,16 +3165,19 @@ export default function ForwardPage() {
|
|||||||
<Select
|
<Select
|
||||||
aria-label="筛选用户"
|
aria-label="筛选用户"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
variant="bordered"
|
|
||||||
selectedKeys={[filterUserId]}
|
selectedKeys={[filterUserId]}
|
||||||
|
variant="bordered"
|
||||||
onSelectionChange={(keys) => {
|
onSelectionChange={(keys) => {
|
||||||
const key = Array.from(keys)[0] as string;
|
const key = Array.from(keys)[0] as string;
|
||||||
|
|
||||||
setFilterUserId(key || "all");
|
setFilterUserId(key || "all");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectItem key="all">全部用户</SelectItem>
|
<SelectItem key="all">全部用户</SelectItem>
|
||||||
{uniqueUsers.map(user => (
|
{uniqueUsers.map((user) => (
|
||||||
<SelectItem key={user.id.toString()}>{user.name}</SelectItem>
|
<SelectItem key={user.id.toString()}>
|
||||||
|
{user.name}
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -3087,26 +3186,33 @@ export default function ForwardPage() {
|
|||||||
<Select
|
<Select
|
||||||
aria-label="筛选隧道"
|
aria-label="筛选隧道"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
variant="bordered"
|
|
||||||
selectedKeys={[filterTunnelId]}
|
selectedKeys={[filterTunnelId]}
|
||||||
|
variant="bordered"
|
||||||
onSelectionChange={(keys) => {
|
onSelectionChange={(keys) => {
|
||||||
const key = Array.from(keys)[0] as string;
|
const key = Array.from(keys)[0] as string;
|
||||||
|
|
||||||
setFilterTunnelId(key || "all");
|
setFilterTunnelId(key || "all");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectItem key="all">全部隧道</SelectItem>
|
<SelectItem key="all">全部隧道</SelectItem>
|
||||||
{tunnels.map(tunnel => (
|
{tunnels.map((tunnel) => (
|
||||||
<SelectItem key={tunnel.id.toString()}>{tunnel.name}</SelectItem>
|
<SelectItem key={tunnel.id.toString()}>
|
||||||
|
{tunnel.name}
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button color="default" variant="flat" onPress={() => {
|
<Button
|
||||||
setFilterUserId("all");
|
color="default"
|
||||||
setFilterTunnelId("all");
|
variant="flat"
|
||||||
}}>
|
onPress={() => {
|
||||||
|
setFilterUserId("all");
|
||||||
|
setFilterTunnelId("all");
|
||||||
|
}}
|
||||||
|
>
|
||||||
重置
|
重置
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="primary" onPress={onClose}>
|
<Button color="primary" onPress={onClose}>
|
||||||
|
|||||||
@@ -259,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",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
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, StaggerList, StaggerItem } from "@/components/animated-page";
|
|
||||||
import { SearchBar } from "@/components/search-bar";
|
|
||||||
|
|
||||||
|
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";
|
||||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||||
@@ -60,9 +64,10 @@ export default function LimitPage() {
|
|||||||
if (!searchKeyword.trim()) return rules;
|
if (!searchKeyword.trim()) return rules;
|
||||||
const lowerKeyword = searchKeyword.toLowerCase();
|
const lowerKeyword = searchKeyword.toLowerCase();
|
||||||
|
|
||||||
return rules.filter(r =>
|
return rules.filter(
|
||||||
(r.name && r.name.toLowerCase().includes(lowerKeyword)) ||
|
(r) =>
|
||||||
(r.tunnelName && r.tunnelName.toLowerCase().includes(lowerKeyword))
|
(r.name && r.name.toLowerCase().includes(lowerKeyword)) ||
|
||||||
|
(r.tunnelName && r.tunnelName.toLowerCase().includes(lowerKeyword)),
|
||||||
);
|
);
|
||||||
}, [rules, searchKeyword]);
|
}, [rules, searchKeyword]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
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,
|
||||||
@@ -20,6 +18,8 @@ import {
|
|||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
|
||||||
|
import { SearchBar } from "@/components/search-bar";
|
||||||
|
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";
|
||||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||||
@@ -828,23 +828,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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1005,11 +1005,13 @@ export default function NodePage() {
|
|||||||
|
|
||||||
if (searchKeyword.trim()) {
|
if (searchKeyword.trim()) {
|
||||||
const lowerKeyword = searchKeyword.toLowerCase();
|
const lowerKeyword = searchKeyword.toLowerCase();
|
||||||
filteredNodes = filteredNodes.filter(n =>
|
|
||||||
(n.name && n.name.toLowerCase().includes(lowerKeyword)) ||
|
filteredNodes = filteredNodes.filter(
|
||||||
(n.serverIp && n.serverIp.toLowerCase().includes(lowerKeyword)) ||
|
(n) =>
|
||||||
(n.serverIpV4 && n.serverIpV4.toLowerCase().includes(lowerKeyword)) ||
|
(n.name && n.name.toLowerCase().includes(lowerKeyword)) ||
|
||||||
(n.serverIpV6 && n.serverIpV6.toLowerCase().includes(lowerKeyword))
|
(n.serverIp && n.serverIp.toLowerCase().includes(lowerKeyword)) ||
|
||||||
|
(n.serverIpV4 && n.serverIpV4.toLowerCase().includes(lowerKeyword)) ||
|
||||||
|
(n.serverIpV6 && n.serverIpV6.toLowerCase().includes(lowerKeyword)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1235,7 +1237,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
|
||||||
@@ -1291,7 +1293,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>
|
||||||
@@ -1310,7 +1312,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>
|
||||||
@@ -1327,7 +1329,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
|
||||||
}
|
}
|
||||||
@@ -1338,7 +1340,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>
|
||||||
@@ -1355,7 +1357,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
|
||||||
}
|
}
|
||||||
@@ -1370,10 +1372,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>
|
||||||
@@ -1383,10 +1385,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>
|
||||||
@@ -1400,10 +1402,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>
|
||||||
@@ -1413,10 +1415,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>
|
||||||
|
|||||||
@@ -416,7 +416,8 @@ export default function PanelSharingPage() {
|
|||||||
Provider 共享
|
Provider 共享
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-default-500">
|
<p className="text-sm text-default-500">
|
||||||
将本地节点分享给其他面板,统一管理 Token、端口范围和到期策略。
|
将本地节点分享给其他面板,统一管理
|
||||||
|
Token、端口范围和到期策略。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -429,7 +430,9 @@ export default function PanelSharingPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-12 text-default-500">加载中...</div>
|
<div className="text-center py-12 text-default-500">
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
) : shares.length === 0 ? (
|
) : shares.length === 0 ? (
|
||||||
<div className="rounded-lg border border-dashed border-divider bg-default-50/60 dark:bg-default-100/20 px-6 py-10 text-center">
|
<div className="rounded-lg border border-dashed border-divider bg-default-50/60 dark:bg-default-100/20 px-6 py-10 text-center">
|
||||||
<p className="text-base font-semibold text-foreground">
|
<p className="text-base font-semibold text-foreground">
|
||||||
@@ -574,7 +577,9 @@ export default function PanelSharingPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{remoteUsageLoading ? (
|
{remoteUsageLoading ? (
|
||||||
<div className="text-center py-12 text-default-500">加载中...</div>
|
<div className="text-center py-12 text-default-500">
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
) : remoteUsageNodes.length === 0 ? (
|
) : remoteUsageNodes.length === 0 ? (
|
||||||
<div className="rounded-lg border border-dashed border-divider bg-default-50/60 dark:bg-default-100/20 px-6 py-10 text-center">
|
<div className="rounded-lg border border-dashed border-divider bg-default-50/60 dark:bg-default-100/20 px-6 py-10 text-center">
|
||||||
<p className="text-base font-semibold text-foreground">
|
<p className="text-base font-semibold text-foreground">
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
useSortable,
|
useSortable,
|
||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
|
||||||
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";
|
||||||
import { Input, Textarea } from "@/shadcn-bridge/heroui/input";
|
import { Input, Textarea } from "@/shadcn-bridge/heroui/input";
|
||||||
@@ -230,9 +229,9 @@ export default function TunnelPage() {
|
|||||||
trafficRatio: tunnel.trafficRatio,
|
trafficRatio: tunnel.trafficRatio,
|
||||||
inIp: tunnel.inIp
|
inIp: tunnel.inIp
|
||||||
? tunnel.inIp
|
? tunnel.inIp
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((ip: string) => ip.trim())
|
.map((ip: string) => ip.trim())
|
||||||
.join("\n")
|
.join("\n")
|
||||||
: "",
|
: "",
|
||||||
ipPreference: tunnel.ipPreference || "",
|
ipPreference: tunnel.ipPreference || "",
|
||||||
status: tunnel.status,
|
status: tunnel.status,
|
||||||
@@ -667,13 +666,16 @@ export default function TunnelPage() {
|
|||||||
} = useSortable({ id });
|
} = useSortable({ id });
|
||||||
|
|
||||||
const style: React.CSSProperties = {
|
const style: React.CSSProperties = {
|
||||||
transform: transform ? CSS.Transform.toString(transform) : undefined,
|
transform: transform
|
||||||
|
? CSS.Transform.toString({
|
||||||
|
...transform,
|
||||||
|
x: Math.round(transform.x),
|
||||||
|
y: Math.round(transform.y),
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
transition: isDragging ? undefined : transition || undefined,
|
transition: isDragging ? undefined : transition || undefined,
|
||||||
opacity: isDragging ? 0.5 : 1,
|
opacity: isDragging ? 0.5 : 1,
|
||||||
willChange: "transform",
|
willChange: isDragging ? "transform" : undefined,
|
||||||
backfaceVisibility: "hidden",
|
|
||||||
WebkitFontSmoothing: "antialiased",
|
|
||||||
MozOsxFontSmoothing: "grayscale",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1384,9 +1386,9 @@ export default function TunnelPage() {
|
|||||||
.filter((id) => id !== -1)
|
.filter((id) => id !== -1)
|
||||||
.map((id) => id.toString()),
|
.map((id) => id.toString()),
|
||||||
]}
|
]}
|
||||||
|
dropdownPlacement="top"
|
||||||
label="节点"
|
label="节点"
|
||||||
placeholder="选择节点(可多选)"
|
placeholder="选择节点(可多选)"
|
||||||
dropdownPlacement="top"
|
|
||||||
selectedKeys={groupNodes
|
selectedKeys={groupNodes
|
||||||
.filter((ct) => ct.nodeId !== -1)
|
.filter((ct) => ct.nodeId !== -1)
|
||||||
.map((ct) => ct.nodeId.toString())}
|
.map((ct) => ct.nodeId.toString())}
|
||||||
@@ -1453,14 +1455,14 @@ export default function TunnelPage() {
|
|||||||
{form.inNodeId.some(
|
{form.inNodeId.some(
|
||||||
(ct) => ct.nodeId === node.id,
|
(ct) => ct.nodeId === node.id,
|
||||||
) && (
|
) && (
|
||||||
<Chip
|
<Chip
|
||||||
color="warning"
|
color="warning"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
>
|
>
|
||||||
已选为入口
|
已选为入口
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
{form.outNodeId &&
|
{form.outNodeId &&
|
||||||
form.outNodeId.some(
|
form.outNodeId.some(
|
||||||
(ct) => ct.nodeId === node.id,
|
(ct) => ct.nodeId === node.id,
|
||||||
@@ -1483,14 +1485,14 @@ export default function TunnelPage() {
|
|||||||
ct.nodeId !== -1,
|
ct.nodeId !== -1,
|
||||||
),
|
),
|
||||||
) && (
|
) && (
|
||||||
<Chip
|
<Chip
|
||||||
color="primary"
|
color="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
>
|
>
|
||||||
已选为其他跳
|
已选为其他跳
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -1600,16 +1602,16 @@ export default function TunnelPage() {
|
|||||||
id.toString(),
|
id.toString(),
|
||||||
),
|
),
|
||||||
]}
|
]}
|
||||||
|
dropdownPlacement="top"
|
||||||
errorMessage={errors.outNodeId}
|
errorMessage={errors.outNodeId}
|
||||||
isInvalid={!!errors.outNodeId}
|
isInvalid={!!errors.outNodeId}
|
||||||
label="节点"
|
label="节点"
|
||||||
placeholder="请选择出口节点(可多选)"
|
placeholder="请选择出口节点(可多选)"
|
||||||
dropdownPlacement="top"
|
|
||||||
selectedKeys={
|
selectedKeys={
|
||||||
form.outNodeId
|
form.outNodeId
|
||||||
? form.outNodeId
|
? form.outNodeId
|
||||||
.filter((ct) => ct.nodeId !== -1)
|
.filter((ct) => ct.nodeId !== -1)
|
||||||
.map((ct) => ct.nodeId.toString())
|
.map((ct) => ct.nodeId.toString())
|
||||||
: []
|
: []
|
||||||
}
|
}
|
||||||
selectionMode="multiple"
|
selectionMode="multiple"
|
||||||
@@ -1676,25 +1678,25 @@ export default function TunnelPage() {
|
|||||||
{form.inNodeId.some(
|
{form.inNodeId.some(
|
||||||
(ct) => ct.nodeId === node.id,
|
(ct) => ct.nodeId === node.id,
|
||||||
) && (
|
) && (
|
||||||
<Chip
|
<Chip
|
||||||
color="warning"
|
color="warning"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
>
|
>
|
||||||
已选为入口
|
已选为入口
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
{getSelectedChainNodeIds().includes(
|
{getSelectedChainNodeIds().includes(
|
||||||
node.id,
|
node.id,
|
||||||
) && (
|
) && (
|
||||||
<Chip
|
<Chip
|
||||||
color="primary"
|
color="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
>
|
>
|
||||||
已选为转发链
|
已选为转发链
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -2057,18 +2059,20 @@ export default function TunnelPage() {
|
|||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={index}
|
key={index}
|
||||||
className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${result.success
|
className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${
|
||||||
? "bg-white dark:bg-gray-800"
|
result.success
|
||||||
: "bg-danger-50 dark:bg-danger-900/30"
|
? "bg-white dark:bg-gray-800"
|
||||||
}`}
|
: "bg-danger-50 dark:bg-danger-900/30"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${result.success
|
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
|
||||||
? "bg-success text-white"
|
result.success
|
||||||
: "bg-danger text-white"
|
? "bg-success text-white"
|
||||||
}`}
|
: "bg-danger text-white"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{result.success ? "✓" : "✗"}
|
{result.success ? "✓" : "✗"}
|
||||||
</span>
|
</span>
|
||||||
@@ -2110,10 +2114,11 @@ export default function TunnelPage() {
|
|||||||
<td className="px-3 py-2 text-center">
|
<td className="px-3 py-2 text-center">
|
||||||
{result.success ? (
|
{result.success ? (
|
||||||
<span
|
<span
|
||||||
className={`font-semibold ${(result.packetLoss || 0) > 0
|
className={`font-semibold ${
|
||||||
? "text-warning"
|
(result.packetLoss || 0) > 0
|
||||||
: "text-success"
|
? "text-warning"
|
||||||
}`}
|
: "text-success"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{result.packetLoss?.toFixed(1)}%
|
{result.packetLoss?.toFixed(1)}%
|
||||||
</span>
|
</span>
|
||||||
@@ -2226,17 +2231,19 @@ export default function TunnelPage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className={`border rounded-lg p-3 ${result.success
|
className={`border rounded-lg p-3 ${
|
||||||
? "border-divider bg-white dark:bg-gray-800"
|
result.success
|
||||||
: "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30"
|
? "border-divider bg-white dark:bg-gray-800"
|
||||||
}`}
|
: "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-2 mb-2">
|
<div className="flex items-start gap-2 mb-2">
|
||||||
<span
|
<span
|
||||||
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${result.success
|
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${
|
||||||
? "bg-success text-white"
|
result.success
|
||||||
: "bg-danger text-white"
|
? "bg-success text-white"
|
||||||
}`}
|
: "bg-danger text-white"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{result.success ? "✓" : "✗"}
|
{result.success ? "✓" : "✗"}
|
||||||
</span>
|
</span>
|
||||||
@@ -2272,10 +2279,11 @@ export default function TunnelPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div
|
<div
|
||||||
className={`text-lg font-bold ${(result.packetLoss || 0) > 0
|
className={`text-lg font-bold ${
|
||||||
? "text-warning"
|
(result.packetLoss || 0) > 0
|
||||||
: "text-success"
|
? "text-warning"
|
||||||
}`}
|
: "text-success"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{result.packetLoss?.toFixed(1)}%
|
{result.packetLoss?.toFixed(1)}%
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
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 {
|
||||||
|
AnimatedPage,
|
||||||
|
StaggerList,
|
||||||
|
StaggerItem,
|
||||||
|
} from "@/components/animated-page";
|
||||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||||
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";
|
||||||
@@ -248,7 +252,7 @@ export default function UserPage() {
|
|||||||
if (response.code === 0) {
|
if (response.code === 0) {
|
||||||
setTunnels(Array.isArray(response.data) ? response.data : []);
|
setTunnels(Array.isArray(response.data) ? response.data : []);
|
||||||
}
|
}
|
||||||
} catch { }
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadSpeedLimits = async () => {
|
const loadSpeedLimits = async () => {
|
||||||
@@ -258,15 +262,15 @@ export default function UserPage() {
|
|||||||
if (response.code === 0) {
|
if (response.code === 0) {
|
||||||
const speedLimitList = Array.isArray(response.data)
|
const speedLimitList = Array.isArray(response.data)
|
||||||
? response.data.map((item) => ({
|
? response.data.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
uploadSpeed: item.uploadSpeed ?? item.speed ?? 0,
|
uploadSpeed: item.uploadSpeed ?? item.speed ?? 0,
|
||||||
downloadSpeed: item.downloadSpeed ?? item.speed ?? 0,
|
downloadSpeed: item.downloadSpeed ?? item.speed ?? 0,
|
||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
setSpeedLimits(speedLimitList);
|
setSpeedLimits(speedLimitList);
|
||||||
}
|
}
|
||||||
} catch { }
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadUserGroups = async () => {
|
const loadUserGroups = async () => {
|
||||||
@@ -276,7 +280,7 @@ export default function UserPage() {
|
|||||||
if (response.code === 0) {
|
if (response.code === 0) {
|
||||||
setUserGroups(Array.isArray(response.data) ? response.data : []);
|
setUserGroups(Array.isArray(response.data) ? response.data : []);
|
||||||
}
|
}
|
||||||
} catch { }
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadUserTunnels = async (userId: number) => {
|
const loadUserTunnels = async (userId: number) => {
|
||||||
@@ -327,7 +331,7 @@ export default function UserPage() {
|
|||||||
if (groupRes.code === 0) {
|
if (groupRes.code === 0) {
|
||||||
currentGroupIds = groupRes.data || [];
|
currentGroupIds = groupRes.data || [];
|
||||||
}
|
}
|
||||||
} catch { }
|
} catch {}
|
||||||
|
|
||||||
setUserForm({
|
setUserForm({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@@ -703,16 +707,14 @@ export default function UserPage() {
|
|||||||
const flowPercent =
|
const flowPercent =
|
||||||
user.flow > 0
|
user.flow > 0
|
||||||
? Math.min(
|
? Math.min(
|
||||||
(usedFlow / (user.flow * 1024 * 1024 * 1024)) * 100,
|
(usedFlow / (user.flow * 1024 * 1024 * 1024)) * 100,
|
||||||
100,
|
100,
|
||||||
)
|
)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StaggerItem key={user.id}>
|
<StaggerItem key={user.id}>
|
||||||
<Card
|
<Card className="shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden h-full">
|
||||||
className="shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden 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">
|
||||||
@@ -771,7 +773,9 @@ export default function UserPage() {
|
|||||||
<div className="space-y-1.5 pt-2 border-t border-divider">
|
<div className="space-y-1.5 pt-2 border-t border-divider">
|
||||||
<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>
|
||||||
<span className="font-medium text-xs">{user.num}</span>
|
<span className="font-medium text-xs">
|
||||||
|
{user.num}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
||||||
@@ -876,7 +880,7 @@ export default function UserPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 用户表单模态框 */}
|
{/* 用户表单模态框 */}
|
||||||
< Modal
|
<Modal
|
||||||
backdrop="blur"
|
backdrop="blur"
|
||||||
isOpen={isUserModalOpen}
|
isOpen={isUserModalOpen}
|
||||||
placement="center"
|
placement="center"
|
||||||
@@ -971,8 +975,8 @@ export default function UserPage() {
|
|||||||
value={
|
value={
|
||||||
userForm.expTime
|
userForm.expTime
|
||||||
? (parseDate(
|
? (parseDate(
|
||||||
userForm.expTime.toISOString().split("T")[0],
|
userForm.expTime.toISOString().split("T")[0],
|
||||||
) as any)
|
) as any)
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
onChange={(date) => {
|
onChange={(date) => {
|
||||||
@@ -1078,11 +1082,12 @@ export default function UserPage() {
|
|||||||
aria-disabled={isAssigned}
|
aria-disabled={isAssigned}
|
||||||
className={`
|
className={`
|
||||||
px-4 py-3 rounded-lg border transition-all duration-200 cursor-pointer
|
px-4 py-3 rounded-lg border transition-all duration-200 cursor-pointer
|
||||||
${isAssigned
|
${
|
||||||
? "bg-default-100/50 dark:bg-default-50/50 border-default-200/50 dark:border-default-100/20 opacity-60 cursor-not-allowed"
|
isAssigned
|
||||||
: isSelected
|
? "bg-default-100/50 dark:bg-default-50/50 border-default-200/50 dark:border-default-100/20 opacity-60 cursor-not-allowed"
|
||||||
? "bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-500/50 shadow-sm"
|
: isSelected
|
||||||
: "bg-white dark:bg-default-50 border-default-200 dark:border-default-100/30 hover:border-primary-200 dark:hover:border-primary-500/30 hover:shadow-sm"
|
? "bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-500/50 shadow-sm"
|
||||||
|
: "bg-white dark:bg-default-50 border-default-200 dark:border-default-100/30 hover:border-primary-200 dark:hover:border-primary-500/30 hover:shadow-sm"
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
role="button"
|
role="button"
|
||||||
@@ -1142,13 +1147,13 @@ export default function UserPage() {
|
|||||||
selectedKeys={
|
selectedKeys={
|
||||||
batchTunnelSelections.get(tunnel.id) !==
|
batchTunnelSelections.get(tunnel.id) !==
|
||||||
null &&
|
null &&
|
||||||
batchTunnelSelections.get(tunnel.id) !==
|
batchTunnelSelections.get(tunnel.id) !==
|
||||||
undefined
|
undefined
|
||||||
? [
|
? [
|
||||||
batchTunnelSelections
|
batchTunnelSelections
|
||||||
.get(tunnel.id)!
|
.get(tunnel.id)!
|
||||||
.toString(),
|
.toString(),
|
||||||
]
|
]
|
||||||
: ["null"]
|
: ["null"]
|
||||||
}
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1408,9 +1413,9 @@ export default function UserPage() {
|
|||||||
setEditTunnelForm((prev) =>
|
setEditTunnelForm((prev) =>
|
||||||
prev
|
prev
|
||||||
? {
|
? {
|
||||||
...prev,
|
...prev,
|
||||||
speedId: value === "null" ? null : Number(value),
|
speedId: value === "null" ? null : Number(value),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -1465,10 +1470,10 @@ export default function UserPage() {
|
|||||||
value={
|
value={
|
||||||
editTunnelForm.expTime
|
editTunnelForm.expTime
|
||||||
? (parseDate(
|
? (parseDate(
|
||||||
new Date(editTunnelForm.expTime)
|
new Date(editTunnelForm.expTime)
|
||||||
.toISOString()
|
.toISOString()
|
||||||
.split("T")[0],
|
.split("T")[0],
|
||||||
) as any)
|
) as any)
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
onChange={(date) => {
|
onChange={(date) => {
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ export function DatePicker({
|
|||||||
required={isRequired}
|
required={isRequired}
|
||||||
type={shouldUseTextInput ? "text" : "date"}
|
type={shouldUseTextInput ? "text" : "date"}
|
||||||
value={shouldUseTextInput ? textValue : formattedValue}
|
value={shouldUseTextInput ? textValue : formattedValue}
|
||||||
|
onBlur={shouldUseTextInput ? commitTextInput : undefined}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const nextValue = event.target.value;
|
const nextValue = event.target.value;
|
||||||
|
|
||||||
@@ -189,7 +190,6 @@ export function DatePicker({
|
|||||||
|
|
||||||
notifyNativeDateChange(nextValue);
|
notifyNativeDateChange(nextValue);
|
||||||
}}
|
}}
|
||||||
onBlur={shouldUseTextInput ? commitTextInput : undefined}
|
|
||||||
onKeyDown={
|
onKeyDown={
|
||||||
shouldUseTextInput
|
shouldUseTextInput
|
||||||
? (event) => {
|
? (event) => {
|
||||||
|
|||||||
@@ -286,26 +286,22 @@ export function Select<T>({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const placementClasses = dropdownPlacement === "top"
|
const placementClasses =
|
||||||
? "bottom-full mb-1"
|
dropdownPlacement === "top" ? "bottom-full mb-1" : "top-full mt-1";
|
||||||
: "top-full mt-1";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={listboxRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute left-0 z-50 w-full space-y-1 overflow-y-auto rounded-md border border-divider bg-background p-2 shadow-md max-h-56",
|
"absolute left-0 z-50 w-full space-y-1 overflow-y-auto rounded-md border border-divider bg-background p-2 shadow-md max-h-56",
|
||||||
placementClasses
|
placementClasses,
|
||||||
)}
|
)}
|
||||||
id={`${generatedId}-listbox`}
|
id={`${generatedId}-listbox`}
|
||||||
ref={listboxRef}
|
|
||||||
role="listbox"
|
role="listbox"
|
||||||
>
|
>
|
||||||
{options.length === 0 ? (
|
{options.length === 0 ? (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn("px-2 py-1 text-default-500", textSizeClass(size))}
|
||||||
"px-2 py-1 text-default-500",
|
|
||||||
textSizeClass(size),
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
暂无可选项
|
暂无可选项
|
||||||
</div>
|
</div>
|
||||||
@@ -334,9 +330,7 @@ export function Select<T>({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"min-w-0 flex-1 truncate text-left text-foreground",
|
"min-w-0 flex-1 truncate text-left text-foreground",
|
||||||
textSizeClass(size),
|
textSizeClass(size),
|
||||||
optionDisabled
|
optionDisabled ? "cursor-not-allowed" : "cursor-pointer",
|
||||||
? "cursor-not-allowed"
|
|
||||||
: "cursor-pointer",
|
|
||||||
)}
|
)}
|
||||||
disabled={optionDisabled}
|
disabled={optionDisabled}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -363,13 +357,7 @@ export function Select<T>({
|
|||||||
label={label}
|
label={label}
|
||||||
>
|
>
|
||||||
{selectionMode === "multiple" ? (
|
{selectionMode === "multiple" ? (
|
||||||
<div
|
<div ref={containerRef} className={cn("relative w-full", className)}>
|
||||||
ref={containerRef}
|
|
||||||
className={cn(
|
|
||||||
"relative w-full",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
aria-controls={`${generatedId}-listbox`}
|
aria-controls={`${generatedId}-listbox`}
|
||||||
aria-expanded={isExpanded}
|
aria-expanded={isExpanded}
|
||||||
|
|||||||
Reference in New Issue
Block a user