fix(frontend): ship pending card-view animation and interaction refinements

This commit is contained in:
sagitchu
2026-02-22 15:52:48 +08:00
parent 79c28103d5
commit d483258eef
16 changed files with 640 additions and 503 deletions
+69 -69
View File
@@ -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>
); );
+89 -89
View File
@@ -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>
); );
} }
+2 -2
View File
@@ -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}
+16 -7
View File
@@ -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>
); );
+13 -11
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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}>
+2 -2
View File
@@ -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",
}} }}
+10 -5
View File
@@ -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]);
+44 -42
View File
@@ -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>
+8 -3
View File
@@ -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">
+77 -69
View File
@@ -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>
+41 -36
View File
@@ -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}