Files
flvx/vite-frontend/src/pages/dashboard.tsx
T
sagitchu 31ef861504 feat(nodes): add node metadata and expiry reminders (#246)
Entire-Checkpoint: d8b429492cbe
2026-03-08 19:44:46 +08:00

1130 lines
40 KiB
TypeScript

import { useState } from "react";
import toast from "react-hot-toast";
import { AnimatedPage } from "@/components/animated-page";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
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 { FlowChartCard } from "@/pages/dashboard/components/flow-chart-card";
import { MetricCard } from "@/pages/dashboard/components/metric-card";
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 {
loading,
userInfo,
userTunnels,
forwardList,
statisticsFlows,
nodeExpiryReminders,
isAdmin,
announcement,
} = useDashboardData();
const [addressModalOpen, setAddressModalOpen] = useState(false);
const [addressModalTitle, setAddressModalTitle] = useState("");
const [addressList, setAddressList] = useState<AddressItem[]>([]);
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 = (expiryTime?: number) => {
if (!expiryTime) {
return {
label: "永久有效",
badgeClassName:
"bg-default-100 text-default-700 dark:bg-default-50 dark:text-default-300",
};
}
const diffMs = expiryTime - Date.now();
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
if (diffDays <= 0) {
return {
label: "已过期",
badgeClassName:
"bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-300",
};
}
if (diffDays === 1) {
return {
label: "明天到期",
badgeClassName:
"bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300",
};
}
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",
};
};
const parseNodeTags = (tags?: string) => {
if (!tags) return [];
return tags
.split(",")
.map((tag) => tag.trim())
.filter(Boolean)
.slice(0, 3);
};
const renderNodeExpiryCard = (node: DashboardNodeExpiryItem) => {
const expiryStatus = getNodeExpiryStatus(node.expiryTime);
const tags = parseNodeTags(node.tags);
return (
<div
key={node.id}
className="rounded-xl border border-amber-200/80 bg-gradient-to-br from-amber-50 via-white to-orange-50 p-4 shadow-sm dark:border-amber-500/20 dark:from-amber-950/20 dark:via-background dark:to-orange-950/10"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-foreground truncate">
{node.name}
</div>
<div className="mt-1 text-xs text-default-500">节点 ID: {node.id}</div>
</div>
<span
className={`shrink-0 rounded-full px-2.5 py-1 text-[11px] font-medium ${expiryStatus.badgeClassName}`}
>
{expiryStatus.label}
</span>
</div>
<div className="mt-3 text-sm text-default-700 dark:text-default-300">
{node.expiryTime
? new Date(node.expiryTime).toLocaleString()
: "未设置到期时间"}
</div>
{node.remark?.trim() && (
<p className="mt-3 line-clamp-2 text-xs leading-5 text-default-600 dark:text-default-400">
{node.remark.trim()}
</p>
)}
{tags.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{tags.map((tag) => (
<span
key={`${node.id}-${tag}`}
className="rounded-full bg-white/80 px-2 py-1 text-[11px] text-default-600 ring-1 ring-amber-200/80 dark:bg-white/5 dark:text-default-300 dark:ring-amber-500/20"
>
#{tag}
</span>
))}
</div>
)}
</div>
);
};
// 处理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<string, number>();
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 (
<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>
);
}
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>
);
};
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 (
<div className="px-3 lg:px-6 flex-grow pt-2 lg:pt-4">
<PageLoadingState message="正在加载数据..." />
</div>
);
}
return (
<AnimatedPage className="px-3 lg:px-6 py-2 lg:py-4">
{announcement && <AnnouncementBanner announcement={announcement} />}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-4 mb-6 lg:mb-8">
<MetricCard
icon={
<svg
aria-hidden="true"
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>
}
iconClassName="bg-blue-100 dark:bg-blue-500/20"
title="总流量"
value={formatFlow(userInfo.flow, "gb")}
/>
<MetricCard
bottomContent={
<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 !== undefined &&
userInfo.flowResetTime !== null && (
<div className="text-xs text-default-500 flex items-center gap-1">
<svg
aria-hidden="true"
className="w-3 h-3"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="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"
fillRule="evenodd"
/>
</svg>
<span className="truncate">
{formatResetTime(userInfo.flowResetTime)}
</span>
</div>
)}
</div>
</div>
}
icon={
<svg
aria-hidden="true"
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
clipRule="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"
fillRule="evenodd"
/>
</svg>
}
iconClassName="bg-green-100 dark:bg-green-500/20"
title="已用流量"
value={formatFlow(calculateUserTotalUsedFlow())}
/>
<MetricCard
icon={
<svg
aria-hidden="true"
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
clipRule="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"
fillRule="evenodd"
/>
</svg>
}
iconClassName="bg-purple-100 dark:bg-purple-500/20"
title="规则配额"
value={formatNumber(userInfo.num || 0)}
/>
<MetricCard
bottomContent={
<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>
}
icon={
<svg
aria-hidden="true"
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
clipRule="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"
fillRule="evenodd"
/>
</svg>
}
iconClassName="bg-orange-100 dark:bg-orange-500/20"
title="已用规则"
value={forwardList.length}
/>
</div>
<FlowChartCard
chartData={processFlowChartData()}
formatFlow={formatFlow}
statisticsFlowsCount={statisticsFlows.length}
/>
{isAdmin && nodeExpiryReminders.length > 0 && (
<Card className="mb-6 lg:mb-8 border border-amber-200/80 bg-gradient-to-br from-amber-50/90 via-background to-orange-50/70 shadow-md dark:border-amber-500/20 dark:from-amber-950/10 dark:to-orange-950/10">
<CardHeader className="pb-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between w-full">
<div className="flex items-center gap-2">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300">
<svg
aria-hidden="true"
className="h-5 w-5"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.981-1.742 2.981H4.42c-1.53 0-2.492-1.647-1.743-2.98l5.58-9.92zM11 13a1 1 0 10-2 0 1 1 0 002 0zm-1-7a1 1 0 00-1 1v3a1 1 0 102 0V7a1 1 0 00-1-1z"
fillRule="evenodd"
/>
</svg>
</div>
<div>
<h2 className="text-lg lg:text-xl font-semibold text-foreground">
节点到期提醒
</h2>
<p className="text-sm text-default-500">
展示 7 天内到期或已过期的节点,便于提前续费或清理
</p>
</div>
</div>
<span className="inline-flex w-fit items-center rounded-full bg-white/80 px-3 py-1 text-xs font-medium text-amber-700 ring-1 ring-amber-200/80 dark:bg-white/5 dark:text-amber-300 dark:ring-amber-500/20">
{nodeExpiryReminders.length} 个提醒
</span>
</div>
</CardHeader>
<CardBody className="pt-0">
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
{nodeExpiryReminders.slice(0, 6).map(renderNodeExpiryCard)}
</div>
{nodeExpiryReminders.length > 6 && (
<p className="mt-4 text-xs text-default-500">
还有 {nodeExpiryReminders.length - 6} 个节点未展开显示,可前往节点页面继续处理。
</p>
)}
</CardBody>
</Card>
)}
{/* 隧道权限 - 管理员不显示 */}
{!isAdmin && (
<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
aria-hidden="true"
className="w-5 h-5 text-primary"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="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"
fillRule="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 ? (
<PageEmptyState className="h-48" message="暂无隧道权限" />
) : (
<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} ID: {tunnel.id}
</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 !== undefined &&
tunnel.flowResetTime !== null && (
<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
aria-hidden="true"
className="w-5 h-5 text-primary"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="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"
fillRule="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
aria-hidden="true"
className="w-12 h-12 text-default-400 mx-auto mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M8 9l4-4 4 4m0 6l-4 4-4-4"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
/>
</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">
<button
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" : ""}`}
disabled={!hasMultipleIps(forward.inIp)}
title={formatInAddress(
forward.inIp,
forward.inPort,
)}
type="button"
onClick={() =>
showAddressModal(
forward.inIp,
forward.inPort,
"入口地址",
)
}
>
{formatInAddress(forward.inIp, forward.inPort)}
</button>
<div className="text-center text-default-400 text-xs">
↓
</div>
<button
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" : ""}`}
disabled={
!hasMultipleRemoteAddresses(
forward.remoteAddr,
)
}
title={formatRemoteAddress(forward.remoteAddr)}
type="button"
onClick={() =>
showRemoteAddressModal(
forward.remoteAddr,
"出口地址",
)
}
>
{formatRemoteAddress(forward.remoteAddr)}
</button>
</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
backdrop="blur"
isOpen={addressModalOpen}
placement="center"
scrollBehavior="outside"
size="2xl"
onClose={() => setAddressModalOpen(false)}
>
<ModalContent>
<ModalHeader className="text-base">{addressModalTitle}</ModalHeader>
<ModalBody className="pb-6">
<div className="mb-4 text-right">
<Button size="sm" onPress={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
isLoading={item.copying}
size="sm"
variant="light"
onPress={() => copyAddress(item)}
>
复制
</Button>
</div>
))}
</div>
</ModalBody>
</ModalContent>
</Modal>
</AnimatedPage>
);
}