-
);
@@ -358,6 +396,7 @@ export default function DashboardPage() {
if (!tunnel) return 0;
const inFlow = tunnel.inFlow || 0;
const outFlow = tunnel.outFlow || 0;
+
// 后端已按计费类型处理流量,前端直接使用入站+出站总和
return inFlow + outFlow;
};
@@ -365,172 +404,213 @@ export default function DashboardPage() {
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;
+ 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 '不重置';
-
+ 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 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 '今日重置';
+ return "今日重置";
} else if (daysUntilReset === 1) {
- return '明日重置';
+ return "明日重置";
} else {
return `${daysUntilReset}天后重置`;
}
};
const groupedForwards = () => {
- const groups: { [key: string]: { tunnelName: string; forwards: Forward[] } } = {};
- forwardList.forEach(forward => {
- const tunnelName = forward.tunnelName || '未知隧道';
+ const groups: {
+ [key: string]: { tunnelName: string; forwards: Forward[] };
+ } = {};
+
+ forwardList.forEach((forward) => {
+ const tunnelName = forward.tunnelName || "未知隧道";
+
if (!groups[tunnelName]) {
groups[tunnelName] = {
tunnelName,
- forwards: []
+ 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 '';
-
+ 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 (!port) return "";
+
if (items.length === 1) {
const ip = items[0];
- if (ip.includes(':') && !ip.startsWith('[')) {
+
+ if (ip.includes(":") && !ip.startsWith("[")) {
return `[${ip}]:${port}`;
} else {
return `${ip}:${port}`;
}
}
-
+
const firstIp = items[0];
let formattedFirstIp;
- if (firstIp.includes(':') && !firstIp.startsWith('[')) {
+
+ 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 (!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);
+ 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);
+ 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);
-
+
+ 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
+ copying: false,
}));
} else {
// 只包含IP,需要添加端口
formattedList = items.map((ip, index) => {
let formattedAddress;
- if (ip.includes(':') && !ip.startsWith('[')) {
+
+ if (ip.includes(":") && !ip.startsWith("[")) {
formattedAddress = `[${ip}]:${port}`;
} else {
formattedAddress = `${ip}:${port}`;
}
+
return {
id: index,
ip: ip,
address: formattedAddress,
- copying: false
+ copying: false,
};
});
}
-
+
setAddressList(formattedList);
setAddressModalTitle(`${title} (${items.length}个)`);
setAddressModalOpen(true);
@@ -538,23 +618,27 @@ export default function DashboardPage() {
const showRemoteAddressModal = (remoteAddr: string, title: string) => {
if (!remoteAddr) return;
-
- const addresses = remoteAddr.split(',').map(addr => addr.trim()).filter(addr => addr);
-
+
+ const addresses = remoteAddr
+ .split(",")
+ .map((addr) => addr.trim())
+ .filter((addr) => addr);
+
if (addresses.length <= 1) {
- copyToClipboard(remoteAddr);
+ copyToClipboard(remoteAddr);
+
return;
}
-
+
const formattedList = addresses.map((address, index) => {
return {
id: index,
ip: address,
address: address,
- copying: false
+ copying: false,
};
});
-
+
setAddressList(formattedList);
setAddressModalTitle(`${title} (${addresses.length}个)`);
setAddressModalOpen(true);
@@ -565,436 +649,666 @@ export default function DashboardPage() {
await navigator.clipboard.writeText(text);
toast.success(`已复制`);
} catch (error) {
- toast.error('复制失败');
+ toast.error("复制失败");
}
};
const copyAddress = async (addressItem: AddressItem) => {
try {
- setAddressList(prev => prev.map(item =>
- item.id === addressItem.id ? { ...item, copying: true } : item
- ));
+ setAddressList((prev) =>
+ prev.map((item) =>
+ item.id === addressItem.id ? { ...item, copying: true } : item,
+ ),
+ );
await copyToClipboard(addressItem.address);
} catch (error) {
- toast.error('复制失败');
+ toast.error("复制失败");
} finally {
- setAddressList(prev => prev.map(item =>
- item.id === addressItem.id ? { ...item, copying: false } : item
- ));
+ 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');
+ 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 (
-
-
-
-
-
-
正在加载数据...
+ 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小时流量统计图表 */}
+
+
+
-
- );
- }
+
+
+ {statisticsFlows.length === 0 ? (
+
+ ) : (
+
+ {/* 流量趋势图 */}
+
+
+
+
+
+ {
+ const v =
+ typeof value === "number" ? value : Number(value);
- return (
-
-
+ 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`;
- {/* 响应式统计卡片 */}
-
-
-
-
-
-
{formatFlow(userInfo.flow, 'gb')}
-
-
-
+ 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;
-
-
-
-
-
{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)}
-
- )}
-
-
-
-
-
+ return (
+
+
{`时间: ${label ?? ""}`}
+
+ {`流量: ${formatFlow(flowValue)}`}
+
+
+ );
+ }
-
-
-
-
-
{formatNumber(userInfo.num || 0)}
-
-
-
+ return null;
+ }}
+ />
+
+
+
+
+
+ )}
+
+
-
-
-
-
-
{forwardList.length}
-
- {renderProgressBar(calculateUsagePercentage('forwards'), 'sm', userInfo.num === 99999)}
-
- {userInfo.num === 99999 ? '无限制' : `${calculateUsagePercentage('forwards').toFixed(1)}%`}
-
-
-
-
-
-
-
- {/* 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`;
- }}
- />
- ;
- 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}
-
-
-
-
+ {/* 隧道权限 - 管理员不显示 */}
+ {!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)}
-
-
-
-
- );
- })}
-
+
+ {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)}
-
+ {/* 转发配置 */}
+
+
+
+
+
+ 转发配置
+
+
+ {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(forward.inFlow || 0)}
-
-
计费
-
{formatFlow(calculateForwardBillingFlow(forward))}
+
+
+
+ 下载
+
+
+ {formatFlow(forward.outFlow || 0)}
+
+
+
+
+ 计费
+
+
+ {formatFlow(
+ calculateForwardBillingFlow(forward),
+ )}
- ))}
-
+
+ ))}
- ))}
-
- )}
-
-
+
+ ))}
+
+ )}
+
+
- {/* 地址列表弹窗 */}
-
setAddressModalOpen(false)} size="2xl"
- scrollBehavior="outside"
+ {/* 地址列表弹窗 */}
+
-
- {addressModalTitle}
-
-
-
-
-
-
- {addressList.map((item) => (
-
- {item.address}
-
-
- ))}
-
-
-
-
-
-
+ isOpen={addressModalOpen}
+ placement="center"
+ scrollBehavior="outside"
+ size="2xl"
+ onClose={() => setAddressModalOpen(false)}
+ >
+
+ {addressModalTitle}
+
+
+
+
+
+
+ {addressList.map((item) => (
+
+
+ {item.address}
+
+
+
+ ))}
+
+
+
+
+
);
-}
+}
diff --git a/vite-frontend/src/pages/forward.tsx b/vite-frontend/src/pages/forward.tsx
index 177791c..7bacf87 100644
--- a/vite-frontend/src/pages/forward.tsx
+++ b/vite-frontend/src/pages/forward.tsx
@@ -4,13 +4,19 @@ import { Button } from "@heroui/button";
import { Input } from "@heroui/input";
import { Textarea } from "@heroui/input";
import { Select, SelectItem } from "@heroui/select";
-import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/modal";
+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 { Alert } from "@heroui/alert";
import { Accordion, AccordionItem } from "@heroui/accordion";
-import toast from 'react-hot-toast';
+import toast from "react-hot-toast";
import {
DndContext,
closestCenter,
@@ -20,30 +26,27 @@ import {
useSensor,
useSensors,
DragEndEvent,
-} from '@dnd-kit/core';
+} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
rectSortingStrategy,
-} from '@dnd-kit/sortable';
+} from "@dnd-kit/sortable";
+import { useSortable } from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+
import {
- useSortable,
-} from '@dnd-kit/sortable';
-import { CSS } from '@dnd-kit/utilities';
-
-
-import {
- createForward,
- getForwardList,
- updateForward,
+ createForward,
+ getForwardList,
+ updateForward,
deleteForward,
forceDeleteForward,
- userTunnel,
+ userTunnel,
pauseForwardService,
resumeForwardService,
diagnoseForward,
- updateForwardOrder
+ updateForwardOrder,
} from "@/api";
import { JwtUtil } from "@/utils/jwt";
@@ -128,34 +131,35 @@ export default function ForwardPage() {
const [loading, setLoading] = useState(true);
const [forwards, setForwards] = useState
([]);
const [tunnels, setTunnels] = useState([]);
-
+
// 检测是否为移动端
const [isMobile, setIsMobile] = useState(false);
-
+
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768);
};
-
+
checkMobile();
- window.addEventListener('resize', checkMobile);
-
- return () => window.removeEventListener('resize', checkMobile);
+ window.addEventListener("resize", checkMobile);
+
+ return () => window.removeEventListener("resize", checkMobile);
}, []);
-
+
// 显示模式状态 - 从localStorage读取,默认为平铺显示
- const [viewMode, setViewMode] = useState<'grouped' | 'direct'>(() => {
+ const [viewMode, setViewMode] = useState<"grouped" | "direct">(() => {
try {
- const savedMode = localStorage.getItem('forward-view-mode');
- return (savedMode as 'grouped' | 'direct') || 'direct';
+ const savedMode = localStorage.getItem("forward-view-mode");
+
+ return (savedMode as "grouped" | "direct") || "direct";
} catch {
- return 'direct';
+ return "direct";
}
});
-
+
// 拖拽排序相关状态
const [forwardOrder, setForwardOrder] = useState([]);
-
+
// 模态框状态
const [modalOpen, setModalOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
@@ -166,41 +170,49 @@ export default function ForwardPage() {
const [deleteLoading, setDeleteLoading] = useState(false);
const [diagnosisLoading, setDiagnosisLoading] = useState(false);
const [forwardToDelete, setForwardToDelete] = useState(null);
- const [currentDiagnosisForward, setCurrentDiagnosisForward] = useState(null);
- const [diagnosisResult, setDiagnosisResult] = useState(null);
- const [addressModalTitle, setAddressModalTitle] = useState('');
+ const [currentDiagnosisForward, setCurrentDiagnosisForward] =
+ useState(null);
+ const [diagnosisResult, setDiagnosisResult] =
+ useState(null);
+ const [addressModalTitle, setAddressModalTitle] = useState("");
const [addressList, setAddressList] = useState([]);
-
+
// 导出相关状态
const [exportModalOpen, setExportModalOpen] = useState(false);
- const [exportData, setExportData] = useState('');
+ const [exportData, setExportData] = useState("");
const [exportLoading, setExportLoading] = useState(false);
- const [selectedTunnelForExport, setSelectedTunnelForExport] = useState(null);
-
+ const [selectedTunnelForExport, setSelectedTunnelForExport] = useState<
+ number | null
+ >(null);
+
// 导入相关状态
const [importModalOpen, setImportModalOpen] = useState(false);
- const [importData, setImportData] = useState('');
+ const [importData, setImportData] = useState("");
const [importLoading, setImportLoading] = useState(false);
- const [selectedTunnelForImport, setSelectedTunnelForImport] = useState(null);
- const [importResults, setImportResults] = useState>([]);
-
+ const [selectedTunnelForImport, setSelectedTunnelForImport] = useState<
+ number | null
+ >(null);
+ const [importResults, setImportResults] = useState<
+ Array<{
+ line: string;
+ success: boolean;
+ message: string;
+ forwardName?: string;
+ }>
+ >([]);
+
// 表单状态
const [form, setForm] = useState({
- name: '',
+ name: "",
tunnelId: null,
inPort: null,
- remoteAddr: '',
- interfaceName: '',
- strategy: 'fifo'
+ remoteAddr: "",
+ interfaceName: "",
+ strategy: "fifo",
});
-
+
// 表单验证错误
- const [errors, setErrors] = useState<{[key: string]: string}>({});
+ const [errors, setErrors] = useState<{ [key: string]: string }>({});
useEffect(() => {
loadData();
@@ -208,45 +220,54 @@ export default function ForwardPage() {
// 切换显示模式并保存到localStorage
const handleViewModeChange = () => {
- const newMode = viewMode === 'grouped' ? 'direct' : 'grouped';
+ const newMode = viewMode === "grouped" ? "direct" : "grouped";
+
setViewMode(newMode);
try {
- localStorage.setItem('forward-view-mode', newMode);
-
+ localStorage.setItem("forward-view-mode", newMode);
+
// 切换到直接显示模式时,初始化拖拽排序顺序
- if (newMode === 'direct') {
+ if (newMode === "direct") {
// 在平铺模式下,只对当前用户的转发进行排序
const currentUserId = JwtUtil.getUserIdFromToken();
let userForwards = forwards;
+
if (currentUserId !== null) {
- userForwards = forwards.filter((f: Forward) => f.userId === currentUserId);
+ userForwards = forwards.filter(
+ (f: Forward) => f.userId === currentUserId,
+ );
}
-
+
// 检查数据库中是否有排序信息
- const hasDbOrdering = userForwards.some((f: Forward) => f.inx !== undefined && f.inx !== 0);
-
+ const hasDbOrdering = userForwards.some(
+ (f: Forward) => f.inx !== undefined && f.inx !== 0,
+ );
+
if (hasDbOrdering) {
// 使用数据库中的排序信息
const dbOrder = userForwards
.sort((a: Forward, b: Forward) => (a.inx ?? 0) - (b.inx ?? 0))
.map((f: Forward) => f.id);
+
setForwardOrder(dbOrder);
-
+
// 同步到localStorage
try {
- localStorage.setItem('forward-order', JSON.stringify(dbOrder));
+ localStorage.setItem("forward-order", JSON.stringify(dbOrder));
} catch (error) {
- console.warn('无法保存排序到localStorage:', error);
+ console.warn("无法保存排序到localStorage:", error);
}
} else {
// 使用本地存储的顺序
- const savedOrder = localStorage.getItem('forward-order');
+ const savedOrder = localStorage.getItem("forward-order");
+
if (savedOrder) {
try {
const orderIds = JSON.parse(savedOrder);
- const validOrder = orderIds.filter((id: number) =>
- userForwards.some((f: Forward) => f.id === id)
+ const validOrder = orderIds.filter((id: number) =>
+ userForwards.some((f: Forward) => f.id === id),
);
+
userForwards.forEach((forward: Forward) => {
if (!validOrder.includes(forward.id)) {
validOrder.push(forward.id);
@@ -262,7 +283,7 @@ export default function ForwardPage() {
}
}
} catch (error) {
- console.warn('无法保存显示模式到localStorage:', error);
+ console.warn("无法保存显示模式到localStorage:", error);
}
};
@@ -272,51 +293,61 @@ export default function ForwardPage() {
try {
const [forwardsRes, tunnelsRes] = await Promise.all([
getForwardList(),
- userTunnel()
+ userTunnel(),
]);
-
+
if (forwardsRes.code === 0) {
- const forwardsData = forwardsRes.data?.map((forward: any) => ({
- ...forward,
- serviceRunning: forward.status === 1
- })) || [];
+ const forwardsData =
+ forwardsRes.data?.map((forward: any) => ({
+ ...forward,
+ serviceRunning: forward.status === 1,
+ })) || [];
+
setForwards(forwardsData);
-
+
// 初始化拖拽排序顺序
- if (viewMode === 'direct') {
+ if (viewMode === "direct") {
// 在平铺模式下,只对当前用户的转发进行排序
const currentUserId = JwtUtil.getUserIdFromToken();
let userForwards = forwardsData;
+
if (currentUserId !== null) {
- userForwards = forwardsData.filter((f: Forward) => f.userId === currentUserId);
+ userForwards = forwardsData.filter(
+ (f: Forward) => f.userId === currentUserId,
+ );
}
-
+
// 检查数据库中是否有排序信息
- const hasDbOrdering = userForwards.some((f: Forward) => f.inx !== undefined && f.inx !== 0);
-
+ const hasDbOrdering = userForwards.some(
+ (f: Forward) => f.inx !== undefined && f.inx !== 0,
+ );
+
if (hasDbOrdering) {
// 使用数据库中的排序信息
const dbOrder = userForwards
.sort((a: Forward, b: Forward) => (a.inx ?? 0) - (b.inx ?? 0))
.map((f: Forward) => f.id);
+
setForwardOrder(dbOrder);
-
+
// 同步到localStorage
try {
- localStorage.setItem('forward-order', JSON.stringify(dbOrder));
+ localStorage.setItem("forward-order", JSON.stringify(dbOrder));
} catch (error) {
- console.warn('无法保存排序到localStorage:', error);
+ console.warn("无法保存排序到localStorage:", error);
}
} else {
// 使用本地存储的顺序
- const savedOrder = localStorage.getItem('forward-order');
+ const savedOrder = localStorage.getItem("forward-order");
+
if (savedOrder) {
try {
const orderIds = JSON.parse(savedOrder);
// 验证保存的顺序是否仍然有效(只包含当前用户的转发)
- const validOrder = orderIds.filter((id: number) =>
- userForwards.some((f: Forward) => f.id === id)
+ const validOrder = orderIds.filter((id: number) =>
+ userForwards.some((f: Forward) => f.id === id),
);
+
// 添加新的转发ID(如果存在)
userForwards.forEach((forward: Forward) => {
if (!validOrder.includes(forward.id)) {
@@ -333,17 +364,17 @@ export default function ForwardPage() {
}
}
} else {
- toast.error(forwardsRes.msg || '获取转发列表失败');
+ toast.error(forwardsRes.msg || "获取转发列表失败");
}
-
+
if (tunnelsRes.code === 0) {
setTunnels(tunnelsRes.data || []);
} else {
- console.warn('获取隧道列表失败:', tunnelsRes.msg);
+ console.warn("获取隧道列表失败:", tunnelsRes.msg);
}
} catch (error) {
- console.error('加载数据失败:', error);
- toast.error('加载数据失败');
+ console.error("加载数据失败:", error);
+ toast.error("加载数据失败");
} finally {
setLoading(false);
}
@@ -352,86 +383,104 @@ export default function ForwardPage() {
// 按用户和隧道分组转发数据
const groupForwardsByUserAndTunnel = (): UserGroup[] => {
const userMap = new Map();
-
+
// 获取排序后的转发列表
- sortedForwards.forEach(forward => {
- const userKey = forward.userId ? forward.userId.toString() : 'unknown';
- const userName = forward.userName || '未知用户';
-
+ sortedForwards.forEach((forward) => {
+ const userKey = forward.userId ? forward.userId.toString() : "unknown";
+ const userName = forward.userName || "未知用户";
+
if (!userMap.has(userKey)) {
userMap.set(userKey, {
userId: forward.userId || null,
userName,
- tunnelGroups: []
+ tunnelGroups: [],
});
}
-
+
const userGroup = userMap.get(userKey)!;
- let tunnelGroup = userGroup.tunnelGroups.find(tg => tg.tunnelId === forward.tunnelId);
-
+ let tunnelGroup = userGroup.tunnelGroups.find(
+ (tg) => tg.tunnelId === forward.tunnelId,
+ );
+
if (!tunnelGroup) {
tunnelGroup = {
tunnelId: forward.tunnelId,
tunnelName: forward.tunnelName,
- forwards: []
+ forwards: [],
};
userGroup.tunnelGroups.push(tunnelGroup);
}
-
+
tunnelGroup.forwards.push(forward);
});
-
+
// 排序:先按用户名,再按隧道名
const result = Array.from(userMap.values());
+
result.sort((a, b) => a.userName.localeCompare(b.userName));
- result.forEach(userGroup => {
- userGroup.tunnelGroups.sort((a, b) => a.tunnelName.localeCompare(b.tunnelName));
+ result.forEach((userGroup) => {
+ userGroup.tunnelGroups.sort((a, b) =>
+ a.tunnelName.localeCompare(b.tunnelName),
+ );
});
-
+
return result;
};
// 表单验证
const validateForm = (): boolean => {
- const newErrors: {[key: string]: string} = {};
-
+ const newErrors: { [key: string]: string } = {};
+
if (!form.name.trim()) {
- newErrors.name = '请输入转发名称';
+ newErrors.name = "请输入转发名称";
} else if (form.name.length < 2 || form.name.length > 50) {
- newErrors.name = '转发名称长度应在2-50个字符之间';
+ newErrors.name = "转发名称长度应在2-50个字符之间";
}
-
+
if (!form.tunnelId) {
- newErrors.tunnelId = '请选择关联隧道';
+ newErrors.tunnelId = "请选择关联隧道";
}
-
+
// 验证入口端口(可选,如果填写则验证)
if (form.inPort !== null && form.inPort !== undefined) {
const port = Number(form.inPort);
+
if (isNaN(port) || port < 1 || port > 65535) {
- newErrors.inPort = '端口必须在 1-65535 之间';
+ newErrors.inPort = "端口必须在 1-65535 之间";
}
}
-
+
if (!form.remoteAddr.trim()) {
- newErrors.remoteAddr = '请输入远程地址';
+ 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+$/;
-
+ 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)) {
+
+ if (
+ !ipv4Pattern.test(addr) &&
+ !ipv6FullPattern.test(addr) &&
+ !domainPattern.test(addr)
+ ) {
newErrors.remoteAddr = `第${i + 1}行地址格式错误`;
break;
}
}
}
-
+
setErrors(newErrors);
+
return Object.keys(newErrors).length === 0;
};
@@ -439,12 +488,12 @@ export default function ForwardPage() {
const handleAdd = () => {
setIsEdit(false);
setForm({
- name: '',
+ name: "",
tunnelId: null,
inPort: null,
- remoteAddr: '',
- interfaceName: '',
- strategy: 'fifo'
+ remoteAddr: "",
+ interfaceName: "",
+ strategy: "fifo",
});
setErrors({});
setModalOpen(true);
@@ -459,9 +508,9 @@ export default function ForwardPage() {
name: forward.name,
tunnelId: forward.tunnelId,
inPort: forward.inPort,
- remoteAddr: forward.remoteAddr.split(',').join('\n'),
- interfaceName: forward.interfaceName || '',
- strategy: forward.strategy || 'fifo'
+ remoteAddr: forward.remoteAddr.split(",").join("\n"),
+ interfaceName: forward.interfaceName || "",
+ strategy: forward.strategy || "fifo",
});
setErrors({});
setModalOpen(true);
@@ -476,31 +525,36 @@ export default function ForwardPage() {
// 确认删除转发
const confirmDelete = async () => {
if (!forwardToDelete) return;
-
+
setDeleteLoading(true);
try {
const res = await deleteForward(forwardToDelete.id);
+
if (res.code === 0) {
- toast.success('删除成功');
+ toast.success("删除成功");
setDeleteModalOpen(false);
loadData();
} else {
// 删除失败,询问是否强制删除
- const confirmed = window.confirm(`常规删除失败:${res.msg || '删除失败'}\n\n是否需要强制删除?\n\n⚠️ 注意:强制删除不会去验证节点端是否已经删除对应的转发服务。`);
+ const confirmed = window.confirm(
+ `常规删除失败:${res.msg || "删除失败"}\n\n是否需要强制删除?\n\n⚠️ 注意:强制删除不会去验证节点端是否已经删除对应的转发服务。`,
+ );
+
if (confirmed) {
const forceRes = await forceDeleteForward(forwardToDelete.id);
+
if (forceRes.code === 0) {
- toast.success('强制删除成功');
+ toast.success("强制删除成功");
setDeleteModalOpen(false);
loadData();
} else {
- toast.error(forceRes.msg || '强制删除失败');
+ toast.error(forceRes.msg || "强制删除失败");
}
}
}
} catch (error) {
- console.error('删除失败:', error);
- toast.error('删除失败');
+ console.error("删除失败:", error);
+ toast.error("删除失败");
} finally {
setDeleteLoading(false);
}
@@ -508,24 +562,25 @@ export default function ForwardPage() {
// 处理隧道选择变化
const handleTunnelChange = (tunnelId: string) => {
- setForm(prev => ({ ...prev, tunnelId: parseInt(tunnelId) }));
+ 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(',');
+ .split("\n")
+ .map((addr) => addr.trim())
+ .filter((addr) => addr)
+ .join(",");
+
+ const addressCount = processedRemoteAddr.split(",").length;
- const addressCount = processedRemoteAddr.split(',').length;
-
let res;
+
if (isEdit) {
// 更新时确保包含必要字段
const updateData = {
@@ -535,8 +590,9 @@ export default function ForwardPage() {
tunnelId: form.tunnelId,
inPort: form.inPort,
remoteAddr: processedRemoteAddr,
- strategy: addressCount > 1 ? form.strategy : 'fifo'
+ strategy: addressCount > 1 ? form.strategy : "fifo",
};
+
res = await updateForward(updateData);
} else {
// 创建时不需要id和userId(后端会自动设置)
@@ -545,21 +601,22 @@ export default function ForwardPage() {
tunnelId: form.tunnelId,
inPort: form.inPort,
remoteAddr: processedRemoteAddr,
- strategy: addressCount > 1 ? form.strategy : 'fifo'
+ strategy: addressCount > 1 ? form.strategy : "fifo",
};
+
res = await createForward(createData);
}
-
+
if (res.code === 0) {
- toast.success(isEdit ? '修改成功' : '创建成功');
+ toast.success(isEdit ? "修改成功" : "创建成功");
setModalOpen(false);
loadData();
} else {
- toast.error(res.msg || '操作失败');
+ toast.error(res.msg || "操作失败");
}
} catch (error) {
- console.error('提交失败:', error);
- toast.error('操作失败');
+ console.error("提交失败:", error);
+ toast.error("操作失败");
} finally {
setSubmitLoading(false);
}
@@ -568,53 +625,55 @@ export default function ForwardPage() {
// 处理服务开关
const handleServiceToggle = async (forward: Forward) => {
if (forward.status !== 1 && forward.status !== 0) {
- toast.error('转发状态异常,无法操作');
+ toast.error("转发状态异常,无法操作");
+
return;
}
const targetState = !forward.serviceRunning;
-
+
try {
// 乐观更新UI
- setForwards(prev => prev.map(f =>
- f.id === forward.id
- ? { ...f, serviceRunning: targetState }
- : f
- ));
+ 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 ? '服务已启动' : '服务已暂停');
+ toast.success(targetState ? "服务已启动" : "服务已暂停");
// 更新转发状态
- setForwards(prev => prev.map(f =>
- f.id === forward.id
- ? { ...f, status: targetState ? 1 : 0 }
- : f
- ));
+ 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 || '操作失败');
+ 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('网络错误,操作失败');
+ setForwards((prev) =>
+ prev.map((f) =>
+ f.id === forward.id ? { ...f, serviceRunning: !targetState } : f,
+ ),
+ );
+ console.error("服务开关操作失败:", error);
+ toast.error("网络错误,操作失败");
}
};
@@ -627,37 +686,42 @@ export default function ForwardPage() {
try {
const response = await diagnoseForward(forward.id);
+
if (response.code === 0) {
setDiagnosisResult(response.data);
} else {
- toast.error(response.msg || '诊断失败');
+ toast.error(response.msg || "诊断失败");
setDiagnosisResult({
forwardName: forward.name,
timestamp: Date.now(),
- results: [{
- success: false,
- description: '诊断失败',
- nodeName: '-',
- nodeId: '-',
- targetIp: forward.remoteAddr.split(',')[0] || '-',
- message: response.msg || '诊断过程中发生错误'
- }]
+ results: [
+ {
+ success: false,
+ description: "诊断失败",
+ nodeName: "-",
+ nodeId: "-",
+ targetIp: forward.remoteAddr.split(",")[0] || "-",
+ message: response.msg || "诊断过程中发生错误",
+ },
+ ],
});
}
} catch (error) {
- console.error('诊断失败:', error);
- toast.error('网络错误,请重试');
+ console.error("诊断失败:", error);
+ toast.error("网络错误,请重试");
setDiagnosisResult({
forwardName: forward.name,
timestamp: Date.now(),
- results: [{
- success: false,
- description: '网络错误',
- nodeName: '-',
- nodeId: '-',
- targetIp: forward.remoteAddr.split(',')[0] || '-',
- message: '无法连接到服务器'
- }]
+ results: [
+ {
+ success: false,
+ description: "网络错误",
+ nodeName: "-",
+ nodeId: "-",
+ targetIp: forward.remoteAddr.split(",")[0] || "-",
+ message: "无法连接到服务器",
+ },
+ ],
});
} finally {
setDiagnosisLoading(false);
@@ -667,106 +731,140 @@ export default function ForwardPage() {
// 获取连接质量
const getQualityDisplay = (averageTime?: number, packetLoss?: number) => {
if (averageTime === undefined || packetLoss === undefined) return null;
-
- if (averageTime < 30 && packetLoss === 0) return { text: '🚀 优秀', color: 'success' };
- if (averageTime < 50 && packetLoss === 0) return { text: '✨ 很好', color: 'success' };
- if (averageTime < 100 && packetLoss < 1) return { text: '👍 良好', color: 'primary' };
- if (averageTime < 150 && packetLoss < 2) return { text: '😐 一般', color: 'warning' };
- if (averageTime < 200 && packetLoss < 5) return { text: '😟 较差', color: 'warning' };
- return { text: '😵 很差', color: 'danger' };
+
+ if (averageTime < 30 && packetLoss === 0)
+ return { text: "🚀 优秀", color: "success" };
+ if (averageTime < 50 && packetLoss === 0)
+ return { text: "✨ 很好", color: "success" };
+ if (averageTime < 100 && packetLoss < 1)
+ return { text: "👍 良好", color: "primary" };
+ if (averageTime < 150 && packetLoss < 2)
+ return { text: "😐 一般", color: "warning" };
+ if (averageTime < 200 && packetLoss < 5)
+ return { text: "😟 较差", color: "warning" };
+
+ return { text: "😵 很差", color: "danger" };
};
// 格式化流量
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';
+ 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) return '';
-
- const items = ipString.split(',').map(item => item.trim()).filter(item => item);
- if (items.length === 0) return '';
-
+ 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 (!port) return "";
+
if (items.length === 1) {
const ip = items[0];
- if (ip.includes(':') && !ip.startsWith('[')) {
+
+ if (ip.includes(":") && !ip.startsWith("[")) {
return `[${ip}]:${port}`;
} else {
return `${ip}:${port}`;
}
}
-
+
const firstIp = items[0];
let formattedFirstIp;
- if (firstIp.includes(':') && !firstIp.startsWith('[')) {
+
+ if (firstIp.includes(":") && !firstIp.startsWith("[")) {
formattedFirstIp = `[${firstIp}]`;
} else {
formattedFirstIp = firstIp;
}
-
+
return `${formattedFirstIp}:${port} (+${items.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 (!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);
+ const addresses = addressString
+ .split(",")
+ .map((addr) => addr.trim())
+ .filter((addr) => addr);
+
return addresses.length > 1;
};
// 显示地址列表弹窗
- const showAddressModal = (addressString: string, port: number | null, title: string) => {
+ const showAddressModal = (
+ addressString: string,
+ port: number | null,
+ title: string,
+ ) => {
if (!addressString) return;
-
+
let addresses: string[];
+
if (port !== null) {
// 入口地址处理
- const items = addressString.split(',').map(item => item.trim()).filter(item => item);
+ const items = addressString
+ .split(",")
+ .map((item) => item.trim())
+ .filter((item) => item);
+
if (items.length <= 1) {
copyToClipboard(formatInAddress(addressString, port), title);
+
return;
}
-
+
// 检查是否已经包含端口
const hasPort = /:\d+$/.test(items[0]);
+
if (hasPort) {
// 已经包含完整的 IP:Port 组合,直接使用
addresses = items;
} else {
// 只包含IP,需要添加端口
- addresses = items.map(ip => {
- if (ip.includes(':') && !ip.startsWith('[')) {
+ addresses = items.map((ip) => {
+ if (ip.includes(":") && !ip.startsWith("[")) {
return `[${ip}]:${port}`;
} else {
return `${ip}:${port}`;
@@ -775,104 +873,123 @@ export default function ForwardPage() {
}
} else {
// 远程地址处理
- addresses = addressString.split(',').map(addr => addr.trim()).filter(addr => addr);
+ 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
- })));
+
+ setAddressList(
+ addresses.map((address, index) => ({
+ id: index,
+ address,
+ copying: false,
+ })),
+ );
setAddressModalTitle(`${title} (${addresses.length}个)`);
setAddressModalOpen(true);
};
// 复制到剪贴板
- const copyToClipboard = async (text: string, label: string = '内容') => {
+ const copyToClipboard = async (text: string, label: string = "内容") => {
try {
await navigator.clipboard.writeText(text);
toast.success(`已复制${label}`);
} catch (error) {
- toast.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, '地址');
+ setAddressList((prev) =>
+ prev.map((item) =>
+ item.id === addressItem.id ? { ...item, copying: true } : item,
+ ),
+ );
+ await copyToClipboard(addressItem.address, "地址");
} catch (error) {
- toast.error('复制失败');
+ toast.error("复制失败");
} finally {
- setAddressList(prev => prev.map(item =>
- item.id === addressItem.id ? { ...item, copying: false } : item
- ));
+ 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 allAddresses = addressList.map((item) => item.address).join("\n");
+
+ await copyToClipboard(allAddresses, "所有地址");
};
- // 导出转发数据
+ // 导出转发数据
const handleExport = () => {
setSelectedTunnelForExport(null);
- setExportData('');
+ setExportData("");
setExportModalOpen(true);
};
// 执行导出
const executeExport = () => {
if (!selectedTunnelForExport) {
- toast.error('请选择要导出的隧道');
+ toast.error("请选择要导出的隧道");
+
return;
}
setExportLoading(true);
-
+
try {
// 根据当前显示模式获取要导出的转发列表
let forwardsToExport: Forward[] = [];
-
- if (viewMode === 'grouped') {
+
+ if (viewMode === "grouped") {
// 分组模式下,获取指定隧道的转发
const userGroups = groupForwardsByUserAndTunnel();
- forwardsToExport = userGroups.flatMap(userGroup =>
+
+ forwardsToExport = userGroups.flatMap((userGroup) =>
userGroup.tunnelGroups
- .filter(tunnelGroup => tunnelGroup.tunnelId === selectedTunnelForExport)
- .flatMap(tunnelGroup => tunnelGroup.forwards)
+ .filter(
+ (tunnelGroup) => tunnelGroup.tunnelId === selectedTunnelForExport,
+ )
+ .flatMap((tunnelGroup) => tunnelGroup.forwards),
);
} else {
// 直接显示模式下,过滤指定隧道的转发
- forwardsToExport = sortedForwards.filter(forward => forward.tunnelId === selectedTunnelForExport);
+ forwardsToExport = sortedForwards.filter(
+ (forward) => forward.tunnelId === selectedTunnelForExport,
+ );
}
-
+
if (forwardsToExport.length === 0) {
- toast.error('所选隧道没有转发数据');
+ toast.error("所选隧道没有转发数据");
setExportLoading(false);
+
return;
}
-
+
// 格式化导出数据:remoteAddr|name|inPort
- const exportLines = forwardsToExport.map(forward => {
+ const exportLines = forwardsToExport.map((forward) => {
return `${forward.remoteAddr}|${forward.name}|${forward.inPort}`;
});
-
- const exportText = exportLines.join('\n');
+
+ const exportText = exportLines.join("\n");
+
setExportData(exportText);
} catch (error) {
- console.error('导出失败:', error);
- toast.error('导出失败');
+ console.error("导出失败:", error);
+ toast.error("导出失败");
} finally {
setExportLoading(false);
}
@@ -880,12 +997,12 @@ export default function ForwardPage() {
// 复制导出数据
const copyExportData = async () => {
- await copyToClipboard(exportData, '转发数据');
+ await copyToClipboard(exportData, "转发数据");
};
// 导入转发数据
const handleImport = () => {
- setImportData('');
+ setImportData("");
setImportResults([]);
setSelectedTunnelForImport(null);
setImportModalOpen(true);
@@ -894,12 +1011,14 @@ export default function ForwardPage() {
// 执行导入
const executeImport = async () => {
if (!importData.trim()) {
- toast.error('请输入要导入的数据');
+ toast.error("请输入要导入的数据");
+
return;
}
if (!selectedTunnelForImport) {
- toast.error('请选择要导入的隧道');
+ toast.error("请选择要导入的隧道");
+
return;
}
@@ -907,57 +1026,77 @@ export default function ForwardPage() {
setImportResults([]); // 清空之前的结果
try {
- const lines = importData.trim().split('\n').filter(line => line.trim());
-
+ const lines = importData
+ .trim()
+ .split("\n")
+ .filter((line) => line.trim());
+
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
- const parts = line.split('|');
-
+ const parts = line.split("|");
+
if (parts.length < 2) {
- setImportResults(prev => [{
- line,
- success: false,
- message: '格式错误:需要至少包含目标地址和转发名称'
- }, ...prev]);
+ setImportResults((prev) => [
+ {
+ line,
+ success: false,
+ message: "格式错误:需要至少包含目标地址和转发名称",
+ },
+ ...prev,
+ ]);
continue;
}
const [remoteAddr, name, inPort] = parts;
-
+
if (!remoteAddr.trim() || !name.trim()) {
- setImportResults(prev => [{
- line,
- success: false,
- message: '目标地址和转发名称不能为空'
- }, ...prev]);
+ setImportResults((prev) => [
+ {
+ line,
+ success: false,
+ message: "目标地址和转发名称不能为空",
+ },
+ ...prev,
+ ]);
continue;
}
// 验证远程地址格式 - 支持单个地址或多个地址用逗号分隔
- const addresses = remoteAddr.trim().split(',');
+ const addresses = remoteAddr.trim().split(",");
const addressPattern = /^[^:]+:\d+$/;
- const isValidFormat = addresses.every(addr => addressPattern.test(addr.trim()));
-
+ const isValidFormat = addresses.every((addr) =>
+ addressPattern.test(addr.trim()),
+ );
+
if (!isValidFormat) {
- setImportResults(prev => [{
- line,
- success: false,
- message: '目标地址格式错误,应为 地址:端口 格式,多个地址用逗号分隔'
- }, ...prev]);
+ setImportResults((prev) => [
+ {
+ line,
+ success: false,
+ message:
+ "目标地址格式错误,应为 地址:端口 格式,多个地址用逗号分隔",
+ },
+ ...prev,
+ ]);
continue;
}
try {
// 处理入口端口
let portNumber: number | null = null;
+
if (inPort && inPort.trim()) {
const port = parseInt(inPort.trim());
+
if (isNaN(port) || port < 1 || port > 65535) {
- setImportResults(prev => [{
- line,
- success: false,
- message: '入口端口格式错误,应为1-65535之间的数字'
- }, ...prev]);
+ setImportResults((prev) => [
+ {
+ line,
+ success: false,
+ message: "入口端口格式错误,应为1-65535之间的数字",
+ },
+ ...prev,
+ ]);
continue;
}
portNumber = port;
@@ -969,40 +1108,48 @@ export default function ForwardPage() {
tunnelId: selectedTunnelForImport, // 使用用户选择的隧道
inPort: portNumber, // 使用指定端口或自动分配
remoteAddr: remoteAddr.trim(),
- strategy: 'fifo'
+ strategy: "fifo",
});
if (response.code === 0) {
- setImportResults(prev => [{
- line,
- success: true,
- message: '创建成功',
- forwardName: name.trim()
- }, ...prev]);
+ setImportResults((prev) => [
+ {
+ line,
+ success: true,
+ message: "创建成功",
+ forwardName: name.trim(),
+ },
+ ...prev,
+ ]);
} else {
- setImportResults(prev => [{
- line,
- success: false,
- message: response.msg || '创建失败'
- }, ...prev]);
+ setImportResults((prev) => [
+ {
+ line,
+ success: false,
+ message: response.msg || "创建失败",
+ },
+ ...prev,
+ ]);
}
} catch (error) {
- setImportResults(prev => [{
- line,
- success: false,
- message: '网络错误,创建失败'
- }, ...prev]);
+ setImportResults((prev) => [
+ {
+ line,
+ success: false,
+ message: "网络错误,创建失败",
+ },
+ ...prev,
+ ]);
}
}
-
-
+
toast.success(`导入执行完成`);
-
+
// 导入完成后刷新转发列表
await loadData(false);
} catch (error) {
- console.error('导入失败:', error);
- toast.error('导入过程中发生错误');
+ console.error("导入失败:", error);
+ toast.error("导入过程中发生错误");
} finally {
setImportLoading(false);
}
@@ -1012,89 +1159,103 @@ export default function ForwardPage() {
const getStatusDisplay = (status: number) => {
switch (status) {
case 1:
- return { color: 'success', text: '正常' };
+ return { color: "success", text: "正常" };
case 0:
- return { color: 'warning', text: '暂停' };
+ return { color: "warning", text: "暂停" };
case -1:
- return { color: 'danger', text: '异常' };
+ return { color: "danger", text: "异常" };
default:
- return { color: 'default', text: '未知' };
+ 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: '随机' };
+ case "fifo":
+ return { color: "primary", text: "主备" };
+ case "round":
+ return { color: "success", text: "轮询" };
+ case "rand":
+ return { color: "warning", text: "随机" };
default:
- return { color: 'default', text: '未知' };
+ 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);
+ const addresses = addressString
+ .split("\n")
+ .map((addr) => addr.trim())
+ .filter((addr) => addr);
+
return addresses.length;
};
// 处理拖拽结束
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
-
+
if (!active || !over || active.id === over.id) return;
-
+
// 确保 forwardOrder 存在且有效
if (!forwardOrder || forwardOrder.length === 0) return;
-
+
const activeId = Number(active.id);
const overId = Number(over.id);
-
+
// 检查 ID 是否有效
if (isNaN(activeId) || isNaN(overId)) return;
-
+
const oldIndex = forwardOrder.indexOf(activeId);
const newIndex = forwardOrder.indexOf(overId);
-
+
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
const newOrder = arrayMove(forwardOrder, oldIndex, newIndex);
+
setForwardOrder(newOrder);
-
+
// 保存到localStorage
try {
- localStorage.setItem('forward-order', JSON.stringify(newOrder));
+ localStorage.setItem("forward-order", JSON.stringify(newOrder));
} catch (error) {
- console.warn('无法保存排序到localStorage:', error);
+ console.warn("无法保存排序到localStorage:", error);
}
-
+
// 持久化到数据库
try {
const forwardsToUpdate = newOrder.map((id, index) => ({
id,
- inx: index
+ inx: index,
}));
-
- const response = await updateForwardOrder({ forwards: forwardsToUpdate });
+
+ const response = await updateForwardOrder({
+ forwards: forwardsToUpdate,
+ });
+
if (response.code === 0) {
// 更新本地数据中的 inx 字段
- setForwards(prev => prev.map(forward => {
- const updatedForward = forwardsToUpdate.find(f => f.id === forward.id);
- if (updatedForward) {
- return { ...forward, inx: updatedForward.inx };
- }
- return forward;
- }));
+ setForwards((prev) =>
+ prev.map((forward) => {
+ const updatedForward = forwardsToUpdate.find(
+ (f) => f.id === forward.id,
+ );
+
+ if (updatedForward) {
+ return { ...forward, inx: updatedForward.inx };
+ }
+
+ return forward;
+ }),
+ );
} else {
- toast.error('保存排序失败:' + (response.msg || '未知错误'));
+ toast.error("保存排序失败:" + (response.msg || "未知错误"));
}
} catch (error) {
- console.error('保存排序到数据库失败:', error);
- toast.error('保存排序失败,请重试');
+ console.error("保存排序到数据库失败:", error);
+ toast.error("保存排序失败,请重试");
}
}
};
@@ -1114,7 +1275,7 @@ export default function ForwardPage() {
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
- })
+ }),
);
const tokenUserId = JwtUtil.getUserIdFromToken();
@@ -1128,9 +1289,12 @@ export default function ForwardPage() {
// 在平铺模式下,只显示当前用户的转发
let filteredForwards = forwards;
- if (viewMode === 'direct') {
+
+ if (viewMode === "direct") {
if (tokenUserId !== null) {
- filteredForwards = forwards.filter(forward => forward.userId === tokenUserId);
+ filteredForwards = forwards.filter(
+ (forward) => forward.userId === tokenUserId,
+ );
}
}
@@ -1143,23 +1307,29 @@ export default function ForwardPage() {
const sortedByDb = [...filteredForwards].sort((a, b) => {
const aInx = a.inx ?? 0;
const bInx = b.inx ?? 0;
+
return aInx - bInx;
});
// 如果数据库中没有排序信息,则使用本地存储的顺序
- if (forwardOrder && forwardOrder.length > 0 && sortedByDb.every(f => f.inx === undefined || f.inx === 0)) {
- const forwardMap = new Map(filteredForwards.map(f => [f.id, f]));
+ if (
+ forwardOrder &&
+ forwardOrder.length > 0 &&
+ sortedByDb.every((f) => f.inx === undefined || f.inx === 0)
+ ) {
+ const forwardMap = new Map(filteredForwards.map((f) => [f.id, f]));
const localSortedForwards: Forward[] = [];
- forwardOrder.forEach(id => {
+ forwardOrder.forEach((id) => {
const forward = forwardMap.get(id);
+
if (forward) {
localSortedForwards.push(forward);
}
});
// 添加不在排序列表中的转发(新添加的)
- filteredForwards.forEach(forward => {
+ filteredForwards.forEach((forward) => {
if (!forwardOrder.includes(forward.id)) {
localSortedForwards.push(forward);
}
@@ -1172,8 +1342,8 @@ export default function ForwardPage() {
}, [forwards, forwardOrder, viewMode, tokenUserId]);
const sortableForwardIds = useMemo(
- () => sortedForwards.map(f => f.id).filter(id => id > 0),
- [sortedForwards]
+ () => sortedForwards.map((f) => f.id).filter((id) => id > 0),
+ [sortedForwards],
);
// 可拖拽的转发卡片组件
@@ -1196,7 +1366,7 @@ export default function ForwardPage() {
transform: transform ? CSS.Transform.toString(transform) : undefined,
transition: isDragging ? undefined : transition || undefined,
opacity: isDragging ? 0.5 : 1,
- willChange: 'transform',
+ willChange: "transform",
};
return (
@@ -1210,93 +1380,128 @@ export default function ForwardPage() {
const renderForwardCard = (forward: Forward, listeners?: any) => {
const statusDisplay = getStatusDisplay(forward.status);
const strategyDisplay = getStrategyDisplay(forward.strategy);
-
+
return (
-
+
-
{forward.name}
-
{forward.tunnelName}
+
+ {forward.name}
+
+
+ {forward.tunnelName}
+
- {viewMode === 'direct' && (
-
)}
handleServiceToggle(forward)}
isDisabled={forward.status !== 1 && forward.status !== 0}
- />
- handleServiceToggle(forward)}
+ />
+
{statusDisplay.text}
-
+
{/* 地址信息 */}
-
showAddressModal(forward.inIp, forward.inPort, '入口端口')}
title={formatInAddress(forward.inIp, forward.inPort)}
+ onClick={() => showAddressModal(forward.inIp, forward.inPort, '入口端口')}
>
- 入口:
+
+ 入口:
+
{formatInAddress(forward.inIp, forward.inPort)}
{hasMultipleAddresses(forward.inIp) && (
-
-
-
showAddressModal(forward.remoteAddr, null, '目标地址')}
title={formatRemoteAddress(forward.remoteAddr)}
+ onClick={() => showAddressModal(forward.remoteAddr, null, '目标地址')}
>
- 目标:
+
+ 目标:
+
{formatRemoteAddress(forward.remoteAddr)}
{hasMultipleAddresses(forward.remoteAddr) && (
-
-
+
+
)}
@@ -1305,62 +1510,76 @@ export default function ForwardPage() {
{/* 统计信息 */}
-
+
{strategyDisplay.text}
-
+
↑{formatFlow(forward.inFlow || 0)}
-
-
- ↓{formatFlow(forward.outFlow || 0)}
-
+
+ ↓{formatFlow(forward.outFlow || 0)}
+
-
+
@@ -1372,270 +1591,334 @@ export default function ForwardPage() {
if (loading) {
return (
-
-
-
-
- 正在加载...
-
+
);
}
const userGroups = groupForwardsByUserAndTunnel();
return (
-
-
- {/* 页面头部 */}
-
-
-
-
- {/* 显示模式切换按钮 */}
-
-
- {/* 根据显示模式渲染不同内容 */}
- {viewMode === 'grouped' ? (
- /* 按用户和隧道分组的转发列表 */
- userGroups.length > 0 ? (
-
- {userGroups.map((userGroup) => (
-
-
-
-
-
-
-
{userGroup.userName}
-
- {userGroup.tunnelGroups.length} 个隧道,
- {userGroup.tunnelGroups.reduce((total, tg) => total + tg.forwards.length, 0)} 个转发
-
-
+ {/* 根据显示模式渲染不同内容 */}
+ {viewMode === "grouped" ? (
+ /* 按用户和隧道分组的转发列表 */
+ userGroups.length > 0 ? (
+
+ {userGroups.map((userGroup) => (
+
+
+
+
+
+
+
+ {userGroup.userName}
+
+
+ {userGroup.tunnelGroups.length} 个隧道,
+ {userGroup.tunnelGroups.reduce(
+ (total, tg) => total + tg.forwards.length,
+ 0,
+ )}{" "}
+ 个转发
+
-
- 用户
-
-
-
-
-
- {userGroup.tunnelGroups.map((tunnelGroup) => (
-
-
-
-
-
{tunnelGroup.tunnelName}
-
+
+ 用户
+
+
+
+
+
+
+ {userGroup.tunnelGroups.map((tunnelGroup) => (
+
+
+
-
-
- {tunnelGroup.forwards.filter(f => f.serviceRunning).length}/{tunnelGroup.forwards.length}
-
+
+
+ {tunnelGroup.tunnelName}
+
- }
- className="shadow-none border border-divider"
- >
-
- {tunnelGroup.forwards.map((forward) => renderForwardCard(forward, undefined))}
+
+
+ {
+ tunnelGroup.forwards.filter(
+ (f) => f.serviceRunning,
+ ).length
+ }
+ /{tunnelGroup.forwards.length}
+
+
-
- ))}
-
-
-
- ))}
-
- ) : (
- /* 空状态 */
-
-
-
-
-
-
暂无转发配置
-
还没有创建任何转发配置,点击上方按钮开始创建
-
-
-
-
- )
+ }
+ className="shadow-none border border-divider"
+ >
+
+ {tunnelGroup.forwards.map((forward) =>
+ renderForwardCard(forward, undefined),
+ )}
+
+
+ ))}
+
+
+
+ ))}
+
) : (
- /* 直接显示模式 */
- forwards.length > 0 ? (
-
+
+
+
+
+
+ 暂无转发配置
+
+
+ 还没有创建任何转发配置,点击上方按钮开始创建
+
+
+
+
+
+ )
+ ) : /* 直接显示模式 */
+ forwards.length > 0 ? (
+
{}} // 添加空的 onDragStart 处理器
- >
-
-
- {sortedForwards.map((forward) => (
- forward && forward.id ? (
-
- ) : null
- ))}
-
-
-
- ) : (
- /* 空状态 */
-
-
-
-
-
-
暂无转发配置
-
还没有创建任何转发配置,点击上方按钮开始创建
-
-
-
-
- )
- )}
-
- {/* 新增/编辑模态框 */}
-
-
- {(onClose) => (
- <>
-
-
- {isEdit ? '编辑转发' : '新增转发'}
-
-
- {isEdit ? '修改现有转发配置的信息' : '创建新的转发配置'}
-
-
-
-
-
+
+ {sortedForwards.map((forward) =>
+ forward && forward.id ? (
+
+ ) : null,
+ )}
+
+
+
+ ) : (
+ /* 空状态 */
+
+
+
+
+
+
+ 暂无转发配置
+
+
+ 还没有创建任何转发配置,点击上方按钮开始创建
+
+
+
+
+
+ )}
+
+ {/* 新增/编辑模态框 */}
+
+
+ {(onClose) => (
+ <>
+
+
+ {isEdit ? "编辑转发" : "新增转发"}
+
+
+ {isEdit ? "修改现有转发配置的信息" : "创建新的转发配置"}
+
+
+
+
+ setForm(prev => ({ ...prev, name: e.target.value }))}
- isInvalid={!!errors.name}
- errorMessage={errors.name}
variant="bordered"
- />
-
-
- {
const value = e.target.value;
setForm(prev => ({
@@ -1643,771 +1926,972 @@ export default function ForwardPage() {
inPort: value ? parseInt(value) : null
}));
}}
- isInvalid={!!errors.inPort}
- errorMessage={errors.inPort}
- variant="bordered"
- description="指定入口端口,留空则从节点可用端口中自动分配"
- />
-
-
-
-
-
- 取消
-
-
- {isEdit ? '保存修改' : '创建转发'}
-
-
- >
- )}
-
-
-
- {/* 删除确认模态框 */}
-
-
- {(onClose) => (
- <>
-
- 确认删除
-
-
-
- 确定要删除转发 "{forwardToDelete?.name}" 吗?
-
-
- 此操作无法撤销,删除后该转发将永久消失。
-
-
-
-
- 取消
-
-
- 确认删除
-
-
- >
- )}
-
-
-
- {/* 地址列表弹窗 */}
-
setAddressModalOpen(false)} size="lg" scrollBehavior="outside">
-
- {addressModalTitle}
-
-
-
- 复制
-
-
-
-
- {addressList.map((item) => (
-
- {item.address}
- copyAddress(item)}
>
- 复制
-
-
- ))}
-
-
-
-
+
主备模式 - 自上而下
+
轮询模式 - 依次轮换
+
随机模式 - 随机选择
+
哈希模式 - IP哈希
+
+ )}
+
+
+
+
+ 取消
+
+
+ {isEdit ? "保存修改" : "创建转发"}
+
+
+ >
+ )}
+
+
- {/* 导出数据模态框 */}
-
{
- setExportModalOpen(false);
- setSelectedTunnelForExport(null);
- setExportData('');
- }}
-
- size="2xl"
+ {/* 删除确认模态框 */}
+
+
+ {(onClose) => (
+ <>
+
+ 确认删除
+
+
+
+ 确定要删除转发{" "}
+
+ "{forwardToDelete?.name}"
+ {" "}
+ 吗?
+
+
+ 此操作无法撤销,删除后该转发将永久消失。
+
+
+
+
+ 取消
+
+
+ 确认删除
+
+
+ >
+ )}
+
+
+
+ {/* 地址列表弹窗 */}
+ setAddressModalOpen(false)}
+ size="lg"
+ scrollBehavior="outside"
+ >
+
+ {addressModalTitle}
+
+
+
+ 复制
+
+
+
+
+ {addressList.map((item) => (
+
+
+ {item.address}
+
+ copyAddress(item)}
+ >
+ 复制
+
+
+ ))}
+
+
+
+
+
+ {/* 导出数据模态框 */}
+ {
+ setExportModalOpen(false);
+ setSelectedTunnelForExport(null);
+ setExportData("");
+ }}
+ size="2xl"
scrollBehavior="outside"
backdrop="blur"
placement="center"
- >
-
-
- 导出转发数据
-
- 格式:目标地址|转发名称|入口端口
-
-
-
-
- {/* 隧道选择 */}
-
-
+
+
+ 导出转发数据
+
+ 格式:目标地址|转发名称|入口端口
+
+
+
+
+ {/* 隧道选择 */}
+
+ {
const selectedKey = Array.from(keys)[0] as string;
setSelectedTunnelForExport(selectedKey ? parseInt(selectedKey) : null);
}}
- variant="bordered"
- isRequired
+ >
+ {tunnels.map((tunnel) => (
+
+ {tunnel.name}
+
+ ))}
+
+
+
+ {/* 导出按钮和数据 */}
+ {exportData && (
+
+
+
+
+ }
>
- {tunnels.map((tunnel) => (
-
- {tunnel.name}
-
- ))}
-
+ 重新生成
+
+
+
+
+
+ }
+ >
+ 复制
+
+ )}
- {/* 导出按钮和数据 */}
- {exportData && (
-
-
-
-
- }
- >
- 重新生成
-
-
-
-
-
- }
- >
- 复制
-
-
- )}
+ {/* 初始导出按钮 */}
+ {!exportData && (
+
+
+
+
+ }
+ >
+ 生成导出数据
+
+
+ )}
- {/* 初始导出按钮 */}
- {!exportData && (
-
-
-
-
- }
- >
- 生成导出数据
-
-
- )}
-
- {/* 导出数据显示 */}
- {exportData && (
-
-
- )}
-
-
-
- setExportModalOpen(false)}
- >
- 关闭
-
-
-
-
+ value={exportData}
+ variant="bordered"
+ />
+
+ )}
+
+
+
+ setExportModalOpen(false)}>
+ 关闭
+
+
+
+
- {/* 导入数据模态框 */}
- setImportModalOpen(false)}
-
- size="2xl"
+ {/* 导入数据模态框 */}
+ setImportModalOpen(false)}
+ size="2xl"
scrollBehavior="outside"
backdrop="blur"
placement="center"
- >
-
-
- 导入转发数据
-
- 格式:目标地址|转发名称|入口端口,每行一个,入口端口留空将自动分配可用端口
-
-
- 目标地址支持单个地址(如:example.com:8080)或多个地址用逗号分隔(如:3.3.3.3:3,4.4.4.4:4)
-
-
-
-
- {/* 隧道选择 */}
-
-
+
+
+ 导入转发数据
+
+ 格式:目标地址|转发名称|入口端口,每行一个,入口端口留空将自动分配可用端口
+
+
+ 目标地址支持单个地址(如:example.com:8080)或多个地址用逗号分隔(如:3.3.3.3:3,4.4.4.4:4)
+
+
+
+
+ {/* 隧道选择 */}
+
+ {
const selectedKey = Array.from(keys)[0] as string;
setSelectedTunnelForImport(selectedKey ? parseInt(selectedKey) : null);
}}
- variant="bordered"
- isRequired
- >
- {tunnels.map((tunnel) => (
-
- {tunnel.name}
-
- ))}
-
-
+ >
+ {tunnels.map((tunnel) => (
+
+ {tunnel.name}
+
+ ))}
+
+
- {/* 输入区域 */}
-
-
setImportData(e.target.value)}
- variant="flat"
- minRows={8}
- maxRows={12}
- classNames={{
+ {/* 输入区域 */}
+
+
+ label="导入数据"
+ maxRows={12}
+ minRows={8}
+ placeholder="请输入要导入的转发数据,格式:目标地址|转发名称|入口端口"
+ value={importData}
+ variant="flat"
+ onChange={(e) => setImportData(e.target.value)}
+ />
+
-
-
-
- {/* 导入结果 */}
- {importResults.length > 0 && (
-
-
-
导入结果
-
-
- 成功:{importResults.filter(r => r.success).length} /
- 总计:{importResults.length}
-
-
+ {/* 导入结果 */}
+ {importResults.length > 0 && (
+
+
+
导入结果
+
+
+ 成功:{importResults.filter((r) => r.success).length} /
+ 总计:{importResults.length}
+
-
-
- {importResults.map((result, index) => (
-
-
- {result.success ? (
-
-
-
- ) : (
-
-
-
- )}
-
-
-
- {result.success ? '成功' : '失败'}
-
- |
- {result.line}
-
-
- {result.message}
-
+
+
+
+ {importResults.map((result, index) => (
+
+
+ {result.success ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+ {result.success ? "成功" : "失败"}
+
+
+ |
+
+
+ {result.line}
+
+
+
+ {result.message}
- ))}
-
+
+ ))}
+
+
+ )}
+
+
+
+ setImportModalOpen(false)}>
+ 关闭
+
+
+ 开始导入
+
+
+
+
+
+ {/* 诊断结果模态框 */}
+
+
+ {(onClose) => (
+ <>
+
+ 转发诊断结果
+ {currentDiagnosisForward && (
+
+
+ {currentDiagnosisForward.name}
+
+
+ 转发服务
+
)}
-
-
-
- setImportModalOpen(false)}
- >
- 关闭
-
-
- 开始导入
-
-
-
-
-
- {/* 诊断结果模态框 */}
-
-
- {(onClose) => (
- <>
-
- 转发诊断结果
- {currentDiagnosisForward && (
-
-
{currentDiagnosisForward.name}
-
- 转发服务
-
+
+
+ {diagnosisLoading ? (
+
+
+
+ 正在诊断...
- )}
-
-
- {diagnosisLoading ? (
-
- ) : diagnosisResult ? (
-
- {/* 统计摘要 */}
-
-
-
{diagnosisResult.results.length}
-
总测试数
+
+ ) : diagnosisResult ? (
+
+ {/* 统计摘要 */}
+
+
+
+ {diagnosisResult.results.length}
-
-
- {diagnosisResult.results.filter(r => r.success).length}
-
-
成功
-
-
-
- {diagnosisResult.results.filter(r => !r.success).length}
-
-
失败
+
+ 总测试数
+
+
+ {
+ diagnosisResult.results.filter((r) => r.success)
+ .length
+ }
+
+
+ 成功
+
+
+
+
+ {
+ diagnosisResult.results.filter((r) => !r.success)
+ .length
+ }
+
+
+ 失败
+
+
+
- {/* 桌面端表格展示 */}
-
- {(() => {
- // 使用后端返回的 chainType 和 inx 字段进行分组
- const groupedResults = {
- entry: diagnosisResult.results.filter(r => r.fromChainType === 1),
- chains: {} as Record
,
- exit: diagnosisResult.results.filter(r => r.fromChainType === 3)
- };
-
- // 按 inx 分组链路测试
- diagnosisResult.results.forEach(r => {
- if (r.fromChainType === 2 && r.fromInx != null) {
- if (!groupedResults.chains[r.fromInx]) {
- groupedResults.chains[r.fromInx] = [];
- }
- groupedResults.chains[r.fromInx].push(r);
+ {/* 桌面端表格展示 */}
+
+ {(() => {
+ // 使用后端返回的 chainType 和 inx 字段进行分组
+ const groupedResults = {
+ entry: diagnosisResult.results.filter(
+ (r) => r.fromChainType === 1,
+ ),
+ chains: {} as Record<
+ number,
+ typeof diagnosisResult.results
+ >,
+ exit: diagnosisResult.results.filter(
+ (r) => r.fromChainType === 3,
+ ),
+ };
+
+ // 按 inx 分组链路测试
+ diagnosisResult.results.forEach((r) => {
+ if (r.fromChainType === 2 && r.fromInx != null) {
+ if (!groupedResults.chains[r.fromInx]) {
+ groupedResults.chains[r.fromInx] = [];
}
- });
+ groupedResults.chains[r.fromInx].push(r);
+ }
+ });
- const renderTableSection = (title: string, results: typeof diagnosisResult.results) => {
- if (results.length === 0) return null;
-
- return (
-
-
-
{title}
-
-
-
-
- | 路径 |
- 状态 |
- 延迟(ms) |
- 丢包率 |
- 质量 |
-
-
-
- {results.map((result, index) => {
- const quality = getQualityDisplay(result.averageTime, result.packetLoss);
-
- return (
-
-
-
-
- {result.success ? '✓' : '✗'}
+ const renderTableSection = (
+ title: string,
+ results: typeof diagnosisResult.results,
+ ) => {
+ if (results.length === 0) return null;
+
+ return (
+
+
+
+ {title}
+
+
+
+
+
+ |
+ 路径
+ |
+
+ 状态
+ |
+
+ 延迟(ms)
+ |
+
+ 丢包率
+ |
+
+ 质量
+ |
+
+
+
+ {results.map((result, index) => {
+ const quality = getQualityDisplay(
+ result.averageTime,
+ result.packetLoss,
+ );
+
+ return (
+
+
+
+
+ {result.success ? "✓" : "✗"}
+
+
+
+ {result.description}
+
+
+ {result.targetIp}:
+ {result.targetPort}
+
+
+
+ |
+
+
+ {result.success ? "成功" : "失败"}
+
+ |
+
+ {result.success ? (
+
+ {result.averageTime?.toFixed(0)}
+
+ ) : (
+
+ -
+
+ )}
+ |
+
+ {result.success ? (
+ 0
+ ? "text-warning"
+ : "text-success"
+ }`}
+ >
+ {result.packetLoss?.toFixed(1)}%
+
+ ) : (
+
+ -
+
+ )}
+ |
+
+ {result.success && quality ? (
+
+ {quality.text}
+
+ ) : (
+
+ -
+
+ )}
+ |
+
+ );
+ })}
+
+
+
+ );
+ };
+
+ return (
+ <>
+ {/* 入口测试 */}
+ {renderTableSection(
+ "🚪 入口测试",
+ groupedResults.entry,
+ )}
+
+ {/* 链路测试(按跳数排序) */}
+ {Object.keys(groupedResults.chains)
+ .map(Number)
+ .sort((a, b) => a - b)
+ .map((hop) =>
+ renderTableSection(
+ `🔗 转发链 - 第${hop}跳`,
+ groupedResults.chains[hop],
+ ),
+ )}
+
+ {/* 出口测试 */}
+ {renderTableSection(
+ "🚀 出口测试",
+ groupedResults.exit,
+ )}
+ >
+ );
+ })()}
+
+
+ {/* 移动端卡片展示 */}
+
+ {(() => {
+ // 使用后端返回的 chainType 和 inx 字段进行分组
+ const groupedResults = {
+ entry: diagnosisResult.results.filter(
+ (r) => r.fromChainType === 1,
+ ),
+ chains: {} as Record<
+ number,
+ typeof diagnosisResult.results
+ >,
+ exit: diagnosisResult.results.filter(
+ (r) => r.fromChainType === 3,
+ ),
+ };
+
+ // 按 inx 分组链路测试
+ diagnosisResult.results.forEach((r) => {
+ if (r.fromChainType === 2 && r.fromInx != null) {
+ if (!groupedResults.chains[r.fromInx]) {
+ groupedResults.chains[r.fromInx] = [];
+ }
+ groupedResults.chains[r.fromInx].push(r);
+ }
+ });
+
+ const renderCardSection = (
+ title: string,
+ results: typeof diagnosisResult.results,
+ ) => {
+ if (results.length === 0) return null;
+
+ return (
+
+
+
+ {title}
+
+
+ {results.map((result, index) => {
+ const quality = getQualityDisplay(
+ result.averageTime,
+ result.packetLoss,
+ );
+
+ return (
+
+
+
+ {result.success ? "✓" : "✗"}
- {result.description}
-
+
+ {result.description}
+
+
{result.targetIp}:{result.targetPort}
-
- |
-
-
- {result.success ? '成功' : '失败'}
-
- |
-
- {result.success ? (
-
- {result.averageTime?.toFixed(0)}
-
- ) : (
- -
- )}
- |
-
- {result.success ? (
- 0 ? 'text-warning' : 'text-success'
- }`}>
- {result.packetLoss?.toFixed(1)}%
-
- ) : (
- -
- )}
- |
-
- {result.success && quality ? (
-
- {quality.text}
+ {result.success ? "成功" : "失败"}
- ) : (
- -
- )}
- |
-
- );
- })}
-
-
-
- );
- };
-
- return (
- <>
- {/* 入口测试 */}
- {renderTableSection('🚪 入口测试', groupedResults.entry)}
-
- {/* 链路测试(按跳数排序) */}
- {Object.keys(groupedResults.chains)
- .map(Number)
- .sort((a, b) => a - b)
- .map(hop => renderTableSection(`🔗 转发链 - 第${hop}跳`, groupedResults.chains[hop]))}
-
- {/* 出口测试 */}
- {renderTableSection('🚀 出口测试', groupedResults.exit)}
- >
- );
- })()}
-
-
- {/* 移动端卡片展示 */}
-
- {(() => {
- // 使用后端返回的 chainType 和 inx 字段进行分组
- const groupedResults = {
- entry: diagnosisResult.results.filter(r => r.fromChainType === 1),
- chains: {} as Record
,
- exit: diagnosisResult.results.filter(r => r.fromChainType === 3)
- };
-
- // 按 inx 分组链路测试
- diagnosisResult.results.forEach(r => {
- if (r.fromChainType === 2 && r.fromInx != null) {
- if (!groupedResults.chains[r.fromInx]) {
- groupedResults.chains[r.fromInx] = [];
- }
- groupedResults.chains[r.fromInx].push(r);
- }
- });
-
- const renderCardSection = (title: string, results: typeof diagnosisResult.results) => {
- if (results.length === 0) return null;
-
- return (
-
-
-
{title}
-
- {results.map((result, index) => {
- const quality = getQualityDisplay(result.averageTime, result.packetLoss);
-
- return (
-
-
-
- {result.success ? '✓' : '✗'}
-
-
-
- {result.description}
-
-
- {result.targetIp}:{result.targetPort}
-
-
-
- {result.success ? '成功' : '失败'}
-
-
-
- {result.success ? (
-
-
-
- {result.averageTime?.toFixed(0)}
-
-
延迟(ms)
-
-
-
0 ? 'text-warning' : 'text-success'
- }`}>
- {result.packetLoss?.toFixed(1)}%
-
-
丢包率
-
-
- {quality && (
- <>
-
- {quality.text}
-
-
质量
- >
- )}
-
-
- ) : (
-
-
- {result.message || '连接失败'}
-
-
- )}
- );
- })}
-
- );
- };
- return (
- <>
- {/* 入口测试 */}
- {renderCardSection('🚪 入口测试', groupedResults.entry)}
-
- {/* 链路测试(按跳数排序) */}
- {Object.keys(groupedResults.chains)
- .map(Number)
- .sort((a, b) => a - b)
- .map(hop => renderCardSection(`🔗 转发链 - 第${hop}跳`, groupedResults.chains[hop]))}
-
- {/* 出口测试 */}
- {renderCardSection('🚀 出口测试', groupedResults.exit)}
- >
+ {result.success ? (
+
+
+
+ {result.averageTime?.toFixed(0)}
+
+
+ 延迟(ms)
+
+
+
+
0
+ ? "text-warning"
+ : "text-success"
+ }`}
+ >
+ {result.packetLoss?.toFixed(1)}%
+
+
+ 丢包率
+
+
+
+ {quality && (
+ <>
+
+ {quality.text}
+
+
+ 质量
+
+ >
+ )}
+
+
+ ) : (
+
+
+ {result.message || "连接失败"}
+
+
+ )}
+
+ );
+ })}
+
);
- })()}
-
+ };
- {/* 失败详情(仅桌面端显示,移动端已在卡片中显示) */}
- {diagnosisResult.results.some(r => !r.success) && (
-
-
失败详情
-
- {diagnosisResult.results.filter(r => !r.success).map((result, index) => (
+ return (
+ <>
+ {/* 入口测试 */}
+ {renderCardSection(
+ "🚪 入口测试",
+ groupedResults.entry,
+ )}
+
+ {/* 链路测试(按跳数排序) */}
+ {Object.keys(groupedResults.chains)
+ .map(Number)
+ .sort((a, b) => a - b)
+ .map((hop) =>
+ renderCardSection(
+ `🔗 转发链 - 第${hop}跳`,
+ groupedResults.chains[hop],
+ ),
+ )}
+
+ {/* 出口测试 */}
+ {renderCardSection(
+ "🚀 出口测试",
+ groupedResults.exit,
+ )}
+ >
+ );
+ })()}
+
+
+ {/* 失败详情(仅桌面端显示,移动端已在卡片中显示) */}
+ {diagnosisResult.results.some((r) => !r.success) && (
+
+
+ 失败详情
+
+
+ {diagnosisResult.results
+ .filter((r) => !r.success)
+ .map((result, index) => (
))}
-
- )}
-
- ) : (
-
+ ) : (
+
+
- )}
-
-
-
- 关闭
+
+ 暂无诊断数据
+
+
+ )}
+
+
+
+ 关闭
+
+ {currentDiagnosisForward && (
+ handleDiagnose(currentDiagnosisForward)}
+ isLoading={diagnosisLoading}
+ >
+ 重新诊断
- {currentDiagnosisForward && (
- handleDiagnose(currentDiagnosisForward)}
- isLoading={diagnosisLoading}
- >
- 重新诊断
-
- )}
-
- >
- )}
-
-
-
-
+ )}
+
+ >
+ )}
+
+
+
);
-}
+}
diff --git a/vite-frontend/src/pages/index.tsx b/vite-frontend/src/pages/index.tsx
index 1446f09..6588303 100644
--- a/vite-frontend/src/pages/index.tsx
+++ b/vite-frontend/src/pages/index.tsx
@@ -3,10 +3,11 @@ import { Input } from "@heroui/input";
import { Card, CardBody, CardHeader } from "@heroui/card";
import { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
-import toast from 'react-hot-toast';
-import axios from 'axios';
-import { isWebViewFunc } from '@/utils/panel';
-import { siteConfig } from '@/config/site';
+import toast from "react-hot-toast";
+import axios from "axios";
+
+import { isWebViewFunc } from "@/utils/panel";
+import { siteConfig } from "@/config/site";
import { title } from "@/components/primitives";
import DefaultLayout from "@/layouts/default";
import { login, LoginData, checkCaptcha } from "@/api";
@@ -14,15 +15,12 @@ import "@/utils/tac.css";
import "@/utils/tac.min.js";
import bgImage from "@/images/bg.jpg";
-
interface LoginForm {
username: string;
password: string;
captchaId: string;
}
-
-
interface CaptchaConfig {
requestCaptchaDataUrl: string;
validCaptchaUrl: string;
@@ -54,6 +52,7 @@ export default function IndexPage() {
const tacInstanceRef = useRef
(null);
const captchaContainerRef = useRef(null);
const [isWebView, setIsWebView] = useState(false);
+
// 清理验证码实例
useEffect(() => {
return () => {
@@ -72,26 +71,26 @@ export default function IndexPage() {
const newErrors: Partial = {};
if (!form.username.trim()) {
- newErrors.username = '请输入用户名';
+ newErrors.username = "请输入用户名";
}
if (!form.password.trim()) {
- newErrors.password = '请输入密码';
+ newErrors.password = "请输入密码";
} else if (form.password.length < 6) {
- newErrors.password = '密码长度至少6位';
+ newErrors.password = "密码长度至少6位";
}
-
setErrors(newErrors);
+
return Object.keys(newErrors).length === 0;
};
// 处理输入变化
const handleInputChange = (field: keyof LoginForm, value: string) => {
- setForm(prev => ({ ...prev, [field]: value }));
+ setForm((prev) => ({ ...prev, [field]: value }));
// 清除该字段的错误
if (errors[field]) {
- setErrors(prev => ({ ...prev, [field]: undefined }));
+ setErrors((prev) => ({ ...prev, [field]: undefined }));
}
};
@@ -109,16 +108,18 @@ export default function IndexPage() {
}
// 使用axios的baseURL,确保在WebView中使用正确的面板地址
- const baseURL = axios.defaults.baseURL || (import.meta.env.VITE_API_BASE ? `${import.meta.env.VITE_API_BASE}/api/v1/` : '/api/v1/');
-
+ const baseURL =
+ axios.defaults.baseURL ||
+ (import.meta.env.VITE_API_BASE
+ ? `${import.meta.env.VITE_API_BASE}/api/v1/`
+ : "/api/v1/");
+
const config: CaptchaConfig = {
requestCaptchaDataUrl: `${baseURL}captcha/generate`,
- validCaptchaUrl: `${baseURL}captcha/verify`,
+ validCaptchaUrl: `${baseURL}captcha/verify`,
bindEl: "#captcha-container",
validSuccess: (res: any, _: any, tac: any) => {
-
-
- form.captchaId = res.data.validToken
+ form.captchaId = res.data.validToken;
setShowCaptcha(false);
tac.destroyWindow();
@@ -134,30 +135,30 @@ export default function IndexPage() {
},
btnRefreshFun: (_event: any, tac: any) => {
tac.reloadCaptcha();
- }
+ },
};
// 检测暗黑模式
- const isDarkMode = document.documentElement.classList.contains('dark') ||
- document.documentElement.getAttribute('data-theme') === 'dark' ||
- window.matchMedia('(prefers-color-scheme: dark)').matches;
-
+ const isDarkMode =
+ document.documentElement.classList.contains("dark") ||
+ document.documentElement.getAttribute("data-theme") === "dark" ||
+ window.matchMedia("(prefers-color-scheme: dark)").matches;
+
// 根据主题调整颜色
const trackColor = isDarkMode ? "#4a5568" : "#7db0be"; // 暗黑模式使用更深的灰蓝色
-
+
const style: CaptchaStyle = {
bgUrl: bgImage,
logoUrl: null,
moveTrackMaskBgColor: trackColor,
- moveTrackMaskBorderColor: trackColor
+ moveTrackMaskBorderColor: trackColor,
};
tacInstanceRef.current = new window.TAC(config, style);
tacInstanceRef.current.init();
-
} catch (error) {
- console.error('初始化验证码失败:', error);
- toast.error('验证码初始化失败,请刷新页面重试');
+ console.error("初始化验证码失败:", error);
+ toast.error("验证码初始化失败,请刷新页面重试");
setShowCaptcha(false);
setLoading(false);
}
@@ -165,8 +166,6 @@ export default function IndexPage() {
// 执行登录请求
const performLogin = async () => {
-
-
try {
const loginData: LoginData = {
username: form.username.trim(),
@@ -175,35 +174,36 @@ export default function IndexPage() {
};
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("token", response.data.token);
localStorage.setItem("role_id", response.data.role_id.toString());
localStorage.setItem("name", response.data.name);
localStorage.setItem("admin", (response.data.role_id === 0).toString());
- toast.success('检测到默认密码,即将跳转到修改密码页面');
+ toast.success("检测到默认密码,即将跳转到修改密码页面");
navigate("/change-password");
+
return;
}
// 保存登录信息
- localStorage.setItem('token', response.data.token);
+ localStorage.setItem("token", response.data.token);
localStorage.setItem("role_id", response.data.role_id.toString());
localStorage.setItem("name", response.data.name);
localStorage.setItem("admin", (response.data.role_id === 0).toString());
// 登录成功
- toast.success('登录成功');
+ toast.success("登录成功");
navigate("/dashboard");
-
} catch (error) {
- console.error('登录错误:', error);
+ console.error("登录错误:", error);
toast.error("网络错误,请稍后重试");
} finally {
setLoading(false);
@@ -218,10 +218,11 @@ export default function IndexPage() {
try {
// 先检查是否需要验证码
const checkResponse = await checkCaptcha();
-
+
if (checkResponse.code !== 0) {
toast.error("检查验证码状态失败,请重试" + checkResponse.msg);
setLoading(false);
+
return;
}
@@ -238,15 +239,14 @@ export default function IndexPage() {
}, 100);
}
} catch (error) {
- console.error('检查验证码状态错误:', error);
+ console.error("检查验证码状态错误:", error);
toast.error("网络错误,请稍后重试" + error);
setLoading(false);
}
};
-
const handleKeyPress = (e: React.KeyboardEvent) => {
- if (e.key === 'Enter' && !loading) {
+ if (e.key === "Enter" && !loading) {
handleLogin();
}
};
@@ -258,42 +258,47 @@ export default function IndexPage() {
登陆
- 请输入您的账号信息
+
+ 请输入您的账号信息
+
handleInputChange('username', e.target.value)}
- onKeyDown={handleKeyPress}
variant="bordered"
- isDisabled={loading}
- isInvalid={!!errors.username}
- errorMessage={errors.username}
+ onChange={(e) =>
+ handleInputChange("username", e.target.value)
+ }
+ onKeyDown={handleKeyPress}
/>
-
+
handleInputChange('password', e.target.value)}
- onKeyDown={handleKeyPress}
variant="bordered"
- isDisabled={loading}
- isInvalid={!!errors.password}
+ onChange={(e) =>
+ handleInputChange("password", e.target.value)
+ }
+ onKeyDown={handleKeyPress}
/>
-
{loading ? (showCaptcha ? "验证中..." : "登录中...") : "登录"}
@@ -302,47 +307,47 @@ export default function IndexPage() {
+ {/* 版权信息 - 固定在底部,不占据布局空间 */}
- {/* 版权信息 - 固定在底部,不占据布局空间 */}
-
-
-
- Powered by{' '}
-
- flux-panel
-
-
-
- v{ isWebView ? siteConfig.app_version : siteConfig.version}
-
-
-
-
+
+
+ Powered by{" "}
+
+ flux-panel
+
+
+
+ v{isWebView ? siteConfig.app_version : siteConfig.version}
+
+
{/* 验证码弹层 */}
{showCaptcha && (
{/* 背景遮罩层 - 模糊效果,暗黑模式下更深 */}
- {/* 验证码容器 */}
-
+ {/* 验证码容器 */}
+
)}
diff --git a/vite-frontend/src/pages/limit.tsx b/vite-frontend/src/pages/limit.tsx
index cff638b..4688463 100644
--- a/vite-frontend/src/pages/limit.tsx
+++ b/vite-frontend/src/pages/limit.tsx
@@ -3,18 +3,23 @@ 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 {
+ 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 toast from "react-hot-toast";
-
-import {
- createSpeedLimit,
- getSpeedLimitList,
- updateSpeedLimit,
- deleteSpeedLimit,
- getTunnelList
+import {
+ createSpeedLimit,
+ getSpeedLimitList,
+ updateSpeedLimit,
+ deleteSpeedLimit,
+ getTunnelList,
} from "@/api";
interface SpeedLimitRule {
@@ -46,7 +51,7 @@ export default function LimitPage() {
const [loading, setLoading] = useState(true);
const [rules, setRules] = useState([]);
const [tunnels, setTunnels] = useState([]);
-
+
// 模态框状态
const [modalOpen, setModalOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
@@ -54,18 +59,18 @@ export default function LimitPage() {
const [submitLoading, setSubmitLoading] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [ruleToDelete, setRuleToDelete] = useState(null);
-
+
// 表单状态
const [form, setForm] = useState({
- name: '',
+ name: "",
speed: 100,
tunnelId: null,
- tunnelName: '',
- status: 1
+ tunnelName: "",
+ status: 1,
});
-
+
// 表单验证错误
- const [errors, setErrors] = useState<{[key: string]: string}>({});
+ const [errors, setErrors] = useState<{ [key: string]: string }>({});
useEffect(() => {
loadData();
@@ -77,23 +82,23 @@ export default function LimitPage() {
try {
const [rulesRes, tunnelsRes] = await Promise.all([
getSpeedLimitList(),
- getTunnelList()
+ getTunnelList(),
]);
-
+
if (rulesRes.code === 0) {
setRules(rulesRes.data || []);
} else {
- toast.error(rulesRes.msg || '获取限速规则失败');
+ toast.error(rulesRes.msg || "获取限速规则失败");
}
-
+
if (tunnelsRes.code === 0) {
setTunnels(tunnelsRes.data || []);
} else {
- console.warn('获取隧道列表失败:', tunnelsRes.msg);
+ console.warn("获取隧道列表失败:", tunnelsRes.msg);
}
} catch (error) {
- console.error('加载数据失败:', error);
- toast.error('加载数据失败');
+ console.error("加载数据失败:", error);
+ toast.error("加载数据失败");
} finally {
setLoading(false);
}
@@ -101,23 +106,24 @@ export default function LimitPage() {
// 表单验证
const validateForm = (): boolean => {
- const newErrors: {[key: string]: string} = {};
-
+ const newErrors: { [key: string]: string } = {};
+
if (!form.name.trim()) {
- newErrors.name = '请输入规则名称';
+ newErrors.name = "请输入规则名称";
} else if (form.name.length < 2 || form.name.length > 50) {
- newErrors.name = '规则名称长度应在2-50个字符之间';
+ newErrors.name = "规则名称长度应在2-50个字符之间";
}
-
+
if (!form.speed || form.speed < 1) {
- newErrors.speed = '请输入有效的速度限制(≥1 Mbps)';
+ newErrors.speed = "请输入有效的速度限制(≥1 Mbps)";
}
-
+
if (!form.tunnelId) {
- newErrors.tunnelId = '请选择要绑定的隧道';
+ newErrors.tunnelId = "请选择要绑定的隧道";
}
-
+
setErrors(newErrors);
+
return Object.keys(newErrors).length === 0;
};
@@ -125,11 +131,11 @@ export default function LimitPage() {
const handleAdd = () => {
setIsEdit(false);
setForm({
- name: '',
+ name: "",
speed: 100,
tunnelId: null,
- tunnelName: '',
- status: 1
+ tunnelName: "",
+ status: 1,
});
setErrors({});
setModalOpen(true);
@@ -144,7 +150,7 @@ export default function LimitPage() {
speed: rule.speed,
tunnelId: rule.tunnelId,
tunnelName: rule.tunnelName,
- status: rule.status
+ status: rule.status,
});
setErrors({});
setModalOpen(true);
@@ -159,20 +165,21 @@ export default function LimitPage() {
// 确认删除规则
const confirmDelete = async () => {
if (!ruleToDelete) return;
-
+
setDeleteLoading(true);
try {
const res = await deleteSpeedLimit(ruleToDelete.id);
+
if (res.code === 0) {
- toast.success('删除成功');
+ toast.success("删除成功");
setDeleteModalOpen(false);
loadData();
} else {
- toast.error(res.msg || '删除失败');
+ toast.error(res.msg || "删除失败");
}
} catch (error) {
- console.error('删除失败:', error);
- toast.error('删除失败');
+ console.error("删除失败:", error);
+ toast.error("删除失败");
} finally {
setDeleteLoading(false);
}
@@ -181,27 +188,29 @@ export default function LimitPage() {
// 提交表单
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 ? '修改成功' : '创建成功');
+ toast.success(isEdit ? "修改成功" : "创建成功");
setModalOpen(false);
loadData();
} else {
- toast.error(res.msg || '操作失败');
+ toast.error(res.msg || "操作失败");
}
} catch (error) {
- console.error('提交失败:', error);
- toast.error('操作失败');
+ console.error("提交失败:", error);
+ toast.error("操作失败");
} finally {
setSubmitLoading(false);
}
@@ -209,269 +218,305 @@ export default function LimitPage() {
if (loading) {
return (
-
-
-
-
- 正在加载...
-
+
);
}
return (
-
-
- {/* 页面头部 */}
-
-
-
+
+ {/* 页面头部 */}
+
+
+ {/* 统一卡片网格 */}
+ {rules.length > 0 ? (
+
+ {rules.map((rule) => (
+
- 新增
-
-
-
- {/* 统一卡片网格 */}
- {rules.length > 0 ? (
-
- {rules.map((rule) => (
-
-
-
-
-
{rule.name}
-
-
- {rule.status === 1 ? '运行' : '异常'}
+
+
+
+
+ {rule.name}
+
+
+
+ {rule.status === 1 ? "运行" : "异常"}
+
+
+
+
+
+
+
+ 速度限制
+
+
+ {rule.speed} Mbps
-
-
-
-
-
速度限制
-
- {rule.speed} Mbps
+
+
+ 绑定隧道
+
+ {rule.tunnelName ? (
+
+ {rule.tunnelName}
-
-
- 绑定隧道
- {rule.tunnelName ? (
-
- {rule.tunnelName}
-
- ) : (
- 未绑定
- )}
-
+ ) : (
+
+ 未绑定
+
+ )}
-
-
-
+
+
+
handleEdit(rule)}
- className="flex-1"
+ size="sm"
startContent={
}
- >
- 编辑
-
-
handleDelete(rule)}
- className="flex-1"
- startContent={
-
-
-
-
- }
- >
- 删除
-
-
-
-
- ))}
-
- ) : (
- /* 空状态 */
-
-
-
-
-
-
暂无限速规则
-
还没有创建任何限速规则,点击上方按钮开始创建
+ onPress={() => handleEdit(rule)}
+ >
+ 编辑
+
+
handleDelete(rule)}
+ className="flex-1"
+ startContent={
+
+
+
+
+ }
+ >
+ 删除
+
+
+
+ ))}
+
+ ) : (
+ /* 空状态 */
+
+
+
+
-
-
- )}
+
+
+ 暂无限速规则
+
+
+ 还没有创建任何限速规则,点击上方按钮开始创建
+
+
+
+
+
+ )}
- {/* 新增/编辑模态框 */}
-
-
- {(onClose) => (
- <>
-
-
- {isEdit ? '编辑限速规则' : '新增限速规则'}
-
-
- {isEdit ? '修改现有限速规则的配置信息' : '创建新的限速规则并绑定到隧道'}
-
-
-
-
-
-
-
- 取消
-
-
- {isEdit ? '保存修改' : '创建规则'}
-
-
- >
- )}
-
-
+ onChange={(e) => setForm(prev => ({ ...prev, speed: parseInt(e.target.value) || 0 }))}
+ }
+ />
- {/* 删除确认模态框 */}
- {
+ 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"
+ isDisabled={isEdit}
+ description={isEdit ? "编辑时无法修改绑定隧道" : undefined}
+ >
+ {tunnels.map((tunnel) => (
+ {tunnel.name}
+ ))}
+
+
+
+
+
+ 取消
+
+
+ {isEdit ? "保存修改" : "创建规则"}
+
+
+ >
+ )}
+
+
+
+ {/* 删除确认模态框 */}
+
-
- {(onClose) => (
- <>
-
- 确认删除
-
-
-
- 确定要删除限速规则 "{ruleToDelete?.name}" 吗?
-
-
- 此操作无法撤销,删除后该规则将永久消失。
-
-
-
-
- 取消
-
-
- 确认删除
-
-
- >
- )}
-
-
-
-
+ >
+
+ {(onClose) => (
+ <>
+
+ 确认删除
+
+
+
+ 确定要删除限速规则{" "}
+
+ "{ruleToDelete?.name}"
+ {" "}
+ 吗?
+
+
+ 此操作无法撤销,删除后该规则将永久消失。
+
+
+
+
+ 取消
+
+
+ 确认删除
+
+
+ >
+ )}
+
+
+
);
-}
\ No newline at end of file
+}
diff --git a/vite-frontend/src/pages/node.tsx b/vite-frontend/src/pages/node.tsx
index c71d220..a61856b 100644
--- a/vite-frontend/src/pages/node.tsx
+++ b/vite-frontend/src/pages/node.tsx
@@ -3,16 +3,21 @@ import { Card, CardBody, CardHeader } from "@heroui/card";
import { Button } from "@heroui/button";
import { Input } from "@heroui/input";
import { Textarea } from "@heroui/input";
-import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/modal";
+import {
+ Modal,
+ ModalContent,
+ ModalHeader,
+ ModalBody,
+ ModalFooter,
+} from "@heroui/modal";
import { Chip } from "@heroui/chip";
import { Switch } from "@heroui/switch";
import { Spinner } from "@heroui/spinner";
import { Alert } from "@heroui/alert";
import { Progress } from "@heroui/progress";
import { Accordion, AccordionItem } from "@heroui/accordion";
-import toast from 'react-hot-toast';
-import axios from 'axios';
-
+import toast from "react-hot-toast";
+import axios from "axios";
import {
DndContext,
KeyboardSensor,
@@ -21,24 +26,23 @@ import {
type DragEndEvent,
useSensor,
useSensors,
-} from '@dnd-kit/core';
+} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
rectSortingStrategy,
sortableKeyboardCoordinates,
useSortable,
-} from '@dnd-kit/sortable';
-import { CSS } from '@dnd-kit/utilities';
+} from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
-
-import {
- createNode,
- getNodeList,
- updateNode,
+import {
+ createNode,
+ getNodeList,
+ updateNode,
deleteNode,
getNodeInstallCommand,
- updateNodeOrder
+ updateNodeOrder,
} from "@/api";
interface Node {
@@ -54,10 +58,10 @@ interface Node {
udpListenAddr?: string;
version?: string;
http?: number; // 0 关 1 开
- tls?: number; // 0 关 1 开
+ tls?: number; // 0 关 1 开
socks?: number; // 0 关 1 开
status: number; // 1: 在线, 0: 离线
- connectionStatus: 'online' | 'offline';
+ connectionStatus: "online" | "offline";
systemInfo?: {
cpuUsage: number;
memoryUsage: number;
@@ -81,7 +85,7 @@ interface NodeForm {
udpListenAddr: string;
interfaceName: string;
http: number; // 0 关 1 开
- tls: number; // 0 关 1 开
+ tls: number; // 0 关 1 开
socks: number; // 0 关 1 开
}
@@ -105,7 +109,7 @@ const SortableItem = ({
transform: transform ? CSS.Transform.toString(transform) : undefined,
transition: isDragging ? undefined : transition || undefined,
opacity: isDragging ? 0.5 : 1,
- willChange: 'transform',
+ willChange: "transform",
};
return (
@@ -122,44 +126,47 @@ export default function NodePage() {
const [wsConnected, setWsConnected] = useState(false);
const [wsConnecting, setWsConnecting] = useState(false);
const [dialogVisible, setDialogVisible] = useState(false);
- const [dialogTitle, setDialogTitle] = useState('');
+ const [dialogTitle, setDialogTitle] = useState("");
const [isEdit, setIsEdit] = useState(false);
const [submitLoading, setSubmitLoading] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [nodeToDelete, setNodeToDelete] = useState(null);
const [protocolDisabled, setProtocolDisabled] = useState(false);
- const [protocolDisabledReason, setProtocolDisabledReason] = useState('');
+ const [protocolDisabledReason, setProtocolDisabledReason] = useState("");
const [form, setForm] = useState({
id: null,
- name: '',
- serverHost: '',
- serverIpV4: '',
- serverIpV6: '',
- port: '1000-65535',
- tcpListenAddr: '[::]',
- udpListenAddr: '[::]',
- interfaceName: '',
+ name: "",
+ serverHost: "",
+ serverIpV4: "",
+ serverIpV6: "",
+ port: "1000-65535",
+ tcpListenAddr: "[::]",
+ udpListenAddr: "[::]",
+ interfaceName: "",
http: 0,
tls: 0,
- socks: 0
+ socks: 0,
});
const [errors, setErrors] = useState>({});
-
+
// 安装命令相关状态
const [installCommandModal, setInstallCommandModal] = useState(false);
- const [installCommand, setInstallCommand] = useState('');
- const [currentNodeName, setCurrentNodeName] = useState('');
-
+ const [installCommand, setInstallCommand] = useState("");
+ const [currentNodeName, setCurrentNodeName] = useState("");
+
const websocketRef = useRef(null);
const reconnectTimerRef = useRef(null);
const reconnectAttemptsRef = useRef(0);
const maxReconnectAttempts = 5;
- const offlineTimersRef = useRef