修复转发暂停问题 增加安卓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
+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>
);
}