mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-08 18:56:37 +08:00
优化流程
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
import { Button } from "@heroui/button";
|
||||
import { Input } from "@heroui/input";
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import { title } from "@/components/primitives";
|
||||
import { updatePassword } from "@/api";
|
||||
import DefaultLayout from "@/layouts/default";
|
||||
import { safeLogout } from "@/utils/logout";
|
||||
|
||||
interface PasswordForm {
|
||||
newUsername: string;
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
export default function ChangePasswordPage() {
|
||||
const [form, setForm] = useState<PasswordForm>({
|
||||
newUsername: '',
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<Partial<PasswordForm>>({});
|
||||
const navigate = useNavigate();
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Partial<PasswordForm> = {};
|
||||
|
||||
if (!form.newUsername.trim()) {
|
||||
newErrors.newUsername = '请输入新用户名';
|
||||
} else if (form.newUsername.length < 3) {
|
||||
newErrors.newUsername = '用户名长度至少3位';
|
||||
} else if (form.newUsername.length > 20) {
|
||||
newErrors.newUsername = '用户名长度不能超过20位';
|
||||
}
|
||||
|
||||
if (!form.currentPassword.trim()) {
|
||||
newErrors.currentPassword = '请输入当前密码';
|
||||
}
|
||||
|
||||
if (!form.newPassword.trim()) {
|
||||
newErrors.newPassword = '请输入新密码';
|
||||
} else if (form.newPassword.length < 6) {
|
||||
newErrors.newPassword = '新密码长度不能少于6位';
|
||||
} else if (form.newPassword.length > 20) {
|
||||
newErrors.newPassword = '新密码长度不能超过20位';
|
||||
}
|
||||
|
||||
if (!form.confirmPassword.trim()) {
|
||||
newErrors.confirmPassword = '请再次输入新密码';
|
||||
} else if (form.confirmPassword !== form.newPassword) {
|
||||
newErrors.confirmPassword = '两次输入密码不一致';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleInputChange = (field: keyof PasswordForm, value: string) => {
|
||||
setForm(prev => ({ ...prev, [field]: value }));
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await updatePassword(form);
|
||||
|
||||
if (response.code === 0) {
|
||||
toast.success(response.msg || '账号密码修改成功');
|
||||
|
||||
// 使用 toast 确认对话框的替代方案
|
||||
setTimeout(() => {
|
||||
toast.success('即将跳转到登陆页面,请重新登录');
|
||||
setTimeout(() => {
|
||||
logout();
|
||||
}, 1000);
|
||||
}, 1000);
|
||||
} else {
|
||||
toast.error(response.msg || '账号密码修改失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('修改账号密码错误:', error);
|
||||
toast.error('修改账号密码时发生错误');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
safeLogout();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !loading) {
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<section className="flex flex-col items-center justify-center gap-4 py-8 md:py-10 min-h-[calc(100vh-200px)]">
|
||||
<div className="w-full max-w-lg">
|
||||
<Card className="w-full">
|
||||
<CardHeader className="pb-0 pt-6 px-6 flex-col items-center">
|
||||
<div className="w-12 h-12 bg-warning-100 rounded-full flex items-center justify-center mb-3">
|
||||
<svg className="w-6 h-6 text-warning-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 className={title({ size: "sm" })}>安全提醒</h1>
|
||||
<p className="text-small text-default-500 mt-2 text-center">检测到您使用的是默认账号密码,为了您的账户安全,请立即修改</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className="px-6 py-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
label="新用户名"
|
||||
placeholder="请输入新用户名(至少3位)"
|
||||
value={form.newUsername}
|
||||
onChange={(e) => handleInputChange('newUsername', e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
variant="bordered"
|
||||
isDisabled={loading}
|
||||
isInvalid={!!errors.newUsername}
|
||||
errorMessage={errors.newUsername}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="当前密码"
|
||||
placeholder="请输入当前密码"
|
||||
type="password"
|
||||
value={form.currentPassword}
|
||||
onChange={(e) => handleInputChange('currentPassword', e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
variant="bordered"
|
||||
isDisabled={loading}
|
||||
isInvalid={!!errors.currentPassword}
|
||||
errorMessage={errors.currentPassword}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="新密码"
|
||||
placeholder="请输入新密码(至少6位)"
|
||||
type="password"
|
||||
value={form.newPassword}
|
||||
onChange={(e) => handleInputChange('newPassword', e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
variant="bordered"
|
||||
isDisabled={loading}
|
||||
isInvalid={!!errors.newPassword}
|
||||
errorMessage={errors.newPassword}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="确认新密码"
|
||||
placeholder="请再次输入新密码"
|
||||
type="password"
|
||||
value={form.confirmPassword}
|
||||
onChange={(e) => handleInputChange('confirmPassword', e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
variant="bordered"
|
||||
isDisabled={loading}
|
||||
isInvalid={!!errors.confirmPassword}
|
||||
errorMessage={errors.confirmPassword}
|
||||
/>
|
||||
|
||||
<Button
|
||||
color="warning"
|
||||
size="lg"
|
||||
onClick={handleSubmit}
|
||||
isLoading={loading}
|
||||
disabled={loading}
|
||||
className="mt-2"
|
||||
>
|
||||
{loading ? "修改中..." : "立即修改账号密码"}
|
||||
</Button>
|
||||
|
||||
<div className="bg-warning-50 border border-warning-200 text-warning-700 px-3 py-2 rounded-lg text-sm text-center">
|
||||
⚠️ 注意:修改账号密码后需要重新登录
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</DefaultLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,842 @@
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { Button } from "@heroui/button";
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody } from "@heroui/modal";
|
||||
import { useState, useEffect } from "react";
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import AdminLayout from "@/layouts/admin";
|
||||
import { getUserPackageInfo } from "@/api";
|
||||
|
||||
interface UserInfo {
|
||||
flow: number;
|
||||
inFlow: number;
|
||||
outFlow: number;
|
||||
num: number;
|
||||
expTime?: string;
|
||||
flowResetTime?: number;
|
||||
}
|
||||
|
||||
interface UserTunnel {
|
||||
id: number;
|
||||
tunnelId: number;
|
||||
tunnelName: string;
|
||||
flow: number;
|
||||
inFlow: number;
|
||||
outFlow: number;
|
||||
num: number;
|
||||
expTime?: string;
|
||||
flowResetTime?: number;
|
||||
tunnelFlow: number;
|
||||
}
|
||||
|
||||
interface Forward {
|
||||
id: number;
|
||||
name: string;
|
||||
tunnelId: number;
|
||||
tunnelName: string;
|
||||
inIp: string;
|
||||
inPort: number;
|
||||
remoteAddr: string;
|
||||
inFlow: number;
|
||||
outFlow: number;
|
||||
}
|
||||
|
||||
interface AddressItem {
|
||||
id: number;
|
||||
ip: string;
|
||||
address: string;
|
||||
copying: boolean;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [userInfo, setUserInfo] = useState<UserInfo>({} as UserInfo);
|
||||
const [userTunnels, setUserTunnels] = useState<UserTunnel[]>([]);
|
||||
const [forwardList, setForwardList] = useState<Forward[]>([]);
|
||||
|
||||
const [addressModalOpen, setAddressModalOpen] = useState(false);
|
||||
const [addressModalTitle, setAddressModalTitle] = useState('');
|
||||
const [addressList, setAddressList] = useState<AddressItem[]>([]);
|
||||
|
||||
// 检查有效期通知
|
||||
const checkExpirationNotifications = (userInfo: UserInfo, tunnels: UserTunnel[]) => {
|
||||
// 避免重复通知,检查是否已经显示过
|
||||
const notificationKey = `expiration-${userInfo.expTime}-${tunnels.map(t => t.expTime).join(',')}`;
|
||||
const lastNotified = localStorage.getItem('lastNotified');
|
||||
|
||||
if (lastNotified === notificationKey) {
|
||||
return; // 已经通知过,不重复显示
|
||||
}
|
||||
|
||||
let hasNotification = false;
|
||||
|
||||
// 检查主账户有效期
|
||||
if (userInfo.expTime) {
|
||||
const expDate = new Date(userInfo.expTime);
|
||||
const now = new Date();
|
||||
|
||||
if (!isNaN(expDate.getTime()) && expDate > now) {
|
||||
const diffTime = expDate.getTime() - now.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays <= 7 && diffDays > 0) {
|
||||
hasNotification = true;
|
||||
if (diffDays === 1) {
|
||||
toast('账户将于明天过期,请及时续费', {
|
||||
icon: '⚠️',
|
||||
duration: 6000,
|
||||
style: { background: '#f59e0b', color: '#fff' }
|
||||
});
|
||||
} else {
|
||||
toast(`账户将于${diffDays}天后过期,请及时续费`, {
|
||||
icon: '⚠️',
|
||||
duration: 6000,
|
||||
style: { background: '#f59e0b', color: '#fff' }
|
||||
});
|
||||
}
|
||||
} else if (diffDays <= 0) {
|
||||
hasNotification = true;
|
||||
toast('账户已过期,请立即续费', {
|
||||
icon: '⚠️',
|
||||
duration: 8000,
|
||||
style: { background: '#ef4444', color: '#fff' }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查隧道有效期
|
||||
tunnels.forEach(tunnel => {
|
||||
if (tunnel.expTime) {
|
||||
const expDate = new Date(tunnel.expTime);
|
||||
const now = new Date();
|
||||
|
||||
if (!isNaN(expDate.getTime()) && expDate > now) {
|
||||
const diffTime = expDate.getTime() - now.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays <= 7 && diffDays > 0) {
|
||||
hasNotification = true;
|
||||
if (diffDays === 1) {
|
||||
toast(`隧道"${tunnel.tunnelName}"将于明天过期`, {
|
||||
icon: '⚠️',
|
||||
duration: 5000,
|
||||
style: { background: '#f59e0b', color: '#fff' }
|
||||
});
|
||||
} else {
|
||||
toast(`隧道"${tunnel.tunnelName}"将于${diffDays}天后过期`, {
|
||||
icon: '⚠️',
|
||||
duration: 5000,
|
||||
style: { background: '#f59e0b', color: '#fff' }
|
||||
});
|
||||
}
|
||||
} else if (diffDays <= 0) {
|
||||
hasNotification = true;
|
||||
toast(`隧道"${tunnel.tunnelName}"已过期`, {
|
||||
icon: '⚠️',
|
||||
duration: 6000,
|
||||
style: { background: '#ef4444', color: '#fff' }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 如果显示了通知,记录防止重复
|
||||
if (hasNotification) {
|
||||
localStorage.setItem('lastNotified', notificationKey);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 重置状态并加载数据,防止页面切换时显示旧数据
|
||||
setLoading(true);
|
||||
setUserInfo({} as UserInfo);
|
||||
setUserTunnels([]);
|
||||
setForwardList([]);
|
||||
|
||||
loadPackageData();
|
||||
localStorage.setItem('e', '/dashboard');
|
||||
}, []);
|
||||
|
||||
const loadPackageData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getUserPackageInfo();
|
||||
if (res.code === 0) {
|
||||
const data = res.data;
|
||||
setUserInfo(data.userInfo || {});
|
||||
setUserTunnels(data.tunnelPermissions || []);
|
||||
setForwardList(data.forwards || []);
|
||||
|
||||
// 检查有效期并显示通知
|
||||
checkExpirationNotifications(data.userInfo, data.tunnelPermissions || []);
|
||||
} else {
|
||||
toast.error(res.msg || '获取套餐信息失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取套餐信息失败:', error);
|
||||
toast.error('获取套餐信息失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatFlow = (value: number, unit: string = 'bytes'): string => {
|
||||
// 99999 表示无限制
|
||||
if (value === 99999) {
|
||||
return '无限制';
|
||||
}
|
||||
|
||||
if (unit === 'gb') {
|
||||
return value + ' GB';
|
||||
} else {
|
||||
if (value === 0) return '0 B';
|
||||
if (value < 1024) return value + ' B';
|
||||
if (value < 1024 * 1024) return (value / 1024).toFixed(2) + ' KB';
|
||||
if (value < 1024 * 1024 * 1024) return (value / (1024 * 1024)).toFixed(2) + ' MB';
|
||||
return (value / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
||||
}
|
||||
};
|
||||
|
||||
const formatNumber = (value: number): string => {
|
||||
// 99999 表示无限制
|
||||
if (value === 99999) {
|
||||
return '无限制';
|
||||
}
|
||||
return value.toString();
|
||||
};
|
||||
|
||||
const getExpStatus = (expTime?: string) => {
|
||||
if (!expTime) return {
|
||||
color: 'text-green-600 dark:text-green-400',
|
||||
bg: 'bg-green-50 dark:bg-green-500/10 border-green-200 dark:border-green-500/20',
|
||||
text: '永久'
|
||||
};
|
||||
|
||||
const now = new Date();
|
||||
const expDate = new Date(expTime);
|
||||
|
||||
if (isNaN(expDate.getTime())) {
|
||||
return {
|
||||
color: 'text-gray-600 dark:text-gray-400',
|
||||
bg: 'bg-gray-50 dark:bg-black/10 border-gray-200 dark:border-gray-500/20',
|
||||
text: '无效'
|
||||
};
|
||||
}
|
||||
|
||||
if (expDate < now) {
|
||||
return {
|
||||
color: 'text-red-600 dark:text-red-400',
|
||||
bg: 'bg-red-50 dark:bg-red-500/10 border-red-200 dark:border-red-500/20',
|
||||
text: '已过期'
|
||||
};
|
||||
}
|
||||
|
||||
const diffTime = expDate.getTime() - now.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays <= 7) {
|
||||
return {
|
||||
color: 'text-red-600 dark:text-red-400',
|
||||
bg: 'bg-red-50 dark:bg-red-500/10 border-red-200 dark:border-red-500/20',
|
||||
text: `${diffDays}天后过期`
|
||||
};
|
||||
} else if (diffDays <= 30) {
|
||||
return {
|
||||
color: 'text-orange-600 dark:text-orange-400',
|
||||
bg: 'bg-orange-50 dark:bg-orange-500/10 border-orange-200 dark:border-orange-500/20',
|
||||
text: `${diffDays}天后过期`
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
color: 'text-green-600 dark:text-green-400',
|
||||
bg: 'bg-green-50 dark:bg-green-500/10 border-green-200 dark:border-green-500/20',
|
||||
text: `${diffDays}天后过期`
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const calculateUserTotalUsedFlow = (): number => {
|
||||
return (userInfo.inFlow || 0) + (userInfo.outFlow || 0);
|
||||
};
|
||||
|
||||
const calculateUsagePercentage = (type: 'flow' | 'forwards'): number => {
|
||||
if (type === 'flow') {
|
||||
const totalUsed = calculateUserTotalUsedFlow();
|
||||
const totalLimit = (userInfo.flow || 0) * 1024 * 1024 * 1024;
|
||||
// 无限制时返回0%
|
||||
if (userInfo.flow === 99999) return 0;
|
||||
return totalLimit > 0 ? Math.min((totalUsed / totalLimit) * 100, 100) : 0;
|
||||
} else if (type === 'forwards') {
|
||||
const totalUsed = forwardList.length;
|
||||
const totalLimit = userInfo.num || 0;
|
||||
// 无限制时返回0%
|
||||
if (userInfo.num === 99999) return 0;
|
||||
return totalLimit > 0 ? Math.min((totalUsed / totalLimit) * 100, 100) : 0;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const getUsageColor = (percentage: number) => {
|
||||
if (percentage >= 90) return 'bg-red-500 dark:bg-red-600';
|
||||
if (percentage >= 70) return 'bg-orange-500 dark:bg-orange-600';
|
||||
return 'bg-blue-500 dark:bg-blue-600';
|
||||
};
|
||||
|
||||
const renderProgressBar = (percentage: number, size: 'sm' | 'md' = 'md', isUnlimited: boolean = false) => {
|
||||
const height = size === 'sm' ? 'h-1.5' : 'h-2';
|
||||
|
||||
if (isUnlimited) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className={`w-full bg-gradient-to-r from-blue-200 to-purple-200 dark:from-blue-500/30 dark:to-purple-500/30 rounded-full ${height}`}>
|
||||
<div className={`${height} bg-gradient-to-r from-blue-500 to-purple-500 rounded-full w-full opacity-60`}></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className={`w-full bg-gray-200 dark:bg-gray-800 rounded-full ${height}`}>
|
||||
<div
|
||||
className={`${height} rounded-full transition-all duration-300 ${getUsageColor(percentage)}`}
|
||||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const calculateTunnelUsedFlow = (tunnel: UserTunnel): number => {
|
||||
if (!tunnel) return 0;
|
||||
const inFlow = tunnel.inFlow || 0;
|
||||
const outFlow = tunnel.outFlow || 0;
|
||||
if (tunnel.tunnelFlow === 1) {
|
||||
return outFlow;
|
||||
} else {
|
||||
return inFlow + outFlow;
|
||||
}
|
||||
};
|
||||
|
||||
const calculateTunnelFlowPercentage = (tunnel: UserTunnel): number => {
|
||||
const totalUsed = calculateTunnelUsedFlow(tunnel);
|
||||
const totalLimit = (tunnel.flow || 0) * 1024 * 1024 * 1024;
|
||||
// 无限制时返回0%
|
||||
if (tunnel.flow === 99999) return 0;
|
||||
return totalLimit > 0 ? Math.min((totalUsed / totalLimit) * 100, 100) : 0;
|
||||
};
|
||||
|
||||
const getTunnelUsedForwards = (tunnelId: number): number => {
|
||||
return forwardList.filter(forward => forward.tunnelId === tunnelId).length;
|
||||
};
|
||||
|
||||
const calculateTunnelForwardPercentage = (tunnel: UserTunnel): number => {
|
||||
const totalUsed = getTunnelUsedForwards(tunnel.tunnelId);
|
||||
const totalLimit = tunnel.num || 0;
|
||||
// 无限制时返回0%
|
||||
if (tunnel.num === 99999) return 0;
|
||||
return totalLimit > 0 ? Math.min((totalUsed / totalLimit) * 100, 100) : 0;
|
||||
};
|
||||
|
||||
const formatResetTime = (resetDay?: number): string => {
|
||||
if (!resetDay) return '';
|
||||
|
||||
const now = new Date();
|
||||
const currentDay = now.getDate();
|
||||
|
||||
let daysUntilReset;
|
||||
if (resetDay > currentDay) {
|
||||
daysUntilReset = resetDay - currentDay;
|
||||
} else if (resetDay < currentDay) {
|
||||
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, resetDay);
|
||||
const diffTime = nextMonth.getTime() - now.getTime();
|
||||
daysUntilReset = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
} else {
|
||||
daysUntilReset = 0;
|
||||
}
|
||||
|
||||
if (daysUntilReset === 0) {
|
||||
return '今日重置';
|
||||
} else if (daysUntilReset === 1) {
|
||||
return '明日重置';
|
||||
} else {
|
||||
return `${daysUntilReset}天后重置`;
|
||||
}
|
||||
};
|
||||
|
||||
const groupedForwards = () => {
|
||||
const groups: { [key: string]: { tunnelName: string; forwards: Forward[] } } = {};
|
||||
forwardList.forEach(forward => {
|
||||
const tunnelName = forward.tunnelName || '未知隧道';
|
||||
if (!groups[tunnelName]) {
|
||||
groups[tunnelName] = {
|
||||
tunnelName,
|
||||
forwards: []
|
||||
};
|
||||
}
|
||||
groups[tunnelName].forwards.push(forward);
|
||||
});
|
||||
return Object.values(groups);
|
||||
};
|
||||
|
||||
const formatInAddress = (ipString: string, port: number): string => {
|
||||
if (!ipString || !port) return '';
|
||||
|
||||
const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip);
|
||||
|
||||
if (ips.length === 0) return '';
|
||||
|
||||
if (ips.length === 1) {
|
||||
const ip = ips[0];
|
||||
if (ip.includes(':') && !ip.startsWith('[')) {
|
||||
return `[${ip}]:${port}`;
|
||||
} else {
|
||||
return `${ip}:${port}`;
|
||||
}
|
||||
}
|
||||
|
||||
const firstIp = ips[0];
|
||||
let formattedFirstIp;
|
||||
|
||||
if (firstIp.includes(':') && !firstIp.startsWith('[')) {
|
||||
formattedFirstIp = `[${firstIp}]`;
|
||||
} else {
|
||||
formattedFirstIp = firstIp;
|
||||
}
|
||||
|
||||
return `${formattedFirstIp}:${port} (+${ips.length - 1})`;
|
||||
};
|
||||
|
||||
const formatRemoteAddress = (remoteAddr: string): string => {
|
||||
if (!remoteAddr) return '';
|
||||
|
||||
const addresses = remoteAddr.split(',').map(addr => addr.trim()).filter(addr => addr);
|
||||
|
||||
if (addresses.length === 0) return '';
|
||||
|
||||
if (addresses.length === 1) {
|
||||
return addresses[0];
|
||||
}
|
||||
|
||||
return `${addresses[0]} (+${addresses.length - 1})`;
|
||||
};
|
||||
|
||||
const hasMultipleIps = (ipString: string): boolean => {
|
||||
if (!ipString) return false;
|
||||
const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip);
|
||||
return ips.length > 1;
|
||||
};
|
||||
|
||||
const hasMultipleRemoteAddresses = (remoteAddr: string): boolean => {
|
||||
if (!remoteAddr) return false;
|
||||
const addresses = remoteAddr.split(',').map(addr => addr.trim()).filter(addr => addr);
|
||||
return addresses.length > 1;
|
||||
};
|
||||
|
||||
const showAddressModal = (ipString: string, port: number, title: string) => {
|
||||
if (!ipString || !port) return;
|
||||
|
||||
const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip);
|
||||
|
||||
if (ips.length <= 1) {
|
||||
copyToClipboard(formatInAddress(ipString, port), title);
|
||||
return;
|
||||
}
|
||||
|
||||
const formattedList = ips.map((ip, index) => {
|
||||
let formattedAddress;
|
||||
if (ip.includes(':') && !ip.startsWith('[')) {
|
||||
formattedAddress = `[${ip}]:${port}`;
|
||||
} else {
|
||||
formattedAddress = `${ip}:${port}`;
|
||||
}
|
||||
return {
|
||||
id: index,
|
||||
ip: ip,
|
||||
address: formattedAddress,
|
||||
copying: false
|
||||
};
|
||||
});
|
||||
|
||||
setAddressList(formattedList);
|
||||
setAddressModalTitle(`${title} (${ips.length}个)`);
|
||||
setAddressModalOpen(true);
|
||||
};
|
||||
|
||||
const showRemoteAddressModal = (remoteAddr: string, title: string) => {
|
||||
if (!remoteAddr) return;
|
||||
|
||||
const addresses = remoteAddr.split(',').map(addr => addr.trim()).filter(addr => addr);
|
||||
|
||||
if (addresses.length <= 1) {
|
||||
copyToClipboard(remoteAddr, title);
|
||||
return;
|
||||
}
|
||||
|
||||
const formattedList = addresses.map((address, index) => {
|
||||
return {
|
||||
id: index,
|
||||
ip: address,
|
||||
address: address,
|
||||
copying: false
|
||||
};
|
||||
});
|
||||
|
||||
setAddressList(formattedList);
|
||||
setAddressModalTitle(`${title} (${addresses.length}个)`);
|
||||
setAddressModalOpen(true);
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string, label: string = '内容') => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(`已复制`);
|
||||
} catch (error) {
|
||||
toast.error('复制失败');
|
||||
}
|
||||
};
|
||||
|
||||
const copyAddress = async (addressItem: AddressItem) => {
|
||||
try {
|
||||
setAddressList(prev => prev.map(item =>
|
||||
item.id === addressItem.id ? { ...item, copying: true } : item
|
||||
));
|
||||
await copyToClipboard(addressItem.address, '地址');
|
||||
} catch (error) {
|
||||
toast.error('复制失败');
|
||||
} finally {
|
||||
setAddressList(prev => prev.map(item =>
|
||||
item.id === addressItem.id ? { ...item, copying: false } : item
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
const copyAllAddresses = async () => {
|
||||
if (addressList.length === 0) return;
|
||||
const allAddresses = addressList.map(item => item.address).join('\n');
|
||||
await copyToClipboard(allAddresses, '所有地址');
|
||||
};
|
||||
|
||||
const calculateForwardBillingFlow = (forward: Forward): number => {
|
||||
if (!forward) return 0;
|
||||
|
||||
const tunnel = userTunnels.find(t => t.tunnelId === forward.tunnelId);
|
||||
if (!tunnel) {
|
||||
return (forward.inFlow || 0) + (forward.outFlow || 0);
|
||||
}
|
||||
|
||||
const inFlow = forward.inFlow || 0;
|
||||
const outFlow = forward.outFlow || 0;
|
||||
|
||||
if (tunnel.tunnelFlow === 1) {
|
||||
return outFlow;
|
||||
} else {
|
||||
return inFlow + outFlow;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className="px-3 lg:px-6 flex-grow pt-2 lg:pt-4">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin h-5 w-5 border-2 border-primary border-t-transparent rounded-full"></div>
|
||||
<span className="text-default-600">正在加载数据...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className="px-3 lg:px-6 py-2 lg:py-4">
|
||||
|
||||
{/* 响应式统计卡片 */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-4 mb-6 lg:mb-8">
|
||||
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
|
||||
<CardBody className="p-3 lg:p-4">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs lg:text-sm text-default-600 truncate">总流量</p>
|
||||
<div className="p-1.5 lg:p-2 bg-blue-100 dark:bg-blue-500/20 rounded-lg flex-shrink-0">
|
||||
<svg className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-base lg:text-xl font-bold text-foreground truncate">{formatFlow(userInfo.flow, 'gb')}</p>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
|
||||
<CardBody className="p-3 lg:p-4">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs lg:text-sm text-default-600 truncate">已用流量</p>
|
||||
<div className="p-1.5 lg:p-2 bg-green-100 dark:bg-green-500/20 rounded-lg flex-shrink-0">
|
||||
<svg className="w-4 h-4 lg:w-5 lg:h-5 text-green-600 dark:text-green-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M12 7a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0V8.414l-4.293 4.293a1 1 0 01-1.414 0L8 10.414l-4.293 4.293a1 1 0 01-1.414-1.414l5-5a1 1 0 011.414 0L11 10.586 14.586 7H12z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-base lg:text-xl font-bold text-foreground truncate">{formatFlow(calculateUserTotalUsedFlow())}</p>
|
||||
<div className="mt-1">
|
||||
{renderProgressBar(calculateUsagePercentage('flow'), 'sm', userInfo.flow === 99999)}
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<p className="text-xs text-default-500 truncate">
|
||||
{userInfo.flow === 99999 ? '无限制' : `${calculateUsagePercentage('flow').toFixed(1)}%`}
|
||||
</p>
|
||||
{userInfo.flowResetTime && (
|
||||
<div className="text-xs text-default-500 flex items-center gap-1">
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="truncate">{formatResetTime(userInfo.flowResetTime)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
|
||||
<CardBody className="p-3 lg:p-4">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs lg:text-sm text-default-600 truncate">转发配额</p>
|
||||
<div className="p-1.5 lg:p-2 bg-purple-100 dark:bg-purple-500/20 rounded-lg flex-shrink-0">
|
||||
<svg className="w-4 h-4 lg:w-5 lg:h-5 text-purple-600 dark:text-purple-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-base lg:text-xl font-bold text-foreground truncate">{formatNumber(userInfo.num || 0)}</p>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
|
||||
<CardBody className="p-3 lg:p-4">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs lg:text-sm text-default-600 truncate">已用转发</p>
|
||||
<div className="p-1.5 lg:p-2 bg-orange-100 dark:bg-orange-500/20 rounded-lg flex-shrink-0">
|
||||
<svg className="w-4 h-4 lg:w-5 lg:h-5 text-orange-600 dark:text-orange-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-base lg:text-xl font-bold text-foreground truncate">{forwardList.length}</p>
|
||||
<div className="mt-1">
|
||||
{renderProgressBar(calculateUsagePercentage('forwards'), 'sm', userInfo.num === 99999)}
|
||||
<p className="text-xs text-default-500 mt-1 truncate">
|
||||
{userInfo.num === 99999 ? '无限制' : `${calculateUsagePercentage('forwards').toFixed(1)}%`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 隧道权限 */}
|
||||
<Card className="mb-6 lg:mb-8 border border-gray-200 dark:border-default-200 shadow-md">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-primary" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<h2 className="text-lg lg:text-xl font-semibold text-foreground">隧道权限</h2>
|
||||
<span className="px-2 py-1 bg-default-100 dark:bg-default-50 text-default-600 rounded-full text-xs">
|
||||
{userTunnels.length}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0">
|
||||
{userTunnels.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<svg className="w-12 h-12 text-default-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||
</svg>
|
||||
<p className="text-default-500">暂无隧道权限</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{userTunnels.map((tunnel) => {
|
||||
const tunnelExpStatus = getExpStatus(tunnel.expTime);
|
||||
return (
|
||||
<div key={tunnel.id} className="border border-gray-200 dark:border-default-100 rounded-lg p-3 lg:p-4 hover:shadow-md transition-shadow">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3 mb-3">
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">{tunnel.tunnelName}</h3>
|
||||
<div className="flex flex-wrap items-center gap-2 mt-1">
|
||||
<span className={`px-2 py-1 rounded-md text-xs font-medium ${tunnel.tunnelFlow === 1 ? 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300' : 'bg-orange-100 dark:bg-orange-500/20 text-orange-700 dark:text-orange-300'}`}>
|
||||
{tunnel.tunnelFlow === 1 ? '单向计费' : '双向计费'}
|
||||
</span>
|
||||
<span className={`px-2 py-1 rounded-md text-xs font-medium border ${tunnelExpStatus.bg} ${tunnelExpStatus.color}`}>
|
||||
{tunnelExpStatus.text}
|
||||
</span>
|
||||
{tunnel.flowResetTime && (
|
||||
<span className="text-xs text-default-500">
|
||||
{formatResetTime(tunnel.flowResetTime)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-default-600 mb-1">流量配额</p>
|
||||
<p className="font-semibold text-foreground">{formatFlow(tunnel.flow, 'gb')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-default-600 mb-1">已用流量</p>
|
||||
<p className="font-semibold text-foreground">{formatFlow(calculateTunnelUsedFlow(tunnel))}</p>
|
||||
<div className="mt-1">
|
||||
{renderProgressBar(calculateTunnelFlowPercentage(tunnel), 'sm', tunnel.flow === 99999)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-default-600 mb-1">转发配额</p>
|
||||
<p className="font-semibold text-foreground">{formatNumber(tunnel.num)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-default-600 mb-1">已用转发</p>
|
||||
<p className="font-semibold text-foreground">{getTunnelUsedForwards(tunnel.tunnelId)}</p>
|
||||
<div className="mt-1">
|
||||
{renderProgressBar(calculateTunnelForwardPercentage(tunnel), 'sm', tunnel.num === 99999)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 转发配置 */}
|
||||
<Card className="border border-gray-200 dark:border-default-200 shadow-md">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-primary" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<h2 className="text-lg lg:text-xl font-semibold text-foreground">转发配置</h2>
|
||||
<span className="px-2 py-1 bg-default-100 dark:bg-default-50 text-default-600 rounded-full text-xs">
|
||||
{forwardList.length}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0">
|
||||
{groupedForwards().length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<svg className="w-12 h-12 text-default-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
|
||||
</svg>
|
||||
<p className="text-default-500">暂无转发配置</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{groupedForwards().map((group) => (
|
||||
<div key={group.tunnelName} className="border border-gray-200 dark:border-default-100 rounded-lg p-3 lg:p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold text-foreground">{group.tunnelName}</h3>
|
||||
<span className="px-2 py-1 bg-primary-100 dark:bg-primary-500/20 text-primary-700 dark:text-primary-300 rounded-md text-sm">
|
||||
{group.forwards.length} 个转发
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{group.forwards.map((forward) => (
|
||||
<div key={forward.id} className="bg-white dark:bg-default-100/50 border border-gray-200 dark:border-default-200 rounded-lg p-3 hover:shadow-md transition-shadow">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h4 className="font-medium text-foreground text-sm mb-2 truncate">{forward.name}</h4>
|
||||
<div className="space-y-1">
|
||||
<code
|
||||
className={`block px-2 py-1 bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300 rounded font-mono text-xs truncate ${hasMultipleIps(forward.inIp) ? 'cursor-pointer hover:bg-green-200 dark:hover:bg-green-500/30' : ''}`}
|
||||
onClick={() => hasMultipleIps(forward.inIp) && showAddressModal(forward.inIp, forward.inPort, '入口地址')}
|
||||
title={formatInAddress(forward.inIp, forward.inPort)}
|
||||
>
|
||||
{formatInAddress(forward.inIp, forward.inPort)}
|
||||
</code>
|
||||
<div className="text-center text-default-400 text-xs">↓</div>
|
||||
<code
|
||||
className={`block px-2 py-1 bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300 rounded font-mono text-xs truncate ${hasMultipleRemoteAddresses(forward.remoteAddr) ? 'cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-500/30' : ''}`}
|
||||
onClick={() => hasMultipleRemoteAddresses(forward.remoteAddr) && showRemoteAddressModal(forward.remoteAddr, '出口地址')}
|
||||
title={formatRemoteAddress(forward.remoteAddr)}
|
||||
>
|
||||
{formatRemoteAddress(forward.remoteAddr)}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-gray-200 dark:border-default-200">
|
||||
<div className="grid grid-cols-3 gap-1 text-xs">
|
||||
<div className="text-center">
|
||||
<div className="text-default-500 mb-1">入站</div>
|
||||
<div className="font-medium text-green-600 dark:text-green-400 truncate">{formatFlow(forward.inFlow || 0)}</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-default-500 mb-1">出站</div>
|
||||
<div className="font-medium text-orange-600 dark:text-orange-400 truncate">{formatFlow(forward.outFlow || 0)}</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-default-500 mb-1">计费</div>
|
||||
<div className="font-medium text-primary truncate">{formatFlow(calculateForwardBillingFlow(forward))}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 地址列表弹窗 */}
|
||||
<Modal isOpen={addressModalOpen} onClose={() => setAddressModalOpen(false)} size="lg">
|
||||
<ModalContent>
|
||||
<ModalHeader className="text-base">{addressModalTitle}</ModalHeader>
|
||||
<ModalBody className="pb-6">
|
||||
<div className="mb-4 text-right">
|
||||
<Button size="sm" onClick={copyAllAddresses}>
|
||||
复制全部
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||
{addressList.map((item) => (
|
||||
<div key={item.id} className="flex justify-between items-center p-3 border border-default-200 dark:border-default-100 rounded-lg">
|
||||
<code className="text-sm flex-1 mr-3 text-foreground">{item.address}</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
isLoading={item.copying}
|
||||
onClick={() => copyAddress(item)}
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,863 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { Button } from "@heroui/button";
|
||||
import { Input } from "@heroui/input";
|
||||
import { Select, SelectItem } from "@heroui/select";
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/modal";
|
||||
import { Chip } from "@heroui/chip";
|
||||
import { Spinner } from "@heroui/spinner";
|
||||
import { Switch } from "@heroui/switch";
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import AdminLayout from "@/layouts/admin";
|
||||
import {
|
||||
createForward,
|
||||
getForwardList,
|
||||
updateForward,
|
||||
deleteForward,
|
||||
forceDeleteForward,
|
||||
userTunnel,
|
||||
pauseForwardService,
|
||||
resumeForwardService
|
||||
} from "@/api";
|
||||
|
||||
interface Forward {
|
||||
id: number;
|
||||
name: string;
|
||||
tunnelId: number;
|
||||
tunnelName: string;
|
||||
inIp: string;
|
||||
inPort: number;
|
||||
remoteAddr: string;
|
||||
strategy: string;
|
||||
status: number;
|
||||
inFlow: number;
|
||||
outFlow: number;
|
||||
serviceRunning: boolean;
|
||||
createdTime: string;
|
||||
userName?: string;
|
||||
}
|
||||
|
||||
interface Tunnel {
|
||||
id: number;
|
||||
name: string;
|
||||
inNodePortSta?: number;
|
||||
inNodePortEnd?: number;
|
||||
}
|
||||
|
||||
interface ForwardForm {
|
||||
id?: number;
|
||||
name: string;
|
||||
tunnelId: number | null;
|
||||
inPort: number | null;
|
||||
remoteAddr: string;
|
||||
strategy: string;
|
||||
}
|
||||
|
||||
interface AddressItem {
|
||||
id: number;
|
||||
address: string;
|
||||
copying: boolean;
|
||||
}
|
||||
|
||||
export default function ForwardPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [forwards, setForwards] = useState<Forward[]>([]);
|
||||
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
||||
|
||||
// 模态框状态
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [addressModalOpen, setAddressModalOpen] = useState(false);
|
||||
const [isEdit, setIsEdit] = useState(false);
|
||||
const [submitLoading, setSubmitLoading] = useState(false);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [forwardToDelete, setForwardToDelete] = useState<Forward | null>(null);
|
||||
const [addressModalTitle, setAddressModalTitle] = useState('');
|
||||
const [addressList, setAddressList] = useState<AddressItem[]>([]);
|
||||
|
||||
// 表单状态
|
||||
const [form, setForm] = useState<ForwardForm>({
|
||||
name: '',
|
||||
tunnelId: null,
|
||||
inPort: null,
|
||||
remoteAddr: '',
|
||||
strategy: 'fifo'
|
||||
});
|
||||
|
||||
// 表单验证错误
|
||||
const [errors, setErrors] = useState<{[key: string]: string}>({});
|
||||
const [selectedTunnel, setSelectedTunnel] = useState<Tunnel | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
// 加载所有数据
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [forwardsRes, tunnelsRes] = await Promise.all([
|
||||
getForwardList(),
|
||||
userTunnel()
|
||||
]);
|
||||
|
||||
if (forwardsRes.code === 0) {
|
||||
setForwards(forwardsRes.data?.map((forward: any) => ({
|
||||
...forward,
|
||||
serviceRunning: forward.status === 1
|
||||
})) || []);
|
||||
} else {
|
||||
toast.error(forwardsRes.msg || '获取转发列表失败');
|
||||
}
|
||||
|
||||
if (tunnelsRes.code === 0) {
|
||||
setTunnels(tunnelsRes.data || []);
|
||||
} else {
|
||||
console.warn('获取隧道列表失败:', tunnelsRes.msg);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载数据失败:', error);
|
||||
toast.error('加载数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 表单验证
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: {[key: string]: string} = {};
|
||||
|
||||
if (!form.name.trim()) {
|
||||
newErrors.name = '请输入转发名称';
|
||||
} else if (form.name.length < 2 || form.name.length > 50) {
|
||||
newErrors.name = '转发名称长度应在2-50个字符之间';
|
||||
}
|
||||
|
||||
if (!form.tunnelId) {
|
||||
newErrors.tunnelId = '请选择关联隧道';
|
||||
}
|
||||
|
||||
if (!form.remoteAddr.trim()) {
|
||||
newErrors.remoteAddr = '请输入远程地址';
|
||||
} else {
|
||||
// 验证地址格式
|
||||
const addresses = form.remoteAddr.split('\n').map(addr => addr.trim()).filter(addr => addr);
|
||||
const ipv4Pattern = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?):\d+$/;
|
||||
const ipv6FullPattern = /^\[((([0-9a-fA-F]{1,4}:){7}([0-9a-fA-F]{1,4}|:))|(([0-9a-fA-F]{1,4}:){6}(:[0-9a-fA-F]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-fA-F]{1,4}:){5}(((:[0-9a-fA-F]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-fA-F]{1,4}:){4}(((:[0-9a-fA-F]{1,4}){1,3})|((:[0-9a-fA-F]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-fA-F]{1,4}:){3}(((:[0-9a-fA-F]{1,4}){1,4})|((:[0-9a-fA-F]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-fA-F]{1,4}:){2}(((:[0-9a-fA-F]{1,4}){1,5})|((:[0-9a-fA-F]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-fA-F]{1,4}:){1}(((:[0-9a-fA-F]{1,4}){1,6})|((:[0-9a-fA-F]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9a-fA-F]{1,4}){1,7})|((:[0-9a-fA-F]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))\]:\d+$/;
|
||||
const domainPattern = /^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*:\d+$/;
|
||||
|
||||
for (let i = 0; i < addresses.length; i++) {
|
||||
const addr = addresses[i];
|
||||
if (!ipv4Pattern.test(addr) && !ipv6FullPattern.test(addr) && !domainPattern.test(addr)) {
|
||||
newErrors.remoteAddr = `第${i + 1}行地址格式错误`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (form.inPort !== null && (form.inPort < 1 || form.inPort > 65535)) {
|
||||
newErrors.inPort = '端口号必须在1-65535之间';
|
||||
}
|
||||
|
||||
if (selectedTunnel && selectedTunnel.inNodePortSta && selectedTunnel.inNodePortEnd && form.inPort) {
|
||||
if (form.inPort < selectedTunnel.inNodePortSta || form.inPort > selectedTunnel.inNodePortEnd) {
|
||||
newErrors.inPort = `端口号必须在${selectedTunnel.inNodePortSta}-${selectedTunnel.inNodePortEnd}范围内`;
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
// 新增转发
|
||||
const handleAdd = () => {
|
||||
setIsEdit(false);
|
||||
setForm({
|
||||
name: '',
|
||||
tunnelId: null,
|
||||
inPort: null,
|
||||
remoteAddr: '',
|
||||
strategy: 'fifo'
|
||||
});
|
||||
setSelectedTunnel(null);
|
||||
setErrors({});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
// 编辑转发
|
||||
const handleEdit = (forward: Forward) => {
|
||||
setIsEdit(true);
|
||||
setForm({
|
||||
id: forward.id,
|
||||
name: forward.name,
|
||||
tunnelId: forward.tunnelId,
|
||||
inPort: forward.inPort,
|
||||
remoteAddr: forward.remoteAddr.split(',').join('\n'),
|
||||
strategy: forward.strategy || 'fifo'
|
||||
});
|
||||
const tunnel = tunnels.find(t => t.id === forward.tunnelId);
|
||||
setSelectedTunnel(tunnel || null);
|
||||
setErrors({});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
// 显示删除确认
|
||||
const handleDelete = (forward: Forward) => {
|
||||
setForwardToDelete(forward);
|
||||
setDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
// 确认删除转发
|
||||
const confirmDelete = async () => {
|
||||
if (!forwardToDelete) return;
|
||||
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
const res = await deleteForward(forwardToDelete.id);
|
||||
if (res.code === 0) {
|
||||
toast.success('删除成功');
|
||||
setDeleteModalOpen(false);
|
||||
loadData();
|
||||
} else {
|
||||
// 删除失败,询问是否强制删除
|
||||
const confirmed = window.confirm(`常规删除失败:${res.msg || '删除失败'}\n\n是否需要强制删除?\n\n⚠️ 注意:强制删除不会去验证节点端是否已经删除对应的转发服务。`);
|
||||
if (confirmed) {
|
||||
const forceRes = await forceDeleteForward(forwardToDelete.id);
|
||||
if (forceRes.code === 0) {
|
||||
toast.success('强制删除成功');
|
||||
setDeleteModalOpen(false);
|
||||
loadData();
|
||||
} else {
|
||||
toast.error(forceRes.msg || '强制删除失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error);
|
||||
toast.error('删除失败');
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理隧道选择变化
|
||||
const handleTunnelChange = (tunnelId: string) => {
|
||||
const tunnel = tunnels.find(t => t.id === parseInt(tunnelId));
|
||||
setSelectedTunnel(tunnel || null);
|
||||
setForm(prev => ({ ...prev, tunnelId: parseInt(tunnelId) }));
|
||||
};
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
setSubmitLoading(true);
|
||||
try {
|
||||
const processedRemoteAddr = form.remoteAddr
|
||||
.split('\n')
|
||||
.map(addr => addr.trim())
|
||||
.filter(addr => addr)
|
||||
.join(',');
|
||||
|
||||
const addressCount = processedRemoteAddr.split(',').length;
|
||||
|
||||
let res;
|
||||
if (isEdit) {
|
||||
res = await updateForward({
|
||||
...form,
|
||||
remoteAddr: processedRemoteAddr,
|
||||
strategy: addressCount > 1 ? form.strategy : 'fifo'
|
||||
});
|
||||
} else {
|
||||
const { id, ...createData } = form;
|
||||
res = await createForward({
|
||||
...createData,
|
||||
remoteAddr: processedRemoteAddr,
|
||||
strategy: addressCount > 1 ? form.strategy : 'fifo'
|
||||
});
|
||||
}
|
||||
|
||||
if (res.code === 0) {
|
||||
toast.success(isEdit ? '修改成功' : '创建成功');
|
||||
setModalOpen(false);
|
||||
loadData();
|
||||
} else {
|
||||
toast.error(res.msg || '操作失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error);
|
||||
toast.error('操作失败');
|
||||
} finally {
|
||||
setSubmitLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理服务开关
|
||||
const handleServiceToggle = async (forward: Forward) => {
|
||||
if (forward.status !== 1 && forward.status !== 0) {
|
||||
toast.error('转发状态异常,无法操作');
|
||||
return;
|
||||
}
|
||||
|
||||
const targetState = !forward.serviceRunning;
|
||||
|
||||
try {
|
||||
// 乐观更新UI
|
||||
setForwards(prev => prev.map(f =>
|
||||
f.id === forward.id
|
||||
? { ...f, serviceRunning: targetState }
|
||||
: f
|
||||
));
|
||||
|
||||
let res;
|
||||
if (targetState) {
|
||||
res = await resumeForwardService(forward.id);
|
||||
} else {
|
||||
res = await pauseForwardService(forward.id);
|
||||
}
|
||||
|
||||
if (res.code === 0) {
|
||||
toast.success(targetState ? '服务已启动' : '服务已暂停');
|
||||
// 更新转发状态
|
||||
setForwards(prev => prev.map(f =>
|
||||
f.id === forward.id
|
||||
? { ...f, status: targetState ? 1 : 0 }
|
||||
: f
|
||||
));
|
||||
} else {
|
||||
// 操作失败,恢复UI状态
|
||||
setForwards(prev => prev.map(f =>
|
||||
f.id === forward.id
|
||||
? { ...f, serviceRunning: !targetState }
|
||||
: f
|
||||
));
|
||||
toast.error(res.msg || '操作失败');
|
||||
}
|
||||
} catch (error) {
|
||||
// 操作失败,恢复UI状态
|
||||
setForwards(prev => prev.map(f =>
|
||||
f.id === forward.id
|
||||
? { ...f, serviceRunning: !targetState }
|
||||
: f
|
||||
));
|
||||
console.error('服务开关操作失败:', error);
|
||||
toast.error('网络错误,操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化流量
|
||||
const formatFlow = (value: number): string => {
|
||||
if (value === 0) return '0 B';
|
||||
if (value < 1024) return value + ' B';
|
||||
if (value < 1024 * 1024) return (value / 1024).toFixed(2) + ' KB';
|
||||
if (value < 1024 * 1024 * 1024) return (value / (1024 * 1024)).toFixed(2) + ' MB';
|
||||
return (value / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
||||
};
|
||||
|
||||
// 格式化入口地址
|
||||
const formatInAddress = (ipString: string, port: number): string => {
|
||||
if (!ipString || !port) return '';
|
||||
|
||||
const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip);
|
||||
if (ips.length === 0) return '';
|
||||
|
||||
if (ips.length === 1) {
|
||||
const ip = ips[0];
|
||||
if (ip.includes(':') && !ip.startsWith('[')) {
|
||||
return `[${ip}]:${port}`;
|
||||
} else {
|
||||
return `${ip}:${port}`;
|
||||
}
|
||||
}
|
||||
|
||||
const firstIp = ips[0];
|
||||
let formattedFirstIp;
|
||||
if (firstIp.includes(':') && !firstIp.startsWith('[')) {
|
||||
formattedFirstIp = `[${firstIp}]`;
|
||||
} else {
|
||||
formattedFirstIp = firstIp;
|
||||
}
|
||||
|
||||
return `${formattedFirstIp}:${port} (+${ips.length - 1})`;
|
||||
};
|
||||
|
||||
// 格式化远程地址
|
||||
const formatRemoteAddress = (addressString: string): string => {
|
||||
if (!addressString) return '';
|
||||
|
||||
const addresses = addressString.split(',').map(addr => addr.trim()).filter(addr => addr);
|
||||
if (addresses.length === 0) return '';
|
||||
if (addresses.length === 1) return addresses[0];
|
||||
|
||||
return `${addresses[0]} (+${addresses.length - 1})`;
|
||||
};
|
||||
|
||||
// 检查是否有多个地址
|
||||
const hasMultipleAddresses = (addressString: string): boolean => {
|
||||
if (!addressString) return false;
|
||||
const addresses = addressString.split(',').map(addr => addr.trim()).filter(addr => addr);
|
||||
return addresses.length > 1;
|
||||
};
|
||||
|
||||
// 显示地址列表弹窗
|
||||
const showAddressModal = (addressString: string, port: number | null, title: string) => {
|
||||
if (!addressString) return;
|
||||
|
||||
let addresses: string[];
|
||||
if (port !== null) {
|
||||
// 入口地址处理
|
||||
const ips = addressString.split(',').map(ip => ip.trim()).filter(ip => ip);
|
||||
if (ips.length <= 1) {
|
||||
copyToClipboard(formatInAddress(addressString, port), title);
|
||||
return;
|
||||
}
|
||||
addresses = ips.map(ip => {
|
||||
if (ip.includes(':') && !ip.startsWith('[')) {
|
||||
return `[${ip}]:${port}`;
|
||||
} else {
|
||||
return `${ip}:${port}`;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 远程地址处理
|
||||
addresses = addressString.split(',').map(addr => addr.trim()).filter(addr => addr);
|
||||
if (addresses.length <= 1) {
|
||||
copyToClipboard(addressString, title);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setAddressList(addresses.map((address, index) => ({
|
||||
id: index,
|
||||
address,
|
||||
copying: false
|
||||
})));
|
||||
setAddressModalTitle(`${title} (${addresses.length}个)`);
|
||||
setAddressModalOpen(true);
|
||||
};
|
||||
|
||||
// 复制到剪贴板
|
||||
const copyToClipboard = async (text: string, label: string = '内容') => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(`已复制${label}`);
|
||||
} catch (error) {
|
||||
toast.error('复制失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 复制地址
|
||||
const copyAddress = async (addressItem: AddressItem) => {
|
||||
try {
|
||||
setAddressList(prev => prev.map(item =>
|
||||
item.id === addressItem.id ? { ...item, copying: true } : item
|
||||
));
|
||||
await copyToClipboard(addressItem.address, '地址');
|
||||
} catch (error) {
|
||||
toast.error('复制失败');
|
||||
} finally {
|
||||
setAddressList(prev => prev.map(item =>
|
||||
item.id === addressItem.id ? { ...item, copying: false } : item
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// 复制所有地址
|
||||
const copyAllAddresses = async () => {
|
||||
if (addressList.length === 0) return;
|
||||
const allAddresses = addressList.map(item => item.address).join('\n');
|
||||
await copyToClipboard(allAddresses, '所有地址');
|
||||
};
|
||||
|
||||
// 获取状态显示
|
||||
const getStatusDisplay = (status: number) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return { color: 'success', text: '正常' };
|
||||
case 0:
|
||||
return { color: 'warning', text: '暂停' };
|
||||
case -1:
|
||||
return { color: 'danger', text: '异常' };
|
||||
default:
|
||||
return { color: 'default', text: '未知' };
|
||||
}
|
||||
};
|
||||
|
||||
// 获取策略显示
|
||||
const getStrategyDisplay = (strategy: string) => {
|
||||
switch (strategy) {
|
||||
case 'fifo':
|
||||
return { color: 'primary', text: '主备' };
|
||||
case 'round':
|
||||
return { color: 'success', text: '轮询' };
|
||||
case 'rand':
|
||||
return { color: 'warning', text: '随机' };
|
||||
default:
|
||||
return { color: 'default', text: '未知' };
|
||||
}
|
||||
};
|
||||
|
||||
// 获取地址数量
|
||||
const getAddressCount = (addressString: string): number => {
|
||||
if (!addressString) return 0;
|
||||
const addresses = addressString.split('\n').map(addr => addr.trim()).filter(addr => addr);
|
||||
return addresses.length;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex items-center gap-3">
|
||||
<Spinner size="sm" />
|
||||
<span className="text-default-600">正在加载...</span>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className="px-3 lg:px-6 py-8">
|
||||
{/* 页面头部 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-foreground">转发管理</h1>
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={handleAdd}
|
||||
startContent={
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
新增转发
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 转发卡片网格 */}
|
||||
{forwards.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{forwards.map((forward) => {
|
||||
const statusDisplay = getStatusDisplay(forward.status);
|
||||
const strategyDisplay = getStrategyDisplay(forward.strategy);
|
||||
|
||||
return (
|
||||
<Card key={forward.id} className="shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-foreground truncate">{forward.name}</h3>
|
||||
<p className="text-sm text-default-500 truncate mt-1">{forward.tunnelName}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-2">
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={forward.serviceRunning}
|
||||
onValueChange={() => handleServiceToggle(forward)}
|
||||
isDisabled={forward.status !== 1 && forward.status !== 0}
|
||||
/>
|
||||
<Chip
|
||||
color={statusDisplay.color as any}
|
||||
variant="flat"
|
||||
size="sm"
|
||||
>
|
||||
{statusDisplay.text}
|
||||
</Chip>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0">
|
||||
<div className="space-y-3">
|
||||
{/* 地址信息 */}
|
||||
<div className="space-y-2">
|
||||
<div
|
||||
className={`cursor-pointer p-2 bg-green-50 dark:bg-green-500/10 rounded border border-green-200 dark:border-green-500/20 ${
|
||||
hasMultipleAddresses(forward.inIp) ? 'hover:bg-green-100 dark:hover:bg-green-500/20' : ''
|
||||
}`}
|
||||
onClick={() => showAddressModal(forward.inIp, forward.inPort, '入口地址')}
|
||||
title={formatInAddress(forward.inIp, forward.inPort)}
|
||||
>
|
||||
<div className="text-xs text-green-600 dark:text-green-400 mb-1">入口地址</div>
|
||||
<code className="text-xs font-mono text-green-700 dark:text-green-300 truncate block">
|
||||
{formatInAddress(forward.inIp, forward.inPort)}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-default-400 text-xs">↓</div>
|
||||
|
||||
<div
|
||||
className={`cursor-pointer p-2 bg-blue-50 dark:bg-blue-500/10 rounded border border-blue-200 dark:border-blue-500/20 ${
|
||||
hasMultipleAddresses(forward.remoteAddr) ? 'hover:bg-blue-100 dark:hover:bg-blue-500/20' : ''
|
||||
}`}
|
||||
onClick={() => showAddressModal(forward.remoteAddr, null, '目标地址')}
|
||||
title={formatRemoteAddress(forward.remoteAddr)}
|
||||
>
|
||||
<div className="text-xs text-blue-600 dark:text-blue-400 mb-1">目标地址</div>
|
||||
<code className="text-xs font-mono text-blue-700 dark:text-blue-300 truncate block">
|
||||
{formatRemoteAddress(forward.remoteAddr)}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 策略和流量 */}
|
||||
<div className="flex justify-between items-center pt-2 border-t border-gray-200 dark:border-default-200">
|
||||
<div className="text-center flex-1">
|
||||
<div className="text-xs text-default-500 mb-1">策略</div>
|
||||
<Chip color={strategyDisplay.color as any} variant="flat" size="sm">
|
||||
{strategyDisplay.text}
|
||||
</Chip>
|
||||
</div>
|
||||
<div className="text-center flex-1">
|
||||
<div className="text-xs text-default-500 mb-1">总流量</div>
|
||||
<div className="text-xs font-medium text-primary">
|
||||
{formatFlow((forward.inFlow || 0) + (forward.outFlow || 0))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
onPress={() => handleEdit(forward)}
|
||||
className="flex-1"
|
||||
startContent={
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
color="danger"
|
||||
onPress={() => handleDelete(forward)}
|
||||
className="flex-1"
|
||||
startContent={
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" clipRule="evenodd" />
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z" clipRule="evenodd" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
/* 空状态 */
|
||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<CardBody className="text-center py-16">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-default-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">暂无转发配置</h3>
|
||||
<p className="text-default-500 text-sm mt-1">还没有创建任何转发配置,点击上方按钮开始创建</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 新增/编辑模态框 */}
|
||||
<Modal
|
||||
isOpen={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
size="lg"
|
||||
scrollBehavior="inside"
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
<h2 className="text-xl font-bold">
|
||||
{isEdit ? '编辑转发' : '新增转发'}
|
||||
</h2>
|
||||
<p className="text-small text-default-500">
|
||||
{isEdit ? '修改现有转发配置的信息' : '创建新的转发配置'}
|
||||
</p>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="转发名称"
|
||||
placeholder="请输入转发名称"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, name: e.target.value }))}
|
||||
isInvalid={!!errors.name}
|
||||
errorMessage={errors.name}
|
||||
variant="bordered"
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="选择隧道"
|
||||
placeholder="请选择关联的隧道"
|
||||
selectedKeys={form.tunnelId ? [form.tunnelId.toString()] : []}
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedKey = Array.from(keys)[0] as string;
|
||||
if (selectedKey) {
|
||||
handleTunnelChange(selectedKey);
|
||||
}
|
||||
}}
|
||||
isInvalid={!!errors.tunnelId}
|
||||
errorMessage={errors.tunnelId}
|
||||
variant="bordered"
|
||||
>
|
||||
{tunnels.map((tunnel) => (
|
||||
<SelectItem key={tunnel.id} value={tunnel.id}>
|
||||
{tunnel.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
label="入口端口"
|
||||
placeholder="留空自动分配"
|
||||
type="number"
|
||||
value={form.inPort?.toString() || ''}
|
||||
onChange={(e) => setForm(prev => ({
|
||||
...prev,
|
||||
inPort: e.target.value ? parseInt(e.target.value) : null
|
||||
}))}
|
||||
isInvalid={!!errors.inPort}
|
||||
errorMessage={errors.inPort}
|
||||
variant="bordered"
|
||||
description={
|
||||
selectedTunnel && selectedTunnel.inNodePortSta && selectedTunnel.inNodePortEnd
|
||||
? `允许范围: ${selectedTunnel.inNodePortSta}-${selectedTunnel.inNodePortEnd}`
|
||||
: '留空将自动分配可用端口'
|
||||
}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="远程地址"
|
||||
placeholder="请输入远程地址,多个地址用换行分隔"
|
||||
value={form.remoteAddr}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, remoteAddr: e.target.value }))}
|
||||
isInvalid={!!errors.remoteAddr}
|
||||
errorMessage={errors.remoteAddr}
|
||||
variant="bordered"
|
||||
description="格式: IP:端口 或 域名:端口,支持多个地址(每行一个)"
|
||||
/>
|
||||
|
||||
{getAddressCount(form.remoteAddr) > 1 && (
|
||||
<Select
|
||||
label="负载策略"
|
||||
placeholder="请选择负载均衡策略"
|
||||
selectedKeys={[form.strategy]}
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedKey = Array.from(keys)[0] as string;
|
||||
setForm(prev => ({ ...prev, strategy: selectedKey }));
|
||||
}}
|
||||
variant="bordered"
|
||||
description="多个目标地址的负载均衡策略"
|
||||
>
|
||||
<SelectItem key="fifo" value="fifo">主备模式 - 自上而下</SelectItem>
|
||||
<SelectItem key="round" value="round">轮询模式 - 依次轮换</SelectItem>
|
||||
<SelectItem key="rand" value="rand">随机模式 - 随机选择</SelectItem>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={handleSubmit}
|
||||
isLoading={submitLoading}
|
||||
>
|
||||
{isEdit ? '保存修改' : '创建转发'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* 删除确认模态框 */}
|
||||
<Modal
|
||||
isOpen={deleteModalOpen}
|
||||
onOpenChange={setDeleteModalOpen}
|
||||
size="sm"
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
<h2 className="text-lg font-bold text-danger">确认删除</h2>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className="text-default-600">
|
||||
确定要删除转发 <span className="font-semibold text-foreground">"{forwardToDelete?.name}"</span> 吗?
|
||||
</p>
|
||||
<p className="text-small text-default-500 mt-2">
|
||||
此操作无法撤销,删除后该转发将永久消失。
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
color="danger"
|
||||
onPress={confirmDelete}
|
||||
isLoading={deleteLoading}
|
||||
>
|
||||
确认删除
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* 地址列表弹窗 */}
|
||||
<Modal isOpen={addressModalOpen} onClose={() => setAddressModalOpen(false)} size="lg">
|
||||
<ModalContent>
|
||||
<ModalHeader className="text-base">{addressModalTitle}</ModalHeader>
|
||||
<ModalBody className="pb-6">
|
||||
<div className="mb-4 text-right">
|
||||
<Button size="sm" onClick={copyAllAddresses}>
|
||||
复制全部
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||
{addressList.map((item) => (
|
||||
<div key={item.id} className="flex justify-between items-center p-3 border border-default-200 dark:border-default-100 rounded-lg">
|
||||
<code className="text-sm flex-1 mr-3 text-foreground">{item.address}</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
isLoading={item.copying}
|
||||
onClick={() => copyAddress(item)}
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { Button } from "@heroui/button";
|
||||
import { Input } from "@heroui/input";
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import { title } from "@/components/primitives";
|
||||
import DefaultLayout from "@/layouts/default";
|
||||
import { login, LoginData } from "@/api";
|
||||
|
||||
interface LoginForm {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export default function IndexPage() {
|
||||
const [form, setForm] = useState<LoginForm>({
|
||||
username: "",
|
||||
password: ""
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<Partial<LoginForm>>({});
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 验证表单
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Partial<LoginForm> = {};
|
||||
|
||||
if (!form.username.trim()) {
|
||||
newErrors.username = '请输入用户名';
|
||||
}
|
||||
|
||||
if (!form.password.trim()) {
|
||||
newErrors.password = '请输入密码';
|
||||
} else if (form.password.length < 6) {
|
||||
newErrors.password = '密码长度至少6位';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
// 处理输入变化
|
||||
const handleInputChange = (field: keyof LoginForm, value: string) => {
|
||||
setForm(prev => ({ ...prev, [field]: value }));
|
||||
// 清除该字段的错误
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const loginData: LoginData = {
|
||||
username: form.username.trim(),
|
||||
password: form.password
|
||||
};
|
||||
|
||||
const response = await login(loginData);
|
||||
|
||||
if (response.code !== 0) {
|
||||
toast.error(response.msg || "登陆失败");
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否需要强制修改密码
|
||||
if (response.data.requirePasswordChange) {
|
||||
localStorage.setItem('token', response.data.token);
|
||||
localStorage.setItem("role_id", response.data.role_id.toString());
|
||||
localStorage.setItem("name", response.data.name);
|
||||
// 设置admin字段 - role_id为0的是管理员
|
||||
localStorage.setItem("admin", (response.data.role_id === 0).toString());
|
||||
toast.success('检测到默认密码,即将跳转到修改密码页面');
|
||||
// 直接跳转到修改密码页面
|
||||
navigate("/change-password");
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存登陆信息
|
||||
localStorage.setItem('token', response.data.token);
|
||||
localStorage.setItem("role_id", response.data.role_id.toString());
|
||||
localStorage.setItem("name", response.data.name);
|
||||
// 设置admin字段 - role_id为0的是管理员
|
||||
localStorage.setItem("admin", (response.data.role_id === 0).toString());
|
||||
|
||||
// 登陆成功
|
||||
toast.success('登陆成功');
|
||||
navigate("/dashboard");
|
||||
|
||||
} catch (error) {
|
||||
console.error('登陆错误:', error);
|
||||
toast.error("网络错误,请稍后重试");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !loading) {
|
||||
handleLogin();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<section className="flex flex-col items-center justify-center gap-4 py-8 md:py-10 min-h-[calc(100vh-200px)]">
|
||||
<div className="w-full max-w-md">
|
||||
<Card className="w-full">
|
||||
<CardHeader className="pb-0 pt-6 px-6 flex-col items-center">
|
||||
<h1 className={title({ size: "sm" })}>登陆</h1>
|
||||
<p className="text-small text-default-500 mt-2">请输入您的账号信息</p>
|
||||
</CardHeader>
|
||||
<CardBody className="px-6 py-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
label="用户名"
|
||||
placeholder="请输入用户名"
|
||||
value={form.username}
|
||||
onChange={(e) => handleInputChange('username', e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
variant="bordered"
|
||||
isDisabled={loading}
|
||||
isInvalid={!!errors.username}
|
||||
errorMessage={errors.username}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="密码"
|
||||
placeholder="请输入密码"
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => handleInputChange('password', e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
variant="bordered"
|
||||
isDisabled={loading}
|
||||
isInvalid={!!errors.password}
|
||||
errorMessage={errors.password}
|
||||
/>
|
||||
|
||||
<Button
|
||||
color="primary"
|
||||
size="lg"
|
||||
onClick={handleLogin}
|
||||
isLoading={loading}
|
||||
disabled={loading}
|
||||
className="mt-2"
|
||||
>
|
||||
{loading ? "登陆中..." : "登陆"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</DefaultLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { Button } from "@heroui/button";
|
||||
import { Input } from "@heroui/input";
|
||||
import { Select, SelectItem } from "@heroui/select";
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/modal";
|
||||
import { Chip } from "@heroui/chip";
|
||||
import { Spinner } from "@heroui/spinner";
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import AdminLayout from "@/layouts/admin";
|
||||
import {
|
||||
createSpeedLimit,
|
||||
getSpeedLimitList,
|
||||
updateSpeedLimit,
|
||||
deleteSpeedLimit,
|
||||
getTunnelList
|
||||
} from "@/api";
|
||||
|
||||
interface SpeedLimitRule {
|
||||
id: number;
|
||||
name: string;
|
||||
speed: number;
|
||||
status: number;
|
||||
tunnelId: number;
|
||||
tunnelName: string;
|
||||
createdTime: string;
|
||||
updatedTime: string;
|
||||
}
|
||||
|
||||
interface Tunnel {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface SpeedLimitForm {
|
||||
id?: number;
|
||||
name: string;
|
||||
speed: number;
|
||||
tunnelId: number | null;
|
||||
tunnelName: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
export default function LimitPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [rules, setRules] = useState<SpeedLimitRule[]>([]);
|
||||
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
||||
|
||||
// 模态框状态
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [isEdit, setIsEdit] = useState(false);
|
||||
const [submitLoading, setSubmitLoading] = useState(false);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [ruleToDelete, setRuleToDelete] = useState<SpeedLimitRule | null>(null);
|
||||
|
||||
// 表单状态
|
||||
const [form, setForm] = useState<SpeedLimitForm>({
|
||||
name: '',
|
||||
speed: 100,
|
||||
tunnelId: null,
|
||||
tunnelName: '',
|
||||
status: 1
|
||||
});
|
||||
|
||||
// 表单验证错误
|
||||
const [errors, setErrors] = useState<{[key: string]: string}>({});
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
// 加载所有数据
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [rulesRes, tunnelsRes] = await Promise.all([
|
||||
getSpeedLimitList(),
|
||||
getTunnelList()
|
||||
]);
|
||||
|
||||
if (rulesRes.code === 0) {
|
||||
setRules(rulesRes.data || []);
|
||||
} else {
|
||||
toast.error(rulesRes.msg || '获取限速规则失败');
|
||||
}
|
||||
|
||||
if (tunnelsRes.code === 0) {
|
||||
setTunnels(tunnelsRes.data || []);
|
||||
} else {
|
||||
console.warn('获取隧道列表失败:', tunnelsRes.msg);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载数据失败:', error);
|
||||
toast.error('加载数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 表单验证
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: {[key: string]: string} = {};
|
||||
|
||||
if (!form.name.trim()) {
|
||||
newErrors.name = '请输入规则名称';
|
||||
} else if (form.name.length < 2 || form.name.length > 50) {
|
||||
newErrors.name = '规则名称长度应在2-50个字符之间';
|
||||
}
|
||||
|
||||
if (!form.speed || form.speed < 1) {
|
||||
newErrors.speed = '请输入有效的速度限制(≥1 Mbps)';
|
||||
}
|
||||
|
||||
if (!form.tunnelId) {
|
||||
newErrors.tunnelId = '请选择要绑定的隧道';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
// 新增规则
|
||||
const handleAdd = () => {
|
||||
setIsEdit(false);
|
||||
setForm({
|
||||
name: '',
|
||||
speed: 100,
|
||||
tunnelId: null,
|
||||
tunnelName: '',
|
||||
status: 1
|
||||
});
|
||||
setErrors({});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
// 编辑规则
|
||||
const handleEdit = (rule: SpeedLimitRule) => {
|
||||
setIsEdit(true);
|
||||
setForm({
|
||||
id: rule.id,
|
||||
name: rule.name,
|
||||
speed: rule.speed,
|
||||
tunnelId: rule.tunnelId,
|
||||
tunnelName: rule.tunnelName,
|
||||
status: rule.status
|
||||
});
|
||||
setErrors({});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
// 显示删除确认
|
||||
const handleDelete = (rule: SpeedLimitRule) => {
|
||||
setRuleToDelete(rule);
|
||||
setDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
// 确认删除规则
|
||||
const confirmDelete = async () => {
|
||||
if (!ruleToDelete) return;
|
||||
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
const res = await deleteSpeedLimit(ruleToDelete.id);
|
||||
if (res.code === 0) {
|
||||
toast.success('删除成功');
|
||||
setDeleteModalOpen(false);
|
||||
loadData();
|
||||
} else {
|
||||
toast.error(res.msg || '删除失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error);
|
||||
toast.error('删除失败');
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
setSubmitLoading(true);
|
||||
try {
|
||||
let res;
|
||||
if (isEdit) {
|
||||
res = await updateSpeedLimit(form);
|
||||
} else {
|
||||
const { id, ...createData } = form;
|
||||
res = await createSpeedLimit(createData);
|
||||
}
|
||||
|
||||
if (res.code === 0) {
|
||||
toast.success(isEdit ? '修改成功' : '创建成功');
|
||||
setModalOpen(false);
|
||||
loadData();
|
||||
} else {
|
||||
toast.error(res.msg || '操作失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error);
|
||||
toast.error('操作失败');
|
||||
} finally {
|
||||
setSubmitLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex items-center gap-3">
|
||||
<Spinner size="sm" />
|
||||
<span className="text-default-600">正在加载...</span>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className="px-3 lg:px-6 py-8">
|
||||
{/* 页面头部 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-foreground">限速管理</h1>
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={handleAdd}
|
||||
startContent={
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
新增规则
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 统一卡片网格 */}
|
||||
{rules.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{rules.map((rule) => (
|
||||
<Card key={rule.id} className="shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">{rule.name}</h3>
|
||||
</div>
|
||||
<Chip
|
||||
color={rule.status === 1 ? "success" : "danger"}
|
||||
variant="flat"
|
||||
size="sm"
|
||||
>
|
||||
{rule.status === 1 ? '运行' : '异常'}
|
||||
</Chip>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-small text-default-600">速度限制</span>
|
||||
<Chip color="secondary" variant="flat" size="sm">
|
||||
{rule.speed} Mbps
|
||||
</Chip>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-small text-default-600">绑定隧道</span>
|
||||
{rule.tunnelName ? (
|
||||
<Chip color="primary" variant="flat" size="sm">
|
||||
{rule.tunnelName}
|
||||
</Chip>
|
||||
) : (
|
||||
<span className="text-default-400 text-small">未绑定</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
onPress={() => handleEdit(rule)}
|
||||
className="flex-1"
|
||||
startContent={
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
color="danger"
|
||||
onPress={() => handleDelete(rule)}
|
||||
className="flex-1"
|
||||
startContent={
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" clipRule="evenodd" />
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z" clipRule="evenodd" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* 空状态 */
|
||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<CardBody className="text-center py-16">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-default-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6v6l4 2m6-6a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">暂无限速规则</h3>
|
||||
<p className="text-default-500 text-sm mt-1">还没有创建任何限速规则,点击上方按钮开始创建</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 新增/编辑模态框 */}
|
||||
<Modal
|
||||
isOpen={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
size="lg"
|
||||
scrollBehavior="inside"
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
<h2 className="text-xl font-bold">
|
||||
{isEdit ? '编辑限速规则' : '新增限速规则'}
|
||||
</h2>
|
||||
<p className="text-small text-default-500">
|
||||
{isEdit ? '修改现有限速规则的配置信息' : '创建新的限速规则并绑定到隧道'}
|
||||
</p>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="规则名称"
|
||||
placeholder="请输入限速规则名称"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, name: e.target.value }))}
|
||||
isInvalid={!!errors.name}
|
||||
errorMessage={errors.name}
|
||||
variant="bordered"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="速度限制"
|
||||
placeholder="请输入速度限制"
|
||||
type="number"
|
||||
value={form.speed.toString()}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, speed: parseInt(e.target.value) || 0 }))}
|
||||
isInvalid={!!errors.speed}
|
||||
errorMessage={errors.speed}
|
||||
variant="bordered"
|
||||
endContent={
|
||||
<div className="pointer-events-none flex items-center">
|
||||
<span className="text-default-400 text-small">Mbps</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="绑定隧道"
|
||||
placeholder="请选择要绑定的隧道"
|
||||
selectedKeys={form.tunnelId ? [form.tunnelId.toString()] : []}
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedKey = Array.from(keys)[0] as string;
|
||||
if (selectedKey) {
|
||||
const selectedTunnel = tunnels.find(tunnel => tunnel.id === parseInt(selectedKey));
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
tunnelId: parseInt(selectedKey),
|
||||
tunnelName: selectedTunnel?.name || ''
|
||||
}));
|
||||
} else {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
tunnelId: null,
|
||||
tunnelName: ''
|
||||
}));
|
||||
}
|
||||
}}
|
||||
isInvalid={!!errors.tunnelId}
|
||||
errorMessage={errors.tunnelId}
|
||||
variant="bordered"
|
||||
>
|
||||
{tunnels.map((tunnel) => (
|
||||
<SelectItem key={tunnel.id}>
|
||||
{tunnel.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={handleSubmit}
|
||||
isLoading={submitLoading}
|
||||
>
|
||||
{isEdit ? '保存修改' : '创建规则'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* 删除确认模态框 */}
|
||||
<Modal
|
||||
isOpen={deleteModalOpen}
|
||||
onOpenChange={setDeleteModalOpen}
|
||||
size="sm"
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
<h2 className="text-lg font-bold text-danger">确认删除</h2>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className="text-default-600">
|
||||
确定要删除限速规则 <span className="font-semibold text-foreground">"{ruleToDelete?.name}"</span> 吗?
|
||||
</p>
|
||||
<p className="text-small text-default-500 mt-2">
|
||||
此操作无法撤销,删除后该规则将永久消失。
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
color="danger"
|
||||
onPress={confirmDelete}
|
||||
isLoading={deleteLoading}
|
||||
>
|
||||
确认删除
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user