修复转发暂停问题 增加安卓apk

This commit is contained in:
qaq
2025-08-20 13:43:35 +08:00
parent 42d04da2a2
commit b1647ed309
82 changed files with 449473 additions and 291 deletions
+77 -10
View File
@@ -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>
);
}
+74 -3
View File
@@ -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: {
+53 -18
View File
@@ -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>
);
};
+1 -1
View File
@@ -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 = () => {
+2 -5
View File
@@ -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">
+48
View File
@@ -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>
);
}
+147
View File
@@ -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>
);
}
+19
View File
@@ -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>
);
*/
+5 -5
View File
@@ -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>
);
}
+6 -6
View File
@@ -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>
);
}
+5 -6
View File
@@ -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>
);
}
+5 -4
View File
@@ -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) => {
+17 -17
View File
@@ -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>
);
}
+18 -37
View File
@@ -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>
);
}
+328
View File
@@ -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>
);
}
+270
View File
@@ -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>
);
};
+18 -17
View File
@@ -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>
);
}
+11 -13
View File
@@ -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>
);
}
-17
View File
@@ -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);
}
// 可以在这里添加其他设置的恢复逻辑
}
});
};