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) return ""; const items = ipString .split(",") .map((item) => item.trim()) .filter((item) => item); if (items.length === 0) return ""; // 检查第一项是否已经包含端口(格式:IP:端口) const firstItem = items[0]; const hasPort = /:\d+$/.test(firstItem); if (hasPort) { // inIp 已经包含完整的 IP:Port 组合 if (items.length === 1) { return items[0]; } return `${items[0]} (+${items.length - 1}个)`; } // inIp 只包含IP,需要添加端口(兼容旧数据) if (!port) return ""; if (items.length === 1) { const ip = items[0]; if (ip.includes(":") && !ip.startsWith("[")) { return `[${ip}]:${port}`; } else { return `${ip}:${port}`; } } const firstIp = items[0]; let formattedFirstIp; if (firstIp.includes(":") && !firstIp.startsWith("[")) { formattedFirstIp = `[${firstIp}]`; } else { formattedFirstIp = firstIp; } return `${formattedFirstIp}:${port} (+${items.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) return; const items = ipString .split(",") .map((item) => item.trim()) .filter((item) => item); if (items.length <= 1) { copyToClipboard(formatInAddress(ipString, port)); return; } // 检查是否已经包含端口 const hasPort = /:\d+$/.test(items[0]); let formattedList; if (hasPort) { // 已经包含完整的 IP:Port 组合,直接使用 formattedList = items.map((item, index) => ({ id: index, ip: item, address: item, copying: false, })); } else { // 只包含IP,需要添加端口 formattedList = items.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} (${items.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 ? (

暂无流量统计数据

) : (
{/* 流量趋势图 */}
{ const v = typeof value === "number" ? value : Number(value); if (!Number.isFinite(v)) return String(value); if (v === 0) return "0"; if (v < 1024) return `${v}B`; if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)}K`; if (v < 1024 * 1024 * 1024) return `${(v / (1024 * 1024)).toFixed(1)}M`; return `${(v / (1024 * 1024 * 1024)).toFixed(1)}G`; }} tickLine={false} /> ; label?: string | number; }) => { if (active && payload && payload.length) { const firstValue = payload[0]?.value; const numericValue = typeof firstValue === "number" ? firstValue : Number(firstValue); const flowValue = Number.isFinite(numericValue) ? numericValue : 0; return (

{`时间: ${label ?? ""}`}

{`流量: ${formatFlow(flowValue)}`}

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