优化流程

This commit is contained in:
qaq
2025-07-02 15:40:14 +08:00
parent d7339af37b
commit 94e01ae63b
66 changed files with 5973 additions and 1988 deletions
+121
View File
@@ -0,0 +1,121 @@
import { Route, Routes, Navigate, useNavigate } from "react-router-dom";
import { useEffect, useState } from "react";
import IndexPage from "@/pages/index";
import ChangePasswordPage from "@/pages/change-password";
import DashboardPage from "@/pages/dashboard";
import ForwardPage from "@/pages/forward";
import LimitPage from "@/pages/limit";
import PageWrapper from "@/components/page-wrapper";
import { isLoggedIn } from "@/utils/auth";
// 路由保护组件 - 使用useNavigate避免循环渲染
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const [checking, setChecking] = useState(true);
const [authenticated, setAuthenticated] = useState(false);
const navigate = useNavigate();
useEffect(() => {
const checkAuth = () => {
const loggedIn = isLoggedIn();
if (!loggedIn) {
// 如果未登录,直接导航到登录页
navigate('/', { replace: true });
return;
}
setAuthenticated(true);
setChecking(false);
};
checkAuth();
}, [navigate]);
if (checking) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-lg">检查登陆状态...</div>
</div>
);
}
return authenticated ? <>{children}</> : null;
};
function App() {
return (
<Routes>
<Route
path="/"
element={
isLoggedIn() ? <Navigate to="/dashboard" replace /> : <IndexPage />
}
/>
<Route
path="/change-password"
element={
<ProtectedRoute>
<ChangePasswordPage />
</ProtectedRoute>
}
/>
<Route
path="/dashboard"
element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
}
/>
<Route
path="/forward"
element={
<ProtectedRoute>
<ForwardPage />
</ProtectedRoute>
}
/>
<Route
path="/tunnel"
element={
<ProtectedRoute>
<PageWrapper title="隧道管理" description="此页面正在开发中...">
<div></div>
</PageWrapper>
</ProtectedRoute>
}
/>
<Route
path="/node"
element={
<ProtectedRoute>
<PageWrapper title="节点监控" description="此页面正在开发中...">
<div></div>
</PageWrapper>
</ProtectedRoute>
}
/>
<Route
path="/user"
element={
<ProtectedRoute>
<PageWrapper title="用户管理" description="此页面正在开发中...">
<div></div>
</PageWrapper>
</ProtectedRoute>
}
/>
<Route
path="/limit"
element={
<ProtectedRoute>
<LimitPage />
</ProtectedRoute>
}
/>
</Routes>
);
}
export default App;
+66
View File
@@ -0,0 +1,66 @@
import Network from './network';
// 登陆相关接口
export interface LoginData {
username: string;
password: string;
}
export interface LoginResponse {
token: string;
role_id: number;
name: string;
requirePasswordChange?: boolean;
}
export const login = (data: LoginData) => Network.post<LoginResponse>("/user/login", data);
// 用户CRUD操作 - 全部使用POST请求
export const createUser = (data: any) => Network.post("/user/create", data);
export const getAllUsers = (pageData: any = {}) => Network.post("/user/list", pageData);
export const updateUser = (data: any) => Network.post("/user/update", data);
export const deleteUser = (id: number) => Network.post("/user/delete", { id });
export const getUserPackageInfo = () => Network.post("/user/package");
// 节点CRUD操作 - 全部使用POST请求
export const createNode = (data: any) => Network.post("/node/create", data);
export const getNodeList = () => Network.post("/node/list");
export const updateNode = (data: any) => Network.post("/node/update", data);
export const deleteNode = (id: number) => Network.post("/node/delete", { id });
export const getNodeInstallCommand = (id: number) => Network.post("/node/install", { id });
// 隧道CRUD操作 - 全部使用POST请求
export const createTunnel = (data: any) => Network.post("/tunnel/create", data);
export const getTunnelList = () => Network.post("/tunnel/list");
export const getTunnelById = (id: number) => Network.post("/tunnel/get", { id });
export const updateTunnel = (data: any) => Network.post("/tunnel/update", data);
export const deleteTunnel = (id: number) => Network.post("/tunnel/delete", { id });
export const diagnoseTunnel = (tunnelId: number) => Network.post("/tunnel/diagnose", { tunnelId });
// 用户隧道权限管理操作 - 全部使用POST请求
export const assignUserTunnel = (data: any) => Network.post("/tunnel/user/assign", data);
export const getUserTunnelList = (queryData: any = {}) => Network.post("/tunnel/user/list", queryData);
export const removeUserTunnel = (params: any) => Network.post("/tunnel/user/remove", params);
export const updateUserTunnelFlow = (params: any) => Network.post("/tunnel/user/updateFlow", params);
export const updateUserTunnel = (data: any) => Network.post("/tunnel/user/update", data);
export const userTunnel = () => Network.post("/tunnel/user/tunnel");
// 转发CRUD操作 - 全部使用POST请求
export const createForward = (data: any) => Network.post("/forward/create", data);
export const getForwardList = () => Network.post("/forward/list");
export const updateForward = (data: any) => Network.post("/forward/update", data);
export const deleteForward = (id: number) => Network.post("/forward/delete", { id });
export const forceDeleteForward = (id: number) => Network.post("/forward/force-delete", { id });
// 转发服务控制操作 - 通过Java后端接口
export const pauseForwardService = (forwardId: number) => Network.post("/forward/pause", { id: forwardId });
export const resumeForwardService = (forwardId: number) => Network.post("/forward/resume", { id: forwardId });
// 限速规则CRUD操作 - 全部使用POST请求
export const createSpeedLimit = (data: any) => Network.post("/speed-limit/create", data);
export const getSpeedLimitList = () => Network.post("/speed-limit/list");
export const updateSpeedLimit = (data: any) => Network.post("/speed-limit/update", data);
export const deleteSpeedLimit = (id: number) => Network.post("/speed-limit/delete", { id });
// 修改密码接口
export const updatePassword = (data: any) => Network.post("/user/updatePassword", data);
+106
View File
@@ -0,0 +1,106 @@
import axios, { AxiosResponse } from 'axios';
// 配置axios基础URL
const baseURL = import.meta.env.VITE_API_BASE ? `${import.meta.env.VITE_API_BASE}/api/v1/` : '/api/v1/';
axios.defaults.baseURL = baseURL;
// 在开发环境下输出API配置信息
if (import.meta.env.DEV) {
console.log('🌐 API Configuration:');
console.log(' - VITE_API_BASE:', import.meta.env.VITE_API_BASE || '(undefined)');
console.log(' - Base URL:', baseURL);
console.log(' - Environment:', import.meta.env.MODE);
}
interface ApiResponse<T = any> {
code: number;
msg: string;
data: T;
}
// 处理token失效的逻辑
function handleTokenExpired() {
// 清除localStorage中的token
window.localStorage.removeItem('token');
window.localStorage.removeItem('role_id');
window.localStorage.removeItem('name');
// 跳转到登录页面
if (window.location.pathname !== '/') {
window.location.href = '/';
}
}
// 检查响应是否为token失效
function isTokenExpired(response: ApiResponse) {
return response && response.code === 401 &&
(response.msg === '未登录或token已过期' ||
response.msg === '无效的token或token已过期' ||
response.msg === '无法获取用户权限信息');
}
const Network = {
get: function<T = any>(path: string = '', data: any = {}): Promise<ApiResponse<T>> {
return new Promise(function(resolve) {
axios.get(path, {
params: data,
timeout: 30000,
headers: {
"Authorization": window.localStorage.getItem('token')
}
})
.then(function(response: AxiosResponse<ApiResponse<T>>) {
// 检查是否token失效
if (isTokenExpired(response.data)) {
handleTokenExpired();
return;
}
resolve(response.data);
})
.catch(function(error: any) {
console.error('GET请求错误:', error);
// 检查是否是401错误(token失效)
if (error.response && error.response.status === 401) {
handleTokenExpired();
return;
}
resolve({"code": -1, "msg": error.message || "网络请求失败", "data": null as T});
});
});
},
post: function<T = any>(path: string = '', data: any = {}): Promise<ApiResponse<T>> {
return new Promise(function(resolve) {
axios.post(path, data, {
timeout: 30000,
headers: {
"Authorization": window.localStorage.getItem('token'),
"Content-Type": "application/json"
}
})
.then(function(response: AxiosResponse<ApiResponse<T>>) {
// 检查是否token失效
if (isTokenExpired(response.data)) {
handleTokenExpired();
return;
}
resolve(response.data);
})
.catch(function(error: any) {
console.error('POST请求错误:', error);
// 检查是否是401错误(token失效)
if (error.response && error.response.status === 401) {
handleTokenExpired();
return;
}
resolve({"code": -1, "msg": error.message || "网络请求失败", "data": null as T});
});
});
}
};
export default Network;
+68
View File
@@ -0,0 +1,68 @@
import * as React from "react";
import { IconSvgProps } from "@/types";
export const Logo: React.FC<IconSvgProps> = ({
size = 36,
height,
...props
}) => (
<svg
fill="none"
height={size || height}
viewBox="0 0 32 32"
width={size || height}
{...props}
>
<path
clipRule="evenodd"
d="M17.6482 10.1305L15.8785 7.02583L7.02979 22.5499H10.5278L17.6482 10.1305ZM19.8798 14.0457L18.11 17.1983L19.394 19.4511H16.8453L15.1056 22.5499H24.7272L19.8798 14.0457Z"
fill="currentColor"
fillRule="evenodd"
/>
</svg>
);
export const MoonFilledIcon = ({
size = 24,
width,
height,
...props
}: IconSvgProps) => (
<svg
aria-hidden="true"
focusable="false"
height={size || height}
role="presentation"
viewBox="0 0 24 24"
width={size || width}
{...props}
>
<path
d="M21.53 15.93c-.16-.27-.61-.69-1.73-.49a8.46 8.46 0 01-1.88.13 8.409 8.409 0 01-5.91-2.82 8.068 8.068 0 01-1.44-8.66c.44-1.01.13-1.54-.09-1.76s-.77-.55-1.83-.11a10.318 10.318 0 00-6.32 10.21 10.475 10.475 0 007.04 8.99 10 10 0 002.89.55c.16.01.32.02.48.02a10.5 10.5 0 008.47-4.27c.67-.93.49-1.519.32-1.79z"
fill="currentColor"
/>
</svg>
);
export const SunFilledIcon = ({
size = 24,
width,
height,
...props
}: IconSvgProps) => (
<svg
aria-hidden="true"
focusable="false"
height={size || height}
role="presentation"
viewBox="0 0 24 24"
width={size || width}
{...props}
>
<g fill="currentColor">
<path d="M19 12a7 7 0 11-7-7 7 7 0 017 7z" />
<path d="M12 22.96a.969.969 0 01-1-.96v-.08a1 1 0 012 0 1.038 1.038 0 01-1 1.04zm7.14-2.82a1.024 1.024 0 01-.71-.29l-.13-.13a1 1 0 011.41-1.41l.13.13a1 1 0 010 1.41.984.984 0 01-.7.29zm-14.28 0a1.024 1.024 0 01-.71-.29 1 1 0 010-1.41l.13-.13a1 1 0 011.41 1.41l-.13.13a1 1 0 01-.7.29zM22 13h-.08a1 1 0 010-2 1.038 1.038 0 011.04 1 .969.969 0 01-.96 1zM2.08 13H2a1 1 0 010-2 1.038 1.038 0 011.04 1 .969.969 0 01-.96 1zm16.93-7.01a1.024 1.024 0 01-.71-.29 1 1 0 010-1.41l.13-.13a1 1 0 011.41 1.41l-.13.13a.984.984 0 01-.7.29zm-14.02 0a1.024 1.024 0 01-.71-.29l-.13-.14a1 1 0 011.41-1.41l.13.13a1 1 0 010 1.41.97.97 0 01-.7.3zM12 3.04a.969.969 0 01-1-.96V2a1 1 0 012 0 1.038 1.038 0 01-1 1.04z" />
</g>
</svg>
);
+32
View File
@@ -0,0 +1,32 @@
import { Link } from "@heroui/link";
import {
Navbar as HeroUINavbar,
NavbarBrand,
NavbarContent,
} from "@heroui/navbar";
import { ThemeSwitch } from "@/components/theme-switch";
import { Logo } from "@/components/icons";
export const Navbar = () => {
return (
<HeroUINavbar maxWidth="xl" position="sticky">
<NavbarContent className="basis-1/5 sm:basis-full" justify="start">
<NavbarBrand className="gap-3 max-w-fit">
<Link
className="flex justify-start items-center gap-1"
color="foreground"
href="/"
>
<Logo />
<p className="font-bold text-inherit">GOST</p>
</Link>
</NavbarBrand>
</NavbarContent>
<NavbarContent className="basis-1/5 sm:basis-full" justify="end">
<ThemeSwitch />
</NavbarContent>
</HeroUINavbar>
);
};
@@ -0,0 +1,56 @@
import React, { useState, useEffect } from 'react';
import AdminLayout from '@/layouts/admin';
interface PageWrapperProps {
children: React.ReactNode;
title: string;
description?: string;
className?: string;
}
export default function PageWrapper({
children,
title,
description,
className = "container mx-auto max-w-7xl px-3 lg:px-6 py-8"
}: PageWrapperProps) {
const [isReady, setIsReady] = useState(false);
useEffect(() => {
// 使用短暂的延迟确保组件完全加载,避免闪烁
const timer = setTimeout(() => {
setIsReady(true);
}, 50);
return () => clearTimeout(timer);
}, []);
if (!isReady) {
return (
<AdminLayout>
<div className="container mx-auto max-w-7xl px-3 lg:px-6 py-8">
<div className="flex items-center justify-center h-64">
<div className="flex items-center gap-3">
<div className="animate-spin h-5 w-5 border-2 border-primary border-t-transparent rounded-full"></div>
<span className="text-default-600">正在加载页面...</span>
</div>
</div>
</div>
</AdminLayout>
);
}
return (
<AdminLayout>
<div className={className}>
<div className="mb-6">
<h1 className="text-2xl font-bold mb-2 text-foreground">{title}</h1>
{description && (
<p className="text-default-600">{description}</p>
)}
</div>
{children}
</div>
</AdminLayout>
);
}
@@ -0,0 +1,41 @@
import { tv } from "tailwind-variants";
export const title = tv({
base: "tracking-tight inline font-semibold",
variants: {
color: {
violet: "from-[#FF1CF7] to-[#b249f8]",
yellow: "from-[#FF705B] to-[#FFB457]",
blue: "from-[#5EA2EF] to-[#0072F5]",
cyan: "from-[#00b7fa] to-[#01cfea]",
green: "from-[#6FEE8D] to-[#17c964]",
pink: "from-[#FF72E1] to-[#F54C7A]",
foreground: "dark:from-[#FFFFFF] dark:to-[#4B4B4B]",
},
size: {
sm: "text-3xl lg:text-4xl",
md: "text-[2.3rem] lg:text-5xl leading-9",
lg: "text-4xl lg:text-6xl",
},
fullWidth: {
true: "w-full block",
},
},
defaultVariants: {
size: "md",
},
compoundVariants: [
{
color: [
"violet",
"yellow",
"blue",
"cyan",
"green",
"pink",
"foreground",
],
class: "bg-clip-text text-transparent bg-gradient-to-b",
},
],
});
@@ -0,0 +1,36 @@
import React, { useEffect } from 'react';
import { useTheme } from '@heroui/use-theme';
interface ThemeProviderProps {
children: React.ReactNode;
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const { theme, setTheme } = useTheme();
useEffect(() => {
// 确保主题与HTML class同步
const updateThemeClass = (currentTheme: string) => {
if (currentTheme === 'dark') {
document.documentElement.classList.add('dark');
document.documentElement.style.colorScheme = 'dark';
} else {
document.documentElement.classList.remove('dark');
document.documentElement.style.colorScheme = 'light';
}
// 保存到localStorage
localStorage.setItem('heroui-theme', currentTheme);
};
// 初始化时检查localStorage
const savedTheme = localStorage.getItem('heroui-theme');
if (savedTheme && savedTheme !== theme) {
setTheme(savedTheme);
}
// 监听主题变化
updateThemeClass(theme);
}, [theme, setTheme]);
return <>{children}</>;
};
@@ -0,0 +1,82 @@
import { FC, useState, useEffect } from "react";
import { VisuallyHidden } from "@react-aria/visually-hidden";
import { SwitchProps, useSwitch } from "@heroui/switch";
import clsx from "clsx";
import { useTheme } from "@heroui/use-theme";
import { SunFilledIcon, MoonFilledIcon } from "@/components/icons";
export interface ThemeSwitchProps {
className?: string;
classNames?: SwitchProps["classNames"];
}
export const ThemeSwitch: FC<ThemeSwitchProps> = ({
className,
classNames,
}) => {
const [isMounted, setIsMounted] = useState(false);
const { theme, setTheme } = useTheme();
const {
Component,
slots,
isSelected,
getBaseProps,
getInputProps,
getWrapperProps,
} = useSwitch({
isSelected: theme === "light",
onChange: () => setTheme(theme === "light" ? "dark" : "light"),
});
useEffect(() => {
setIsMounted(true);
}, [isMounted]);
// Prevent Hydration Mismatch
if (!isMounted) return <div className="w-6 h-6" />;
return (
<Component
aria-label={isSelected ? "Switch to dark mode" : "Switch to light mode"}
{...getBaseProps({
className: clsx(
"px-px transition-opacity hover:opacity-80 cursor-pointer",
className,
classNames?.base,
),
})}
>
<VisuallyHidden>
<input {...getInputProps()} />
</VisuallyHidden>
<div
{...getWrapperProps()}
className={slots.wrapper({
class: clsx(
[
"w-auto h-auto",
"bg-transparent",
"rounded-lg",
"flex items-center justify-center",
"group-data-[selected=true]:bg-transparent",
"!text-default-500",
"pt-px",
"px-0",
"mx-0",
],
classNames?.wrapper,
),
})}
>
{isSelected ? (
<MoonFilledIcon size={22} />
) : (
<SunFilledIcon size={22} />
)}
</div>
</Component>
);
};
+15
View File
@@ -0,0 +1,15 @@
export type SiteConfig = typeof siteConfig;
export const siteConfig = {
name: "GOST",
description: "GOST 代理管理系统",
navItems: [],
navMenuItems: [],
links: {
github: "https://github.com/go-gost/gost",
twitter: "",
docs: "",
discord: "",
sponsor: "",
},
};
+434
View File
@@ -0,0 +1,434 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useLocation, Link } from 'react-router-dom';
import { Button } from "@heroui/button";
import { Dropdown, DropdownTrigger, DropdownMenu, DropdownItem } from "@heroui/dropdown";
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, useDisclosure } from "@heroui/modal";
import { Input } from "@heroui/input";
import { toast } from 'react-hot-toast';
import { Logo } from '@/components/icons';
import { ThemeSwitch } from '@/components/theme-switch';
import { updatePassword } from '@/api';
import { safeLogout } from '@/utils/logout';
interface MenuItem {
path: string;
label: string;
icon: React.ReactNode;
adminOnly?: boolean;
}
interface PasswordForm {
newUsername: string;
currentPassword: string;
newPassword: string;
confirmPassword: string;
}
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const navigate = useNavigate();
const location = useLocation();
const { isOpen, onOpen, onOpenChange } = useDisclosure();
const [isMobile, setIsMobile] = useState(false);
const [mobileMenuVisible, setMobileMenuVisible] = useState(false);
const [username, setUsername] = useState('');
const [isAdmin, setIsAdmin] = useState(false);
const [passwordLoading, setPasswordLoading] = useState(false);
const [passwordForm, setPasswordForm] = useState<PasswordForm>({
newUsername: '',
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
// 菜单项配置
const menuItems: MenuItem[] = [
{
path: '/dashboard',
label: '仪表板',
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z" />
</svg>
)
},
{
path: '/forward',
label: '转发管理',
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
)
},
{
path: '/tunnel',
label: '隧道管理',
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z" clipRule="evenodd" />
</svg>
),
adminOnly: true
},
{
path: '/node',
label: '节点监控',
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 3a1 1 0 000 2v8a2 2 0 002 2h2.586l-1.293 1.293a1 1 0 101.414 1.414L10 15.414l2.293 2.293a1 1 0 001.414-1.414L12.414 15H15a2 2 0 002-2V5a1 1 0 100-2H3zm11.707 4.707a1 1 0 00-1.414-1.414L10 9.586 8.707 8.293a1 1 0 00-1.414 0l-2 2a1 1 0 101.414 1.414L8 10.414l1.293 1.293a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
),
adminOnly: true
},
{
path: '/user',
label: '用户管理',
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" />
</svg>
),
adminOnly: true
},
{
path: '/limit',
label: '限速管理',
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clipRule="evenodd" />
</svg>
),
adminOnly: true
}
];
// 检查移动端
const checkMobile = () => {
setIsMobile(window.innerWidth <= 768);
if (window.innerWidth > 768) {
setMobileMenuVisible(false);
}
};
useEffect(() => {
// 获取用户信息
const name = localStorage.getItem('name') || 'Admin';
// 兼容处理:如果没有admin字段,根据role_id判断(0为管理员)
let adminFlag = localStorage.getItem('admin') === 'true';
if (localStorage.getItem('admin') === null) {
const roleId = parseInt(localStorage.getItem('role_id') || '1', 10);
adminFlag = roleId === 0;
// 补充设置admin字段,避免下次再次判断
localStorage.setItem('admin', adminFlag.toString());
}
setUsername(name);
setIsAdmin(adminFlag);
// 响应式检查
checkMobile();
window.addEventListener('resize', checkMobile);
return () => {
window.removeEventListener('resize', checkMobile);
};
}, []);
// 退出登录
const handleLogout = () => {
safeLogout();
navigate('/');
};
// 切换移动端菜单
const toggleMobileMenu = () => {
setMobileMenuVisible(!mobileMenuVisible);
};
// 隐藏移动端菜单
const hideMobileMenu = () => {
setMobileMenuVisible(false);
};
// 菜单点击处理
const handleMenuClick = (path: string) => {
navigate(path);
if (isMobile) {
hideMobileMenu();
}
};
// 密码表单验证
const validatePasswordForm = (): boolean => {
if (!passwordForm.newUsername.trim()) {
toast.error('请输入新用户名');
return false;
}
if (passwordForm.newUsername.length < 3) {
toast.error('用户名长度至少3位');
return false;
}
if (!passwordForm.currentPassword) {
toast.error('请输入当前密码');
return false;
}
if (!passwordForm.newPassword) {
toast.error('请输入新密码');
return false;
}
if (passwordForm.newPassword.length < 6) {
toast.error('新密码长度不能少于6位');
return false;
}
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
toast.error('两次输入密码不一致');
return false;
}
return true;
};
// 提交密码修改
const handlePasswordSubmit = async () => {
if (!validatePasswordForm()) return;
setPasswordLoading(true);
try {
const response = await updatePassword(passwordForm);
if (response.code === 0) {
toast.success('密码修改成功,请重新登录');
onOpenChange();
handleLogout();
} else {
toast.error(response.msg || '密码修改失败');
}
} catch (error) {
toast.error('修改密码时发生错误');
console.error('修改密码错误:', error);
} finally {
setPasswordLoading(false);
}
};
// 重置密码表单
const resetPasswordForm = () => {
setPasswordForm({
newUsername: '',
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
};
// 过滤菜单项(根据权限)
const filteredMenuItems = menuItems.filter(item =>
!item.adminOnly || isAdmin
);
return (
<div className="flex h-screen bg-gray-50 dark:bg-black">
{/* 移动端遮罩层 */}
{isMobile && mobileMenuVisible && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40"
onClick={hideMobileMenu}
/>
)}
{/* 左侧菜单栏 */}
<aside className={`
${isMobile ? 'fixed' : 'relative'}
${isMobile && !mobileMenuVisible ? '-translate-x-full' : 'translate-x-0'}
${isMobile ? 'w-64' : 'w-64'}
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
flex flex-col
h-full
`}>
{/* Logo 区域 */}
<div className="px-4 py-3 h-14 flex items-center">
<div className="flex items-center gap-3">
<Logo size={28} />
<div>
<h1 className="text-base font-bold text-foreground">GOST 管理后台</h1>
<p className="text-xs text-default-500">v1.0.2</p>
</div>
</div>
</div>
{/* 菜单导航 */}
<nav className="flex-1 px-4 py-6">
<ul className="space-y-1">
{filteredMenuItems.map((item) => {
const isActive = location.pathname === item.path;
return (
<li key={item.path}>
<button
onClick={() => handleMenuClick(item.path)}
className={`
w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left
transition-colors duration-200 min-h-[44px]
${isActive
? 'bg-primary-100 dark:bg-primary-600/20 text-primary-600 dark:text-primary-300'
: 'text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-900'
}
`}
>
<div className="flex-shrink-0">
{item.icon}
</div>
<span className="font-medium text-sm">{item.label}</span>
</button>
</li>
);
})}
</ul>
</nav>
</aside>
{/* 主内容区域 */}
<div className="flex flex-col flex-1 overflow-hidden">
{/* 顶部导航栏 */}
<header className="bg-white dark:bg-black shadow-md border-b border-gray-200 dark:border-gray-600 h-14 flex items-center justify-between px-4 lg:px-6 relative z-10">
<div className="flex items-center gap-4">
{/* 移动端菜单按钮 */}
{isMobile && (
<Button
isIconOnly
variant="light"
onPress={toggleMobileMenu}
className="lg:hidden"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</Button>
)}
</div>
<div className="flex items-center gap-3">
<ThemeSwitch />
{/* 用户菜单 */}
<Dropdown placement="bottom-end">
<DropdownTrigger>
<Button variant="light" className="text-sm font-medium text-foreground">
{username}
<svg className="w-4 h-4 ml-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="用户菜单">
<DropdownItem
key="change-password"
startContent={
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z" clipRule="evenodd" />
</svg>
}
onPress={onOpen}
>
修改密码
</DropdownItem>
<DropdownItem
key="logout"
startContent={
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z" clipRule="evenodd" />
</svg>
}
className="text-danger"
color="danger"
onPress={handleLogout}
>
退出登录
</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
</header>
{/* 主内容 */}
<main className="flex-1 overflow-y-auto bg-gray-100 dark:bg-black">
{children}
</main>
</div>
{/* 修改密码弹窗 */}
<Modal
isOpen={isOpen}
onOpenChange={() => {
onOpenChange();
resetPasswordForm();
}}
size={isMobile ? 'full' : 'md'}
scrollBehavior="inside"
>
<ModalContent>
{(onClose: () => void) => (
<>
<ModalHeader className="flex flex-col gap-1">修改密码</ModalHeader>
<ModalBody>
<div className="space-y-4">
<Input
label="新用户名"
placeholder="请输入新用户名(至少3位)"
value={passwordForm.newUsername}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPasswordForm(prev => ({ ...prev, newUsername: e.target.value }))}
variant="bordered"
/>
<Input
label="当前密码"
type="password"
placeholder="请输入当前密码"
value={passwordForm.currentPassword}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPasswordForm(prev => ({ ...prev, currentPassword: e.target.value }))}
variant="bordered"
/>
<Input
label="新密码"
type="password"
placeholder="请输入新密码(至少6位)"
value={passwordForm.newPassword}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPasswordForm(prev => ({ ...prev, newPassword: e.target.value }))}
variant="bordered"
/>
<Input
label="确认密码"
type="password"
placeholder="请再次输入新密码"
value={passwordForm.confirmPassword}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPasswordForm(prev => ({ ...prev, confirmPassword: e.target.value }))}
variant="bordered"
/>
</div>
</ModalBody>
<ModalFooter>
<Button color="default" variant="light" onPress={onClose}>
取消
</Button>
<Button
color="primary"
onPress={handlePasswordSubmit}
isLoading={passwordLoading}
>
确定
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { Link } from "@heroui/link";
import { Navbar } from "@/components/navbar";
export default function DefaultLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="relative flex flex-col h-screen">
<Navbar />
<main className="container mx-auto max-w-7xl px-6 flex-grow pt-16">
{children}
</main>
<footer className="w-full flex items-center justify-center py-3">
<Link
isExternal
className="flex items-center gap-1 text-current"
href="https://heroui.com"
title="heroui.com homepage"
>
</Link>
</footer>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App.tsx";
import { Provider } from "./provider.tsx";
import "@/styles/globals.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<BrowserRouter>
<Provider>
<App />
</Provider>
</BrowserRouter>
);
+200
View File
@@ -0,0 +1,200 @@
import { Button } from "@heroui/button";
import { Input } from "@heroui/input";
import { Card, CardBody, CardHeader } from "@heroui/card";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import toast from 'react-hot-toast';
import { title } from "@/components/primitives";
import { updatePassword } from "@/api";
import DefaultLayout from "@/layouts/default";
import { safeLogout } from "@/utils/logout";
interface PasswordForm {
newUsername: string;
currentPassword: string;
newPassword: string;
confirmPassword: string;
}
export default function ChangePasswordPage() {
const [form, setForm] = useState<PasswordForm>({
newUsername: '',
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<Partial<PasswordForm>>({});
const navigate = useNavigate();
const validateForm = (): boolean => {
const newErrors: Partial<PasswordForm> = {};
if (!form.newUsername.trim()) {
newErrors.newUsername = '请输入新用户名';
} else if (form.newUsername.length < 3) {
newErrors.newUsername = '用户名长度至少3位';
} else if (form.newUsername.length > 20) {
newErrors.newUsername = '用户名长度不能超过20位';
}
if (!form.currentPassword.trim()) {
newErrors.currentPassword = '请输入当前密码';
}
if (!form.newPassword.trim()) {
newErrors.newPassword = '请输入新密码';
} else if (form.newPassword.length < 6) {
newErrors.newPassword = '新密码长度不能少于6位';
} else if (form.newPassword.length > 20) {
newErrors.newPassword = '新密码长度不能超过20位';
}
if (!form.confirmPassword.trim()) {
newErrors.confirmPassword = '请再次输入新密码';
} else if (form.confirmPassword !== form.newPassword) {
newErrors.confirmPassword = '两次输入密码不一致';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleInputChange = (field: keyof PasswordForm, value: string) => {
setForm(prev => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: undefined }));
}
};
const handleSubmit = async () => {
if (!validateForm()) return;
setLoading(true);
try {
const response = await updatePassword(form);
if (response.code === 0) {
toast.success(response.msg || '账号密码修改成功');
// 使用 toast 确认对话框的替代方案
setTimeout(() => {
toast.success('即将跳转到登陆页面,请重新登录');
setTimeout(() => {
logout();
}, 1000);
}, 1000);
} else {
toast.error(response.msg || '账号密码修改失败');
}
} catch (error) {
console.error('修改账号密码错误:', error);
toast.error('修改账号密码时发生错误');
} finally {
setLoading(false);
}
};
const logout = () => {
safeLogout();
navigate('/');
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !loading) {
handleSubmit();
}
};
return (
<DefaultLayout>
<section className="flex flex-col items-center justify-center gap-4 py-8 md:py-10 min-h-[calc(100vh-200px)]">
<div className="w-full max-w-lg">
<Card className="w-full">
<CardHeader className="pb-0 pt-6 px-6 flex-col items-center">
<div className="w-12 h-12 bg-warning-100 rounded-full flex items-center justify-center mb-3">
<svg className="w-6 h-6 text-warning-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</div>
<h1 className={title({ size: "sm" })}>安全提醒</h1>
<p className="text-small text-default-500 mt-2 text-center">检测到您使用的是默认账号密码,为了您的账户安全,请立即修改</p>
</CardHeader>
<CardBody className="px-6 py-6">
<div className="flex flex-col gap-4">
<Input
label="新用户名"
placeholder="请输入新用户名(至少3位)"
value={form.newUsername}
onChange={(e) => handleInputChange('newUsername', e.target.value)}
onKeyDown={handleKeyPress}
variant="bordered"
isDisabled={loading}
isInvalid={!!errors.newUsername}
errorMessage={errors.newUsername}
/>
<Input
label="当前密码"
placeholder="请输入当前密码"
type="password"
value={form.currentPassword}
onChange={(e) => handleInputChange('currentPassword', e.target.value)}
onKeyDown={handleKeyPress}
variant="bordered"
isDisabled={loading}
isInvalid={!!errors.currentPassword}
errorMessage={errors.currentPassword}
/>
<Input
label="新密码"
placeholder="请输入新密码(至少6位)"
type="password"
value={form.newPassword}
onChange={(e) => handleInputChange('newPassword', e.target.value)}
onKeyDown={handleKeyPress}
variant="bordered"
isDisabled={loading}
isInvalid={!!errors.newPassword}
errorMessage={errors.newPassword}
/>
<Input
label="确认新密码"
placeholder="请再次输入新密码"
type="password"
value={form.confirmPassword}
onChange={(e) => handleInputChange('confirmPassword', e.target.value)}
onKeyDown={handleKeyPress}
variant="bordered"
isDisabled={loading}
isInvalid={!!errors.confirmPassword}
errorMessage={errors.confirmPassword}
/>
<Button
color="warning"
size="lg"
onClick={handleSubmit}
isLoading={loading}
disabled={loading}
className="mt-2"
>
{loading ? "修改中..." : "立即修改账号密码"}
</Button>
<div className="bg-warning-50 border border-warning-200 text-warning-700 px-3 py-2 rounded-lg text-sm text-center">
⚠️ 注意:修改账号密码后需要重新登录
</div>
</div>
</CardBody>
</Card>
</div>
</section>
</DefaultLayout>
);
}
+842
View File
@@ -0,0 +1,842 @@
import { Card, CardBody, CardHeader } from "@heroui/card";
import { Button } from "@heroui/button";
import { Modal, ModalContent, ModalHeader, ModalBody } from "@heroui/modal";
import { useState, useEffect } from "react";
import toast from 'react-hot-toast';
import AdminLayout from "@/layouts/admin";
import { getUserPackageInfo } from "@/api";
interface UserInfo {
flow: number;
inFlow: number;
outFlow: number;
num: number;
expTime?: string;
flowResetTime?: number;
}
interface UserTunnel {
id: number;
tunnelId: number;
tunnelName: string;
flow: number;
inFlow: number;
outFlow: number;
num: number;
expTime?: string;
flowResetTime?: number;
tunnelFlow: number;
}
interface Forward {
id: number;
name: string;
tunnelId: number;
tunnelName: string;
inIp: string;
inPort: number;
remoteAddr: string;
inFlow: number;
outFlow: number;
}
interface AddressItem {
id: number;
ip: string;
address: string;
copying: boolean;
}
export default function DashboardPage() {
const [loading, setLoading] = useState(true);
const [userInfo, setUserInfo] = useState<UserInfo>({} as UserInfo);
const [userTunnels, setUserTunnels] = useState<UserTunnel[]>([]);
const [forwardList, setForwardList] = useState<Forward[]>([]);
const [addressModalOpen, setAddressModalOpen] = useState(false);
const [addressModalTitle, setAddressModalTitle] = useState('');
const [addressList, setAddressList] = useState<AddressItem[]>([]);
// 检查有效期通知
const checkExpirationNotifications = (userInfo: UserInfo, tunnels: UserTunnel[]) => {
// 避免重复通知,检查是否已经显示过
const notificationKey = `expiration-${userInfo.expTime}-${tunnels.map(t => t.expTime).join(',')}`;
const lastNotified = localStorage.getItem('lastNotified');
if (lastNotified === notificationKey) {
return; // 已经通知过,不重复显示
}
let hasNotification = false;
// 检查主账户有效期
if (userInfo.expTime) {
const expDate = new Date(userInfo.expTime);
const now = new Date();
if (!isNaN(expDate.getTime()) && expDate > now) {
const diffTime = expDate.getTime() - now.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays <= 7 && diffDays > 0) {
hasNotification = true;
if (diffDays === 1) {
toast('账户将于明天过期,请及时续费', {
icon: '⚠️',
duration: 6000,
style: { background: '#f59e0b', color: '#fff' }
});
} else {
toast(`账户将于${diffDays}天后过期,请及时续费`, {
icon: '⚠️',
duration: 6000,
style: { background: '#f59e0b', color: '#fff' }
});
}
} else if (diffDays <= 0) {
hasNotification = true;
toast('账户已过期,请立即续费', {
icon: '⚠️',
duration: 8000,
style: { background: '#ef4444', color: '#fff' }
});
}
}
}
// 检查隧道有效期
tunnels.forEach(tunnel => {
if (tunnel.expTime) {
const expDate = new Date(tunnel.expTime);
const now = new Date();
if (!isNaN(expDate.getTime()) && expDate > now) {
const diffTime = expDate.getTime() - now.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays <= 7 && diffDays > 0) {
hasNotification = true;
if (diffDays === 1) {
toast(`隧道"${tunnel.tunnelName}"将于明天过期`, {
icon: '⚠️',
duration: 5000,
style: { background: '#f59e0b', color: '#fff' }
});
} else {
toast(`隧道"${tunnel.tunnelName}"将于${diffDays}天后过期`, {
icon: '⚠️',
duration: 5000,
style: { background: '#f59e0b', color: '#fff' }
});
}
} else if (diffDays <= 0) {
hasNotification = true;
toast(`隧道"${tunnel.tunnelName}"已过期`, {
icon: '⚠️',
duration: 6000,
style: { background: '#ef4444', color: '#fff' }
});
}
}
}
});
// 如果显示了通知,记录防止重复
if (hasNotification) {
localStorage.setItem('lastNotified', notificationKey);
}
};
useEffect(() => {
// 重置状态并加载数据,防止页面切换时显示旧数据
setLoading(true);
setUserInfo({} as UserInfo);
setUserTunnels([]);
setForwardList([]);
loadPackageData();
localStorage.setItem('e', '/dashboard');
}, []);
const loadPackageData = async () => {
setLoading(true);
try {
const res = await getUserPackageInfo();
if (res.code === 0) {
const data = res.data;
setUserInfo(data.userInfo || {});
setUserTunnels(data.tunnelPermissions || []);
setForwardList(data.forwards || []);
// 检查有效期并显示通知
checkExpirationNotifications(data.userInfo, data.tunnelPermissions || []);
} else {
toast.error(res.msg || '获取套餐信息失败');
}
} catch (error) {
console.error('获取套餐信息失败:', error);
toast.error('获取套餐信息失败');
} finally {
setLoading(false);
}
};
const formatFlow = (value: number, unit: string = 'bytes'): string => {
// 99999 表示无限制
if (value === 99999) {
return '无限制';
}
if (unit === 'gb') {
return value + ' GB';
} else {
if (value === 0) return '0 B';
if (value < 1024) return value + ' B';
if (value < 1024 * 1024) return (value / 1024).toFixed(2) + ' KB';
if (value < 1024 * 1024 * 1024) return (value / (1024 * 1024)).toFixed(2) + ' MB';
return (value / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
};
const formatNumber = (value: number): string => {
// 99999 表示无限制
if (value === 99999) {
return '无限制';
}
return value.toString();
};
const getExpStatus = (expTime?: string) => {
if (!expTime) return {
color: 'text-green-600 dark:text-green-400',
bg: 'bg-green-50 dark:bg-green-500/10 border-green-200 dark:border-green-500/20',
text: '永久'
};
const now = new Date();
const expDate = new Date(expTime);
if (isNaN(expDate.getTime())) {
return {
color: 'text-gray-600 dark:text-gray-400',
bg: 'bg-gray-50 dark:bg-black/10 border-gray-200 dark:border-gray-500/20',
text: '无效'
};
}
if (expDate < now) {
return {
color: 'text-red-600 dark:text-red-400',
bg: 'bg-red-50 dark:bg-red-500/10 border-red-200 dark:border-red-500/20',
text: '已过期'
};
}
const diffTime = expDate.getTime() - now.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays <= 7) {
return {
color: 'text-red-600 dark:text-red-400',
bg: 'bg-red-50 dark:bg-red-500/10 border-red-200 dark:border-red-500/20',
text: `${diffDays}天后过期`
};
} else if (diffDays <= 30) {
return {
color: 'text-orange-600 dark:text-orange-400',
bg: 'bg-orange-50 dark:bg-orange-500/10 border-orange-200 dark:border-orange-500/20',
text: `${diffDays}天后过期`
};
} else {
return {
color: 'text-green-600 dark:text-green-400',
bg: 'bg-green-50 dark:bg-green-500/10 border-green-200 dark:border-green-500/20',
text: `${diffDays}天后过期`
};
}
};
const calculateUserTotalUsedFlow = (): number => {
return (userInfo.inFlow || 0) + (userInfo.outFlow || 0);
};
const calculateUsagePercentage = (type: 'flow' | 'forwards'): number => {
if (type === 'flow') {
const totalUsed = calculateUserTotalUsedFlow();
const totalLimit = (userInfo.flow || 0) * 1024 * 1024 * 1024;
// 无限制时返回0%
if (userInfo.flow === 99999) return 0;
return totalLimit > 0 ? Math.min((totalUsed / totalLimit) * 100, 100) : 0;
} else if (type === 'forwards') {
const totalUsed = forwardList.length;
const totalLimit = userInfo.num || 0;
// 无限制时返回0%
if (userInfo.num === 99999) return 0;
return totalLimit > 0 ? Math.min((totalUsed / totalLimit) * 100, 100) : 0;
}
return 0;
};
const getUsageColor = (percentage: number) => {
if (percentage >= 90) return 'bg-red-500 dark:bg-red-600';
if (percentage >= 70) return 'bg-orange-500 dark:bg-orange-600';
return 'bg-blue-500 dark:bg-blue-600';
};
const renderProgressBar = (percentage: number, size: 'sm' | 'md' = 'md', isUnlimited: boolean = false) => {
const height = size === 'sm' ? 'h-1.5' : 'h-2';
if (isUnlimited) {
return (
<div className="w-full">
<div className={`w-full bg-gradient-to-r from-blue-200 to-purple-200 dark:from-blue-500/30 dark:to-purple-500/30 rounded-full ${height}`}>
<div className={`${height} bg-gradient-to-r from-blue-500 to-purple-500 rounded-full w-full opacity-60`}></div>
</div>
</div>
);
}
return (
<div className="w-full">
<div className={`w-full bg-gray-200 dark:bg-gray-800 rounded-full ${height}`}>
<div
className={`${height} rounded-full transition-all duration-300 ${getUsageColor(percentage)}`}
style={{ width: `${Math.min(percentage, 100)}%` }}
></div>
</div>
</div>
);
};
const calculateTunnelUsedFlow = (tunnel: UserTunnel): number => {
if (!tunnel) return 0;
const inFlow = tunnel.inFlow || 0;
const outFlow = tunnel.outFlow || 0;
if (tunnel.tunnelFlow === 1) {
return outFlow;
} else {
return inFlow + outFlow;
}
};
const calculateTunnelFlowPercentage = (tunnel: UserTunnel): number => {
const totalUsed = calculateTunnelUsedFlow(tunnel);
const totalLimit = (tunnel.flow || 0) * 1024 * 1024 * 1024;
// 无限制时返回0%
if (tunnel.flow === 99999) return 0;
return totalLimit > 0 ? Math.min((totalUsed / totalLimit) * 100, 100) : 0;
};
const getTunnelUsedForwards = (tunnelId: number): number => {
return forwardList.filter(forward => forward.tunnelId === tunnelId).length;
};
const calculateTunnelForwardPercentage = (tunnel: UserTunnel): number => {
const totalUsed = getTunnelUsedForwards(tunnel.tunnelId);
const totalLimit = tunnel.num || 0;
// 无限制时返回0%
if (tunnel.num === 99999) return 0;
return totalLimit > 0 ? Math.min((totalUsed / totalLimit) * 100, 100) : 0;
};
const formatResetTime = (resetDay?: number): string => {
if (!resetDay) return '';
const now = new Date();
const currentDay = now.getDate();
let daysUntilReset;
if (resetDay > currentDay) {
daysUntilReset = resetDay - currentDay;
} else if (resetDay < currentDay) {
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, resetDay);
const diffTime = nextMonth.getTime() - now.getTime();
daysUntilReset = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
} else {
daysUntilReset = 0;
}
if (daysUntilReset === 0) {
return '今日重置';
} else if (daysUntilReset === 1) {
return '明日重置';
} else {
return `${daysUntilReset}天后重置`;
}
};
const groupedForwards = () => {
const groups: { [key: string]: { tunnelName: string; forwards: Forward[] } } = {};
forwardList.forEach(forward => {
const tunnelName = forward.tunnelName || '未知隧道';
if (!groups[tunnelName]) {
groups[tunnelName] = {
tunnelName,
forwards: []
};
}
groups[tunnelName].forwards.push(forward);
});
return Object.values(groups);
};
const formatInAddress = (ipString: string, port: number): string => {
if (!ipString || !port) return '';
const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip);
if (ips.length === 0) return '';
if (ips.length === 1) {
const ip = ips[0];
if (ip.includes(':') && !ip.startsWith('[')) {
return `[${ip}]:${port}`;
} else {
return `${ip}:${port}`;
}
}
const firstIp = ips[0];
let formattedFirstIp;
if (firstIp.includes(':') && !firstIp.startsWith('[')) {
formattedFirstIp = `[${firstIp}]`;
} else {
formattedFirstIp = firstIp;
}
return `${formattedFirstIp}:${port} (+${ips.length - 1})`;
};
const formatRemoteAddress = (remoteAddr: string): string => {
if (!remoteAddr) return '';
const addresses = remoteAddr.split(',').map(addr => addr.trim()).filter(addr => addr);
if (addresses.length === 0) return '';
if (addresses.length === 1) {
return addresses[0];
}
return `${addresses[0]} (+${addresses.length - 1})`;
};
const hasMultipleIps = (ipString: string): boolean => {
if (!ipString) return false;
const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip);
return ips.length > 1;
};
const hasMultipleRemoteAddresses = (remoteAddr: string): boolean => {
if (!remoteAddr) return false;
const addresses = remoteAddr.split(',').map(addr => addr.trim()).filter(addr => addr);
return addresses.length > 1;
};
const showAddressModal = (ipString: string, port: number, title: string) => {
if (!ipString || !port) return;
const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip);
if (ips.length <= 1) {
copyToClipboard(formatInAddress(ipString, port), title);
return;
}
const formattedList = ips.map((ip, index) => {
let formattedAddress;
if (ip.includes(':') && !ip.startsWith('[')) {
formattedAddress = `[${ip}]:${port}`;
} else {
formattedAddress = `${ip}:${port}`;
}
return {
id: index,
ip: ip,
address: formattedAddress,
copying: false
};
});
setAddressList(formattedList);
setAddressModalTitle(`${title} (${ips.length}个)`);
setAddressModalOpen(true);
};
const showRemoteAddressModal = (remoteAddr: string, title: string) => {
if (!remoteAddr) return;
const addresses = remoteAddr.split(',').map(addr => addr.trim()).filter(addr => addr);
if (addresses.length <= 1) {
copyToClipboard(remoteAddr, title);
return;
}
const formattedList = addresses.map((address, index) => {
return {
id: index,
ip: address,
address: address,
copying: false
};
});
setAddressList(formattedList);
setAddressModalTitle(`${title} (${addresses.length}个)`);
setAddressModalOpen(true);
};
const copyToClipboard = async (text: string, label: string = '内容') => {
try {
await navigator.clipboard.writeText(text);
toast.success(`已复制`);
} catch (error) {
toast.error('复制失败');
}
};
const copyAddress = async (addressItem: AddressItem) => {
try {
setAddressList(prev => prev.map(item =>
item.id === addressItem.id ? { ...item, copying: true } : item
));
await copyToClipboard(addressItem.address, '地址');
} catch (error) {
toast.error('复制失败');
} finally {
setAddressList(prev => prev.map(item =>
item.id === addressItem.id ? { ...item, copying: false } : item
));
}
};
const copyAllAddresses = async () => {
if (addressList.length === 0) return;
const allAddresses = addressList.map(item => item.address).join('\n');
await copyToClipboard(allAddresses, '所有地址');
};
const calculateForwardBillingFlow = (forward: Forward): number => {
if (!forward) return 0;
const tunnel = userTunnels.find(t => t.tunnelId === forward.tunnelId);
if (!tunnel) {
return (forward.inFlow || 0) + (forward.outFlow || 0);
}
const inFlow = forward.inFlow || 0;
const outFlow = forward.outFlow || 0;
if (tunnel.tunnelFlow === 1) {
return outFlow;
} else {
return inFlow + outFlow;
}
};
if (loading) {
return (
<AdminLayout>
<div className="px-3 lg:px-6 flex-grow pt-2 lg:pt-4">
<div className="flex items-center justify-center h-64">
<div className="flex items-center gap-3">
<div className="animate-spin h-5 w-5 border-2 border-primary border-t-transparent rounded-full"></div>
<span className="text-default-600">正在加载数据...</span>
</div>
</div>
</div>
</AdminLayout>
);
}
return (
<AdminLayout>
<div className="px-3 lg:px-6 py-2 lg:py-4">
{/* 响应式统计卡片 */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-4 mb-6 lg:mb-8">
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
<CardBody className="p-3 lg:p-4">
<div className="flex flex-col space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs lg:text-sm text-default-600 truncate">总流量</p>
<div className="p-1.5 lg:p-2 bg-blue-100 dark:bg-blue-500/20 rounded-lg flex-shrink-0">
<svg className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z" />
</svg>
</div>
</div>
<p className="text-base lg:text-xl font-bold text-foreground truncate">{formatFlow(userInfo.flow, 'gb')}</p>
</div>
</CardBody>
</Card>
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
<CardBody className="p-3 lg:p-4">
<div className="flex flex-col space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs lg:text-sm text-default-600 truncate">已用流量</p>
<div className="p-1.5 lg:p-2 bg-green-100 dark:bg-green-500/20 rounded-lg flex-shrink-0">
<svg className="w-4 h-4 lg:w-5 lg:h-5 text-green-600 dark:text-green-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M12 7a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0V8.414l-4.293 4.293a1 1 0 01-1.414 0L8 10.414l-4.293 4.293a1 1 0 01-1.414-1.414l5-5a1 1 0 011.414 0L11 10.586 14.586 7H12z" clipRule="evenodd" />
</svg>
</div>
</div>
<p className="text-base lg:text-xl font-bold text-foreground truncate">{formatFlow(calculateUserTotalUsedFlow())}</p>
<div className="mt-1">
{renderProgressBar(calculateUsagePercentage('flow'), 'sm', userInfo.flow === 99999)}
<div className="flex items-center justify-between mt-1">
<p className="text-xs text-default-500 truncate">
{userInfo.flow === 99999 ? '无限制' : `${calculateUsagePercentage('flow').toFixed(1)}%`}
</p>
{userInfo.flowResetTime && (
<div className="text-xs text-default-500 flex items-center gap-1">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clipRule="evenodd" />
</svg>
<span className="truncate">{formatResetTime(userInfo.flowResetTime)}</span>
</div>
)}
</div>
</div>
</div>
</CardBody>
</Card>
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
<CardBody className="p-3 lg:p-4">
<div className="flex flex-col space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs lg:text-sm text-default-600 truncate">转发配额</p>
<div className="p-1.5 lg:p-2 bg-purple-100 dark:bg-purple-500/20 rounded-lg flex-shrink-0">
<svg className="w-4 h-4 lg:w-5 lg:h-5 text-purple-600 dark:text-purple-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</div>
</div>
<p className="text-base lg:text-xl font-bold text-foreground truncate">{formatNumber(userInfo.num || 0)}</p>
</div>
</CardBody>
</Card>
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
<CardBody className="p-3 lg:p-4">
<div className="flex flex-col space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs lg:text-sm text-default-600 truncate">已用转发</p>
<div className="p-1.5 lg:p-2 bg-orange-100 dark:bg-orange-500/20 rounded-lg flex-shrink-0">
<svg className="w-4 h-4 lg:w-5 lg:h-5 text-orange-600 dark:text-orange-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z" clipRule="evenodd" />
</svg>
</div>
</div>
<p className="text-base lg:text-xl font-bold text-foreground truncate">{forwardList.length}</p>
<div className="mt-1">
{renderProgressBar(calculateUsagePercentage('forwards'), 'sm', userInfo.num === 99999)}
<p className="text-xs text-default-500 mt-1 truncate">
{userInfo.num === 99999 ? '无限制' : `${calculateUsagePercentage('forwards').toFixed(1)}%`}
</p>
</div>
</div>
</CardBody>
</Card>
</div>
{/* 隧道权限 */}
<Card className="mb-6 lg:mb-8 border border-gray-200 dark:border-default-200 shadow-md">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-primary" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z" clipRule="evenodd" />
</svg>
<h2 className="text-lg lg:text-xl font-semibold text-foreground">隧道权限</h2>
<span className="px-2 py-1 bg-default-100 dark:bg-default-50 text-default-600 rounded-full text-xs">
{userTunnels.length}
</span>
</div>
</CardHeader>
<CardBody className="pt-0">
{userTunnels.length === 0 ? (
<div className="text-center py-12">
<svg className="w-12 h-12 text-default-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
<p className="text-default-500">暂无隧道权限</p>
</div>
) : (
<div className="space-y-3">
{userTunnels.map((tunnel) => {
const tunnelExpStatus = getExpStatus(tunnel.expTime);
return (
<div key={tunnel.id} className="border border-gray-200 dark:border-default-100 rounded-lg p-3 lg:p-4 hover:shadow-md transition-shadow">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3 mb-3">
<div>
<h3 className="font-semibold text-foreground">{tunnel.tunnelName}</h3>
<div className="flex flex-wrap items-center gap-2 mt-1">
<span className={`px-2 py-1 rounded-md text-xs font-medium ${tunnel.tunnelFlow === 1 ? 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300' : 'bg-orange-100 dark:bg-orange-500/20 text-orange-700 dark:text-orange-300'}`}>
{tunnel.tunnelFlow === 1 ? '单向计费' : '双向计费'}
</span>
<span className={`px-2 py-1 rounded-md text-xs font-medium border ${tunnelExpStatus.bg} ${tunnelExpStatus.color}`}>
{tunnelExpStatus.text}
</span>
{tunnel.flowResetTime && (
<span className="text-xs text-default-500">
{formatResetTime(tunnel.flowResetTime)}
</span>
)}
</div>
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-4">
<div>
<p className="text-sm text-default-600 mb-1">流量配额</p>
<p className="font-semibold text-foreground">{formatFlow(tunnel.flow, 'gb')}</p>
</div>
<div>
<p className="text-sm text-default-600 mb-1">已用流量</p>
<p className="font-semibold text-foreground">{formatFlow(calculateTunnelUsedFlow(tunnel))}</p>
<div className="mt-1">
{renderProgressBar(calculateTunnelFlowPercentage(tunnel), 'sm', tunnel.flow === 99999)}
</div>
</div>
<div>
<p className="text-sm text-default-600 mb-1">转发配额</p>
<p className="font-semibold text-foreground">{formatNumber(tunnel.num)}</p>
</div>
<div>
<p className="text-sm text-default-600 mb-1">已用转发</p>
<p className="font-semibold text-foreground">{getTunnelUsedForwards(tunnel.tunnelId)}</p>
<div className="mt-1">
{renderProgressBar(calculateTunnelForwardPercentage(tunnel), 'sm', tunnel.num === 99999)}
</div>
</div>
</div>
</div>
);
})}
</div>
)}
</CardBody>
</Card>
{/* 转发配置 */}
<Card className="border border-gray-200 dark:border-default-200 shadow-md">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-primary" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
<h2 className="text-lg lg:text-xl font-semibold text-foreground">转发配置</h2>
<span className="px-2 py-1 bg-default-100 dark:bg-default-50 text-default-600 rounded-full text-xs">
{forwardList.length}
</span>
</div>
</CardHeader>
<CardBody className="pt-0">
{groupedForwards().length === 0 ? (
<div className="text-center py-12">
<svg className="w-12 h-12 text-default-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
</svg>
<p className="text-default-500">暂无转发配置</p>
</div>
) : (
<div className="space-y-4">
{groupedForwards().map((group) => (
<div key={group.tunnelName} className="border border-gray-200 dark:border-default-100 rounded-lg p-3 lg:p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-foreground">{group.tunnelName}</h3>
<span className="px-2 py-1 bg-primary-100 dark:bg-primary-500/20 text-primary-700 dark:text-primary-300 rounded-md text-sm">
{group.forwards.length} 个转发
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{group.forwards.map((forward) => (
<div key={forward.id} className="bg-white dark:bg-default-100/50 border border-gray-200 dark:border-default-200 rounded-lg p-3 hover:shadow-md transition-shadow">
<div className="space-y-3">
<div>
<h4 className="font-medium text-foreground text-sm mb-2 truncate">{forward.name}</h4>
<div className="space-y-1">
<code
className={`block px-2 py-1 bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300 rounded font-mono text-xs truncate ${hasMultipleIps(forward.inIp) ? 'cursor-pointer hover:bg-green-200 dark:hover:bg-green-500/30' : ''}`}
onClick={() => hasMultipleIps(forward.inIp) && showAddressModal(forward.inIp, forward.inPort, '入口地址')}
title={formatInAddress(forward.inIp, forward.inPort)}
>
{formatInAddress(forward.inIp, forward.inPort)}
</code>
<div className="text-center text-default-400 text-xs">↓</div>
<code
className={`block px-2 py-1 bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300 rounded font-mono text-xs truncate ${hasMultipleRemoteAddresses(forward.remoteAddr) ? 'cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-500/30' : ''}`}
onClick={() => hasMultipleRemoteAddresses(forward.remoteAddr) && showRemoteAddressModal(forward.remoteAddr, '出口地址')}
title={formatRemoteAddress(forward.remoteAddr)}
>
{formatRemoteAddress(forward.remoteAddr)}
</code>
</div>
</div>
<div className="pt-2 border-t border-gray-200 dark:border-default-200">
<div className="grid grid-cols-3 gap-1 text-xs">
<div className="text-center">
<div className="text-default-500 mb-1">入站</div>
<div className="font-medium text-green-600 dark:text-green-400 truncate">{formatFlow(forward.inFlow || 0)}</div>
</div>
<div className="text-center">
<div className="text-default-500 mb-1">出站</div>
<div className="font-medium text-orange-600 dark:text-orange-400 truncate">{formatFlow(forward.outFlow || 0)}</div>
</div>
<div className="text-center">
<div className="text-default-500 mb-1">计费</div>
<div className="font-medium text-primary truncate">{formatFlow(calculateForwardBillingFlow(forward))}</div>
</div>
</div>
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</CardBody>
</Card>
{/* 地址列表弹窗 */}
<Modal isOpen={addressModalOpen} onClose={() => setAddressModalOpen(false)} size="lg">
<ModalContent>
<ModalHeader className="text-base">{addressModalTitle}</ModalHeader>
<ModalBody className="pb-6">
<div className="mb-4 text-right">
<Button size="sm" onClick={copyAllAddresses}>
复制全部
</Button>
</div>
<div className="space-y-2 max-h-60 overflow-y-auto">
{addressList.map((item) => (
<div key={item.id} className="flex justify-between items-center p-3 border border-default-200 dark:border-default-100 rounded-lg">
<code className="text-sm flex-1 mr-3 text-foreground">{item.address}</code>
<Button
size="sm"
variant="light"
isLoading={item.copying}
onClick={() => copyAddress(item)}
>
复制
</Button>
</div>
))}
</div>
</ModalBody>
</ModalContent>
</Modal>
</div>
</AdminLayout>
);
}
+863
View File
@@ -0,0 +1,863 @@
import { useState, useEffect } from "react";
import { Card, CardBody, CardHeader } from "@heroui/card";
import { Button } from "@heroui/button";
import { Input } from "@heroui/input";
import { Select, SelectItem } from "@heroui/select";
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/modal";
import { Chip } from "@heroui/chip";
import { Spinner } from "@heroui/spinner";
import { Switch } from "@heroui/switch";
import toast from 'react-hot-toast';
import AdminLayout from "@/layouts/admin";
import {
createForward,
getForwardList,
updateForward,
deleteForward,
forceDeleteForward,
userTunnel,
pauseForwardService,
resumeForwardService
} from "@/api";
interface Forward {
id: number;
name: string;
tunnelId: number;
tunnelName: string;
inIp: string;
inPort: number;
remoteAddr: string;
strategy: string;
status: number;
inFlow: number;
outFlow: number;
serviceRunning: boolean;
createdTime: string;
userName?: string;
}
interface Tunnel {
id: number;
name: string;
inNodePortSta?: number;
inNodePortEnd?: number;
}
interface ForwardForm {
id?: number;
name: string;
tunnelId: number | null;
inPort: number | null;
remoteAddr: string;
strategy: string;
}
interface AddressItem {
id: number;
address: string;
copying: boolean;
}
export default function ForwardPage() {
const [loading, setLoading] = useState(true);
const [forwards, setForwards] = useState<Forward[]>([]);
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
// 模态框状态
const [modalOpen, setModalOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [addressModalOpen, setAddressModalOpen] = useState(false);
const [isEdit, setIsEdit] = useState(false);
const [submitLoading, setSubmitLoading] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [forwardToDelete, setForwardToDelete] = useState<Forward | null>(null);
const [addressModalTitle, setAddressModalTitle] = useState('');
const [addressList, setAddressList] = useState<AddressItem[]>([]);
// 表单状态
const [form, setForm] = useState<ForwardForm>({
name: '',
tunnelId: null,
inPort: null,
remoteAddr: '',
strategy: 'fifo'
});
// 表单验证错误
const [errors, setErrors] = useState<{[key: string]: string}>({});
const [selectedTunnel, setSelectedTunnel] = useState<Tunnel | null>(null);
useEffect(() => {
loadData();
}, []);
// 加载所有数据
const loadData = async () => {
setLoading(true);
try {
const [forwardsRes, tunnelsRes] = await Promise.all([
getForwardList(),
userTunnel()
]);
if (forwardsRes.code === 0) {
setForwards(forwardsRes.data?.map((forward: any) => ({
...forward,
serviceRunning: forward.status === 1
})) || []);
} else {
toast.error(forwardsRes.msg || '获取转发列表失败');
}
if (tunnelsRes.code === 0) {
setTunnels(tunnelsRes.data || []);
} else {
console.warn('获取隧道列表失败:', tunnelsRes.msg);
}
} catch (error) {
console.error('加载数据失败:', error);
toast.error('加载数据失败');
} finally {
setLoading(false);
}
};
// 表单验证
const validateForm = (): boolean => {
const newErrors: {[key: string]: string} = {};
if (!form.name.trim()) {
newErrors.name = '请输入转发名称';
} else if (form.name.length < 2 || form.name.length > 50) {
newErrors.name = '转发名称长度应在2-50个字符之间';
}
if (!form.tunnelId) {
newErrors.tunnelId = '请选择关联隧道';
}
if (!form.remoteAddr.trim()) {
newErrors.remoteAddr = '请输入远程地址';
} else {
// 验证地址格式
const addresses = form.remoteAddr.split('\n').map(addr => addr.trim()).filter(addr => addr);
const ipv4Pattern = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?):\d+$/;
const ipv6FullPattern = /^\[((([0-9a-fA-F]{1,4}:){7}([0-9a-fA-F]{1,4}|:))|(([0-9a-fA-F]{1,4}:){6}(:[0-9a-fA-F]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-fA-F]{1,4}:){5}(((:[0-9a-fA-F]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-fA-F]{1,4}:){4}(((:[0-9a-fA-F]{1,4}){1,3})|((:[0-9a-fA-F]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-fA-F]{1,4}:){3}(((:[0-9a-fA-F]{1,4}){1,4})|((:[0-9a-fA-F]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-fA-F]{1,4}:){2}(((:[0-9a-fA-F]{1,4}){1,5})|((:[0-9a-fA-F]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-fA-F]{1,4}:){1}(((:[0-9a-fA-F]{1,4}){1,6})|((:[0-9a-fA-F]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9a-fA-F]{1,4}){1,7})|((:[0-9a-fA-F]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))\]:\d+$/;
const domainPattern = /^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*:\d+$/;
for (let i = 0; i < addresses.length; i++) {
const addr = addresses[i];
if (!ipv4Pattern.test(addr) && !ipv6FullPattern.test(addr) && !domainPattern.test(addr)) {
newErrors.remoteAddr = `第${i + 1}行地址格式错误`;
break;
}
}
}
if (form.inPort !== null && (form.inPort < 1 || form.inPort > 65535)) {
newErrors.inPort = '端口号必须在1-65535之间';
}
if (selectedTunnel && selectedTunnel.inNodePortSta && selectedTunnel.inNodePortEnd && form.inPort) {
if (form.inPort < selectedTunnel.inNodePortSta || form.inPort > selectedTunnel.inNodePortEnd) {
newErrors.inPort = `端口号必须在${selectedTunnel.inNodePortSta}-${selectedTunnel.inNodePortEnd}范围内`;
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// 新增转发
const handleAdd = () => {
setIsEdit(false);
setForm({
name: '',
tunnelId: null,
inPort: null,
remoteAddr: '',
strategy: 'fifo'
});
setSelectedTunnel(null);
setErrors({});
setModalOpen(true);
};
// 编辑转发
const handleEdit = (forward: Forward) => {
setIsEdit(true);
setForm({
id: forward.id,
name: forward.name,
tunnelId: forward.tunnelId,
inPort: forward.inPort,
remoteAddr: forward.remoteAddr.split(',').join('\n'),
strategy: forward.strategy || 'fifo'
});
const tunnel = tunnels.find(t => t.id === forward.tunnelId);
setSelectedTunnel(tunnel || null);
setErrors({});
setModalOpen(true);
};
// 显示删除确认
const handleDelete = (forward: Forward) => {
setForwardToDelete(forward);
setDeleteModalOpen(true);
};
// 确认删除转发
const confirmDelete = async () => {
if (!forwardToDelete) return;
setDeleteLoading(true);
try {
const res = await deleteForward(forwardToDelete.id);
if (res.code === 0) {
toast.success('删除成功');
setDeleteModalOpen(false);
loadData();
} else {
// 删除失败,询问是否强制删除
const confirmed = window.confirm(`常规删除失败:${res.msg || '删除失败'}\n\n是否需要强制删除?\n\n⚠️ 注意:强制删除不会去验证节点端是否已经删除对应的转发服务。`);
if (confirmed) {
const forceRes = await forceDeleteForward(forwardToDelete.id);
if (forceRes.code === 0) {
toast.success('强制删除成功');
setDeleteModalOpen(false);
loadData();
} else {
toast.error(forceRes.msg || '强制删除失败');
}
}
}
} catch (error) {
console.error('删除失败:', error);
toast.error('删除失败');
} finally {
setDeleteLoading(false);
}
};
// 处理隧道选择变化
const handleTunnelChange = (tunnelId: string) => {
const tunnel = tunnels.find(t => t.id === parseInt(tunnelId));
setSelectedTunnel(tunnel || null);
setForm(prev => ({ ...prev, tunnelId: parseInt(tunnelId) }));
};
// 提交表单
const handleSubmit = async () => {
if (!validateForm()) return;
setSubmitLoading(true);
try {
const processedRemoteAddr = form.remoteAddr
.split('\n')
.map(addr => addr.trim())
.filter(addr => addr)
.join(',');
const addressCount = processedRemoteAddr.split(',').length;
let res;
if (isEdit) {
res = await updateForward({
...form,
remoteAddr: processedRemoteAddr,
strategy: addressCount > 1 ? form.strategy : 'fifo'
});
} else {
const { id, ...createData } = form;
res = await createForward({
...createData,
remoteAddr: processedRemoteAddr,
strategy: addressCount > 1 ? form.strategy : 'fifo'
});
}
if (res.code === 0) {
toast.success(isEdit ? '修改成功' : '创建成功');
setModalOpen(false);
loadData();
} else {
toast.error(res.msg || '操作失败');
}
} catch (error) {
console.error('提交失败:', error);
toast.error('操作失败');
} finally {
setSubmitLoading(false);
}
};
// 处理服务开关
const handleServiceToggle = async (forward: Forward) => {
if (forward.status !== 1 && forward.status !== 0) {
toast.error('转发状态异常,无法操作');
return;
}
const targetState = !forward.serviceRunning;
try {
// 乐观更新UI
setForwards(prev => prev.map(f =>
f.id === forward.id
? { ...f, serviceRunning: targetState }
: f
));
let res;
if (targetState) {
res = await resumeForwardService(forward.id);
} else {
res = await pauseForwardService(forward.id);
}
if (res.code === 0) {
toast.success(targetState ? '服务已启动' : '服务已暂停');
// 更新转发状态
setForwards(prev => prev.map(f =>
f.id === forward.id
? { ...f, status: targetState ? 1 : 0 }
: f
));
} else {
// 操作失败,恢复UI状态
setForwards(prev => prev.map(f =>
f.id === forward.id
? { ...f, serviceRunning: !targetState }
: f
));
toast.error(res.msg || '操作失败');
}
} catch (error) {
// 操作失败,恢复UI状态
setForwards(prev => prev.map(f =>
f.id === forward.id
? { ...f, serviceRunning: !targetState }
: f
));
console.error('服务开关操作失败:', error);
toast.error('网络错误,操作失败');
}
};
// 格式化流量
const formatFlow = (value: number): string => {
if (value === 0) return '0 B';
if (value < 1024) return value + ' B';
if (value < 1024 * 1024) return (value / 1024).toFixed(2) + ' KB';
if (value < 1024 * 1024 * 1024) return (value / (1024 * 1024)).toFixed(2) + ' MB';
return (value / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
};
// 格式化入口地址
const formatInAddress = (ipString: string, port: number): string => {
if (!ipString || !port) return '';
const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip);
if (ips.length === 0) return '';
if (ips.length === 1) {
const ip = ips[0];
if (ip.includes(':') && !ip.startsWith('[')) {
return `[${ip}]:${port}`;
} else {
return `${ip}:${port}`;
}
}
const firstIp = ips[0];
let formattedFirstIp;
if (firstIp.includes(':') && !firstIp.startsWith('[')) {
formattedFirstIp = `[${firstIp}]`;
} else {
formattedFirstIp = firstIp;
}
return `${formattedFirstIp}:${port} (+${ips.length - 1})`;
};
// 格式化远程地址
const formatRemoteAddress = (addressString: string): string => {
if (!addressString) return '';
const addresses = addressString.split(',').map(addr => addr.trim()).filter(addr => addr);
if (addresses.length === 0) return '';
if (addresses.length === 1) return addresses[0];
return `${addresses[0]} (+${addresses.length - 1})`;
};
// 检查是否有多个地址
const hasMultipleAddresses = (addressString: string): boolean => {
if (!addressString) return false;
const addresses = addressString.split(',').map(addr => addr.trim()).filter(addr => addr);
return addresses.length > 1;
};
// 显示地址列表弹窗
const showAddressModal = (addressString: string, port: number | null, title: string) => {
if (!addressString) return;
let addresses: string[];
if (port !== null) {
// 入口地址处理
const ips = addressString.split(',').map(ip => ip.trim()).filter(ip => ip);
if (ips.length <= 1) {
copyToClipboard(formatInAddress(addressString, port), title);
return;
}
addresses = ips.map(ip => {
if (ip.includes(':') && !ip.startsWith('[')) {
return `[${ip}]:${port}`;
} else {
return `${ip}:${port}`;
}
});
} else {
// 远程地址处理
addresses = addressString.split(',').map(addr => addr.trim()).filter(addr => addr);
if (addresses.length <= 1) {
copyToClipboard(addressString, title);
return;
}
}
setAddressList(addresses.map((address, index) => ({
id: index,
address,
copying: false
})));
setAddressModalTitle(`${title} (${addresses.length}个)`);
setAddressModalOpen(true);
};
// 复制到剪贴板
const copyToClipboard = async (text: string, label: string = '内容') => {
try {
await navigator.clipboard.writeText(text);
toast.success(`已复制${label}`);
} catch (error) {
toast.error('复制失败');
}
};
// 复制地址
const copyAddress = async (addressItem: AddressItem) => {
try {
setAddressList(prev => prev.map(item =>
item.id === addressItem.id ? { ...item, copying: true } : item
));
await copyToClipboard(addressItem.address, '地址');
} catch (error) {
toast.error('复制失败');
} finally {
setAddressList(prev => prev.map(item =>
item.id === addressItem.id ? { ...item, copying: false } : item
));
}
};
// 复制所有地址
const copyAllAddresses = async () => {
if (addressList.length === 0) return;
const allAddresses = addressList.map(item => item.address).join('\n');
await copyToClipboard(allAddresses, '所有地址');
};
// 获取状态显示
const getStatusDisplay = (status: number) => {
switch (status) {
case 1:
return { color: 'success', text: '正常' };
case 0:
return { color: 'warning', text: '暂停' };
case -1:
return { color: 'danger', text: '异常' };
default:
return { color: 'default', text: '未知' };
}
};
// 获取策略显示
const getStrategyDisplay = (strategy: string) => {
switch (strategy) {
case 'fifo':
return { color: 'primary', text: '主备' };
case 'round':
return { color: 'success', text: '轮询' };
case 'rand':
return { color: 'warning', text: '随机' };
default:
return { color: 'default', text: '未知' };
}
};
// 获取地址数量
const getAddressCount = (addressString: string): number => {
if (!addressString) return 0;
const addresses = addressString.split('\n').map(addr => addr.trim()).filter(addr => addr);
return addresses.length;
};
if (loading) {
return (
<AdminLayout>
<div className="flex items-center justify-center h-64">
<div className="flex items-center gap-3">
<Spinner size="sm" />
<span className="text-default-600">正在加载...</span>
</div>
</div>
</AdminLayout>
);
}
return (
<AdminLayout>
<div className="px-3 lg:px-6 py-8">
{/* 页面头部 */}
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-foreground">转发管理</h1>
<Button
color="primary"
onPress={handleAdd}
startContent={
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" />
</svg>
}
>
新增转发
</Button>
</div>
{/* 转发卡片网格 */}
{forwards.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{forwards.map((forward) => {
const statusDisplay = getStatusDisplay(forward.status);
const strategyDisplay = getStrategyDisplay(forward.strategy);
return (
<Card key={forward.id} className="shadow-sm border border-gray-200 dark:border-gray-700">
<CardHeader className="pb-3">
<div className="flex justify-between items-start w-full">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground truncate">{forward.name}</h3>
<p className="text-sm text-default-500 truncate mt-1">{forward.tunnelName}</p>
</div>
<div className="flex items-center gap-2 ml-2">
<Switch
size="sm"
isSelected={forward.serviceRunning}
onValueChange={() => handleServiceToggle(forward)}
isDisabled={forward.status !== 1 && forward.status !== 0}
/>
<Chip
color={statusDisplay.color as any}
variant="flat"
size="sm"
>
{statusDisplay.text}
</Chip>
</div>
</div>
</CardHeader>
<CardBody className="pt-0">
<div className="space-y-3">
{/* 地址信息 */}
<div className="space-y-2">
<div
className={`cursor-pointer p-2 bg-green-50 dark:bg-green-500/10 rounded border border-green-200 dark:border-green-500/20 ${
hasMultipleAddresses(forward.inIp) ? 'hover:bg-green-100 dark:hover:bg-green-500/20' : ''
}`}
onClick={() => showAddressModal(forward.inIp, forward.inPort, '入口地址')}
title={formatInAddress(forward.inIp, forward.inPort)}
>
<div className="text-xs text-green-600 dark:text-green-400 mb-1">入口地址</div>
<code className="text-xs font-mono text-green-700 dark:text-green-300 truncate block">
{formatInAddress(forward.inIp, forward.inPort)}
</code>
</div>
<div className="text-center text-default-400 text-xs">↓</div>
<div
className={`cursor-pointer p-2 bg-blue-50 dark:bg-blue-500/10 rounded border border-blue-200 dark:border-blue-500/20 ${
hasMultipleAddresses(forward.remoteAddr) ? 'hover:bg-blue-100 dark:hover:bg-blue-500/20' : ''
}`}
onClick={() => showAddressModal(forward.remoteAddr, null, '目标地址')}
title={formatRemoteAddress(forward.remoteAddr)}
>
<div className="text-xs text-blue-600 dark:text-blue-400 mb-1">目标地址</div>
<code className="text-xs font-mono text-blue-700 dark:text-blue-300 truncate block">
{formatRemoteAddress(forward.remoteAddr)}
</code>
</div>
</div>
{/* 策略和流量 */}
<div className="flex justify-between items-center pt-2 border-t border-gray-200 dark:border-default-200">
<div className="text-center flex-1">
<div className="text-xs text-default-500 mb-1">策略</div>
<Chip color={strategyDisplay.color as any} variant="flat" size="sm">
{strategyDisplay.text}
</Chip>
</div>
<div className="text-center flex-1">
<div className="text-xs text-default-500 mb-1">总流量</div>
<div className="text-xs font-medium text-primary">
{formatFlow((forward.inFlow || 0) + (forward.outFlow || 0))}
</div>
</div>
</div>
</div>
<div className="flex gap-2 mt-4">
<Button
size="sm"
variant="flat"
color="primary"
onPress={() => handleEdit(forward)}
className="flex-1"
startContent={
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
}
>
编辑
</Button>
<Button
size="sm"
variant="flat"
color="danger"
onPress={() => handleDelete(forward)}
className="flex-1"
startContent={
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" clipRule="evenodd" />
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z" clipRule="evenodd" />
</svg>
}
>
删除
</Button>
</div>
</CardBody>
</Card>
);
})}
</div>
) : (
/* 空状态 */
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
<CardBody className="text-center py-16">
<div className="flex flex-col items-center gap-4">
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-default-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-foreground">暂无转发配置</h3>
<p className="text-default-500 text-sm mt-1">还没有创建任何转发配置,点击上方按钮开始创建</p>
</div>
</div>
</CardBody>
</Card>
)}
{/* 新增/编辑模态框 */}
<Modal
isOpen={modalOpen}
onOpenChange={setModalOpen}
size="lg"
scrollBehavior="inside"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<h2 className="text-xl font-bold">
{isEdit ? '编辑转发' : '新增转发'}
</h2>
<p className="text-small text-default-500">
{isEdit ? '修改现有转发配置的信息' : '创建新的转发配置'}
</p>
</ModalHeader>
<ModalBody>
<div className="space-y-4">
<Input
label="转发名称"
placeholder="请输入转发名称"
value={form.name}
onChange={(e) => setForm(prev => ({ ...prev, name: e.target.value }))}
isInvalid={!!errors.name}
errorMessage={errors.name}
variant="bordered"
/>
<Select
label="选择隧道"
placeholder="请选择关联的隧道"
selectedKeys={form.tunnelId ? [form.tunnelId.toString()] : []}
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as string;
if (selectedKey) {
handleTunnelChange(selectedKey);
}
}}
isInvalid={!!errors.tunnelId}
errorMessage={errors.tunnelId}
variant="bordered"
>
{tunnels.map((tunnel) => (
<SelectItem key={tunnel.id} value={tunnel.id}>
{tunnel.name}
</SelectItem>
))}
</Select>
<Input
label="入口端口"
placeholder="留空自动分配"
type="number"
value={form.inPort?.toString() || ''}
onChange={(e) => setForm(prev => ({
...prev,
inPort: e.target.value ? parseInt(e.target.value) : null
}))}
isInvalid={!!errors.inPort}
errorMessage={errors.inPort}
variant="bordered"
description={
selectedTunnel && selectedTunnel.inNodePortSta && selectedTunnel.inNodePortEnd
? `允许范围: ${selectedTunnel.inNodePortSta}-${selectedTunnel.inNodePortEnd}`
: '留空将自动分配可用端口'
}
/>
<Input
label="远程地址"
placeholder="请输入远程地址,多个地址用换行分隔"
value={form.remoteAddr}
onChange={(e) => setForm(prev => ({ ...prev, remoteAddr: e.target.value }))}
isInvalid={!!errors.remoteAddr}
errorMessage={errors.remoteAddr}
variant="bordered"
description="格式: IP:端口 或 域名:端口,支持多个地址(每行一个)"
/>
{getAddressCount(form.remoteAddr) > 1 && (
<Select
label="负载策略"
placeholder="请选择负载均衡策略"
selectedKeys={[form.strategy]}
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as string;
setForm(prev => ({ ...prev, strategy: selectedKey }));
}}
variant="bordered"
description="多个目标地址的负载均衡策略"
>
<SelectItem key="fifo" value="fifo">主备模式 - 自上而下</SelectItem>
<SelectItem key="round" value="round">轮询模式 - 依次轮换</SelectItem>
<SelectItem key="rand" value="rand">随机模式 - 随机选择</SelectItem>
</Select>
)}
</div>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
取消
</Button>
<Button
color="primary"
onPress={handleSubmit}
isLoading={submitLoading}
>
{isEdit ? '保存修改' : '创建转发'}
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
{/* 删除确认模态框 */}
<Modal
isOpen={deleteModalOpen}
onOpenChange={setDeleteModalOpen}
size="sm"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<h2 className="text-lg font-bold text-danger">确认删除</h2>
</ModalHeader>
<ModalBody>
<p className="text-default-600">
确定要删除转发 <span className="font-semibold text-foreground">"{forwardToDelete?.name}"</span> 吗?
</p>
<p className="text-small text-default-500 mt-2">
此操作无法撤销,删除后该转发将永久消失。
</p>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
取消
</Button>
<Button
color="danger"
onPress={confirmDelete}
isLoading={deleteLoading}
>
确认删除
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
{/* 地址列表弹窗 */}
<Modal isOpen={addressModalOpen} onClose={() => setAddressModalOpen(false)} size="lg">
<ModalContent>
<ModalHeader className="text-base">{addressModalTitle}</ModalHeader>
<ModalBody className="pb-6">
<div className="mb-4 text-right">
<Button size="sm" onClick={copyAllAddresses}>
复制全部
</Button>
</div>
<div className="space-y-2 max-h-60 overflow-y-auto">
{addressList.map((item) => (
<div key={item.id} className="flex justify-between items-center p-3 border border-default-200 dark:border-default-100 rounded-lg">
<code className="text-sm flex-1 mr-3 text-foreground">{item.address}</code>
<Button
size="sm"
variant="light"
isLoading={item.copying}
onClick={() => copyAddress(item)}
>
复制
</Button>
</div>
))}
</div>
</ModalBody>
</ModalContent>
</Modal>
</div>
</AdminLayout>
);
}
+162
View File
@@ -0,0 +1,162 @@
import { Button } from "@heroui/button";
import { Input } from "@heroui/input";
import { Card, CardBody, CardHeader } from "@heroui/card";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import toast from 'react-hot-toast';
import { title } from "@/components/primitives";
import DefaultLayout from "@/layouts/default";
import { login, LoginData } from "@/api";
interface LoginForm {
username: string;
password: string;
}
export default function IndexPage() {
const [form, setForm] = useState<LoginForm>({
username: "",
password: ""
});
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<Partial<LoginForm>>({});
const navigate = useNavigate();
// 验证表单
const validateForm = (): boolean => {
const newErrors: Partial<LoginForm> = {};
if (!form.username.trim()) {
newErrors.username = '请输入用户名';
}
if (!form.password.trim()) {
newErrors.password = '请输入密码';
} else if (form.password.length < 6) {
newErrors.password = '密码长度至少6位';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// 处理输入变化
const handleInputChange = (field: keyof LoginForm, value: string) => {
setForm(prev => ({ ...prev, [field]: value }));
// 清除该字段的错误
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: undefined }));
}
};
const handleLogin = async () => {
if (!validateForm()) return;
setLoading(true);
try {
const loginData: LoginData = {
username: form.username.trim(),
password: form.password
};
const response = await login(loginData);
if (response.code !== 0) {
toast.error(response.msg || "登陆失败");
return;
}
// 检查是否需要强制修改密码
if (response.data.requirePasswordChange) {
localStorage.setItem('token', response.data.token);
localStorage.setItem("role_id", response.data.role_id.toString());
localStorage.setItem("name", response.data.name);
// 设置admin字段 - role_id为0的是管理员
localStorage.setItem("admin", (response.data.role_id === 0).toString());
toast.success('检测到默认密码,即将跳转到修改密码页面');
// 直接跳转到修改密码页面
navigate("/change-password");
return;
}
// 保存登陆信息
localStorage.setItem('token', response.data.token);
localStorage.setItem("role_id", response.data.role_id.toString());
localStorage.setItem("name", response.data.name);
// 设置admin字段 - role_id为0的是管理员
localStorage.setItem("admin", (response.data.role_id === 0).toString());
// 登陆成功
toast.success('登陆成功');
navigate("/dashboard");
} catch (error) {
console.error('登陆错误:', error);
toast.error("网络错误,请稍后重试");
} finally {
setLoading(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !loading) {
handleLogin();
}
};
return (
<DefaultLayout>
<section className="flex flex-col items-center justify-center gap-4 py-8 md:py-10 min-h-[calc(100vh-200px)]">
<div className="w-full max-w-md">
<Card className="w-full">
<CardHeader className="pb-0 pt-6 px-6 flex-col items-center">
<h1 className={title({ size: "sm" })}>登陆</h1>
<p className="text-small text-default-500 mt-2">请输入您的账号信息</p>
</CardHeader>
<CardBody className="px-6 py-6">
<div className="flex flex-col gap-4">
<Input
label="用户名"
placeholder="请输入用户名"
value={form.username}
onChange={(e) => handleInputChange('username', e.target.value)}
onKeyDown={handleKeyPress}
variant="bordered"
isDisabled={loading}
isInvalid={!!errors.username}
errorMessage={errors.username}
/>
<Input
label="密码"
placeholder="请输入密码"
type="password"
value={form.password}
onChange={(e) => handleInputChange('password', e.target.value)}
onKeyDown={handleKeyPress}
variant="bordered"
isDisabled={loading}
isInvalid={!!errors.password}
errorMessage={errors.password}
/>
<Button
color="primary"
size="lg"
onClick={handleLogin}
isLoading={loading}
disabled={loading}
className="mt-2"
>
{loading ? "登陆中..." : "登陆"}
</Button>
</div>
</CardBody>
</Card>
</div>
</section>
</DefaultLayout>
);
}
+470
View File
@@ -0,0 +1,470 @@
import { useState, useEffect } from "react";
import { Card, CardBody, CardHeader } from "@heroui/card";
import { Button } from "@heroui/button";
import { Input } from "@heroui/input";
import { Select, SelectItem } from "@heroui/select";
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/modal";
import { Chip } from "@heroui/chip";
import { Spinner } from "@heroui/spinner";
import toast from 'react-hot-toast';
import AdminLayout from "@/layouts/admin";
import {
createSpeedLimit,
getSpeedLimitList,
updateSpeedLimit,
deleteSpeedLimit,
getTunnelList
} from "@/api";
interface SpeedLimitRule {
id: number;
name: string;
speed: number;
status: number;
tunnelId: number;
tunnelName: string;
createdTime: string;
updatedTime: string;
}
interface Tunnel {
id: number;
name: string;
}
interface SpeedLimitForm {
id?: number;
name: string;
speed: number;
tunnelId: number | null;
tunnelName: string;
status: number;
}
export default function LimitPage() {
const [loading, setLoading] = useState(true);
const [rules, setRules] = useState<SpeedLimitRule[]>([]);
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
// 模态框状态
const [modalOpen, setModalOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [isEdit, setIsEdit] = useState(false);
const [submitLoading, setSubmitLoading] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [ruleToDelete, setRuleToDelete] = useState<SpeedLimitRule | null>(null);
// 表单状态
const [form, setForm] = useState<SpeedLimitForm>({
name: '',
speed: 100,
tunnelId: null,
tunnelName: '',
status: 1
});
// 表单验证错误
const [errors, setErrors] = useState<{[key: string]: string}>({});
useEffect(() => {
loadData();
}, []);
// 加载所有数据
const loadData = async () => {
setLoading(true);
try {
const [rulesRes, tunnelsRes] = await Promise.all([
getSpeedLimitList(),
getTunnelList()
]);
if (rulesRes.code === 0) {
setRules(rulesRes.data || []);
} else {
toast.error(rulesRes.msg || '获取限速规则失败');
}
if (tunnelsRes.code === 0) {
setTunnels(tunnelsRes.data || []);
} else {
console.warn('获取隧道列表失败:', tunnelsRes.msg);
}
} catch (error) {
console.error('加载数据失败:', error);
toast.error('加载数据失败');
} finally {
setLoading(false);
}
};
// 表单验证
const validateForm = (): boolean => {
const newErrors: {[key: string]: string} = {};
if (!form.name.trim()) {
newErrors.name = '请输入规则名称';
} else if (form.name.length < 2 || form.name.length > 50) {
newErrors.name = '规则名称长度应在2-50个字符之间';
}
if (!form.speed || form.speed < 1) {
newErrors.speed = '请输入有效的速度限制(≥1 Mbps)';
}
if (!form.tunnelId) {
newErrors.tunnelId = '请选择要绑定的隧道';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// 新增规则
const handleAdd = () => {
setIsEdit(false);
setForm({
name: '',
speed: 100,
tunnelId: null,
tunnelName: '',
status: 1
});
setErrors({});
setModalOpen(true);
};
// 编辑规则
const handleEdit = (rule: SpeedLimitRule) => {
setIsEdit(true);
setForm({
id: rule.id,
name: rule.name,
speed: rule.speed,
tunnelId: rule.tunnelId,
tunnelName: rule.tunnelName,
status: rule.status
});
setErrors({});
setModalOpen(true);
};
// 显示删除确认
const handleDelete = (rule: SpeedLimitRule) => {
setRuleToDelete(rule);
setDeleteModalOpen(true);
};
// 确认删除规则
const confirmDelete = async () => {
if (!ruleToDelete) return;
setDeleteLoading(true);
try {
const res = await deleteSpeedLimit(ruleToDelete.id);
if (res.code === 0) {
toast.success('删除成功');
setDeleteModalOpen(false);
loadData();
} else {
toast.error(res.msg || '删除失败');
}
} catch (error) {
console.error('删除失败:', error);
toast.error('删除失败');
} finally {
setDeleteLoading(false);
}
};
// 提交表单
const handleSubmit = async () => {
if (!validateForm()) return;
setSubmitLoading(true);
try {
let res;
if (isEdit) {
res = await updateSpeedLimit(form);
} else {
const { id, ...createData } = form;
res = await createSpeedLimit(createData);
}
if (res.code === 0) {
toast.success(isEdit ? '修改成功' : '创建成功');
setModalOpen(false);
loadData();
} else {
toast.error(res.msg || '操作失败');
}
} catch (error) {
console.error('提交失败:', error);
toast.error('操作失败');
} finally {
setSubmitLoading(false);
}
};
if (loading) {
return (
<AdminLayout>
<div className="flex items-center justify-center h-64">
<div className="flex items-center gap-3">
<Spinner size="sm" />
<span className="text-default-600">正在加载...</span>
</div>
</div>
</AdminLayout>
);
}
return (
<AdminLayout>
<div className="px-3 lg:px-6 py-8">
{/* 页面头部 */}
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-foreground">限速管理</h1>
<Button
color="primary"
onPress={handleAdd}
startContent={
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" />
</svg>
}
>
新增规则
</Button>
</div>
{/* 统一卡片网格 */}
{rules.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{rules.map((rule) => (
<Card key={rule.id} className="shadow-sm border border-gray-200 dark:border-gray-700">
<CardHeader className="pb-3">
<div className="flex justify-between items-start w-full">
<div>
<h3 className="font-semibold text-foreground">{rule.name}</h3>
</div>
<Chip
color={rule.status === 1 ? "success" : "danger"}
variant="flat"
size="sm"
>
{rule.status === 1 ? '运行' : '异常'}
</Chip>
</div>
</CardHeader>
<CardBody className="pt-0">
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-small text-default-600">速度限制</span>
<Chip color="secondary" variant="flat" size="sm">
{rule.speed} Mbps
</Chip>
</div>
<div className="flex justify-between items-center">
<span className="text-small text-default-600">绑定隧道</span>
{rule.tunnelName ? (
<Chip color="primary" variant="flat" size="sm">
{rule.tunnelName}
</Chip>
) : (
<span className="text-default-400 text-small">未绑定</span>
)}
</div>
</div>
<div className="flex gap-2 mt-4">
<Button
size="sm"
variant="flat"
color="primary"
onPress={() => handleEdit(rule)}
className="flex-1"
startContent={
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
}
>
编辑
</Button>
<Button
size="sm"
variant="flat"
color="danger"
onPress={() => handleDelete(rule)}
className="flex-1"
startContent={
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" clipRule="evenodd" />
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z" clipRule="evenodd" />
</svg>
}
>
删除
</Button>
</div>
</CardBody>
</Card>
))}
</div>
) : (
/* 空状态 */
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
<CardBody className="text-center py-16">
<div className="flex flex-col items-center gap-4">
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-default-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6v6l4 2m6-6a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-foreground">暂无限速规则</h3>
<p className="text-default-500 text-sm mt-1">还没有创建任何限速规则,点击上方按钮开始创建</p>
</div>
</div>
</CardBody>
</Card>
)}
{/* 新增/编辑模态框 */}
<Modal
isOpen={modalOpen}
onOpenChange={setModalOpen}
size="lg"
scrollBehavior="inside"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<h2 className="text-xl font-bold">
{isEdit ? '编辑限速规则' : '新增限速规则'}
</h2>
<p className="text-small text-default-500">
{isEdit ? '修改现有限速规则的配置信息' : '创建新的限速规则并绑定到隧道'}
</p>
</ModalHeader>
<ModalBody>
<div className="space-y-4">
<Input
label="规则名称"
placeholder="请输入限速规则名称"
value={form.name}
onChange={(e) => setForm(prev => ({ ...prev, name: e.target.value }))}
isInvalid={!!errors.name}
errorMessage={errors.name}
variant="bordered"
/>
<Input
label="速度限制"
placeholder="请输入速度限制"
type="number"
value={form.speed.toString()}
onChange={(e) => setForm(prev => ({ ...prev, speed: parseInt(e.target.value) || 0 }))}
isInvalid={!!errors.speed}
errorMessage={errors.speed}
variant="bordered"
endContent={
<div className="pointer-events-none flex items-center">
<span className="text-default-400 text-small">Mbps</span>
</div>
}
/>
<Select
label="绑定隧道"
placeholder="请选择要绑定的隧道"
selectedKeys={form.tunnelId ? [form.tunnelId.toString()] : []}
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as string;
if (selectedKey) {
const selectedTunnel = tunnels.find(tunnel => tunnel.id === parseInt(selectedKey));
setForm(prev => ({
...prev,
tunnelId: parseInt(selectedKey),
tunnelName: selectedTunnel?.name || ''
}));
} else {
setForm(prev => ({
...prev,
tunnelId: null,
tunnelName: ''
}));
}
}}
isInvalid={!!errors.tunnelId}
errorMessage={errors.tunnelId}
variant="bordered"
>
{tunnels.map((tunnel) => (
<SelectItem key={tunnel.id}>
{tunnel.name}
</SelectItem>
))}
</Select>
</div>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
取消
</Button>
<Button
color="primary"
onPress={handleSubmit}
isLoading={submitLoading}
>
{isEdit ? '保存修改' : '创建规则'}
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
{/* 删除确认模态框 */}
<Modal
isOpen={deleteModalOpen}
onOpenChange={setDeleteModalOpen}
size="sm"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<h2 className="text-lg font-bold text-danger">确认删除</h2>
</ModalHeader>
<ModalBody>
<p className="text-default-600">
确定要删除限速规则 <span className="font-semibold text-foreground">"{ruleToDelete?.name}"</span> 吗?
</p>
<p className="text-small text-default-500 mt-2">
此操作无法撤销,删除后该规则将永久消失。
</p>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
取消
</Button>
<Button
color="danger"
onPress={confirmDelete}
isLoading={deleteLoading}
>
确认删除
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</div>
</AdminLayout>
);
}
+53
View File
@@ -0,0 +1,53 @@
import type { NavigateOptions } from "react-router-dom";
import * as React from "react";
import { HeroUIProvider } from "@heroui/system";
import { useHref, useNavigate } from "react-router-dom";
import { Toaster } from 'react-hot-toast';
import { ThemeProvider } from '@/components/theme-provider';
declare module "@react-types/shared" {
interface RouterConfig {
routerOptions: NavigateOptions;
}
}
export interface ProvidersProps {
children: React.ReactNode;
}
export function Provider({ children }: ProvidersProps) {
const navigate = useNavigate();
return (
<HeroUIProvider navigate={navigate} useHref={useHref}>
<ThemeProvider>
{children}
<Toaster
position="top-center"
toastOptions={{
duration: 3000,
className: 'dark:bg-gray-800 dark:text-white',
style: {
background: 'var(--toaster-bg, #ffffff)',
color: 'var(--toaster-color, #000000)',
border: '1px solid var(--toaster-border, #e5e7eb)',
},
success: {
style: {
background: '#10b981',
color: '#ffffff',
},
},
error: {
style: {
background: '#ef4444',
color: '#ffffff',
},
},
}}
/>
</ThemeProvider>
</HeroUIProvider>
);
}
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+5
View File
@@ -0,0 +1,5 @@
import { SVGProps } from "react";
export type IconSvgProps = SVGProps<SVGSVGElement> & {
size?: number;
};
+72
View File
@@ -0,0 +1,72 @@
import { getRoleIdFromToken, isTokenValid } from './jwt';
/**
* 权限工具类
*/
/**
* 获取当前用户的token
* @returns token
*/
export function getToken(): string | null {
return localStorage.getItem('token');
}
/**
* 获取当前用户的角色ID
* @returns 角色ID
*/
export function getCurrentUserRoleId(): number | null {
const token = getToken();
if (!token || !isTokenValid(token)) {
return null;
}
return getRoleIdFromToken(token);
}
/**
* 判断当前用户是否是管理员
* @returns 是否是管理员
*/
export function isAdmin(): boolean {
const roleId = getCurrentUserRoleId();
return roleId === 0;
}
/**
* 判断当前用户是否有指定角色
* @param targetRoleId 目标角色ID
* @returns 是否有指定角色
*/
export function hasRole(targetRoleId: number): boolean {
const roleId = getCurrentUserRoleId();
return roleId === targetRoleId;
}
/**
* 判断当前用户是否已登录且token有效
* @returns 是否已登录
*/
export function isLoggedIn(): boolean {
const token = getToken();
return token ? isTokenValid(token) : false;
}
/**
* 权限检查装饰器函数
* @param fn 要执行的函数
* @param errorMsg 权限不足时的错误提示
* @returns 包装后的函数
*/
export function requireAdmin<T extends (...args: any[]) => any>(
fn: T,
errorMsg: string = '权限不足,仅管理员可操作'
): T {
return ((...args: Parameters<T>) => {
if (!isAdmin()) {
console.warn(errorMsg);
return false;
}
return fn(...args);
}) as T;
}
+113
View File
@@ -0,0 +1,113 @@
/**
* JWT工具类 - TypeScript版本
*/
interface JWTPayload {
sub: string;
role_id: number;
user: string;
exp: number;
iat: number;
}
/**
* 从JWT Token中获取payload
* @param token JWT Token
* @returns payload数据
*/
function getPayloadFromToken(token: string): JWTPayload | null {
try {
if (!token) return null;
const parts = token.split('.');
if (parts.length !== 3) return null;
const encodedPayload = parts[1];
const decodedPayload = atob(encodedPayload);
return JSON.parse(decodedPayload) as JWTPayload;
} catch (error) {
return null;
}
}
/**
* 从JWT Token中获取用户ID
* @param token JWT Token
* @returns 用户ID
*/
export function getUserIdFromToken(token: string): number | null {
const payload = getPayloadFromToken(token);
return payload ? parseInt(payload.sub) : null;
}
/**
* 从JWT Token中获取用户角色ID
* @param token JWT Token
* @returns 角色ID
*/
export function getRoleIdFromToken(token: string): number | null {
const payload = getPayloadFromToken(token);
return payload ? payload.role_id : null;
}
/**
* 从JWT Token中获取用户名
* @param token JWT Token
* @returns 用户名
*/
export function getUsernameFromToken(token: string): string | null {
const payload = getPayloadFromToken(token);
return payload ? payload.user : null;
}
/**
* 验证token是否过期
* @param token JWT Token
* @returns 是否有效
*/
export function isTokenValid(token: string): boolean {
const payload = getPayloadFromToken(token);
if (!payload) return false;
const now = Math.floor(Date.now() / 1000);
return payload.exp > now;
}
// JwtUtil对象,提供便捷的静态方法调用
export const JwtUtil = {
/**
* 从localStorage获取token并解析用户ID
* @returns 用户ID
*/
getUserIdFromToken(): number | null {
const token = localStorage.getItem('token');
return token ? getUserIdFromToken(token) : null;
},
/**
* 从localStorage获取token并解析角色ID
* @returns 角色ID
*/
getRoleIdFromToken(): number | null {
const token = localStorage.getItem('token');
return token ? getRoleIdFromToken(token) : null;
},
/**
* 从localStorage获取token并解析用户名
* @returns 用户名
*/
getUsernameFromToken(): string | null {
const token = localStorage.getItem('token');
return token ? getUsernameFromToken(token) : null;
},
/**
* 验证localStorage中的token是否有效
* @returns 是否有效
*/
isTokenValid(): boolean {
const token = localStorage.getItem('token');
return token ? isTokenValid(token) : false;
}
};
+24
View File
@@ -0,0 +1,24 @@
/**
* 安全退出登录函数
* 清除登录相关数据,但保留用户偏好设置(如主题)
*/
export const safeLogout = () => {
// 需要保留的用户偏好设置
const preservedSettings = {
theme: localStorage.getItem('heroui-theme'),
// 可以在这里添加其他需要保留的设置
};
// 清除所有localStorage数据
localStorage.clear();
// 恢复保留的设置
Object.entries(preservedSettings).forEach(([key, value]) => {
if (value) {
if (key === 'theme') {
localStorage.setItem('heroui-theme', value);
}
// 可以在这里添加其他设置的恢复逻辑
}
});
};
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />