mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-08 02:36:37 +08:00
修复转发暂停问题 增加安卓apk
This commit is contained in:
+77
-10
@@ -1,5 +1,5 @@
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import IndexPage from "@/pages/index";
|
||||
import ChangePasswordPage from "@/pages/change-password";
|
||||
@@ -8,15 +8,60 @@ import ForwardPage from "@/pages/forward";
|
||||
import TunnelPage from "@/pages/tunnel";
|
||||
import NodePage from "@/pages/node";
|
||||
import UserPage from "@/pages/user";
|
||||
import ProfilePage from "@/pages/profile";
|
||||
import LimitPage from "@/pages/limit";
|
||||
import ConfigPage from "@/pages/config";
|
||||
import { SettingsPage } from "@/pages/settings";
|
||||
|
||||
import AdminLayout from "@/layouts/admin";
|
||||
import H5Layout from "@/layouts/h5";
|
||||
import H5SimpleLayout from "@/layouts/h5-simple";
|
||||
|
||||
import { isLoggedIn } from "@/utils/auth";
|
||||
import { siteConfig } from "@/config/site";
|
||||
|
||||
// 检测是否为H5模式
|
||||
const useH5Mode = () => {
|
||||
// 立即检测H5模式,避免初始渲染时的闪屏
|
||||
const getInitialH5Mode = () => {
|
||||
// 检测移动设备或小屏幕
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
// 检测是否为移动端浏览器
|
||||
const isMobileBrowser = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
// 检测URL参数是否包含h5模式
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const isH5Param = urlParams.get('h5') === 'true';
|
||||
|
||||
return isMobile || isMobileBrowser || isH5Param;
|
||||
};
|
||||
|
||||
const [isH5, setIsH5] = useState(getInitialH5Mode);
|
||||
|
||||
useEffect(() => {
|
||||
const checkH5Mode = () => {
|
||||
// 检测移动设备或小屏幕
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
// 检测是否为移动端浏览器
|
||||
const isMobileBrowser = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
// 检测URL参数是否包含h5模式
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const isH5Param = urlParams.get('h5') === 'true';
|
||||
|
||||
setIsH5(isMobile || isMobileBrowser || isH5Param);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', checkH5Mode);
|
||||
|
||||
return () => window.removeEventListener('resize', checkH5Mode);
|
||||
}, []);
|
||||
|
||||
return isH5;
|
||||
};
|
||||
|
||||
// 简化的路由保护组件 - 使用浏览器重定向避免React循环
|
||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const ProtectedRoute = ({ children, useSimpleLayout = false }: { children: React.ReactNode, useSimpleLayout?: boolean }) => {
|
||||
const authenticated = isLoggedIn();
|
||||
const isH5 = useH5Mode();
|
||||
|
||||
useEffect(() => {
|
||||
if (!authenticated) {
|
||||
@@ -27,13 +72,23 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
|
||||
if (!authenticated) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-lg">重定向中...</div>
|
||||
<div className="flex items-center justify-center min-h-screen bg-white dark:bg-black">
|
||||
<div className="text-lg text-gray-700 dark:text-gray-200"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
// 根据模式和页面类型选择布局
|
||||
let Layout;
|
||||
if (isH5 && useSimpleLayout) {
|
||||
Layout = H5SimpleLayout;
|
||||
} else if (isH5) {
|
||||
Layout = H5Layout;
|
||||
} else {
|
||||
Layout = AdminLayout;
|
||||
}
|
||||
|
||||
return <Layout>{children}</Layout>;
|
||||
};
|
||||
|
||||
|
||||
@@ -50,8 +105,8 @@ const LoginRoute = () => {
|
||||
|
||||
if (authenticated) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-lg">重定向中...</div>
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-100 dark:bg-black">
|
||||
<div className="text-lg text-gray-700 dark:text-gray-200"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -130,15 +185,23 @@ function App() {
|
||||
<Route
|
||||
path="/user"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProtectedRoute useSimpleLayout={true}>
|
||||
<UserPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/profile"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProfilePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/limit"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProtectedRoute useSimpleLayout={true}>
|
||||
<LimitPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
@@ -146,11 +209,15 @@ function App() {
|
||||
<Route
|
||||
path="/config"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProtectedRoute useSimpleLayout={true}>
|
||||
<ConfigPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={<SettingsPage />}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,63 @@
|
||||
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;
|
||||
// 检测是否在WebView中运行
|
||||
const isWebView = (): boolean => {
|
||||
try {
|
||||
if (typeof (window as any).AndroidInterface !== 'undefined') {
|
||||
const result = (window as any).AndroidInterface.isWebView();
|
||||
return result === "true";
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取WebView中当前选中的面板地址
|
||||
const getWebViewPanelAddress = (): string | null => {
|
||||
try {
|
||||
if (typeof (window as any).AndroidInterface !== 'undefined') {
|
||||
const currentAddress = (window as any).AndroidInterface.getCurrentPanelAddress();
|
||||
// 确保返回的地址不为空且已选中
|
||||
if (currentAddress && currentAddress.trim()) {
|
||||
return currentAddress;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化时获取基础URL
|
||||
let baseURL: string = '';
|
||||
|
||||
// 重新初始化baseURL的函数
|
||||
export const reinitializeBaseURL = () => {
|
||||
if (isWebView()) {
|
||||
const panelAddress = getWebViewPanelAddress();
|
||||
if (panelAddress && panelAddress.trim()) {
|
||||
baseURL = `${panelAddress}/api/v1/`;
|
||||
axios.defaults.baseURL = baseURL;
|
||||
return true;
|
||||
} else {
|
||||
baseURL = '';
|
||||
axios.defaults.baseURL = '';
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// 非WebView环境使用默认配置
|
||||
baseURL = import.meta.env.VITE_API_BASE ? `${import.meta.env.VITE_API_BASE}/api/v1/` : '/api/v1/';
|
||||
axios.defaults.baseURL = baseURL;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// 初始调用
|
||||
reinitializeBaseURL();
|
||||
|
||||
|
||||
|
||||
// 在开发环境下输出API配置信息
|
||||
if (import.meta.env.DEV) {
|
||||
@@ -10,6 +65,10 @@ if (import.meta.env.DEV) {
|
||||
console.log(' - VITE_API_BASE:', import.meta.env.VITE_API_BASE || '(undefined)');
|
||||
console.log(' - Base URL:', baseURL);
|
||||
console.log(' - Environment:', import.meta.env.MODE);
|
||||
console.log(' - Is WebView:', isWebView());
|
||||
if (isWebView()) {
|
||||
console.log(' - WebView Panel Address:', getWebViewPanelAddress() || '(not set)');
|
||||
}
|
||||
}
|
||||
|
||||
interface ApiResponse<T = any> {
|
||||
@@ -42,6 +101,12 @@ function isTokenExpired(response: ApiResponse) {
|
||||
const Network = {
|
||||
get: function<T = any>(path: string = '', data: any = {}): Promise<ApiResponse<T>> {
|
||||
return new Promise(function(resolve) {
|
||||
// 如果baseURL是默认值且是WebView环境,说明没有设置面板地址
|
||||
if (baseURL === '') {
|
||||
resolve({"code": -1, "msg": " - 请先设置面板地址", "data": null as T});
|
||||
return;
|
||||
}
|
||||
|
||||
axios.get(path, {
|
||||
params: data,
|
||||
timeout: 30000,
|
||||
@@ -73,6 +138,12 @@ const Network = {
|
||||
|
||||
post: function<T = any>(path: string = '', data: any = {}): Promise<ApiResponse<T>> {
|
||||
return new Promise(function(resolve) {
|
||||
// 如果baseURL是默认值且是WebView环境,说明没有设置面板地址
|
||||
if (baseURL === '') {
|
||||
resolve({"code": -1, "msg": " - 请先设置面板地址", "data": null as T});
|
||||
return;
|
||||
}
|
||||
|
||||
axios.post(path, data, {
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
|
||||
@@ -5,14 +5,35 @@ import {
|
||||
NavbarBrand,
|
||||
NavbarContent,
|
||||
} from "@heroui/navbar";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { ThemeSwitch } from "@/components/theme-switch";
|
||||
import { Logo } from "@/components/icons";
|
||||
import { siteConfig, getCachedConfig } from "@/config/site";
|
||||
|
||||
export const Navbar = () => {
|
||||
const navigate = useNavigate();
|
||||
// 初始状态使用siteConfig中已经从缓存读取的值,避免闪烁
|
||||
const [appName, setAppName] = useState(siteConfig.name);
|
||||
const [isWebView, setIsWebView] = useState(false);
|
||||
|
||||
// 检测是否在WebView中运行
|
||||
useEffect(() => {
|
||||
const detectWebView = () => {
|
||||
try {
|
||||
// 尝试调用Android注册的JavaScript接口
|
||||
if (typeof (window as any).AndroidInterface !== 'undefined') {
|
||||
const result = (window as any).AndroidInterface.isWebView();
|
||||
setIsWebView(result === "true");
|
||||
} else {
|
||||
setIsWebView(false);
|
||||
}
|
||||
} catch (error) {
|
||||
setIsWebView(false);
|
||||
}
|
||||
};
|
||||
|
||||
detectWebView();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 异步检查是否有更新的配置
|
||||
@@ -54,23 +75,37 @@ export const Navbar = () => {
|
||||
}, [appName]);
|
||||
|
||||
return (
|
||||
<HeroUINavbar maxWidth="xl" position="sticky" height="60px" className="shrink-0">
|
||||
<NavbarContent className="basis-1/5 sm:basis-full" justify="start">
|
||||
<NavbarBrand className="gap-2 max-w-fit">
|
||||
<Link
|
||||
className="flex justify-start items-center gap-2 max-w-[200px] sm:max-w-none"
|
||||
color="foreground"
|
||||
href="/"
|
||||
>
|
||||
<Logo size={24} />
|
||||
<p className="font-bold text-inherit truncate">{appName}</p>
|
||||
</Link>
|
||||
</NavbarBrand>
|
||||
</NavbarContent>
|
||||
<>
|
||||
<HeroUINavbar maxWidth="xl" position="sticky" height="60px" className="shrink-0">
|
||||
<NavbarContent className="basis-1/5 sm:basis-full" justify="start">
|
||||
<NavbarBrand className="gap-2 max-w-fit">
|
||||
<Link
|
||||
className="flex justify-start items-center gap-2 max-w-[200px] sm:max-w-none"
|
||||
color="foreground"
|
||||
href="/"
|
||||
>
|
||||
<Logo size={24} />
|
||||
<p className="font-bold text-inherit truncate">{appName}</p>
|
||||
</Link>
|
||||
</NavbarBrand>
|
||||
</NavbarContent>
|
||||
|
||||
<NavbarContent className="basis-1/5 sm:basis-full" justify="end">
|
||||
<ThemeSwitch />
|
||||
</NavbarContent>
|
||||
</HeroUINavbar>
|
||||
<NavbarContent className="basis-1/5 sm:basis-full" justify="end">
|
||||
{/* WebView设置图标 */}
|
||||
{isWebView && (
|
||||
<button
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
|
||||
onClick={() => navigate('/settings')}
|
||||
title="面板设置"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</NavbarContent>
|
||||
</HeroUINavbar>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -31,8 +31,8 @@ export default function PageWrapper({
|
||||
<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 className="animate-spin h-5 w-5 border-2 border-gray-200 dark:border-gray-700 border-t-gray-600 dark:border-t-gray-300 rounded-full"></div>
|
||||
<span className="text-default-600"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,18 +18,26 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
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);
|
||||
// 始终跟随系统主题
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
if (systemTheme !== theme) {
|
||||
setTheme(systemTheme);
|
||||
}
|
||||
|
||||
// 监听主题变化
|
||||
updateThemeClass(theme);
|
||||
|
||||
// 监听系统主题变化
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleThemeChange = (e: MediaQueryListEvent) => {
|
||||
const newTheme = e.matches ? 'dark' : 'light';
|
||||
setTheme(newTheme);
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleThemeChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleThemeChange);
|
||||
}, [theme, setTheme]);
|
||||
|
||||
return <>{children}</>;
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -4,7 +4,7 @@ export type SiteConfig = typeof siteConfig;
|
||||
|
||||
// 缓存相关常量
|
||||
const CACHE_PREFIX = 'vite_config_';
|
||||
const VERSION = "1.2.10";
|
||||
const VERSION = "1.2.11";
|
||||
|
||||
|
||||
const getInitialConfig = () => {
|
||||
|
||||
@@ -7,7 +7,6 @@ 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';
|
||||
import { siteConfig } from '@/config/site';
|
||||
@@ -243,7 +242,7 @@ export default function AdminLayout({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`flex ${isMobile ? 'min-h-screen' : 'h-screen'} bg-gray-50 dark:bg-black`}>
|
||||
<div className={`flex ${isMobile ? 'min-h-screen' : 'h-screen'} bg-gray-100 dark:bg-black`}>
|
||||
{/* 移动端遮罩层 */}
|
||||
{isMobile && mobileMenuVisible && (
|
||||
<div
|
||||
@@ -345,9 +344,7 @@ export default function AdminLayout({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<ThemeSwitch />
|
||||
|
||||
{/* 用户菜单 */}
|
||||
{/* 用户菜单 */}
|
||||
<Dropdown placement="bottom-end">
|
||||
<DropdownTrigger>
|
||||
<Button variant="light" className="text-sm font-medium text-foreground">
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from "@heroui/button";
|
||||
|
||||
import { Logo } from '@/components/icons';
|
||||
import { siteConfig } from '@/config/site';
|
||||
|
||||
export default function H5SimpleLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleBack = () => {
|
||||
navigate('/profile');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-gray-100 dark:bg-black">
|
||||
{/* 顶部导航栏 */}
|
||||
<header className="bg-white dark:bg-black shadow-sm border-b border-gray-200 dark:border-gray-600 h-14 flex items-center justify-between px-4 relative z-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
isIconOnly
|
||||
variant="light"
|
||||
size="sm"
|
||||
onPress={handleBack}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</Button>
|
||||
<Logo size={20} />
|
||||
<h1 className="text-sm font-bold text-foreground">{siteConfig.name}</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<main className="flex-1 overflow-y-auto bg-gray-100 dark:bg-black">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
|
||||
import { Logo } from '@/components/icons';
|
||||
import { siteConfig } from '@/config/site';
|
||||
|
||||
interface TabItem {
|
||||
path: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
adminOnly?: boolean;
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default function H5Layout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
// Tabbar配置
|
||||
const tabItems: TabItem[] = [
|
||||
{
|
||||
path: '/dashboard',
|
||||
label: '首页',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" />
|
||||
</svg>
|
||||
)
|
||||
},
|
||||
{
|
||||
path: '/forward',
|
||||
label: '转发',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" 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-6 h-6" 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-6 h-6" 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: '/profile',
|
||||
label: '我的',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" 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>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
// 兼容处理:如果没有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());
|
||||
}
|
||||
|
||||
|
||||
setIsAdmin(adminFlag);
|
||||
}, []);
|
||||
|
||||
// Tab点击处理
|
||||
const handleTabClick = (path: string) => {
|
||||
navigate(path);
|
||||
};
|
||||
|
||||
// 过滤tab项(根据权限)
|
||||
const filteredTabItems = tabItems.filter(item =>
|
||||
!item.adminOnly || isAdmin
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-gray-100 dark:bg-black">
|
||||
{/* 顶部导航栏 */}
|
||||
<header className="bg-white dark:bg-black shadow-sm border-b border-gray-200 dark:border-gray-600 h-14 flex items-center justify-between px-4 relative z-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<Logo size={20} />
|
||||
<h1 className="text-sm font-bold text-foreground">{siteConfig.name}</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<main className="flex-1 overflow-y-auto bg-gray-100 dark:bg-black">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* 底部Tabbar */}
|
||||
<nav className="bg-white dark:bg-black border-t border-gray-200 dark:border-gray-600 h-16 flex items-center justify-around px-2 relative z-10">
|
||||
{filteredTabItems.map((item) => {
|
||||
const isActive = location.pathname === item.path;
|
||||
return (
|
||||
<button
|
||||
key={item.path}
|
||||
onClick={() => handleTabClick(item.path)}
|
||||
className={`
|
||||
flex flex-col items-center justify-center flex-1 h-full
|
||||
transition-colors duration-200 min-h-[44px]
|
||||
${isActive
|
||||
? 'text-primary-600 dark:text-primary-400'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex-shrink-0 mb-1">
|
||||
{item.icon}
|
||||
</div>
|
||||
<span className="text-xs font-medium">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,3 +13,22 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
</Provider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
|
||||
/**
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { HashRouter } from "react-router-dom";
|
||||
|
||||
import App from "./App.tsx";
|
||||
import { Provider } from "./provider.tsx";
|
||||
import "@/styles/globals.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<HashRouter>
|
||||
<Provider>
|
||||
<App />
|
||||
</Provider>
|
||||
</HashRouter>
|
||||
);
|
||||
|
||||
*/
|
||||
@@ -9,7 +9,7 @@ import { Select, SelectItem } from "@heroui/select";
|
||||
import toast from 'react-hot-toast';
|
||||
import { updateConfigs } from '@/api';
|
||||
import { SettingsIcon } from '@/components/icons';
|
||||
import AdminLayout from "@/layouts/admin";
|
||||
|
||||
import { isAdmin } from '@/utils/auth';
|
||||
import { getCachedConfigs, clearConfigCache, updateSiteConfig } from '@/config/site';
|
||||
|
||||
@@ -323,16 +323,16 @@ export default function ConfigPage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AdminLayout>
|
||||
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Spinner size="lg" label="加载配置中..." />
|
||||
</div>
|
||||
</AdminLayout>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
@@ -422,6 +422,6 @@ export default function ConfigPage() {
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</AdminLayout>
|
||||
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { useState, useEffect } from "react";
|
||||
import toast from 'react-hot-toast';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
|
||||
import AdminLayout from "@/layouts/admin";
|
||||
|
||||
import { getUserPackageInfo } from "@/api";
|
||||
|
||||
interface UserInfo {
|
||||
@@ -574,21 +574,21 @@ export default function DashboardPage() {
|
||||
|
||||
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>
|
||||
<div className="animate-spin h-5 w-5 border-2 border-gray-200 dark:border-gray-700 border-t-gray-600 dark:border-t-gray-300 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">
|
||||
|
||||
{/* 响应式统计卡片 */}
|
||||
@@ -950,6 +950,6 @@ export default function DashboardPage() {
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
import AdminLayout from "@/layouts/admin";
|
||||
|
||||
import {
|
||||
createForward,
|
||||
getForwardList,
|
||||
@@ -1338,26 +1338,25 @@ export default function ForwardPage() {
|
||||
|
||||
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>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
const userGroups = groupForwardsByUserAndTunnel();
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
|
||||
<div className="px-3 lg:px-6 py-8">
|
||||
{/* 页面头部 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold text-foreground">转发管理</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 显示模式切换按钮 */}
|
||||
@@ -2144,6 +2143,6 @@ export default function ForwardPage() {
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import toast from 'react-hot-toast';
|
||||
import axios from 'axios';
|
||||
|
||||
import { title } from "@/components/primitives";
|
||||
import DefaultLayout from "@/layouts/default";
|
||||
@@ -103,12 +104,12 @@ export default function IndexPage() {
|
||||
tacInstanceRef.current = null;
|
||||
}
|
||||
|
||||
// 获取API基础URL
|
||||
const baseURL = import.meta.env.VITE_API_BASE ? `${import.meta.env.VITE_API_BASE}/api/v1` : '/api/v1';
|
||||
// 使用axios的baseURL,确保在WebView中使用正确的面板地址
|
||||
const baseURL = axios.defaults.baseURL || (import.meta.env.VITE_API_BASE ? `${import.meta.env.VITE_API_BASE}/api/v1/` : '/api/v1/');
|
||||
|
||||
const config: CaptchaConfig = {
|
||||
requestCaptchaDataUrl: `${baseURL}/captcha/generate`,
|
||||
validCaptchaUrl: `${baseURL}/captcha/verify`,
|
||||
requestCaptchaDataUrl: `${baseURL}captcha/generate`,
|
||||
validCaptchaUrl: `${baseURL}captcha/verify`,
|
||||
bindEl: "#captcha-container",
|
||||
validSuccess: (res: any, _: any, tac: any) => {
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Chip } from "@heroui/chip";
|
||||
import { Spinner } from "@heroui/spinner";
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import AdminLayout from "@/layouts/admin";
|
||||
|
||||
import {
|
||||
createSpeedLimit,
|
||||
getSpeedLimitList,
|
||||
@@ -209,34 +209,34 @@ export default function LimitPage() {
|
||||
|
||||
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 className="flex-1">
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
onPress={handleAdd}
|
||||
|
||||
>
|
||||
新增
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 统一卡片网格 */}
|
||||
@@ -466,6 +466,6 @@ export default function LimitPage() {
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
|
||||
);
|
||||
}
|
||||
@@ -9,8 +9,9 @@ import { Spinner } from "@heroui/spinner";
|
||||
import { Alert } from "@heroui/alert";
|
||||
import { Progress } from "@heroui/progress";
|
||||
import toast from 'react-hot-toast';
|
||||
import axios from 'axios';
|
||||
|
||||
|
||||
import AdminLayout from "@/layouts/admin";
|
||||
import {
|
||||
createNode,
|
||||
getNodeList,
|
||||
@@ -120,9 +121,9 @@ export default function NodePage() {
|
||||
closeWebSocket();
|
||||
}
|
||||
|
||||
// 构建WebSocket URL,使用与API相同的地址逻辑
|
||||
let baseUrl = import.meta.env.VITE_API_BASE || window.location.origin;
|
||||
const wsUrl = baseUrl.replace(/^http/, 'ws') + `/system-info?type=0&secret=${localStorage.getItem('token')}`;
|
||||
// 构建WebSocket URL,使用axios的baseURL
|
||||
const baseUrl = axios.defaults.baseURL || (import.meta.env.VITE_API_BASE ? `${import.meta.env.VITE_API_BASE}/api/v1/` : '/api/v1/');
|
||||
const wsUrl = baseUrl.replace(/^http/, 'ws').replace(/\/api\/v1\/$/, '') + `/system-info?type=0&secret=${localStorage.getItem('token')}`;
|
||||
|
||||
try {
|
||||
websocketRef.current = new WebSocket(wsUrl);
|
||||
@@ -546,43 +547,23 @@ export default function NodePage() {
|
||||
};
|
||||
|
||||
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>
|
||||
<div className="flex gap-2">
|
||||
<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>
|
||||
<div className="flex-1">
|
||||
</div>
|
||||
|
||||
{/* WebSocket支持说明 */}
|
||||
<div className="mb-6 p-4 bg-warning-50 dark:bg-warning-100/10 border border-warning-200 dark:border-warning-300/20 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<svg className="w-5 h-5 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>
|
||||
<div className="flex-1">
|
||||
|
||||
<p className="text-sm text-warning-700 dark:text-warning-300">
|
||||
节点实时监控依赖WebSocket连接。如果面板开启了<strong>反向代理</strong>或使用了<strong>CDN</strong>,
|
||||
请确保已开启WebSocket支持,否则节点状态可能不显示节点信息。
|
||||
具体配置方法请自行搜索相关教程或询问AI助手。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
onPress={handleAdd}
|
||||
|
||||
>
|
||||
新增
|
||||
</Button>
|
||||
|
||||
</div>
|
||||
|
||||
{/* 节点列表 */}
|
||||
@@ -954,6 +935,6 @@ export default function NodePage() {
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardBody } from "@heroui/card";
|
||||
import { Button } from "@heroui/button";
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, useDisclosure } from "@heroui/modal";
|
||||
import { Input } from "@heroui/input";
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { updatePassword } from '@/api';
|
||||
import { safeLogout } from '@/utils/logout';
|
||||
import { siteConfig } from '@/config/site';
|
||||
|
||||
interface PasswordForm {
|
||||
newUsername: string;
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
path: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
const navigate = useNavigate();
|
||||
const { isOpen, onOpen, onOpenChange } = useDisclosure();
|
||||
const [username, setUsername] = useState('');
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [passwordLoading, setPasswordLoading] = useState(false);
|
||||
const [passwordForm, setPasswordForm] = useState<PasswordForm>({
|
||||
newUsername: '',
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
|
||||
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);
|
||||
}, []);
|
||||
|
||||
// 管理员菜单项
|
||||
const adminMenuItems: MenuItem[] = [
|
||||
{
|
||||
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>
|
||||
),
|
||||
color: 'bg-orange-100 dark:bg-orange-500/20 text-orange-600 dark:text-orange-400',
|
||||
description: '管理用户限速策略'
|
||||
},
|
||||
{
|
||||
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>
|
||||
),
|
||||
color: 'bg-blue-100 dark:bg-blue-500/20 text-blue-600 dark:text-blue-400',
|
||||
description: '管理系统用户'
|
||||
},
|
||||
{
|
||||
path: '/config',
|
||||
label: '网站配置',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" />
|
||||
</svg>
|
||||
),
|
||||
color: 'bg-purple-100 dark:bg-purple-500/20 text-purple-600 dark:text-purple-400',
|
||||
description: '配置网站设置'
|
||||
}
|
||||
];
|
||||
|
||||
// 退出登录
|
||||
const handleLogout = () => {
|
||||
safeLogout();
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
// 密码表单验证
|
||||
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: ''
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-3 lg:px-6 py-8 flex flex-col h-full">
|
||||
|
||||
<div className="space-y-6 flex-1">
|
||||
{/* 用户信息卡片 */}
|
||||
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
|
||||
<CardBody className="p-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-12 h-12 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-primary" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-base font-medium text-foreground">{username}</h3>
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
<span className={`px-2 py-1 rounded-md text-xs font-medium ${
|
||||
isAdmin
|
||||
? 'bg-primary-100 dark:bg-primary-500/20 text-primary-700 dark:text-primary-300'
|
||||
: 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300'
|
||||
}`}>
|
||||
{isAdmin ? '管理员' : '普通用户'}
|
||||
</span>
|
||||
<span className="text-xs text-default-500">
|
||||
{new Date().toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
</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-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{/* 管理员功能 */}
|
||||
{isAdmin && adminMenuItems.map((item) => (
|
||||
<button
|
||||
key={item.path}
|
||||
onClick={() => navigate(item.path)}
|
||||
className="flex flex-col items-center p-3 rounded-2xl bg-gray-50 dark:bg-default-100 hover:bg-gray-100 dark:hover:bg-default-200 transition-colors duration-200"
|
||||
>
|
||||
<div className={`w-10 h-10 ${item.color} rounded-full flex items-center justify-center mb-2`}>
|
||||
{item.icon}
|
||||
</div>
|
||||
<span className="text-xs text-foreground text-center">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* 修改密码 */}
|
||||
<button
|
||||
onClick={onOpen}
|
||||
className="flex flex-col items-center p-3 rounded-2xl bg-gray-50 dark:bg-default-100 hover:bg-gray-100 dark:hover:bg-default-200 transition-colors duration-200"
|
||||
>
|
||||
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-500/20 text-blue-600 dark:text-blue-400 rounded-full flex items-center justify-center mb-2">
|
||||
<svg className="w-5 h-5" 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>
|
||||
</div>
|
||||
<span className="text-xs text-foreground text-center">修改密码</span>
|
||||
</button>
|
||||
|
||||
{/* 退出登录 */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex flex-col items-center p-3 rounded-2xl bg-gray-50 dark:bg-default-100 hover:bg-gray-100 dark:hover:bg-default-200 transition-colors duration-200"
|
||||
>
|
||||
<div className="w-10 h-10 bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 rounded-full flex items-center justify-center mb-2">
|
||||
<svg className="w-5 h-5" 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>
|
||||
</div>
|
||||
<span className="text-xs text-foreground text-center">退出登录</span>
|
||||
</button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
|
||||
{/* 版权信息 */}
|
||||
<div className="text-center py-4 mt-auto">
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
Powered by{' '}
|
||||
<a
|
||||
href="https://github.com/bqlpfy/forward-panel"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
哆啦A梦
|
||||
</a>
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
v{siteConfig.version}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 修改密码弹窗 */}
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={() => {
|
||||
onOpenChange();
|
||||
resetPasswordForm();
|
||||
}}
|
||||
size="full"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Input } from "@heroui/input";
|
||||
import { Button } from "@heroui/button";
|
||||
import { Card, CardBody } from "@heroui/card";
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import toast from 'react-hot-toast';
|
||||
import { reinitializeBaseURL } from '@/api/network';
|
||||
|
||||
interface PanelAddress {
|
||||
name: string;
|
||||
address: string;
|
||||
}
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [panelAddresses, setPanelAddresses] = useState<PanelAddress[]>([]);
|
||||
const [currentAddress, setCurrentAddress] = useState<string>('');
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newAddress, setNewAddress] = useState('');
|
||||
|
||||
// 验证面板地址格式
|
||||
const validatePanelAddress = (address: string): boolean => {
|
||||
try {
|
||||
// 基本格式检查:必须以 http:// 或 https:// 开头
|
||||
if (!address.startsWith('http://') && !address.startsWith('https://')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 使用URL构造函数验证完整URL格式
|
||||
const url = new URL(address);
|
||||
|
||||
// 检查主机名不能为空
|
||||
if (!url.hostname || url.hostname.trim() === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查主机名
|
||||
const hostname = url.hostname;
|
||||
|
||||
// 支持 localhost
|
||||
if (hostname === 'localhost') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 支持 IPv4 地址
|
||||
const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/;
|
||||
if (ipv4Pattern.test(hostname)) {
|
||||
const parts = hostname.split('.');
|
||||
return parts.every(part => {
|
||||
const num = parseInt(part);
|
||||
return num >= 0 && num <= 255;
|
||||
});
|
||||
}
|
||||
|
||||
// 支持 IPv6 地址
|
||||
const ipv6Pattern = /^\[([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\]$|^\[([0-9a-fA-F]{1,4}:)*:([0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{1,4}\]$/;
|
||||
if (ipv6Pattern.test(hostname)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 支持域名
|
||||
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])?)*\.[a-zA-Z]{2,}$/;
|
||||
if (domainPattern.test(hostname)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
// URL构造函数失败说明格式不正确
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 加载面板地址列表
|
||||
const loadPanelAddresses = () => {
|
||||
try {
|
||||
if (typeof (window as any).AndroidInterface !== 'undefined') {
|
||||
const addressesStr = (window as any).AndroidInterface.getPanelAddresses();
|
||||
const currentAddr = (window as any).AndroidInterface.getCurrentPanelAddress();
|
||||
|
||||
if (addressesStr && addressesStr.trim()) {
|
||||
const addresses = addressesStr.split(',').filter((item: string) => item.trim()).map((item: string) => {
|
||||
const [name, address] = item.split('|');
|
||||
return { name: name || '未命名', address: address || '' };
|
||||
});
|
||||
setPanelAddresses(addresses);
|
||||
} else {
|
||||
// 如果没有数据或数据为空,清空列表
|
||||
setPanelAddresses([]);
|
||||
}
|
||||
|
||||
setCurrentAddress(currentAddr || '');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载面板地址失败:', error);
|
||||
setPanelAddresses([]);
|
||||
}
|
||||
};
|
||||
|
||||
// 添加新面板地址
|
||||
const addPanelAddress = () => {
|
||||
if (!newName.trim() || !newAddress.trim()) {
|
||||
toast.error('请输入名称和地址');
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证地址格式
|
||||
if (!validatePanelAddress(newAddress.trim())) {
|
||||
toast.error('地址格式不正确,请检查:\n• 必须是完整的URL格式\n• 必须以 http:// 或 https:// 开头\n• 支持域名、IPv4、IPv6 地址\n• 端口号范围:1-65535\n• 示例:http://192.168.1.100:3000');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof (window as any).AndroidInterface !== 'undefined') {
|
||||
(window as any).AndroidInterface.savePanelAddress(newName.trim(), newAddress.trim());
|
||||
toast.success('添加成功');
|
||||
setNewName('');
|
||||
setNewAddress('');
|
||||
loadPanelAddresses();
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('添加失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 设置当前面板地址
|
||||
const setCurrentPanel = (address: string) => {
|
||||
try {
|
||||
if (typeof (window as any).AndroidInterface !== 'undefined') {
|
||||
(window as any).AndroidInterface.setCurrentPanelAddress(address);
|
||||
setCurrentAddress(address);
|
||||
reinitializeBaseURL();
|
||||
toast.success('设置成功,API地址已更新');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('设置失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 删除面板地址
|
||||
const deletePanelAddress = (name: string, address: string) => {
|
||||
try {
|
||||
if (typeof (window as any).AndroidInterface !== 'undefined') {
|
||||
(window as any).AndroidInterface.deletePanelAddress(name, address);
|
||||
|
||||
// 如果删除的是当前选中的地址,重新初始化baseURL
|
||||
if (currentAddress === address) {
|
||||
setCurrentAddress('');
|
||||
reinitializeBaseURL();
|
||||
toast.success('删除成功,当前面板地址已清空');
|
||||
} else {
|
||||
toast.success('删除成功');
|
||||
}
|
||||
|
||||
loadPanelAddresses();
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 页面加载时获取数据
|
||||
useEffect(() => {
|
||||
loadPanelAddresses();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
{/* 顶部导航 */}
|
||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="max-w-4xl mx-auto px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
isIconOnly
|
||||
variant="light"
|
||||
onClick={() => navigate(-1)}
|
||||
className="text-gray-600 dark:text-gray-300"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">面板设置</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="max-w-4xl mx-auto px-4 py-6">
|
||||
<div className="space-y-6">
|
||||
{/* 添加新地址 */}
|
||||
<Card className="border border-gray-200 dark:border-gray-700">
|
||||
<CardBody className="p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">添加新面板地址</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="名称"
|
||||
placeholder="请输入面板名称"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="地址"
|
||||
placeholder="http://192.168.1.100:3000"
|
||||
value={newAddress}
|
||||
onChange={(e) => setNewAddress(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button color="primary" onClick={addPanelAddress}>
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 地址列表 */}
|
||||
<Card className="border border-gray-200 dark:border-gray-700">
|
||||
<CardBody className="p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">已保存的面板地址</h2>
|
||||
{panelAddresses.length === 0 ? (
|
||||
<p className="text-gray-500 dark:text-gray-400 text-center py-8">暂无保存的面板地址</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{panelAddresses.map((panel, index) => (
|
||||
<div key={index} className="border border-gray-200 dark:border-gray-600 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900 dark:text-white">{panel.name}</span>
|
||||
{currentAddress === panel.address && (
|
||||
<span className="px-2 py-1 bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300 text-xs rounded">
|
||||
当前
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">{panel.address}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{currentAddress !== panel.address && (
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
onClick={() => setCurrentPanel(panel.address)}
|
||||
>
|
||||
设为当前
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
color="danger"
|
||||
variant="light"
|
||||
onClick={() => deletePanelAddress(panel.name, panel.address)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -10,7 +10,7 @@ import { Divider } from "@heroui/divider";
|
||||
import { Alert } from "@heroui/alert";
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import AdminLayout from "@/layouts/admin";
|
||||
|
||||
import {
|
||||
createTunnel,
|
||||
getTunnelList,
|
||||
@@ -412,34 +412,35 @@ export default function TunnelPage() {
|
||||
|
||||
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 className="flex-1">
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
onPress={handleAdd}
|
||||
|
||||
>
|
||||
新增
|
||||
</Button>
|
||||
|
||||
</div>
|
||||
|
||||
{/* 隧道卡片网格 */}
|
||||
@@ -1039,6 +1040,6 @@ export default function TunnelPage() {
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
|
||||
);
|
||||
}
|
||||
@@ -48,9 +48,9 @@ import {
|
||||
getSpeedLimitList,
|
||||
resetUserFlow
|
||||
} from '@/api';
|
||||
import { SearchIcon, PlusIcon, EditIcon, DeleteIcon, UserIcon, SettingsIcon } from '@/components/icons';
|
||||
import { SearchIcon, EditIcon, DeleteIcon, UserIcon, SettingsIcon } from '@/components/icons';
|
||||
import { parseDate } from "@internationalized/date";
|
||||
import AdminLayout from "@/layouts/admin";
|
||||
|
||||
|
||||
// 工具函数
|
||||
const formatFlow = (value: number, unit: string = 'bytes'): string => {
|
||||
@@ -527,13 +527,11 @@ export default function UserPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
|
||||
<div className="px-3 lg:px-6 py-8">
|
||||
{/* 页面头部 */}
|
||||
<div className="flex flex-col gap-4 mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<UserIcon className="w-6 h-6 text-primary" />
|
||||
<h1 className="text-2xl font-bold">用户管理</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-center justify-between">
|
||||
@@ -563,13 +561,13 @@ export default function UserPage() {
|
||||
</div>
|
||||
|
||||
<Button
|
||||
color="primary"
|
||||
startContent={<PlusIcon className="w-4 h-4" />}
|
||||
onClick={handleAdd}
|
||||
className="min-h-8"
|
||||
>
|
||||
新增用户
|
||||
</Button>
|
||||
variant="flat"
|
||||
color="primary"
|
||||
onPress={handleAdd}
|
||||
|
||||
>
|
||||
新增
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1436,6 +1434,6 @@ export default function UserPage() {
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
|
||||
);
|
||||
}
|
||||
@@ -3,22 +3,5 @@
|
||||
* 清除登录相关数据,但保留用户偏好设置(如主题)
|
||||
*/
|
||||
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);
|
||||
}
|
||||
// 可以在这里添加其他设置的恢复逻辑
|
||||
}
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user