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 { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; 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; } interface StatisticsFlow { id: number; userId: number; flow: number; totalFlow: number; time: string; } export default function DashboardPage() { const [loading, setLoading] = useState(true); const [userInfo, setUserInfo] = useState({} as UserInfo); const [userTunnels, setUserTunnels] = useState([]); const [forwardList, setForwardList] = useState([]); const [statisticsFlows, setStatisticsFlows] = useState([]); const [isAdmin, setIsAdmin] = useState(false); const [addressModalOpen, setAddressModalOpen] = useState(false); const [addressModalTitle, setAddressModalTitle] = useState(''); const [addressList, setAddressList] = useState([]); // 检查有效期通知 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([]); setStatisticsFlows([]); // 检查用户是否是管理员 const adminStatus = localStorage.getItem('admin'); setIsAdmin(adminStatus === 'true'); 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 || []); setStatisticsFlows(data.statisticsFlows || []); // 检查有效期并显示通知 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(); }; // 处理24小时流量统计数据 const processFlowChartData = () => { // 生成最近24小时的时间数组(从当前小时往前推24小时) const now = new Date(); const hours: string[] = []; for (let i = 23; i >= 0; i--) { const time = new Date(now.getTime() - i * 60 * 60 * 1000); const hourString = time.getHours().toString().padStart(2, '0') + ':00'; hours.push(hourString); } // 创建数据映射 const flowMap = new Map(); statisticsFlows.forEach(item => { flowMap.set(item.time, item.flow || 0); }); // 生成图表数据,没有数据的小时显示为0 return hours.map(hour => ({ time: hour, flow: flowMap.get(hour) || 0, // 格式化显示用的流量值 formattedFlow: formatFlow(flowMap.get(hour) || 0) })); }; 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 (
); } return (
); }; const calculateTunnelUsedFlow = (tunnel: UserTunnel): number => { if (!tunnel) return 0; const inFlow = tunnel.inFlow || 0; const outFlow = tunnel.outFlow || 0; // 后端已按计费类型处理流量,前端直接使用入站+出站总和 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 === undefined || resetDay === null) return ''; if (resetDay === 0) 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)); 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); 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) => { 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 inFlow = forward.inFlow || 0; const outFlow = forward.outFlow || 0; // 后端已按计费类型处理流量,前端直接使用入站+出站总和 return inFlow + outFlow; }; if (loading) { return (
正在加载数据...
); } return (
{/* 响应式统计卡片 */}

总流量

{formatFlow(userInfo.flow, 'gb')}

已用流量

{formatFlow(calculateUserTotalUsedFlow())}

{renderProgressBar(calculateUsagePercentage('flow'), 'sm', userInfo.flow === 99999)}

{userInfo.flow === 99999 ? '无限制' : `${calculateUsagePercentage('flow').toFixed(1)}%`}

{(userInfo.flowResetTime !== undefined && userInfo.flowResetTime !== null) && (
{formatResetTime(userInfo.flowResetTime)}
)}

转发配额

{formatNumber(userInfo.num || 0)}

已用转发

{forwardList.length}

{renderProgressBar(calculateUsagePercentage('forwards'), 'sm', userInfo.num === 99999)}

{userInfo.num === 99999 ? '无限制' : `${calculateUsagePercentage('forwards').toFixed(1)}%`}

{/* 24小时流量统计图表 */}

24小时流量统计

{statisticsFlows.length === 0 ? (

暂无流量统计数据

) : (
{/* 流量趋势图 */}
{ if (value === 0) return '0'; if (value < 1024) return `${value}B`; if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)}K`; if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)}M`; return `${(value / (1024 * 1024 * 1024)).toFixed(1)}G`; }} /> { if (active && payload && payload.length) { return (

{`时间: ${label}`}

{`流量: ${formatFlow(payload[0]?.value as number || 0)}`}

); } return null; }} />
)}
{/* 隧道权限 - 管理员不显示 */} {!isAdmin && (

隧道权限

{userTunnels.length}
{userTunnels.length === 0 ? (

暂无隧道权限

) : (
{userTunnels.map((tunnel) => { const tunnelExpStatus = getExpStatus(tunnel.expTime); return (

{tunnel.tunnelName} ID: {tunnel.id}

{tunnel.tunnelFlow === 1 ? '单向计费' : '双向计费'} {tunnelExpStatus.text} {(tunnel.flowResetTime !== undefined && tunnel.flowResetTime !== null) && ( {formatResetTime(tunnel.flowResetTime)} )}

流量配额

{formatFlow(tunnel.flow, 'gb')}

已用流量

{formatFlow(calculateTunnelUsedFlow(tunnel))}

{renderProgressBar(calculateTunnelFlowPercentage(tunnel), 'sm', tunnel.flow === 99999)}

转发配额

{formatNumber(tunnel.num)}

已用转发

{getTunnelUsedForwards(tunnel.tunnelId)}

{renderProgressBar(calculateTunnelForwardPercentage(tunnel), 'sm', tunnel.num === 99999)}
); })}
)}
)} {/* 转发配置 */}

转发配置

{forwardList.length}
{groupedForwards().length === 0 ? (

暂无转发配置

) : (
{groupedForwards().map((group) => (

{group.tunnelName}

{group.forwards.length} 个转发
{group.forwards.map((forward) => (

{forward.name}

hasMultipleIps(forward.inIp) && showAddressModal(forward.inIp, forward.inPort, '入口地址')} title={formatInAddress(forward.inIp, forward.inPort)} > {formatInAddress(forward.inIp, forward.inPort)}
↓
hasMultipleRemoteAddresses(forward.remoteAddr) && showRemoteAddressModal(forward.remoteAddr, '出口地址')} title={formatRemoteAddress(forward.remoteAddr)} > {formatRemoteAddress(forward.remoteAddr)}
上传
{formatFlow(forward.inFlow || 0)}
下载
{formatFlow(forward.outFlow || 0)}
计费
{formatFlow(calculateForwardBillingFlow(forward))}
))}
))}
)}
{/* 地址列表弹窗 */} setAddressModalOpen(false)} size="lg"> {addressModalTitle}
{addressList.map((item) => (
{item.address}
))}
); }