import { useState } from "react"; import toast from "react-hot-toast"; import { useNavigate } from "react-router-dom"; import { AnimatedPage } from "@/components/animated-page"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card"; import { Button } from "@/shadcn-bridge/heroui/button"; import { Dropdown, DropdownTrigger, DropdownMenu, DropdownItem, } from "@/shadcn-bridge/heroui/dropdown"; import { Modal, ModalContent, ModalHeader, ModalBody, } from "@/shadcn-bridge/heroui/modal"; import { PageEmptyState, PageLoadingState } from "@/components/page-state"; import { AnnouncementBanner } from "@/pages/dashboard/components/announcement-banner"; import { AnnouncementModal } from "@/pages/dashboard/components/announcement-modal"; import { FlowChartCard } from "@/pages/dashboard/components/flow-chart-card"; import { MetricCard } from "@/pages/dashboard/components/metric-card"; import { getSessionName } from "@/utils/session"; import { safeLogout } from "@/utils/logout"; import { formatNodeRenewalTime, getNodeRenewalCycleLabel, getNodeRenewalSnapshot, } from "@/pages/node/renewal"; import { useDashboardData, type DashboardForward as Forward, type DashboardNodeExpiryItem, type DashboardUserTunnel as UserTunnel, } from "@/pages/dashboard/use-dashboard-data"; interface AddressItem { id: number; ip: string; address: string; copying: boolean; } export default function DashboardPage() { const navigate = useNavigate(); const username = getSessionName() || "User"; const handleLogout = () => { safeLogout(); toast.success("已退出登录"); }; const { loading, userInfo, userTunnels, forwardList, statisticsFlows, nodeExpiryReminders, isAdmin, announcement, isAnnouncementModalOpen, setIsAnnouncementModalOpen, dismissAnnouncementModal, } = useDashboardData(); const [addressModalOpen, setAddressModalOpen] = useState(false); const [addressModalTitle, setAddressModalTitle] = useState(""); const [addressList, setAddressList] = useState([]); 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 getNodeExpiryStatus = ( nextDueTime?: number, renewalState: "unset" | "expired" | "dueSoon" | "scheduled" = "unset", ) => { if (!nextDueTime || renewalState === "unset") { return { label: "未设置", badgeClassName: "bg-default-100 text-default-700 dark:bg-default-50 dark:text-default-300", nextDueTime: undefined as number | undefined, }; } const diffDays = Math.ceil( (nextDueTime - Date.now()) / (1000 * 60 * 60 * 24), ); if (renewalState === "expired" || diffDays <= 0) { return { label: "已逾期", badgeClassName: "bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-300", nextDueTime, }; } if (diffDays === 1) { return { label: "明天到期", badgeClassName: "bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300", nextDueTime, }; } return { label: `${diffDays}天后到期`, badgeClassName: diffDays <= 7 ? "bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300" : "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300", nextDueTime, }; }; const renderNodeExpiryCard = (node: DashboardNodeExpiryItem) => { const renewalSnapshot = getNodeRenewalSnapshot( node.expiryTime, node.renewalCycle, ); const expiryStatus = getNodeExpiryStatus( renewalSnapshot.nextDueTime, renewalSnapshot.state, ); return (
{node.name}
节点 ID: {node.id}
{expiryStatus.label}
{formatNodeRenewalTime(renewalSnapshot.nextDueTime)}
{getNodeRenewalCycleLabel(node.renewalCycle)}
{node.remark?.trim() && (

{node.remark.trim()}

)}
); }; // 处理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 | number) => { 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: number; 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: string; 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: AddressItem[]; 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: string; 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 { 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 { 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 ( {/* 顶部个人状态和下拉菜单 */}

Good morning, {username}

Here's what's happening with your network today.

} onPress={() => navigate("/profile")} > 个人资料 } onPress={handleLogout} > 退出登录
{announcement && } {announcement && ( setIsAnnouncementModalOpen(false)} onDontShowAgain={dismissAnnouncementModal} /> )}
} icon={ } iconClassName="bg-green-100 dark:bg-green-500/20" title="已用流量" value={formatFlow(calculateUserTotalUsedFlow())} />
} icon={ } iconClassName="bg-orange-100 dark:bg-orange-500/20" title="已用规则" value={forwardList.length} />
{isAdmin && nodeExpiryReminders.length > 0 && (

节点到期提醒

展示 7 天内需要续费或已经逾期的节点,基于月付/季付/年付周期自动推算

{nodeExpiryReminders.length} 个提醒
{nodeExpiryReminders.slice(0, 6).map(renderNodeExpiryCard)}
{nodeExpiryReminders.length > 6 && (

还有 {nodeExpiryReminders.length - 6}{" "} 个节点未展开显示,可前往节点页面继续处理。

)}
)} {/* 隧道权限 - 管理员不显示 */} {!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}

↓
上传
{formatFlow(forward.inFlow || 0)}
下载
{formatFlow(forward.outFlow || 0)}
计费
{formatFlow( calculateForwardBillingFlow(forward), )}
))}
))}
)}
{/* 地址列表弹窗 */} setAddressModalOpen(false)} > {addressModalTitle}
{addressList.map((item) => (
{item.address}
))}
); }