Compare commits

...

10 Commits

Author SHA1 Message Date
sagit daf34d0f6c fix(backend): prevent login block when captcha enabled without cloudflare key (#194)
When captcha_enabled=true but cloudflare_secret_key is not configured,
the login flow would block users with "未配置Cloudflare Site Key" error.
Now captcha is treated as disabled if the secret key is missing, allowing
users to log in normally on fresh PostgreSQL installations.

Fixes login issue on new panel setups with PostgreSQL.
2026-02-22 22:54:51 +08:00
sagit bb505d461d fix(frontend): enable modal scroll on panel sharing page (#193)
Add scrollBehavior="inside" to Create Share and Edit Share modals
to allow content scrolling on mobile devices when form fields
exceed viewport height.
2026-02-22 21:53:29 +08:00
sagit 00be0ac31e fix(frontend): enable content scroll on mobile (#192)
Main content area had overflow-hidden on mobile which prevented
scrolling when content exceeded viewport height. Changed to
overflow-y-auto for consistent scroll behavior across all devices.
2026-02-22 21:12:45 +08:00
sagit fc5624a190 fix(frontend): modal footer buttons visible on mobile (#190)
ModalFooter used flex-col-reverse which caused buttons to display in
reverse order on mobile and potentially pushed cancel button out of
view. Changed to flex-wrap justify-end for consistent cross-platform
button display.
2026-02-22 20:05:02 +08:00
sagit 42ae3457b5 feat(frontend): persist filter state across page reloads (#189)
* feat(frontend): persist filter state across page reloads

Add useLocalStorageState hook to persist filter/search state in
forward, node, tunnel, user, and limit pages. Reset filter clears
the persisted value from localStorage.

* feat(ui): add collapsible sidebar and smooth animations
2026-02-22 19:01:03 +08:00
sagit 088027da7b fix(frontend): resolve remaining font blur in node and forward cards (#188)
## Summary
- apply the same anti-blur drag transform strategy used in tunnel cards
to node cards
- update forward card and table-row sortable transforms to round x/y
pixels and reduce subpixel text blur
- limit `willChange` usage to active dragging so text does not remain on
a promoted layer while idle

## Verification
- npm run build (vite-frontend)
2026-02-22 16:55:08 +08:00
sagitchu d37adee5df fix(frontend): resolve remaining font blur in node and forward cards 2026-02-22 16:53:35 +08:00
sagit c147e52d72 fix(frontend): ship pending card-view animation and interaction refinements (#186)
## Summary
- include the full pending frontend refinements across card views,
search/filter interactions, and bridge components
- keep sortable/card animation behavior aligned with the latest
anti-blur adjustments in tunnel card rendering
- bundle related UI consistency updates across tunnel, forward, node,
user, and dashboard pages

## Verification
- npm run build (vite-frontend)
2026-02-22 15:54:30 +08:00
sagitchu d483258eef fix(frontend): ship pending card-view animation and interaction refinements 2026-02-22 15:52:48 +08:00
sagit 79c28103d5 fix(frontend): prevent font blur in sortable card components (#185)
Add backface-visibility: hidden and font-smoothing properties to SortableItem components in tunnel, forward, and node pages to prevent GPU subpixel rendering blur during drag operations.

- tunnel.tsx: SortableItem wrapper anti-blur fix
- forward.tsx: SortableCard wrapper anti-blur fix
- node.tsx: SortableItem component anti-blur fix

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-22 15:11:47 +08:00
20 changed files with 720 additions and 455 deletions
+13 -2
View File
@@ -988,10 +988,21 @@ func (h *Handler) captchaEnabled() (bool, error) {
if err != nil {
return false, err
}
if cfg == nil {
if cfg == nil || !strings.EqualFold(cfg.Value, "true") {
return false, nil
}
return strings.EqualFold(cfg.Value, "true"), nil
// captcha_enabled=true, but we need to verify cloudflare_secret_key is configured
// If secret key is not configured, treat captcha as disabled to avoid blocking login
secretKey, err := h.repo.GetConfigByName("cloudflare_secret_key")
if err != nil {
return false, err
}
if secretKey == nil || strings.TrimSpace(secretKey.Value) == "" {
return false, nil
}
return true, nil
}
func (h *Handler) markCaptchaToken(token string) {
+2 -1
View File
@@ -1 +1,2 @@
VITE_API_BASE=
VITE_GITHUB_REPO=https://github.com/Sagit-chu/flvx
VITE_APP_VERSION=beta
+69 -69
View File
@@ -6,21 +6,21 @@ import React from "react";
* Use this as the outermost wrapper inside each page component.
*/
export const AnimatedPage = ({
children,
className,
children,
className,
}: {
children: React.ReactNode;
className?: string;
children: React.ReactNode;
className?: string;
}) => (
<motion.div
animate={{ opacity: 1, y: 0 }}
className={className}
exit={{ opacity: 0, y: -8 }}
initial={{ opacity: 0, y: 16 }}
transition={{ duration: 0.28, ease: [0.25, 0.46, 0.45, 0.94] }}
>
{children}
</motion.div>
<motion.div
animate={{ opacity: 1, y: 0 }}
className={className}
exit={{ opacity: 0, y: -8 }}
initial={{ opacity: 0, y: 16 }}
transition={{ duration: 0.28, ease: [0.25, 0.46, 0.45, 0.94] }}
>
{children}
</motion.div>
);
/**
@@ -28,26 +28,26 @@ export const AnimatedPage = ({
* Children should use `staggerItem` as their `variants` prop.
*/
export const staggerContainer = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.05,
delayChildren: 0.05,
},
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.05,
delayChildren: 0.05,
},
},
};
/**
* Individual stagger item variant — fade-in + slide-up.
*/
export const staggerItem = {
hidden: { opacity: 0, y: 12 },
show: {
opacity: 1,
y: 0,
transition: { duration: 0.25, ease: [0.25, 0.46, 0.45, 0.94] },
},
hidden: { opacity: 0, y: 12 },
show: {
opacity: 1,
y: 0,
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.
*/
export const StaggerList = ({
children,
className,
as = "div",
children,
className,
as = "div",
}: {
children: React.ReactNode;
className?: string;
as?: "div" | "ul" | "tbody";
children: React.ReactNode;
className?: string;
as?: "div" | "ul" | "tbody";
}) => {
const Component = motion[as] as React.ElementType;
const Component = motion[as] as React.ElementType;
return (
<Component
animate="show"
className={className}
initial="hidden"
variants={staggerContainer}
>
{children}
</Component>
);
return (
<Component
animate="show"
className={className}
initial="hidden"
variants={staggerContainer}
>
{children}
</Component>
);
};
/**
* Individual animated item for use inside StaggerList.
*/
export const StaggerItem = ({
children,
className,
as = "div",
children,
className,
as = "div",
}: {
children: React.ReactNode;
className?: string;
as?: "div" | "li" | "tr";
children: React.ReactNode;
className?: string;
as?: "div" | "li" | "tr";
}) => {
const Component = motion[as] as React.ElementType;
const Component = motion[as] as React.ElementType;
return (
<Component className={className} variants={staggerItem}>
{children}
</Component>
);
return (
<Component className={className} variants={staggerItem}>
{children}
</Component>
);
};
/**
@@ -103,20 +103,20 @@ export const StaggerItem = ({
* Uses opacity + translateY to avoid font blurriness caused by scale transforms.
*/
export const FadeIn = ({
children,
delay = 0,
className,
children,
delay = 0,
className,
}: {
children: React.ReactNode;
delay?: number;
className?: string;
children: React.ReactNode;
delay?: number;
className?: string;
}) => (
<motion.div
animate={{ opacity: 1, y: 0 }}
className={className}
initial={{ opacity: 0, y: 8 }}
transition={{ duration: 0.2, delay, ease: "easeOut" }}
>
{children}
</motion.div>
<motion.div
animate={{ opacity: 1, y: 0 }}
className={className}
initial={{ opacity: 0, y: 8 }}
transition={{ duration: 0.2, delay, ease: "easeOut" }}
>
{children}
</motion.div>
);
+89 -89
View File
@@ -5,97 +5,97 @@ import { Input } from "@/shadcn-bridge/heroui/input";
import { SearchIcon } from "@/components/icons";
interface SearchBarProps {
isVisible: boolean;
value: string;
placeholder?: string;
onOpen: () => void;
onClose: () => void;
onChange: (value: string) => void;
isVisible: boolean;
value: string;
placeholder?: string;
onOpen: () => void;
onClose: () => void;
onChange: (value: string) => void;
}
export function SearchBar({
isVisible,
value,
placeholder = "搜索",
onOpen,
onClose,
onChange,
isVisible,
value,
placeholder = "搜索",
onOpen,
onClose,
onChange,
}: SearchBarProps) {
return (
// Fixed h-8 so the container never changes height — eliminates the vertical jitter
<div className="flex items-center gap-2 h-8 overflow-hidden">
<AnimatePresence mode="wait" initial={false}>
{!isVisible ? (
<motion.div
key="search-btn"
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
transition={{ duration: 0.12 }}
>
<Button
isIconOnly
aria-label="搜索"
className="text-default-600"
color="default"
size="sm"
variant="flat"
onPress={onOpen}
>
<SearchIcon className="w-4 h-4" />
</Button>
</motion.div>
) : (
<motion.div
key="search-input"
animate={{ opacity: 1, x: 0 }}
className="flex w-full items-center gap-2"
exit={{ opacity: 0, x: -8 }}
initial={{ opacity: 0, x: -16 }}
transition={{ duration: 0.18, ease: [0.25, 0.46, 0.45, 0.94] }}
>
<Input
autoFocus
classNames={{
base: "bg-default-100",
input: "bg-transparent text-sm focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none",
inputWrapper:
"bg-default-100 border-0 shadow-none h-8 min-h-8",
}}
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
<Button
isIconOnly
aria-label="关闭搜索"
className="text-default-600 shrink-0"
color="default"
size="sm"
variant="light"
onPress={() => {
onClose();
onChange("");
}}
>
<svg
aria-hidden="true"
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M6 18L18 6M6 6l12 12"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
</Button>
</motion.div>
)}
</AnimatePresence>
</div>
);
return (
// Fixed h-8 so the container never changes height — eliminates the vertical jitter
<div className="flex items-center gap-2 h-8 overflow-hidden">
<AnimatePresence initial={false} mode="wait">
{!isVisible ? (
<motion.div
key="search-btn"
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
transition={{ duration: 0.12 }}
>
<Button
isIconOnly
aria-label="搜索"
className="text-default-600"
color="default"
size="sm"
variant="flat"
onPress={onOpen}
>
<SearchIcon className="w-4 h-4" />
</Button>
</motion.div>
) : (
<motion.div
key="search-input"
animate={{ opacity: 1, x: 0 }}
className="flex w-full items-center gap-2"
exit={{ opacity: 0, x: -8 }}
initial={{ opacity: 0, x: -16 }}
transition={{ duration: 0.18, ease: [0.25, 0.46, 0.45, 0.94] }}
>
<Input
autoFocus
classNames={{
base: "bg-default-100",
input:
"bg-transparent text-sm focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none",
inputWrapper: "bg-default-100 border-0 shadow-none h-8 min-h-8",
}}
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
<Button
isIconOnly
aria-label="关闭搜索"
className="text-default-600 shrink-0"
color="default"
size="sm"
variant="light"
onPress={() => {
onClose();
onChange("");
}}
>
<svg
aria-hidden="true"
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M6 18L18 6M6 6l12 12"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
</Button>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
+2 -2
View File
@@ -92,7 +92,7 @@ function SelectContent({
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",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
data-slot="select-content"
@@ -104,7 +104,7 @@ function SelectContent({
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
@@ -0,0 +1,51 @@
import { useCallback, useState, type SetStateAction } from "react";
const readStoredValue = <T>(key: string, fallbackValue: T): T => {
try {
const rawValue = localStorage.getItem(key);
if (rawValue === null) {
return fallbackValue;
}
return JSON.parse(rawValue) as T;
} catch {
return fallbackValue;
}
};
export const useLocalStorageState = <T>(
key: string,
initialValue: T,
): readonly [T, (value: SetStateAction<T>) => void, () => void] => {
const [value, setValue] = useState<T>(() =>
readStoredValue(key, initialValue),
);
const setPersistedValue = useCallback(
(nextValue: SetStateAction<T>) => {
setValue((prevValue) => {
const resolvedValue =
typeof nextValue === "function"
? (nextValue as (value: T) => T)(prevValue)
: nextValue;
try {
localStorage.setItem(key, JSON.stringify(resolvedValue));
} catch {}
return resolvedValue;
});
},
[key],
);
const resetPersistedValue = useCallback(() => {
setValue(initialValue);
try {
localStorage.removeItem(key);
} catch {}
}, [initialValue, key]);
return [value, setPersistedValue, resetPersistedValue] as const;
};
+63 -21
View File
@@ -50,6 +50,7 @@ export default function AdminLayout({
const { isOpen, onOpen, onOpenChange } = useDisclosure();
const [mobileMenuVisible, setMobileMenuVisible] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(() => localStorage.getItem("sidebar_collapsed") === "true");
const [username, setUsername] = useState("");
const [isAdmin, setIsAdmin] = useState(false);
const [passwordLoading, setPasswordLoading] = useState(false);
@@ -204,6 +205,13 @@ export default function AdminLayout({
setMobileMenuVisible(false);
};
// 切换折叠状态
const toggleCollapse = () => {
const newCollapsed = !isCollapsed;
setIsCollapsed(newCollapsed);
localStorage.setItem("sidebar_collapsed", newCollapsed.toString());
};
// 菜单点击处理
const handleMenuClick = (path: string) => {
navigate(path);
@@ -304,32 +312,32 @@ export default function AdminLayout({
className={`
${isMobile ? "fixed" : "relative"}
${isMobile && !mobileMenuVisible ? "-translate-x-full" : "translate-x-0"}
${isMobile ? "w-64" : "w-72"}
${isMobile ? "w-64" : isCollapsed ? "w-20" : "w-72"}
bg-white dark:bg-black
shadow-lg
border-r border-gray-200 dark:border-gray-600
z-50
transition-transform duration-300 ease-in-out
transition-all duration-300 ease-in-out
flex flex-col
${isMobile ? "h-screen" : "h-full"}
${isMobile ? "top-0 left-0" : ""}
`}
>
{/* Logo 区域 */}
<div className="px-3 py-3 h-14 flex items-center">
<div className="flex items-center gap-2 w-full">
<Logo size={24} />
<div className="flex-1 min-w-0">
<h1 className="text-sm font-bold text-foreground overflow-hidden whitespace-nowrap">
{siteConfig.name}
</h1>
<p className="text-xs text-default-500">v{siteConfig.version}</p>
</div>
<div className="px-5 h-14 flex items-center overflow-hidden whitespace-nowrap box-border">
<div className="flex-shrink-0 flex items-center justify-center w-10">
<Logo size={28} />
</div>
<div className={`transition-all duration-300 overflow-hidden ${isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[180px] opacity-100 ml-2"}`}>
<h1 className="text-sm font-bold text-foreground overflow-hidden whitespace-nowrap text-ellipsis">
{siteConfig.name}
</h1>
<p className="text-xs text-default-500 overflow-hidden whitespace-nowrap text-ellipsis">v{siteConfig.version}</p>
</div>
</div>
{/* 菜单导航 */}
<nav className="flex-1 px-4 py-6 overflow-y-auto">
<nav className="flex-1 px-3 py-6 overflow-y-auto overflow-x-hidden">
<ul className="space-y-1">
{filteredMenuItems.map((item) => {
const isActive = location.pathname === item.path;
@@ -337,9 +345,10 @@ export default function AdminLayout({
return (
<li key={item.path}>
<motion.button
title={isCollapsed ? item.label : undefined}
className={`
w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left
relative min-h-[44px]
w-full flex items-center p-2 rounded-lg text-left
relative min-h-[44px] overflow-hidden transition-colors
${isActive
? "text-primary-600 dark:text-primary-300"
: "text-gray-700 dark:text-gray-200"
@@ -352,7 +361,11 @@ export default function AdminLayout({
<motion.div
className="absolute inset-0 rounded-lg bg-primary-100 dark:bg-primary-600/20"
layoutId="sidebar-active"
transition={{ type: "spring", stiffness: 380, damping: 30 }}
transition={{
type: "spring",
stiffness: 380,
damping: 30,
}}
/>
)}
{!isActive && (
@@ -362,8 +375,14 @@ export default function AdminLayout({
whileHover={{ opacity: 1 }}
/>
)}
<div className="flex-shrink-0 relative z-10">{item.icon}</div>
<span className="font-medium text-sm relative z-10">{item.label}</span>
<div className="flex-shrink-0 w-10 h-10 flex items-center justify-center relative z-10">
{item.icon}
</div>
<div className={`transition-all duration-300 overflow-hidden flex items-center ${isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[200px] opacity-100 ml-2"}`}>
<span className="font-medium text-sm relative z-10 whitespace-nowrap">
{item.label}
</span>
</div>
</motion.button>
</li>
);
@@ -371,9 +390,9 @@ export default function AdminLayout({
</ul>
</nav>
{/* 底部版权信息 */}
<div className="px-4 py-2 pb-4 mt-auto flex-shrink-0">
<div className="text-center">
{/* 底部版权信息和折叠按钮 */}
<div className="px-5 py-2 pb-4 mt-auto flex-shrink-0 flex items-center justify-between overflow-hidden whitespace-nowrap box-border">
<div className={`transition-all duration-300 overflow-hidden flex items-center ${isCollapsed ? "max-w-0 opacity-0" : "max-w-[200px] opacity-100"}`}>
<p className="text-xs text-gray-400 dark:text-gray-500">
Powered by{" "}
<a
@@ -386,6 +405,29 @@ export default function AdminLayout({
</a>
</p>
</div>
{/* 桌面端折叠按钮 */}
{!isMobile && (
<Button
isIconOnly
variant="light"
size="sm"
className="flex-shrink-0 text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300 min-w-0 w-10 h-10 rounded-full ml-auto"
onPress={toggleCollapse}
>
{isCollapsed ? (
// 向右扩展的提示
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
) : (
// 向左收起的提示
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
)}
</Button>
)}
</div>
</aside>
@@ -491,7 +533,7 @@ export default function AdminLayout({
{/* 主内容 */}
<main
className={`flex-1 bg-gray-100 dark:bg-black overflow-hidden ${isMobile ? "" : "overflow-y-scroll"}`}
className="flex-1 bg-gray-100 dark:bg-black overflow-y-auto"
>
<AnimatePresence mode="wait">
<motion.div
+13 -11
View File
@@ -1,5 +1,7 @@
import { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import toast from "react-hot-toast";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Input } from "@/shadcn-bridge/heroui/input";
@@ -16,8 +18,6 @@ import {
ModalFooter,
ModalHeader,
} from "@/shadcn-bridge/heroui/modal";
import toast from "react-hot-toast";
import {
updateConfigs,
exportBackup,
@@ -149,7 +149,7 @@ const getInitialConfigs = (): Record<string, string> => {
initialConfigs[key] = cachedValue;
}
});
} catch { }
} catch {}
return initialConfigs;
};
@@ -528,10 +528,11 @@ export default function ConfigPage() {
<button
key={option.value}
aria-pressed={isSelected}
className={`w-full px-4 py-3 rounded-lg border transition-all duration-200 cursor-pointer text-left ${isSelected
? "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"
}`}
className={`w-full px-4 py-3 rounded-lg border transition-all duration-200 cursor-pointer text-left ${
isSelected
? "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"
onClick={() => toggleTypeSelection(option.value, setTypes)}
>
@@ -545,10 +546,11 @@ export default function ConfigPage() {
size="md"
/>
<span
className={`font-medium ${isSelected
? "text-default-900 dark:text-default-100"
: "text-default-700 dark:text-default-500"
}`}
className={`font-medium ${
isSelected
? "text-default-900 dark:text-default-100"
: "text-default-700 dark:text-default-500"
}`}
>
{option.label}
</span>
+1 -1
View File
@@ -1,7 +1,7 @@
import { useState } from "react";
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 { Button } from "@/shadcn-bridge/heroui/button";
import {
@@ -1,4 +1,5 @@
import type { AnnouncementData } from "@/api";
import ReactMarkdown from "react-markdown";
import rehypeSanitize from "rehype-sanitize";
import remarkGfm from "remark-gfm";
@@ -40,10 +41,10 @@ export const AnnouncementBanner = ({
</h3>
<div className="text-sm text-blue-800/90 dark:text-blue-100/90 break-words leading-relaxed">
<ReactMarkdown
rehypePlugins={[rehypeSanitize]}
remarkPlugins={[remarkGfm]}
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
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>
),
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 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 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>
),
}}
rehypePlugins={[rehypeSanitize]}
remarkPlugins={[remarkGfm]}
>
{announcement.content}
</ReactMarkdown>
+175 -65
View File
@@ -1,7 +1,5 @@
import { useState, useEffect, useMemo } from "react";
import toast from "react-hot-toast";
import { AnimatedPage } from "@/components/animated-page";
import { SearchBar } from "@/components/search-bar";
import {
DndContext,
closestCenter,
@@ -22,6 +20,8 @@ import {
import { useSortable } from "@dnd-kit/sortable";
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 { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@/shadcn-bridge/heroui/input";
@@ -80,6 +80,7 @@ import {
import { buildForwardOrder, FORWARD_ORDER_KEY } from "@/pages/forward/order";
import { PageLoadingState } from "@/components/page-state";
import { useMobileBreakpoint } from "@/hooks/useMobileBreakpoint";
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
import { saveOrder } from "@/utils/order-storage";
import { JwtUtil } from "@/utils/jwt";
@@ -121,14 +122,15 @@ interface ForwardForm {
strategy: string;
}
export default function ForwardPage() {
const [loading, setLoading] = useState(true);
const [forwards, setForwards] = useState<Forward[]>([]);
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
const isMobile = useMobileBreakpoint();
const [searchKeyword, setSearchKeyword] = useState("");
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
"forward-search-keyword",
"",
);
const [isSearchVisible, setIsSearchVisible] = useState(false);
// 显示模式状态 - 从localStorage读取,默认为平铺显示
@@ -137,14 +139,16 @@ export default function ForwardPage() {
const savedMode = localStorage.getItem("forward-view-mode");
return (savedMode as "grouped" | "direct") || "direct";
} catch (e) {
} catch {
return "direct";
}
});
// 筛选状态
const [filterUserId, setFilterUserId] = useState<string>("all");
const [filterTunnelId, setFilterTunnelId] = useState<string>("all");
const [filterUserId, setFilterUserId, resetFilterUserId] =
useLocalStorageState<string>("forward-filter-user-id", "all");
const [filterTunnelId, setFilterTunnelId, resetFilterTunnelId] =
useLocalStorageState<string>("forward-filter-tunnel-id", "all");
// 拖拽排序相关状态
const [forwardOrder, setForwardOrder] = useState<number[]>([]);
@@ -274,8 +278,6 @@ export default function ForwardPage() {
}
};
// 表单验证
const validateForm = (): boolean => {
const newErrors: { [key: string]: string } = {};
@@ -1100,19 +1102,27 @@ export default function ForwardPage() {
if (filterUserId !== "all") {
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") {
const targetTunnelId = parseInt(filterTunnelId);
filteredForwards = filteredForwards.filter(f => f.tunnelId === targetTunnelId);
filteredForwards = filteredForwards.filter(
(f) => f.tunnelId === targetTunnelId,
);
}
if (searchKeyword.trim()) {
const lowerKeyword = searchKeyword.toLowerCase();
filteredForwards = filteredForwards.filter(f =>
(f.name && f.name.toLowerCase().includes(lowerKeyword)) ||
(f.remoteAddr && f.remoteAddr.toLowerCase().includes(lowerKeyword)) ||
(f.userName && f.userName.toLowerCase().includes(lowerKeyword))
filteredForwards = filteredForwards.filter(
(f) =>
(f.name && f.name.toLowerCase().includes(lowerKeyword)) ||
(f.remoteAddr && f.remoteAddr.toLowerCase().includes(lowerKeyword)) ||
(f.userName && f.userName.toLowerCase().includes(lowerKeyword)),
);
}
@@ -1157,7 +1167,15 @@ export default function ForwardPage() {
}
return sortedByDb;
}, [forwards, forwardOrder, viewMode, tokenUserId, filterUserId, filterTunnelId, searchKeyword]);
}, [
forwards,
forwardOrder,
viewMode,
tokenUserId,
filterUserId,
filterTunnelId,
searchKeyword,
]);
const sortableForwardIds = useMemo(
() => sortedForwards.map((f) => f.id).filter((id) => id > 0),
@@ -1175,11 +1193,17 @@ export default function ForwardPage() {
isDragging,
} = useSortable({ id: forward.id });
const style = {
transform: transform ? CSS.Transform.toString(transform) : undefined,
const style: React.CSSProperties = {
transform: transform
? CSS.Transform.toString({
...transform,
x: Math.round(transform.x),
y: Math.round(transform.y),
})
: undefined,
transition: isDragging ? undefined : transition || undefined,
opacity: isDragging ? 0.5 : 1,
willChange: "transform",
willChange: isDragging ? "transform" : undefined,
};
return (
@@ -1192,8 +1216,10 @@ export default function ForwardPage() {
// 生成用作筛选项的用户和隧道列表
const uniqueUsers = useMemo(() => {
const userMap = new Map<number, { id: number; name: string }>();
forwards.forEach((f) => {
const uId = f.userId || 0;
if (!userMap.has(uId)) {
userMap.set(uId, { id: uId, name: f.userName || "未知用户" });
}
@@ -1203,7 +1229,22 @@ export default function ForwardPage() {
}, [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 {
attributes,
listeners,
@@ -1214,7 +1255,13 @@ export default function ForwardPage() {
} = useSortable({ id: forward.id });
const style = {
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,
opacity: isDragging ? 0.5 : 1,
backgroundColor: isDragging ? "var(--nextui-default-100)" : undefined,
@@ -1223,7 +1270,7 @@ export default function ForwardPage() {
const strategyDisplay = getStrategyDisplay(forward.strategy);
return (
<TableRow ref={setNodeRef} style={style} key={forward.id}>
<TableRow key={forward.id} ref={setNodeRef} style={style}>
{selectMode && (
<TableCell>
<Checkbox
@@ -1239,7 +1286,12 @@ export default function ForwardPage() {
{...listeners}
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" />
</svg>
</div>
@@ -1251,11 +1303,13 @@ export default function ForwardPage() {
</TableCell>
<TableCell className="whitespace-nowrap">
<Chip
size="sm"
color="secondary"
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>
</TableCell>
<TableCell className="whitespace-nowrap font-semibold text-foreground">
@@ -1309,10 +1363,10 @@ export default function ForwardPage() {
<TableCell>
<div className="flex items-center gap-2.5 whitespace-nowrap">
<Switch
color="success"
isDisabled={forward.status !== 1 && forward.status !== 0}
isSelected={forward.serviceRunning}
size="sm"
color="success"
onValueChange={() => handleServiceToggle(forward)}
/>
</div>
@@ -1326,8 +1380,18 @@ export default function ForwardPage() {
title="编辑"
onPress={() => handleEdit(forward)}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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" />
<svg
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>
</Button>
<Button
@@ -1337,8 +1401,18 @@ export default function ForwardPage() {
title="诊断"
onPress={() => handleDiagnose(forward)}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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" />
<svg
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>
</Button>
<Button
@@ -1348,8 +1422,18 @@ export default function ForwardPage() {
title="删除"
onPress={() => handleDelete(forward)}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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" />
<svg
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>
</Button>
</div>
@@ -1508,9 +1592,9 @@ export default function ForwardPage() {
</div>
{/* 统计信息 */}
<div className="flex items-center justify-between pt-2 border-t border-divider">
<div className="flex flex-wrap items-center justify-between pt-2 border-t border-divider gap-1">
<Chip
className="text-xs"
className="text-xs whitespace-nowrap"
color={strategyDisplay.color as any}
size="sm"
variant="flat"
@@ -1519,7 +1603,7 @@ export default function ForwardPage() {
</Chip>
<div className="flex items-center gap-1">
<Chip
className="text-xs"
className="text-xs whitespace-nowrap"
color="primary"
size="sm"
variant="flat"
@@ -1528,7 +1612,7 @@ export default function ForwardPage() {
</Chip>
</div>
<Chip
className="text-xs"
className="text-xs whitespace-nowrap"
color="success"
size="sm"
variant="flat"
@@ -1638,8 +1722,16 @@ export default function ForwardPage() {
<Button
isIconOnly
aria-label="筛选条件"
className={filterUserId !== "all" || filterTunnelId !== "all" ? "bg-primary/20 text-primary relative" : "text-default-600 relative"}
color={filterUserId !== "all" || filterTunnelId !== "all" ? "primary" : "default"}
className={
filterUserId !== "all" || filterTunnelId !== "all"
? "bg-primary/20 text-primary relative"
: "text-default-600 relative"
}
color={
filterUserId !== "all" || filterTunnelId !== "all"
? "primary"
: "default"
}
size="sm"
title="筛选条件"
variant="flat"
@@ -1653,10 +1745,10 @@ export default function ForwardPage() {
viewBox="0 0 24 24"
>
<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"
strokeLinejoin="round"
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>
{(filterUserId !== "all" || filterTunnelId !== "all") && (
@@ -1824,8 +1916,10 @@ export default function ForwardPage() {
}}
>
<TableHeader>
{selectMode && <TableColumn className="w-14">选择</TableColumn>}
<TableColumn className="w-10 pl-4"></TableColumn>
{selectMode && (
<TableColumn className="w-14">选择</TableColumn>
)}
<TableColumn className="w-10 pl-4" />
<TableColumn>用户</TableColumn>
<TableColumn>隧道</TableColumn>
<TableColumn>名称</TableColumn>
@@ -1838,22 +1932,26 @@ export default function ForwardPage() {
</TableHeader>
<TableBody emptyContent="暂无转发配置" items={sortedForwards}>
{(forward) => (
<SortableContext items={sortableForwardIds} strategy={verticalListSortingStrategy} key={forward.id}>
<SortableContext
key={forward.id}
items={sortableForwardIds}
strategy={verticalListSortingStrategy}
>
<SortableTableRow
forward={forward}
selectMode={selectMode}
selectedIds={selectedIds}
toggleSelect={toggleSelect}
getStrategyDisplay={getStrategyDisplay}
formatFlow={formatFlow}
formatInAddress={formatInAddress}
formatRemoteAddress={formatRemoteAddress}
handleServiceToggle={handleServiceToggle}
handleEdit={handleEdit}
forward={forward}
getStrategyDisplay={getStrategyDisplay}
handleDelete={handleDelete}
handleDiagnose={handleDiagnose}
showAddressModal={showAddressModal}
handleEdit={handleEdit}
handleServiceToggle={handleServiceToggle}
hasMultipleAddresses={hasMultipleAddresses}
formatFlow={formatFlow}
selectMode={selectMode}
selectedIds={selectedIds}
showAddressModal={showAddressModal}
toggleSelect={toggleSelect}
/>
</SortableContext>
)}
@@ -3051,14 +3149,16 @@ export default function ForwardPage() {
{/* 筛选模态框 */}
<Modal
isOpen={isFilterModalOpen}
onOpenChange={setIsFilterModalOpen}
placement="center"
size="md"
onOpenChange={setIsFilterModalOpen}
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">筛选条件</ModalHeader>
<ModalHeader className="flex flex-col gap-1">
筛选条件
</ModalHeader>
<ModalBody>
<div className="flex flex-col gap-4 py-2">
<div className="flex flex-col gap-2">
@@ -3066,16 +3166,19 @@ export default function ForwardPage() {
<Select
aria-label="筛选用户"
className="w-full"
variant="bordered"
selectedKeys={[filterUserId]}
variant="bordered"
onSelectionChange={(keys) => {
const key = Array.from(keys)[0] as string;
setFilterUserId(key || "all");
}}
>
<SelectItem key="all">全部用户</SelectItem>
{uniqueUsers.map(user => (
<SelectItem key={user.id.toString()}>{user.name}</SelectItem>
{uniqueUsers.map((user) => (
<SelectItem key={user.id.toString()}>
{user.name}
</SelectItem>
))}
</Select>
</div>
@@ -3084,26 +3187,33 @@ export default function ForwardPage() {
<Select
aria-label="筛选隧道"
className="w-full"
variant="bordered"
selectedKeys={[filterTunnelId]}
variant="bordered"
onSelectionChange={(keys) => {
const key = Array.from(keys)[0] as string;
setFilterTunnelId(key || "all");
}}
>
<SelectItem key="all">全部隧道</SelectItem>
{tunnels.map(tunnel => (
<SelectItem key={tunnel.id.toString()}>{tunnel.name}</SelectItem>
{tunnels.map((tunnel) => (
<SelectItem key={tunnel.id.toString()}>
{tunnel.name}
</SelectItem>
))}
</Select>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button color="default" variant="flat" onPress={() => {
setFilterUserId("all");
setFilterTunnelId("all");
}}>
<Button
color="default"
variant="flat"
onPress={() => {
resetFilterUserId();
resetFilterTunnelId();
}}
>
重置
</Button>
<Button color="primary" onPress={onClose}>
+2 -2
View File
@@ -259,9 +259,9 @@ export default function IndexPage() {
theme: (document.documentElement.classList.contains(
"dark",
) ||
document.documentElement.getAttribute("data-theme") ===
document.documentElement.getAttribute("data-theme") ===
"dark" ||
window.matchMedia("(prefers-color-scheme: dark)").matches
window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light") as "light" | "dark" | "auto",
}}
+15 -6
View File
@@ -1,8 +1,12 @@
import { useState, useEffect, useMemo } from "react";
import toast from "react-hot-toast";
import { AnimatedPage, StaggerList, StaggerItem } from "@/components/animated-page";
import { SearchBar } from "@/components/search-bar";
import {
AnimatedPage,
StaggerList,
StaggerItem,
} from "@/components/animated-page";
import { SearchBar } from "@/components/search-bar";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@/shadcn-bridge/heroui/input";
@@ -23,6 +27,7 @@ import {
getTunnelList,
} from "@/api";
import { PageLoadingState } from "@/components/page-state";
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
interface SpeedLimitRule {
id: number;
@@ -53,16 +58,20 @@ export default function LimitPage() {
const [loading, setLoading] = useState(true);
const [rules, setRules] = useState<SpeedLimitRule[]>([]);
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
const [searchKeyword, setSearchKeyword] = useState("");
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
"limit-search-keyword",
"",
);
const [isSearchVisible, setIsSearchVisible] = useState(false);
const filteredRules = useMemo(() => {
if (!searchKeyword.trim()) return rules;
const lowerKeyword = searchKeyword.toLowerCase();
return rules.filter(r =>
(r.name && r.name.toLowerCase().includes(lowerKeyword)) ||
(r.tunnelName && r.tunnelName.toLowerCase().includes(lowerKeyword))
return rules.filter(
(r) =>
(r.name && r.name.toLowerCase().includes(lowerKeyword)) ||
(r.tunnelName && r.tunnelName.toLowerCase().includes(lowerKeyword)),
);
}, [rules, searchKeyword]);
+58 -46
View File
@@ -1,7 +1,5 @@
import { useState, useEffect, useMemo, useCallback } from "react";
import toast from "react-hot-toast";
import { AnimatedPage } from "@/components/animated-page";
import { SearchBar } from "@/components/search-bar";
import {
DndContext,
KeyboardSensor,
@@ -20,6 +18,8 @@ import {
} from "@dnd-kit/sortable";
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 { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@/shadcn-bridge/heroui/input";
@@ -62,6 +62,7 @@ import { tryCopyInstallCommand } from "@/pages/node/install-command";
import { buildNodeSystemInfo } from "@/pages/node/system-info";
import { useNodeOfflineTimers } from "@/pages/node/use-node-offline-timers";
import { useNodeRealtime } from "@/pages/node/use-node-realtime";
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
import { loadStoredOrder, saveOrder } from "@/utils/order-storage";
interface Node {
@@ -129,11 +130,17 @@ const SortableItem = ({
isDragging,
} = useSortable({ id });
const style = {
transform: transform ? CSS.Transform.toString(transform) : undefined,
const style: React.CSSProperties = {
transform: transform
? CSS.Transform.toString({
...transform,
x: Math.round(transform.x),
y: Math.round(transform.y),
})
: undefined,
transition: isDragging ? undefined : transition || undefined,
opacity: isDragging ? 0.5 : 1,
willChange: "transform",
willChange: isDragging ? "transform" : undefined,
};
return (
@@ -152,7 +159,10 @@ export default function NodePage() {
const [nodeList, setNodeList] = useState<Node[]>([]);
const [nodeOrder, setNodeOrder] = useState<number[]>([]);
const [loading, setLoading] = useState(false);
const [searchKeyword, setSearchKeyword] = useState("");
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
"node-search-keyword",
"",
);
const [isSearchVisible, setIsSearchVisible] = useState(false);
const [dialogVisible, setDialogVisible] = useState(false);
const [dialogTitle, setDialogTitle] = useState("");
@@ -825,23 +835,23 @@ export default function NodePage() {
prev.map((n) =>
n.id === form.id
? {
...n,
name: form.name,
serverIp:
form.serverIpV4?.trim() ||
form.serverIpV6?.trim() ||
form.serverHost?.trim() ||
"",
serverIpV4: form.serverIpV4,
serverIpV6: form.serverIpV6,
port: form.port,
tcpListenAddr: form.tcpListenAddr,
udpListenAddr: form.udpListenAddr,
interfaceName: form.interfaceName,
http: form.http,
tls: form.tls,
socks: form.socks,
}
...n,
name: form.name,
serverIp:
form.serverIpV4?.trim() ||
form.serverIpV6?.trim() ||
form.serverHost?.trim() ||
"",
serverIpV4: form.serverIpV4,
serverIpV6: form.serverIpV6,
port: form.port,
tcpListenAddr: form.tcpListenAddr,
udpListenAddr: form.udpListenAddr,
interfaceName: form.interfaceName,
http: form.http,
tls: form.tls,
socks: form.socks,
}
: n,
),
);
@@ -1002,11 +1012,13 @@ export default function NodePage() {
if (searchKeyword.trim()) {
const lowerKeyword = searchKeyword.toLowerCase();
filteredNodes = filteredNodes.filter(n =>
(n.name && n.name.toLowerCase().includes(lowerKeyword)) ||
(n.serverIp && n.serverIp.toLowerCase().includes(lowerKeyword)) ||
(n.serverIpV4 && n.serverIpV4.toLowerCase().includes(lowerKeyword)) ||
(n.serverIpV6 && n.serverIpV6.toLowerCase().includes(lowerKeyword))
filteredNodes = filteredNodes.filter(
(n) =>
(n.name && n.name.toLowerCase().includes(lowerKeyword)) ||
(n.serverIp && n.serverIp.toLowerCase().includes(lowerKeyword)) ||
(n.serverIpV4 && n.serverIpV4.toLowerCase().includes(lowerKeyword)) ||
(n.serverIpV6 && n.serverIpV6.toLowerCase().includes(lowerKeyword)),
);
}
@@ -1232,7 +1244,7 @@ export default function NodePage() {
</span>
<div className="text-right text-xs min-w-0 flex-1 ml-2 min-h-[2.125rem]">
{node.serverIpV4?.trim() ||
node.serverIpV6?.trim() ? (
node.serverIpV6?.trim() ? (
<div className="space-y-0.5">
{node.serverIpV4?.trim() && (
<span
@@ -1288,7 +1300,7 @@ export default function NodePage() {
</span>
<span className="text-xs">
{node.connectionStatus === "online" &&
node.systemInfo
node.systemInfo
? formatUptime(node.systemInfo.uptime)
: "-"}
</span>
@@ -1307,7 +1319,7 @@ export default function NodePage() {
<span>CPU</span>
<span className="font-mono">
{node.connectionStatus === "online" &&
node.systemInfo
node.systemInfo
? `${node.systemInfo.cpuUsage.toFixed(1)}%`
: "-"}
</span>
@@ -1324,7 +1336,7 @@ export default function NodePage() {
size="sm"
value={
node.connectionStatus === "online" &&
node.systemInfo
node.systemInfo
? node.systemInfo.cpuUsage
: 0
}
@@ -1335,7 +1347,7 @@ export default function NodePage() {
<span>内存</span>
<span className="font-mono">
{node.connectionStatus === "online" &&
node.systemInfo
node.systemInfo
? `${node.systemInfo.memoryUsage.toFixed(1)}%`
: "-"}
</span>
@@ -1352,7 +1364,7 @@ export default function NodePage() {
size="sm"
value={
node.connectionStatus === "online" &&
node.systemInfo
node.systemInfo
? node.systemInfo.memoryUsage
: 0
}
@@ -1367,10 +1379,10 @@ export default function NodePage() {
</div>
<div className="font-mono">
{node.connectionStatus === "online" &&
node.systemInfo
node.systemInfo
? formatSpeed(
node.systemInfo.uploadSpeed,
)
node.systemInfo.uploadSpeed,
)
: "-"}
</div>
</div>
@@ -1380,10 +1392,10 @@ export default function NodePage() {
</div>
<div className="font-mono">
{node.connectionStatus === "online" &&
node.systemInfo
node.systemInfo
? formatSpeed(
node.systemInfo.downloadSpeed,
)
node.systemInfo.downloadSpeed,
)
: "-"}
</div>
</div>
@@ -1397,10 +1409,10 @@ export default function NodePage() {
</div>
<div className="font-mono text-primary-700 dark:text-primary-300">
{node.connectionStatus === "online" &&
node.systemInfo
node.systemInfo
? formatTraffic(
node.systemInfo.uploadTraffic,
)
node.systemInfo.uploadTraffic,
)
: "-"}
</div>
</div>
@@ -1410,10 +1422,10 @@ export default function NodePage() {
</div>
<div className="font-mono text-success-700 dark:text-success-300">
{node.connectionStatus === "online" &&
node.systemInfo
node.systemInfo
? formatTraffic(
node.systemInfo.downloadTraffic,
)
node.systemInfo.downloadTraffic,
)
: "-"}
</div>
</div>
+10 -5
View File
@@ -416,7 +416,8 @@ export default function PanelSharingPage() {
Provider 共享
</h2>
<p className="text-sm text-default-500">
将本地节点分享给其他面板,统一管理 Token、端口范围和到期策略。
将本地节点分享给其他面板,统一管理
Token、端口范围和到期策略。
</p>
</div>
<Button
@@ -429,7 +430,9 @@ export default function PanelSharingPage() {
</div>
{loading ? (
<div className="text-center py-12 text-default-500">加载中...</div>
<div className="text-center py-12 text-default-500">
加载中...
</div>
) : 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">
<p className="text-base font-semibold text-foreground">
@@ -574,7 +577,9 @@ export default function PanelSharingPage() {
</div>
{remoteUsageLoading ? (
<div className="text-center py-12 text-default-500">加载中...</div>
<div className="text-center py-12 text-default-500">
加载中...
</div>
) : 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">
<p className="text-base font-semibold text-foreground">
@@ -669,7 +674,7 @@ export default function PanelSharingPage() {
</Tabs>
{/* Create Share Modal */}
<Modal isOpen={createShareOpen} onClose={() => setCreateShareOpen(false)}>
<Modal isOpen={createShareOpen} onClose={() => setCreateShareOpen(false)} scrollBehavior="inside">
<ModalContent>
<ModalHeader>创建分享</ModalHeader>
<ModalBody>
@@ -772,7 +777,7 @@ export default function PanelSharingPage() {
</Modal>
{/* Edit Share Modal */}
<Modal isOpen={editShareOpen} onClose={() => setEditShareOpen(false)}>
<Modal isOpen={editShareOpen} onClose={() => setEditShareOpen(false)} scrollBehavior="inside">
<ModalContent>
<ModalHeader>编辑分享</ModalHeader>
<ModalBody>
+90 -72
View File
@@ -1,7 +1,5 @@
import { useState, useEffect, useMemo } from "react";
import toast from "react-hot-toast";
import { AnimatedPage } from "@/components/animated-page";
import { SearchBar } from "@/components/search-bar";
import {
DndContext,
KeyboardSensor,
@@ -20,6 +18,8 @@ import {
} from "@dnd-kit/sortable";
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 { Button } from "@/shadcn-bridge/heroui/button";
import { Input, Textarea } from "@/shadcn-bridge/heroui/input";
@@ -59,6 +59,7 @@ import {
getTunnelTypeDisplay,
validateTunnelForm,
} from "@/pages/tunnel/form";
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
import { loadStoredOrder, saveOrder } from "@/utils/order-storage";
import { extractApiErrorMessage } from "@/api/error-message";
@@ -113,7 +114,10 @@ export default function TunnelPage() {
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
const [tunnelOrder, setTunnelOrder] = useState<number[]>([]);
const [nodes, setNodes] = useState<Node[]>([]);
const [searchKeyword, setSearchKeyword] = useState("");
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
"tunnel-search-keyword",
"",
);
const [isSearchVisible, setIsSearchVisible] = useState(false);
// 模态框状态
@@ -230,9 +234,9 @@ export default function TunnelPage() {
trafficRatio: tunnel.trafficRatio,
inIp: tunnel.inIp
? tunnel.inIp
.split(",")
.map((ip: string) => ip.trim())
.join("\n")
.split(",")
.map((ip: string) => ip.trim())
.join("\n")
: "",
ipPreference: tunnel.ipPreference || "",
status: tunnel.status,
@@ -605,9 +609,11 @@ export default function TunnelPage() {
if (searchKeyword.trim()) {
const lowerKeyword = searchKeyword.toLowerCase();
filteredTunnels = filteredTunnels.filter(t =>
(t.name && t.name.toLowerCase().includes(lowerKeyword)) ||
(t.inIp && t.inIp.toLowerCase().includes(lowerKeyword))
filteredTunnels = filteredTunnels.filter(
(t) =>
(t.name && t.name.toLowerCase().includes(lowerKeyword)) ||
(t.inIp && t.inIp.toLowerCase().includes(lowerKeyword)),
);
}
@@ -666,11 +672,17 @@ export default function TunnelPage() {
isDragging,
} = useSortable({ id });
const style = {
transform: transform ? CSS.Transform.toString(transform) : undefined,
const style: React.CSSProperties = {
transform: transform
? CSS.Transform.toString({
...transform,
x: Math.round(transform.x),
y: Math.round(transform.y),
})
: undefined,
transition: isDragging ? undefined : transition || undefined,
opacity: isDragging ? 0.5 : 1,
willChange: "transform",
willChange: isDragging ? "transform" : undefined,
};
return (
@@ -1381,9 +1393,9 @@ export default function TunnelPage() {
.filter((id) => id !== -1)
.map((id) => id.toString()),
]}
dropdownPlacement="top"
label="节点"
placeholder="选择节点(可多选)"
dropdownPlacement="top"
selectedKeys={groupNodes
.filter((ct) => ct.nodeId !== -1)
.map((ct) => ct.nodeId.toString())}
@@ -1450,14 +1462,14 @@ export default function TunnelPage() {
{form.inNodeId.some(
(ct) => ct.nodeId === node.id,
) && (
<Chip
color="warning"
size="sm"
variant="flat"
>
已选为入口
</Chip>
)}
<Chip
color="warning"
size="sm"
variant="flat"
>
已选为入口
</Chip>
)}
{form.outNodeId &&
form.outNodeId.some(
(ct) => ct.nodeId === node.id,
@@ -1480,14 +1492,14 @@ export default function TunnelPage() {
ct.nodeId !== -1,
),
) && (
<Chip
color="primary"
size="sm"
variant="flat"
>
已选为其他跳
</Chip>
)}
<Chip
color="primary"
size="sm"
variant="flat"
>
已选为其他跳
</Chip>
)}
</div>
</div>
</SelectItem>
@@ -1597,16 +1609,16 @@ export default function TunnelPage() {
id.toString(),
),
]}
dropdownPlacement="top"
errorMessage={errors.outNodeId}
isInvalid={!!errors.outNodeId}
label="节点"
placeholder="请选择出口节点(可多选)"
dropdownPlacement="top"
selectedKeys={
form.outNodeId
? form.outNodeId
.filter((ct) => ct.nodeId !== -1)
.map((ct) => ct.nodeId.toString())
.filter((ct) => ct.nodeId !== -1)
.map((ct) => ct.nodeId.toString())
: []
}
selectionMode="multiple"
@@ -1673,25 +1685,25 @@ export default function TunnelPage() {
{form.inNodeId.some(
(ct) => ct.nodeId === node.id,
) && (
<Chip
color="warning"
size="sm"
variant="flat"
>
已选为入口
</Chip>
)}
<Chip
color="warning"
size="sm"
variant="flat"
>
已选为入口
</Chip>
)}
{getSelectedChainNodeIds().includes(
node.id,
) && (
<Chip
color="primary"
size="sm"
variant="flat"
>
已选为转发链
</Chip>
)}
<Chip
color="primary"
size="sm"
variant="flat"
>
已选为转发链
</Chip>
)}
</div>
</div>
</SelectItem>
@@ -2054,18 +2066,20 @@ export default function TunnelPage() {
return (
<tr
key={index}
className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${result.success
? "bg-white dark:bg-gray-800"
: "bg-danger-50 dark:bg-danger-900/30"
}`}
className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${
result.success
? "bg-white dark:bg-gray-800"
: "bg-danger-50 dark:bg-danger-900/30"
}`}
>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<span
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${result.success
? "bg-success text-white"
: "bg-danger text-white"
}`}
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
result.success
? "bg-success text-white"
: "bg-danger text-white"
}`}
>
{result.success ? "✓" : "✗"}
</span>
@@ -2107,10 +2121,11 @@ export default function TunnelPage() {
<td className="px-3 py-2 text-center">
{result.success ? (
<span
className={`font-semibold ${(result.packetLoss || 0) > 0
? "text-warning"
: "text-success"
}`}
className={`font-semibold ${
(result.packetLoss || 0) > 0
? "text-warning"
: "text-success"
}`}
>
{result.packetLoss?.toFixed(1)}%
</span>
@@ -2223,17 +2238,19 @@ export default function TunnelPage() {
return (
<div
key={index}
className={`border rounded-lg p-3 ${result.success
? "border-divider bg-white dark:bg-gray-800"
: "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30"
}`}
className={`border rounded-lg p-3 ${
result.success
? "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">
<span
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${result.success
? "bg-success text-white"
: "bg-danger text-white"
}`}
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${
result.success
? "bg-success text-white"
: "bg-danger text-white"
}`}
>
{result.success ? "✓" : "✗"}
</span>
@@ -2269,10 +2286,11 @@ export default function TunnelPage() {
</div>
<div className="text-center">
<div
className={`text-lg font-bold ${(result.packetLoss || 0) > 0
? "text-warning"
: "text-success"
}`}
className={`text-lg font-bold ${
(result.packetLoss || 0) > 0
? "text-warning"
: "text-success"
}`}
>
{result.packetLoss?.toFixed(1)}%
</div>
+46 -37
View File
@@ -1,8 +1,12 @@
import { useState, useEffect } from "react";
import toast from "react-hot-toast";
import { AnimatedPage, StaggerList, StaggerItem } from "@/components/animated-page";
import { parseDate } from "@internationalized/date";
import {
AnimatedPage,
StaggerList,
StaggerItem,
} from "@/components/animated-page";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Input } from "@/shadcn-bridge/heroui/input";
@@ -61,6 +65,7 @@ import {
SettingsIcon,
} from "@/components/icons";
import { PageLoadingState } from "@/components/page-state";
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
// 工具函数
const formatFlow = (value: number, unit: string = "bytes"): string => {
@@ -121,7 +126,10 @@ export default function UserPage() {
// 状态管理
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [searchKeyword, setSearchKeyword] = useState("");
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
"user-search-keyword",
"",
);
const [isSearchVisible, setIsSearchVisible] = useState(false);
const [pagination, setPagination] = useState<PaginationType>({
current: 1,
@@ -248,7 +256,7 @@ export default function UserPage() {
if (response.code === 0) {
setTunnels(Array.isArray(response.data) ? response.data : []);
}
} catch { }
} catch {}
};
const loadSpeedLimits = async () => {
@@ -258,15 +266,15 @@ export default function UserPage() {
if (response.code === 0) {
const speedLimitList = Array.isArray(response.data)
? response.data.map((item) => ({
...item,
uploadSpeed: item.uploadSpeed ?? item.speed ?? 0,
downloadSpeed: item.downloadSpeed ?? item.speed ?? 0,
}))
...item,
uploadSpeed: item.uploadSpeed ?? item.speed ?? 0,
downloadSpeed: item.downloadSpeed ?? item.speed ?? 0,
}))
: [];
setSpeedLimits(speedLimitList);
}
} catch { }
} catch {}
};
const loadUserGroups = async () => {
@@ -276,7 +284,7 @@ export default function UserPage() {
if (response.code === 0) {
setUserGroups(Array.isArray(response.data) ? response.data : []);
}
} catch { }
} catch {}
};
const loadUserTunnels = async (userId: number) => {
@@ -327,7 +335,7 @@ export default function UserPage() {
if (groupRes.code === 0) {
currentGroupIds = groupRes.data || [];
}
} catch { }
} catch {}
setUserForm({
id: user.id,
@@ -703,16 +711,14 @@ export default function UserPage() {
const flowPercent =
user.flow > 0
? Math.min(
(usedFlow / (user.flow * 1024 * 1024 * 1024)) * 100,
100,
)
(usedFlow / (user.flow * 1024 * 1024 * 1024)) * 100,
100,
)
: 0;
return (
<StaggerItem key={user.id}>
<Card
className="shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden h-full"
>
<Card className="shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden h-full">
<CardHeader className="pb-2 md:pb-2">
<div className="flex justify-between items-start w-full">
<div className="flex-1 min-w-0">
@@ -771,7 +777,9 @@ export default function UserPage() {
<div className="space-y-1.5 pt-2 border-t border-divider">
<div className="flex justify-between text-sm">
<span className="text-default-600">转发数量</span>
<span className="font-medium text-xs">{user.num}</span>
<span className="font-medium text-xs">
{user.num}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-default-600">重置日期</span>
@@ -876,7 +884,7 @@ export default function UserPage() {
)}
{/* 用户表单模态框 */}
< Modal
<Modal
backdrop="blur"
isOpen={isUserModalOpen}
placement="center"
@@ -971,8 +979,8 @@ export default function UserPage() {
value={
userForm.expTime
? (parseDate(
userForm.expTime.toISOString().split("T")[0],
) as any)
userForm.expTime.toISOString().split("T")[0],
) as any)
: null
}
onChange={(date) => {
@@ -1078,11 +1086,12 @@ export default function UserPage() {
aria-disabled={isAssigned}
className={`
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"
: isSelected
? "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"
${
isAssigned
? "bg-default-100/50 dark:bg-default-50/50 border-default-200/50 dark:border-default-100/20 opacity-60 cursor-not-allowed"
: isSelected
? "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"
@@ -1142,13 +1151,13 @@ export default function UserPage() {
selectedKeys={
batchTunnelSelections.get(tunnel.id) !==
null &&
batchTunnelSelections.get(tunnel.id) !==
batchTunnelSelections.get(tunnel.id) !==
undefined
? [
batchTunnelSelections
.get(tunnel.id)!
.toString(),
]
batchTunnelSelections
.get(tunnel.id)!
.toString(),
]
: ["null"]
}
size="sm"
@@ -1408,9 +1417,9 @@ export default function UserPage() {
setEditTunnelForm((prev) =>
prev
? {
...prev,
speedId: value === "null" ? null : Number(value),
}
...prev,
speedId: value === "null" ? null : Number(value),
}
: null,
);
}}
@@ -1465,10 +1474,10 @@ export default function UserPage() {
value={
editTunnelForm.expTime
? (parseDate(
new Date(editTunnelForm.expTime)
.toISOString()
.split("T")[0],
) as any)
new Date(editTunnelForm.expTime)
.toISOString()
.split("T")[0],
) as any)
: null
}
onChange={(date) => {
@@ -177,6 +177,7 @@ export function DatePicker({
required={isRequired}
type={shouldUseTextInput ? "text" : "date"}
value={shouldUseTextInput ? textValue : formattedValue}
onBlur={shouldUseTextInput ? commitTextInput : undefined}
onChange={(event) => {
const nextValue = event.target.value;
@@ -189,7 +190,6 @@ export function DatePicker({
notifyNativeDateChange(nextValue);
}}
onBlur={shouldUseTextInput ? commitTextInput : undefined}
onKeyDown={
shouldUseTextInput
? (event) => {
@@ -207,7 +207,7 @@ export function ModalFooter({
return (
<div
className={cn(
"mt-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
"mt-4 flex flex-wrap justify-end gap-2",
context?.classNames?.footer,
className,
)}
@@ -286,26 +286,22 @@ export function Select<T>({
return null;
}
const placementClasses = dropdownPlacement === "top"
? "bottom-full mb-1"
: "top-full mt-1";
const placementClasses =
dropdownPlacement === "top" ? "bottom-full mb-1" : "top-full mt-1";
return (
<div
ref={listboxRef}
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",
placementClasses
placementClasses,
)}
id={`${generatedId}-listbox`}
ref={listboxRef}
role="listbox"
>
{options.length === 0 ? (
<div
className={cn(
"px-2 py-1 text-default-500",
textSizeClass(size),
)}
className={cn("px-2 py-1 text-default-500", textSizeClass(size))}
>
暂无可选项
</div>
@@ -334,9 +330,7 @@ export function Select<T>({
className={cn(
"min-w-0 flex-1 truncate text-left text-foreground",
textSizeClass(size),
optionDisabled
? "cursor-not-allowed"
: "cursor-pointer",
optionDisabled ? "cursor-not-allowed" : "cursor-pointer",
)}
disabled={optionDisabled}
type="button"
@@ -363,13 +357,7 @@ export function Select<T>({
label={label}
>
{selectionMode === "multiple" ? (
<div
ref={containerRef}
className={cn(
"relative w-full",
className,
)}
>
<div ref={containerRef} className={cn("relative w-full", className)}>
<button
aria-controls={`${generatedId}-listbox`}
aria-expanded={isExpanded}