+
diff --git a/vite-frontend/src/pages/node.tsx b/vite-frontend/src/pages/node.tsx
new file mode 100644
index 0000000..d3c3ed7
--- /dev/null
+++ b/vite-frontend/src/pages/node.tsx
@@ -0,0 +1,800 @@
+import { useState, useEffect, useRef } from "react";
+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 { Chip } from "@heroui/chip";
+import { Spinner } from "@heroui/spinner";
+import { Progress } from "@heroui/progress";
+import toast from 'react-hot-toast';
+
+import AdminLayout from "@/layouts/admin";
+import {
+ createNode,
+ getNodeList,
+ updateNode,
+ deleteNode,
+ getNodeInstallCommand
+} from "@/api";
+
+interface Node {
+ id: number;
+ name: string;
+ ip: string;
+ serverIp: string;
+ portSta: number;
+ portEnd: number;
+ version?: string;
+ status: number; // 1: 在线, 0: 离线
+ connectionStatus: 'online' | 'offline';
+ systemInfo?: {
+ cpuUsage: number;
+ memoryUsage: number;
+ uploadTraffic: number;
+ downloadTraffic: number;
+ uploadSpeed: number;
+ downloadSpeed: number;
+ uptime: number;
+ } | null;
+ copyLoading?: boolean;
+}
+
+interface NodeForm {
+ id: number | null;
+ name: string;
+ ipString: string;
+ serverIp: string;
+ portSta: number;
+ portEnd: number;
+}
+
+export default function NodePage() {
+ const [nodeList, setNodeList] = useState
([]);
+ const [loading, setLoading] = useState(false);
+ const [dialogVisible, setDialogVisible] = useState(false);
+ const [dialogTitle, setDialogTitle] = useState('');
+ const [isEdit, setIsEdit] = useState(false);
+ const [submitLoading, setSubmitLoading] = useState(false);
+ const [form, setForm] = useState({
+ id: null,
+ name: '',
+ ipString: '',
+ serverIp: '',
+ portSta: 1000,
+ portEnd: 65535
+ });
+ const [errors, setErrors] = useState>({});
+
+ const websocketRef = useRef(null);
+ const reconnectTimerRef = useRef(null);
+ const reconnectAttemptsRef = useRef(0);
+ const maxReconnectAttempts = 5;
+
+ useEffect(() => {
+ loadNodes();
+ initWebSocket();
+
+ return () => {
+ closeWebSocket();
+ };
+ }, []);
+
+ // 加载节点列表
+ const loadNodes = async () => {
+ setLoading(true);
+ try {
+ const res = await getNodeList();
+ if (res.code === 0) {
+ setNodeList(res.data.map((node: any) => ({
+ ...node,
+ connectionStatus: node.status === 1 ? 'online' : 'offline',
+ systemInfo: null,
+ copyLoading: false
+ })));
+ } else {
+ toast.error(res.msg || '加载节点列表失败');
+ }
+ } catch (error) {
+ toast.error('网络错误,请重试');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // 初始化WebSocket连接
+ const initWebSocket = () => {
+ if (websocketRef.current &&
+ (websocketRef.current.readyState === WebSocket.OPEN ||
+ websocketRef.current.readyState === WebSocket.CONNECTING)) {
+ return;
+ }
+
+ if (websocketRef.current) {
+ closeWebSocket();
+ }
+
+ // 构建WebSocket URL,将HTTP协议转换为WS协议
+ let baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8080';
+ const wsUrl = baseUrl.replace(/^http/, 'ws') + `/system-info?type=0&secret=${localStorage.getItem('token')}`;
+
+ try {
+ websocketRef.current = new WebSocket(wsUrl);
+
+ websocketRef.current.onopen = () => {
+ reconnectAttemptsRef.current = 0;
+ };
+
+ websocketRef.current.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data);
+ handleWebSocketMessage(data);
+ } catch (error) {
+ // 解析失败时不输出错误信息
+ }
+ };
+
+ websocketRef.current.onerror = () => {
+ // WebSocket错误时不输出错误信息
+ };
+
+ websocketRef.current.onclose = () => {
+ websocketRef.current = null;
+ attemptReconnect();
+ };
+ } catch (error) {
+ attemptReconnect();
+ }
+ };
+
+ // 处理WebSocket消息
+ const handleWebSocketMessage = (data: any) => {
+ const { id, type, data: messageData } = data;
+
+ if (type === 'status') {
+ setNodeList(prev => prev.map(node => {
+ if (node.id == id) {
+ return {
+ ...node,
+ connectionStatus: messageData === 1 ? 'online' : 'offline',
+ systemInfo: messageData === 0 ? null : node.systemInfo
+ };
+ }
+ return node;
+ }));
+ } else if (type === 'info') {
+ setNodeList(prev => prev.map(node => {
+ if (node.id == id) {
+ try {
+ let systemInfo;
+ if (typeof messageData === 'string') {
+ systemInfo = JSON.parse(messageData);
+ } else {
+ systemInfo = messageData;
+ }
+
+ const currentUpload = parseInt(systemInfo.bytes_transmitted) || 0;
+ const currentDownload = parseInt(systemInfo.bytes_received) || 0;
+ const currentUptime = parseInt(systemInfo.uptime) || 0;
+
+ let uploadSpeed = 0;
+ let downloadSpeed = 0;
+
+ if (node.systemInfo && node.systemInfo.uptime) {
+ const timeDiff = currentUptime - node.systemInfo.uptime;
+
+ if (timeDiff > 0 && timeDiff <= 10) {
+ const lastUpload = node.systemInfo.uploadTraffic || 0;
+ const lastDownload = node.systemInfo.downloadTraffic || 0;
+
+ const uploadDiff = currentUpload - lastUpload;
+ const downloadDiff = currentDownload - lastDownload;
+
+ const uploadReset = currentUpload < lastUpload;
+ const downloadReset = currentDownload < lastDownload;
+
+ if (!uploadReset && uploadDiff >= 0) {
+ uploadSpeed = uploadDiff / timeDiff;
+ }
+
+ if (!downloadReset && downloadDiff >= 0) {
+ downloadSpeed = downloadDiff / timeDiff;
+ }
+ }
+ }
+
+ return {
+ ...node,
+ connectionStatus: 'online',
+ systemInfo: {
+ cpuUsage: parseFloat(systemInfo.cpu_usage) || 0,
+ memoryUsage: parseFloat(systemInfo.memory_usage) || 0,
+ uploadTraffic: currentUpload,
+ downloadTraffic: currentDownload,
+ uploadSpeed: uploadSpeed,
+ downloadSpeed: downloadSpeed,
+ uptime: currentUptime
+ }
+ };
+ } catch (error) {
+ return node;
+ }
+ }
+ return node;
+ }));
+ }
+ };
+
+ // 尝试重新连接
+ const attemptReconnect = () => {
+ if (reconnectAttemptsRef.current < maxReconnectAttempts) {
+ reconnectAttemptsRef.current++;
+
+ reconnectTimerRef.current = setTimeout(() => {
+ initWebSocket();
+ }, 3000 * reconnectAttemptsRef.current);
+ }
+ };
+
+ // 关闭WebSocket连接
+ const closeWebSocket = () => {
+ if (reconnectTimerRef.current) {
+ clearTimeout(reconnectTimerRef.current);
+ reconnectTimerRef.current = null;
+ }
+
+ reconnectAttemptsRef.current = 0;
+
+ if (websocketRef.current) {
+ websocketRef.current.onopen = null;
+ websocketRef.current.onmessage = null;
+ websocketRef.current.onerror = null;
+ websocketRef.current.onclose = null;
+
+ if (websocketRef.current.readyState === WebSocket.OPEN ||
+ websocketRef.current.readyState === WebSocket.CONNECTING) {
+ websocketRef.current.close();
+ }
+
+ websocketRef.current = null;
+ }
+
+ setNodeList(prev => prev.map(node => ({
+ ...node,
+ connectionStatus: 'offline',
+ systemInfo: null
+ })));
+ };
+
+ // 格式化字节数
+ const formatBytes = (bytes: number): string => {
+ if (bytes === 0) return '0 B';
+
+ const k = 1024;
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+ };
+
+ // 格式化速度
+ const formatSpeed = (bytesPerSecond: number): string => {
+ if (bytesPerSecond === 0) return '0 B/s';
+
+ const k = 1024;
+ const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s'];
+ const i = Math.floor(Math.log(bytesPerSecond) / Math.log(k));
+
+ return parseFloat((bytesPerSecond / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+ };
+
+ // 获取进度条颜色
+ const getProgressColor = (value: number, offline = false): "default" | "primary" | "secondary" | "success" | "warning" | "danger" => {
+ if (offline) return "default";
+ if (value <= 50) return "success";
+ if (value <= 80) return "warning";
+ return "danger";
+ };
+
+ // 验证IP地址格式
+ const validateIp = (ip: string): boolean => {
+ if (!ip || !ip.trim()) return false;
+
+ const trimmedIp = ip.trim();
+
+ // IPv4格式验证
+ const ipv4Regex = /^(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]?)$/;
+
+ // IPv6格式验证
+ const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;
+
+ if (ipv4Regex.test(trimmedIp) || ipv6Regex.test(trimmedIp) || trimmedIp === 'localhost') {
+ return true;
+ }
+
+ // 验证域名格式
+ if (/^\d+$/.test(trimmedIp)) return false;
+
+ const domainRegex = /^[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])?)+$/;
+ const singleLabelDomain = /^[a-zA-Z][a-zA-Z0-9\-]{0,62}$/;
+
+ return domainRegex.test(trimmedIp) || singleLabelDomain.test(trimmedIp);
+ };
+
+ // 表单验证
+ const validateForm = (): boolean => {
+ const newErrors: Record = {};
+
+ if (!form.name.trim()) {
+ newErrors.name = '请输入节点名称';
+ } else if (form.name.trim().length < 2) {
+ newErrors.name = '节点名称长度至少2位';
+ } else if (form.name.trim().length > 50) {
+ newErrors.name = '节点名称长度不能超过50位';
+ }
+
+ if (!form.ipString.trim()) {
+ newErrors.ipString = '请输入入口IP地址';
+ } else {
+ const ips = form.ipString.split('\n').map(ip => ip.trim()).filter(ip => ip);
+ if (ips.length === 0) {
+ newErrors.ipString = '请输入至少一个有效IP地址';
+ } else {
+ for (let i = 0; i < ips.length; i++) {
+ if (!validateIp(ips[i])) {
+ newErrors.ipString = `第${i + 1}行IP地址格式错误: ${ips[i]}`;
+ break;
+ }
+ }
+ }
+ }
+
+ if (!form.serverIp.trim()) {
+ newErrors.serverIp = '请输入服务器IP地址';
+ } else if (!validateIp(form.serverIp.trim())) {
+ newErrors.serverIp = '请输入有效的IPv4、IPv6地址或域名';
+ }
+
+ if (!form.portSta || form.portSta < 1 || form.portSta > 65535) {
+ newErrors.portSta = '端口范围必须在1-65535之间';
+ }
+
+ if (!form.portEnd || form.portEnd < 1 || form.portEnd > 65535) {
+ newErrors.portEnd = '端口范围必须在1-65535之间';
+ } else if (form.portEnd < form.portSta) {
+ newErrors.portEnd = '结束端口不能小于起始端口';
+ }
+
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ // 新增节点
+ const handleAdd = () => {
+ setDialogTitle('新增节点');
+ setIsEdit(false);
+ setDialogVisible(true);
+ resetForm();
+ };
+
+ // 编辑节点
+ const handleEdit = (node: Node) => {
+ setDialogTitle('编辑节点');
+ setIsEdit(true);
+ setForm({
+ id: node.id,
+ name: node.name,
+ ipString: node.ip ? node.ip.split(',').map(ip => ip.trim()).join('\n') : '',
+ serverIp: node.serverIp || '',
+ portSta: node.portSta,
+ portEnd: node.portEnd
+ });
+ setDialogVisible(true);
+ };
+
+ // 删除节点
+ const handleDelete = async (node: Node) => {
+ if (window.confirm(`确定要删除节点 "${node.name}" 吗?`)) {
+ try {
+ const res = await deleteNode(node.id);
+ if (res.code === 0) {
+ toast.success('删除成功');
+ setNodeList(prev => prev.filter(n => n.id !== node.id));
+ } else {
+ toast.error(res.msg || '删除失败');
+ }
+ } catch (error) {
+ toast.error('网络错误,请重试');
+ }
+ }
+ };
+
+ // 复制安装命令
+ const handleCopyInstallCommand = async (node: Node) => {
+ setNodeList(prev => prev.map(n =>
+ n.id === node.id ? { ...n, copyLoading: true } : n
+ ));
+
+ try {
+ const res = await getNodeInstallCommand(node.id);
+ if (res.code === 0 && res.data) {
+ await navigator.clipboard.writeText(res.data);
+ toast.success('安装命令已复制到剪贴板');
+ } else {
+ toast.error(res.msg || '获取安装命令失败');
+ }
+ } catch (error) {
+ toast.error('复制失败,请手动复制');
+ } finally {
+ setNodeList(prev => prev.map(n =>
+ n.id === node.id ? { ...n, copyLoading: false } : n
+ ));
+ }
+ };
+
+ // 提交表单
+ const handleSubmit = async () => {
+ if (!validateForm()) return;
+
+ setSubmitLoading(true);
+
+ try {
+ const ipString = form.ipString
+ .split('\n')
+ .map(ip => ip.trim())
+ .filter(ip => ip)
+ .join(',');
+
+ const submitData = {
+ ...form,
+ ip: ipString
+ };
+ delete (submitData as any).ipString;
+
+ const apiCall = isEdit ? updateNode : createNode;
+ const data = isEdit ? submitData : {
+ name: form.name,
+ ip: ipString,
+ serverIp: form.serverIp,
+ portSta: form.portSta,
+ portEnd: form.portEnd
+ };
+
+ const res = await apiCall(data);
+ if (res.code === 0) {
+ toast.success(isEdit ? '更新成功' : '创建成功');
+ setDialogVisible(false);
+
+ if (isEdit) {
+ setNodeList(prev => prev.map(n =>
+ n.id === form.id ? {
+ ...n,
+ name: form.name,
+ ip: ipString,
+ serverIp: form.serverIp,
+ portSta: form.portSta,
+ portEnd: form.portEnd
+ } : n
+ ));
+ } else {
+ loadNodes();
+ }
+ } else {
+ toast.error(res.msg || (isEdit ? '更新失败' : '创建失败'));
+ }
+ } catch (error) {
+ toast.error('网络错误,请重试');
+ } finally {
+ setSubmitLoading(false);
+ }
+ };
+
+ // 重置表单
+ const resetForm = () => {
+ setForm({
+ id: null,
+ name: '',
+ ipString: '',
+ serverIp: '',
+ portSta: 1000,
+ portEnd: 65535
+ });
+ setErrors({});
+ };
+
+ return (
+
+
+ {/* 页面头部 */}
+
+
节点管理
+
+
+
+ {/* 节点列表 */}
+ {loading ? (
+
+ ) : nodeList.length === 0 ? (
+
+
+
+
+
+
暂无节点配置
+
还没有创建任何节点配置,点击上方按钮开始创建
+
+
+
+
+ ) : (
+
+ {nodeList.map((node) => (
+
+
+
+
+
{node.name}
+
{node.serverIp}
+
+
+
+ {node.connectionStatus === 'online' ? '在线' : '离线'}
+
+
+
+
+
+
+ {/* 基础信息 */}
+
+
+ 服务器
+ {node.serverIp}
+
+
+
入口IP
+
+ {node.ip ? (
+ node.ip.split(',').length > 1 ? (
+ {node.ip.split(',')[0].trim()} +{node.ip.split(',').length - 1}个
+ ) : (
+ {node.ip.trim()}
+ )
+ ) : '-'}
+
+
+
+ 端口
+ {node.portSta}-{node.portEnd}
+
+
+ 版本
+ {node.version || '未知'}
+
+
+
+ {/* 系统监控 */}
+
+
+
+
+ CPU
+
+ {node.connectionStatus === 'online' && node.systemInfo
+ ? `${node.systemInfo.cpuUsage.toFixed(1)}%`
+ : '-'
+ }
+
+
+
+
+
+
+ 内存
+
+ {node.connectionStatus === 'online' && node.systemInfo
+ ? `${node.systemInfo.memoryUsage.toFixed(1)}%`
+ : '-'
+ }
+
+
+
+
+
+
+
+
+
上传
+
+ {node.connectionStatus === 'online' && node.systemInfo
+ ? formatSpeed(node.systemInfo.uploadSpeed)
+ : '-'
+ }
+
+
+
+
下载
+
+ {node.connectionStatus === 'online' && node.systemInfo
+ ? formatSpeed(node.systemInfo.downloadSpeed)
+ : '-'
+ }
+
+
+
+
+
+ {/* 操作按钮 */}
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ {/* 新增/编辑节点对话框 */}
+
setDialogVisible(false)}
+ size="2xl"
+ scrollBehavior="inside"
+ >
+
+ {dialogTitle}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/vite-frontend/src/pages/tunnel.tsx b/vite-frontend/src/pages/tunnel.tsx
new file mode 100644
index 0000000..e55f03a
--- /dev/null
+++ b/vite-frontend/src/pages/tunnel.tsx
@@ -0,0 +1,1032 @@
+import { useState, useEffect } from "react";
+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 { Chip } from "@heroui/chip";
+import { Spinner } from "@heroui/spinner";
+import { Divider } from "@heroui/divider";
+import { Alert } from "@heroui/alert";
+import toast from 'react-hot-toast';
+
+import AdminLayout from "@/layouts/admin";
+import {
+ createTunnel,
+ getTunnelList,
+ updateTunnel,
+ deleteTunnel,
+ getNodeList,
+ diagnoseTunnel
+} from "@/api";
+
+interface Tunnel {
+ id: number;
+ name: string;
+ type: number; // 1: 端口转发, 2: 隧道转发
+ inNodeId: number;
+ outNodeId?: number;
+ inIp: string;
+ outIp?: string;
+ protocol?: string;
+ tcpListenAddr: string;
+ udpListenAddr: string;
+ flow: number; // 1: 单向, 2: 双向
+ trafficRatio: number;
+ status: number;
+ createdTime: string;
+}
+
+interface Node {
+ id: number;
+ name: string;
+ status: number; // 1: 在线, 0: 离线
+}
+
+interface TunnelForm {
+ id?: number;
+ name: string;
+ type: number;
+ inNodeId: number | null;
+ outNodeId?: number | null;
+ protocol: string;
+ tcpListenAddr: string;
+ udpListenAddr: string;
+ flow: number;
+ trafficRatio: number;
+ status: number;
+}
+
+interface DiagnosisResult {
+ tunnelName: string;
+ tunnelType: string;
+ timestamp: number;
+ results: Array<{
+ success: boolean;
+ description: string;
+ nodeName: string;
+ nodeId: string;
+ targetIp: string;
+ message?: string;
+ averageTime?: number;
+ packetLoss?: number;
+ }>;
+}
+
+export default function TunnelPage() {
+ const [loading, setLoading] = useState(true);
+ const [tunnels, setTunnels] = useState([]);
+ const [nodes, setNodes] = useState([]);
+
+ // 模态框状态
+ const [modalOpen, setModalOpen] = useState(false);
+ const [deleteModalOpen, setDeleteModalOpen] = useState(false);
+ const [diagnosisModalOpen, setDiagnosisModalOpen] = useState(false);
+ const [isEdit, setIsEdit] = useState(false);
+ const [submitLoading, setSubmitLoading] = useState(false);
+ const [deleteLoading, setDeleteLoading] = useState(false);
+ const [diagnosisLoading, setDiagnosisLoading] = useState(false);
+ const [tunnelToDelete, setTunnelToDelete] = useState(null);
+ const [currentDiagnosisTunnel, setCurrentDiagnosisTunnel] = useState(null);
+ const [diagnosisResult, setDiagnosisResult] = useState(null);
+
+ // 表单状态
+ const [form, setForm] = useState({
+ name: '',
+ type: 1,
+ inNodeId: null,
+ outNodeId: null,
+ protocol: 'tls',
+ tcpListenAddr: '0.0.0.0',
+ udpListenAddr: '0.0.0.0',
+ flow: 1,
+ trafficRatio: 1.0,
+ status: 1
+ });
+
+ // 表单验证错误
+ const [errors, setErrors] = useState<{[key: string]: string}>({});
+
+ useEffect(() => {
+ loadData();
+ }, []);
+
+ // 加载所有数据
+ const loadData = async () => {
+ setLoading(true);
+ try {
+ const [tunnelsRes, nodesRes] = await Promise.all([
+ getTunnelList(),
+ getNodeList()
+ ]);
+
+ if (tunnelsRes.code === 0) {
+ setTunnels(tunnelsRes.data || []);
+ } else {
+ toast.error(tunnelsRes.msg || '获取隧道列表失败');
+ }
+
+ if (nodesRes.code === 0) {
+ setNodes(nodesRes.data || []);
+ } else {
+ console.warn('获取节点列表失败:', nodesRes.msg);
+ }
+ } catch (error) {
+ console.error('加载数据失败:', error);
+ toast.error('加载数据失败');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // 表单验证
+ const validateForm = (): boolean => {
+ const newErrors: {[key: string]: string} = {};
+
+ if (!form.name.trim()) {
+ newErrors.name = '请输入隧道名称';
+ } else if (form.name.length < 2 || form.name.length > 50) {
+ newErrors.name = '隧道名称长度应在2-50个字符之间';
+ }
+
+ if (!form.inNodeId) {
+ newErrors.inNodeId = '请选择入口节点';
+ }
+
+ if (!form.tcpListenAddr.trim()) {
+ newErrors.tcpListenAddr = '请输入TCP监听地址';
+ }
+
+ if (!form.udpListenAddr.trim()) {
+ newErrors.udpListenAddr = '请输入UDP监听地址';
+ }
+
+ if (form.trafficRatio < 0.0 || form.trafficRatio > 100.0) {
+ newErrors.trafficRatio = '流量倍率必须在0.0-100.0之间';
+ }
+
+ // 隧道转发时的验证
+ if (form.type === 2) {
+ if (!form.outNodeId) {
+ newErrors.outNodeId = '请选择出口节点';
+ } else if (form.inNodeId === form.outNodeId) {
+ newErrors.outNodeId = '隧道转发模式下,入口和出口不能是同一个节点';
+ }
+
+ if (!form.protocol) {
+ newErrors.protocol = '请选择协议类型';
+ }
+ }
+
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ // 新增隧道
+ const handleAdd = () => {
+ setIsEdit(false);
+ setForm({
+ name: '',
+ type: 1,
+ inNodeId: null,
+ outNodeId: null,
+ protocol: 'tls',
+ tcpListenAddr: '0.0.0.0',
+ udpListenAddr: '0.0.0.0',
+ flow: 1,
+ trafficRatio: 1.0,
+ status: 1
+ });
+ setErrors({});
+ setModalOpen(true);
+ };
+
+ // 编辑隧道 - 只能修改部分字段
+ const handleEdit = (tunnel: Tunnel) => {
+ setIsEdit(true);
+ setForm({
+ id: tunnel.id,
+ name: tunnel.name,
+ type: tunnel.type,
+ inNodeId: tunnel.inNodeId,
+ outNodeId: tunnel.outNodeId || null,
+ protocol: tunnel.protocol || 'tls',
+ tcpListenAddr: tunnel.tcpListenAddr || '0.0.0.0',
+ udpListenAddr: tunnel.udpListenAddr || '0.0.0.0',
+ flow: tunnel.flow,
+ trafficRatio: tunnel.trafficRatio,
+ status: tunnel.status
+ });
+ setErrors({});
+ setModalOpen(true);
+ };
+
+ // 删除隧道
+ const handleDelete = (tunnel: Tunnel) => {
+ setTunnelToDelete(tunnel);
+ setDeleteModalOpen(true);
+ };
+
+ const confirmDelete = async () => {
+ if (!tunnelToDelete) return;
+
+ setDeleteLoading(true);
+ try {
+ const response = await deleteTunnel(tunnelToDelete.id);
+ if (response.code === 0) {
+ toast.success('删除成功');
+ setDeleteModalOpen(false);
+ setTunnelToDelete(null);
+ loadData();
+ } else {
+ toast.error(response.msg || '删除失败');
+ }
+ } catch (error) {
+ console.error('删除失败:', error);
+ toast.error('删除失败');
+ } finally {
+ setDeleteLoading(false);
+ }
+ };
+
+ // 隧道类型改变时的处理
+ const handleTypeChange = (type: number) => {
+ setForm(prev => ({
+ ...prev,
+ type,
+ outNodeId: type === 1 ? null : prev.outNodeId,
+ protocol: type === 1 ? 'tls' : prev.protocol
+ }));
+ };
+
+ // 提交表单
+ const handleSubmit = async () => {
+ if (!validateForm()) return;
+
+ setSubmitLoading(true);
+ try {
+ const data = { ...form };
+
+ const response = isEdit
+ ? await updateTunnel(data)
+ : await createTunnel(data);
+
+ if (response.code === 0) {
+ toast.success(isEdit ? '更新成功' : '创建成功');
+ setModalOpen(false);
+ loadData();
+ } else {
+ toast.error(response.msg || (isEdit ? '更新失败' : '创建失败'));
+ }
+ } catch (error) {
+ console.error('提交失败:', error);
+ toast.error('网络错误,请重试');
+ } finally {
+ setSubmitLoading(false);
+ }
+ };
+
+ // 诊断隧道
+ const handleDiagnose = async (tunnel: Tunnel) => {
+ setCurrentDiagnosisTunnel(tunnel);
+ setDiagnosisModalOpen(true);
+ setDiagnosisLoading(true);
+ setDiagnosisResult(null);
+
+ try {
+ const response = await diagnoseTunnel(tunnel.id);
+ if (response.code === 0) {
+ setDiagnosisResult(response.data);
+ } else {
+ toast.error(response.msg || '诊断失败');
+ setDiagnosisResult({
+ tunnelName: tunnel.name,
+ tunnelType: tunnel.type === 1 ? '端口转发' : '隧道转发',
+ timestamp: Date.now(),
+ results: [{
+ success: false,
+ description: '诊断失败',
+ nodeName: '-',
+ nodeId: '-',
+ targetIp: '-',
+ message: response.msg || '诊断过程中发生错误'
+ }]
+ });
+ }
+ } catch (error) {
+ console.error('诊断失败:', error);
+ toast.error('网络错误,请重试');
+ setDiagnosisResult({
+ tunnelName: tunnel.name,
+ tunnelType: tunnel.type === 1 ? '端口转发' : '隧道转发',
+ timestamp: Date.now(),
+ results: [{
+ success: false,
+ description: '网络错误',
+ nodeName: '-',
+ nodeId: '-',
+ targetIp: '-',
+ message: '无法连接到服务器'
+ }]
+ });
+ } finally {
+ setDiagnosisLoading(false);
+ }
+ };
+
+ // 获取显示的IP(处理多IP)
+ const getDisplayIp = (ipString?: string): string => {
+ if (!ipString) return '-';
+
+ const ips = ipString.split(',').map(ip => ip.trim()).filter(ip => ip);
+
+ if (ips.length === 0) return '-';
+ if (ips.length === 1) return ips[0];
+
+ return `${ips[0]} 等${ips.length}个`;
+ };
+
+ // 获取节点名称
+ const getNodeName = (nodeId?: number): string => {
+ if (!nodeId) return '-';
+ const node = nodes.find(n => n.id === nodeId);
+ return node ? node.name : `节点${nodeId}`;
+ };
+
+ // 获取状态显示
+ const getStatusDisplay = (status: number) => {
+ switch (status) {
+ case 1:
+ return { text: '启用', color: 'success' };
+ case 0:
+ return { text: '禁用', color: 'default' };
+ default:
+ return { text: '未知', color: 'warning' };
+ }
+ };
+
+ // 获取类型显示
+ const getTypeDisplay = (type: number) => {
+ switch (type) {
+ case 1:
+ return { text: '端口转发', color: 'primary' };
+ case 2:
+ return { text: '隧道转发', color: 'secondary' };
+ default:
+ return { text: '未知', color: 'default' };
+ }
+ };
+
+ // 获取流量计算显示
+ const getFlowDisplay = (flow: number) => {
+ switch (flow) {
+ case 1:
+ return '单向计算';
+ case 2:
+ return '双向计算';
+ default:
+ return '未知';
+ }
+ };
+
+ // 格式化时间
+ const formatTime = (timestamp: string | number): string => {
+ if (!timestamp) return '-';
+ const date = new Date(timestamp);
+ return date.toLocaleString('zh-CN');
+ };
+
+ // 获取连接质量
+ 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 (loading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+ {/* 页面头部 */}
+
+
隧道管理
+
+
+
+ {/* 隧道卡片网格 */}
+ {tunnels.length > 0 ? (
+
+ {tunnels.map((tunnel) => {
+ const statusDisplay = getStatusDisplay(tunnel.status);
+ const typeDisplay = getTypeDisplay(tunnel.type);
+
+ return (
+
+
+
+
+
{tunnel.name}
+
+
+ {typeDisplay.text}
+
+
+ {statusDisplay.text}
+
+
+
+
+
+
+
+
+ {/* 流程展示 */}
+
+
+
+ 入口节点
+
+
+ {getNodeName(tunnel.inNodeId)}
+
+
+ {getDisplayIp(tunnel.inIp)}
+
+
+
+
+
+
+
+
+ {tunnel.type === 1 ? '出口节点(同入口)' : '出口节点'}
+
+
+
+ {tunnel.type === 1 ? getNodeName(tunnel.inNodeId) : getNodeName(tunnel.outNodeId)}
+
+
+ {tunnel.type === 1 ? getDisplayIp(tunnel.inIp) : getDisplayIp(tunnel.outIp)}
+
+
+
+
+ {/* 配置信息 */}
+
+
+
流量计算
+
+ {getFlowDisplay(tunnel.flow)}
+
+
+
+
倍率
+
+ {tunnel.trafficRatio}x
+
+
+
+
+ {/* 时间信息 */}
+
+ 创建时间: {formatTime(tunnel.createdTime)}
+
+
+
+
+
+
+
+
+
+
+ );
+ })}
+
+ ) : (
+ /* 空状态 */
+
+
+
+
+
+
暂无隧道配置
+
还没有创建任何隧道配置,点击上方按钮开始创建
+
+
+
+
+ )}
+
+ {/* 新增/编辑模态框 */}
+
+
+ {(onClose) => (
+ <>
+
+
+ {isEdit ? '编辑隧道' : '新增隧道'}
+
+
+ {isEdit ? '修改现有隧道配置的信息' : '创建新的隧道配置'}
+
+
+
+
+
setForm(prev => ({ ...prev, name: e.target.value }))}
+ isInvalid={!!errors.name}
+ errorMessage={errors.name}
+ variant="bordered"
+ />
+
+
+
+
+
+
+
setForm(prev => ({
+ ...prev,
+ trafficRatio: parseFloat(e.target.value) || 0
+ }))}
+ isInvalid={!!errors.trafficRatio}
+ errorMessage={errors.trafficRatio}
+ variant="bordered"
+ endContent={
+
+ x
+
+ }
+ />
+
+
+
+
入口配置
+
+
+
+
+
setForm(prev => ({ ...prev, tcpListenAddr: e.target.value }))}
+ isInvalid={!!errors.tcpListenAddr}
+ errorMessage={errors.tcpListenAddr}
+ variant="bordered"
+ startContent={
+
+ TCP
+
+ }
+ />
+
+
setForm(prev => ({ ...prev, udpListenAddr: e.target.value }))}
+ isInvalid={!!errors.udpListenAddr}
+ errorMessage={errors.udpListenAddr}
+ variant="bordered"
+ startContent={
+
+ UDP
+
+ }
+ />
+
+
+ {/* 隧道转发时显示出口配置 */}
+ {form.type === 2 && (
+ <>
+
+
出口配置
+
+
+
+
+ >
+ )}
+
+ {/* 端口转发提示 */}
+ {form.type === 1 && (
+
+ )}
+
+
+
+
+
+
+ >
+ )}
+
+
+
+ {/* 删除确认模态框 */}
+
+
+ {(onClose) => (
+ <>
+
+ 确认删除
+
+
+ 确定要删除隧道 "{tunnelToDelete?.name}" 吗?
+ 此操作不可恢复,请谨慎操作。
+
+
+
+
+
+ >
+ )}
+
+
+
+ {/* 诊断结果模态框 */}
+
+
+ {(onClose) => (
+ <>
+
+ 隧道诊断结果
+ {currentDiagnosisTunnel && (
+
+ {currentDiagnosisTunnel.name}
+
+ {currentDiagnosisTunnel.type === 1 ? '端口转发' : '隧道转发'}
+
+
+ )}
+
+
+ {diagnosisLoading ? (
+
+ ) : diagnosisResult ? (
+
+ {diagnosisResult.results.map((result, index) => {
+ const quality = getQualityDisplay(result.averageTime, result.packetLoss);
+
+ return (
+
+
+
+
+
+ {result.success ? '✓' : '✗'}
+
+
+
{result.description}
+
{result.nodeName}
+
+
+
+ {result.success ? '成功' : '失败'}
+
+
+
+
+ {result.success ? (
+
+
+
+
{result.averageTime?.toFixed(0)}
+
平均延迟(ms)
+
+
+
{result.packetLoss?.toFixed(1)}
+
丢包率(%)
+
+
+ {quality && (
+ <>
+
+ {quality.text}
+
+
连接质量
+ >
+ )}
+
+
+
+ 目标地址: {result.targetIp}
+
+
+ ) : (
+
+
+ 目标地址: {result.targetIp}
+
+
+
+ )}
+
+
+ );
+ })}
+
+ ) : (
+
+ )}
+
+
+
+ {currentDiagnosisTunnel && (
+
+ )}
+
+ >
+ )}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/vite-frontend/src/pages/user.tsx b/vite-frontend/src/pages/user.tsx
new file mode 100644
index 0000000..dc2a660
--- /dev/null
+++ b/vite-frontend/src/pages/user.tsx
@@ -0,0 +1,1014 @@
+import { useState, useEffect } from 'react';
+import { Button } from "@heroui/button";
+import { Card, CardBody, CardHeader } from "@heroui/card";
+import { Input } from "@heroui/input";
+import {
+ Table,
+ TableHeader,
+ TableColumn,
+ TableBody,
+ TableRow,
+ TableCell
+} from "@heroui/table";
+import {
+ Modal,
+ ModalContent,
+ ModalHeader,
+ ModalBody,
+ ModalFooter,
+ useDisclosure
+} from "@heroui/modal";
+import { Chip } from "@heroui/chip";
+import { Pagination } from "@heroui/pagination";
+import { Select, SelectItem } from "@heroui/select";
+import { RadioGroup, Radio } from "@heroui/radio";
+import { DatePicker } from "@heroui/date-picker";
+import { Spinner } from "@heroui/spinner";
+import { Progress } from "@heroui/progress";
+import { Tabs, Tab } from "@heroui/tabs";
+import toast from 'react-hot-toast';
+import {
+ User,
+ UserForm,
+ UserTunnel,
+ UserTunnelForm,
+ Tunnel,
+ SpeedLimit,
+ Pagination as PaginationType
+} from '@/types';
+import {
+ getAllUsers,
+ createUser,
+ updateUser,
+ deleteUser,
+ getTunnelList,
+ assignUserTunnel,
+ getUserTunnelList,
+ removeUserTunnel,
+ updateUserTunnel,
+ getSpeedLimitList
+} from '@/api';
+import { SearchIcon, PlusIcon, EditIcon, DeleteIcon, UserIcon, SettingsIcon } from '@/components/icons';
+import { CalendarDate, parseDate } from "@internationalized/date";
+import AdminLayout from "@/layouts/admin";
+
+// 工具函数
+const formatFlow = (value: number, unit: string = 'bytes'): string => {
+ if (unit === 'gb') {
+ return `${value} GB`;
+ } else {
+ if (value === 0) return '0 B';
+ if (value < 1024) return `${value} B`;
+ if (value < 1024 * 1024) return `${(value / 1024).toFixed(2)} KB`;
+ if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(2)} MB`;
+ return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`;
+ }
+};
+
+const formatDate = (timestamp: number): string => {
+ return new Date(timestamp).toLocaleString();
+};
+
+const getExpireStatus = (expTime: number) => {
+ const now = Date.now();
+ if (expTime < now) {
+ return { color: 'danger' as const, text: '已过期' };
+ }
+ const diffDays = Math.ceil((expTime - now) / (1000 * 60 * 60 * 24));
+ if (diffDays <= 7) {
+ return { color: 'warning' as const, text: `${diffDays}天后过期` };
+ }
+ return { color: 'success' as const, text: '正常' };
+};
+
+// 获取用户状态(根据status字段)
+const getUserStatus = (user: User) => {
+ if (user.status === 1) {
+ return { color: 'success' as const, text: '正常' };
+ } else {
+ return { color: 'danger' as const, text: '禁用' };
+ }
+};
+
+const calculateUserTotalUsedFlow = (user: User): number => {
+ return (user.inFlow || 0) + (user.outFlow || 0);
+};
+
+const calculateTunnelUsedFlow = (tunnel: UserTunnel): number => {
+ const inFlow = tunnel.inFlow || 0;
+ const outFlow = tunnel.outFlow || 0;
+
+ if (tunnel.tunnelFlow === 1) {
+ return outFlow; // 单向计算:只计算上传流量
+ } else {
+ return inFlow + outFlow; // 双向计算或默认:计算上传和下载的总流量
+ }
+};
+
+export default function UserPage() {
+ // 状态管理
+ const [users, setUsers] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [searchKeyword, setSearchKeyword] = useState('');
+ const [pagination, setPagination] = useState({
+ current: 1,
+ size: 10,
+ total: 0
+ });
+
+ // 用户表单相关状态
+ const { isOpen: isUserModalOpen, onOpen: onUserModalOpen, onClose: onUserModalClose } = useDisclosure();
+ const [isEdit, setIsEdit] = useState(false);
+ const [userForm, setUserForm] = useState({
+ user: '',
+ pwd: '',
+ status: 1,
+ flow: 100,
+ num: 10,
+ expTime: null,
+ flowResetTime: 1
+ });
+ const [userFormLoading, setUserFormLoading] = useState(false);
+
+ // 隧道权限管理相关状态
+ const { isOpen: isTunnelModalOpen, onOpen: onTunnelModalOpen, onClose: onTunnelModalClose } = useDisclosure();
+ const [currentUser, setCurrentUser] = useState(null);
+ const [userTunnels, setUserTunnels] = useState([]);
+ const [tunnelListLoading, setTunnelListLoading] = useState(false);
+
+ // 分配新隧道权限相关状态
+ const [tunnelForm, setTunnelForm] = useState({
+ tunnelId: null,
+ flow: 100,
+ num: 10,
+ expTime: null,
+ flowResetTime: 1,
+ speedId: null
+ });
+ const [assignLoading, setAssignLoading] = useState(false);
+
+ // 编辑隧道权限相关状态
+ const { isOpen: isEditTunnelModalOpen, onOpen: onEditTunnelModalOpen, onClose: onEditTunnelModalClose } = useDisclosure();
+ const [editTunnelForm, setEditTunnelForm] = useState(null);
+ const [editTunnelLoading, setEditTunnelLoading] = useState(false);
+
+ // 其他数据
+ const [tunnels, setTunnels] = useState([]);
+ const [speedLimits, setSpeedLimits] = useState([]);
+
+ // 生命周期
+ useEffect(() => {
+ loadUsers();
+ loadTunnels();
+ loadSpeedLimits();
+ }, [pagination.current, pagination.size, searchKeyword]);
+
+ // 数据加载函数
+ const loadUsers = async () => {
+ setLoading(true);
+ try {
+ const response = await getAllUsers({
+ current: pagination.current,
+ size: pagination.size,
+ keyword: searchKeyword
+ });
+
+ if (response.code === 0) {
+ const data = response.data || {};
+ setUsers(data.records || []);
+ setPagination(prev => ({
+ ...prev,
+ total: data.total || 0,
+ current: data.current || 1,
+ size: data.size || 10
+ }));
+ } else {
+ toast.error(response.msg || '获取用户列表失败');
+ }
+ } catch (error) {
+ toast.error('获取用户列表失败');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const loadTunnels = async () => {
+ try {
+ const response = await getTunnelList();
+ if (response.code === 0) {
+ setTunnels(response.data || []);
+ }
+ } catch (error) {
+ console.error('获取隧道列表失败:', error);
+ }
+ };
+
+ const loadSpeedLimits = async () => {
+ try {
+ const response = await getSpeedLimitList();
+ if (response.code === 0) {
+ setSpeedLimits(response.data || []);
+ }
+ } catch (error) {
+ console.error('获取限速规则列表失败:', error);
+ }
+ };
+
+ const loadUserTunnels = async (userId: number) => {
+ setTunnelListLoading(true);
+ try {
+ const response = await getUserTunnelList({ userId });
+ if (response.code === 0) {
+ setUserTunnels(response.data || []);
+ } else {
+ toast.error(response.msg || '获取隧道权限列表失败');
+ }
+ } catch (error) {
+ toast.error('获取隧道权限列表失败');
+ } finally {
+ setTunnelListLoading(false);
+ }
+ };
+
+ // 用户管理操作
+ const handleSearch = () => {
+ setPagination(prev => ({ ...prev, current: 1 }));
+ loadUsers();
+ };
+
+ const handleAdd = () => {
+ setIsEdit(false);
+ setUserForm({
+ user: '',
+ pwd: '',
+ status: 1,
+ flow: 100,
+ num: 10,
+ expTime: null,
+ flowResetTime: 1
+ });
+ onUserModalOpen();
+ };
+
+ const handleEdit = (user: User) => {
+ setIsEdit(true);
+ setUserForm({
+ id: user.id,
+ name: user.name,
+ user: user.user,
+ pwd: '',
+ status: user.status,
+ flow: user.flow,
+ num: user.num,
+ expTime: user.expTime ? new Date(user.expTime) : null,
+ flowResetTime: user.flowResetTime || 1
+ });
+ onUserModalOpen();
+ };
+
+ const handleDelete = async (user: User) => {
+ try {
+ const response = await deleteUser(user.id);
+ if (response.code === 0) {
+ toast.success('删除成功');
+ loadUsers();
+ } else {
+ toast.error(response.msg || '删除失败');
+ }
+ } catch (error) {
+ toast.error('删除失败');
+ }
+ };
+
+ const handleSubmitUser = async () => {
+ if (!userForm.user || (!userForm.pwd && !isEdit) || !userForm.expTime) {
+ toast.error('请填写完整信息');
+ return;
+ }
+
+ setUserFormLoading(true);
+ try {
+ const submitData: any = {
+ ...userForm,
+ expTime: userForm.expTime.getTime()
+ };
+
+ if (isEdit && !submitData.pwd) {
+ delete submitData.pwd;
+ }
+
+ const response = isEdit ? await updateUser(submitData) : await createUser(submitData);
+
+ if (response.code === 0) {
+ toast.success(isEdit ? '更新成功' : '创建成功');
+ onUserModalClose();
+ loadUsers();
+ } else {
+ toast.error(response.msg || (isEdit ? '更新失败' : '创建失败'));
+ }
+ } catch (error) {
+ toast.error(isEdit ? '更新失败' : '创建失败');
+ } finally {
+ setUserFormLoading(false);
+ }
+ };
+
+ // 隧道权限管理操作
+ const handleManageTunnels = (user: User) => {
+ setCurrentUser(user);
+ setTunnelForm({
+ tunnelId: null,
+ flow: 100,
+ num: 10,
+ expTime: null,
+ flowResetTime: 1,
+ speedId: null
+ });
+ onTunnelModalOpen();
+ loadUserTunnels(user.id);
+ };
+
+ const handleAssignTunnel = async () => {
+ if (!tunnelForm.tunnelId || !tunnelForm.expTime || !currentUser) {
+ toast.error('请填写完整信息');
+ return;
+ }
+
+ setAssignLoading(true);
+ try {
+ const response = await assignUserTunnel({
+ userId: currentUser.id,
+ tunnelId: tunnelForm.tunnelId,
+ flow: tunnelForm.flow,
+ num: tunnelForm.num,
+ expTime: tunnelForm.expTime.getTime(),
+ flowResetTime: tunnelForm.flowResetTime,
+ speedId: tunnelForm.speedId
+ });
+
+ if (response.code === 0) {
+ toast.success('分配成功');
+ setTunnelForm({
+ tunnelId: null,
+ flow: 100,
+ num: 10,
+ expTime: null,
+ flowResetTime: 1,
+ speedId: null
+ });
+ loadUserTunnels(currentUser.id);
+ } else {
+ toast.error(response.msg || '分配失败');
+ }
+ } catch (error) {
+ toast.error('分配失败');
+ } finally {
+ setAssignLoading(false);
+ }
+ };
+
+ const handleEditTunnel = (userTunnel: UserTunnel) => {
+ setEditTunnelForm({
+ ...userTunnel,
+ expTime: userTunnel.expTime
+ });
+ onEditTunnelModalOpen();
+ };
+
+ const handleUpdateTunnel = async () => {
+ if (!editTunnelForm) return;
+
+ setEditTunnelLoading(true);
+ try {
+ const response = await updateUserTunnel({
+ id: editTunnelForm.id,
+ flow: editTunnelForm.flow,
+ num: editTunnelForm.num,
+ expTime: editTunnelForm.expTime,
+ flowResetTime: editTunnelForm.flowResetTime,
+ speedId: editTunnelForm.speedId
+ });
+
+ if (response.code === 0) {
+ toast.success('更新成功');
+ onEditTunnelModalClose();
+ if (currentUser) {
+ loadUserTunnels(currentUser.id);
+ }
+ } else {
+ toast.error(response.msg || '更新失败');
+ }
+ } catch (error) {
+ toast.error('更新失败');
+ } finally {
+ setEditTunnelLoading(false);
+ }
+ };
+
+ const handleRemoveTunnel = async (userTunnel: UserTunnel) => {
+ try {
+ const response = await removeUserTunnel({ id: userTunnel.id });
+ if (response.code === 0) {
+ toast.success('删除成功');
+ if (currentUser) {
+ loadUserTunnels(currentUser.id);
+ }
+ } else {
+ toast.error(response.msg || '删除失败');
+ }
+ } catch (error) {
+ toast.error('删除失败');
+ }
+ };
+
+ // 过滤数据
+ const availableTunnels = tunnels.filter(
+ tunnel => !userTunnels.some(ut => ut.tunnelId === tunnel.id)
+ );
+
+ const availableSpeedLimits = speedLimits.filter(
+ speedLimit => speedLimit.tunnelId === tunnelForm.tunnelId
+ );
+
+ const editAvailableSpeedLimits = speedLimits.filter(
+ speedLimit => speedLimit.tunnelId === editTunnelForm?.tunnelId
+ );
+
+ return (
+
+
+ {/* 页面头部 */}
+
+
+
+
用户管理
+
+
+
+
+ setSearchKeyword(e.target.value)}
+ placeholder="搜索用户名"
+ startContent={}
+ onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
+ className="flex-1"
+ classNames={{
+ base: "bg-default-100",
+ input: "bg-transparent",
+ inputWrapper: "bg-default-100 border-2 border-default-200 hover:border-default-300 focus-within:border-primary data-[hover=true]:border-default-300"
+ }}
+ />
+
+
+
+
}
+ onClick={handleAdd}
+ className="min-h-8"
+ >
+ 新增用户
+
+
+
+
+ {/* 用户列表 */}
+ {loading ? (
+
+ ) : users.length === 0 ? (
+
+
+
+
+
+
+
+
暂无用户数据
+
还没有创建任何用户,点击上方按钮开始创建
+
+
+
+
+ ) : (
+
+ {users.map((user) => {
+ const userStatus = getUserStatus(user);
+ const expStatus = user.expTime ? getExpireStatus(user.expTime) : null;
+ const usedFlow = calculateUserTotalUsedFlow(user);
+ const flowPercent = user.flow > 0 ? Math.min((usedFlow / (user.flow * 1024 * 1024 * 1024)) * 100, 100) : 0;
+
+ return (
+
+
+
+
+
+ {user.name || user.user}
+
+
@{user.user}
+
+
+
+ {userStatus.text}
+
+
+
+
+
+
+
+ {/* 流量信息 */}
+
+
+ 流量限制
+ {formatFlow(user.flow, 'gb')}
+
+
+ 已使用
+ {formatFlow(usedFlow)}
+
+
+
+ {/* 其他信息 */}
+
+
+ 转发数量
+ {user.num}
+
+
+ 重置日期
+ 每月{user.flowResetTime || 1}号
+
+ {user.expTime && (
+
+
过期时间
+
+
{formatDate(user.expTime)}
+ {expStatus && (
+
+ {expStatus.text}
+
+ )}
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+ {/* 分页 */}
+ {pagination.total > 0 && (
+
+
setPagination(prev => ({ ...prev, current: page }))}
+ showControls
+ showShadow
+ />
+
+ )}
+
+ {/* 用户表单模态框 */}
+
+
+
+ {isEdit ? '编辑用户' : '新增用户'}
+
+
+
+ setUserForm(prev => ({ ...prev, user: e.target.value }))}
+ isRequired
+ />
+ setUserForm(prev => ({ ...prev, pwd: e.target.value }))}
+ placeholder={isEdit ? '留空则不修改密码' : '请输入密码'}
+ isRequired={!isEdit}
+ />
+ setUserForm(prev => ({ ...prev, flow: Number(e.target.value) || 0 }))}
+ isRequired
+ />
+ setUserForm(prev => ({ ...prev, num: Number(e.target.value) || 0 }))}
+ isRequired
+ />
+
+ {
+ if (date) {
+ const jsDate = new Date(date.year, date.month - 1, date.day);
+ setUserForm(prev => ({ ...prev, expTime: jsDate }));
+ } else {
+ setUserForm(prev => ({ ...prev, expTime: null }));
+ }
+ }}
+ isRequired
+ showMonthAndYearPickers
+ className="cursor-pointer"
+ />
+
+
+ setUserForm(prev => ({ ...prev, status: Number(value) }))}
+ orientation="horizontal"
+ >
+ 正常
+ 禁用
+
+
+
+
+
+
+
+
+
+ {/* 隧道权限管理模态框 */}
+
+
+
+ 用户 {currentUser?.user} 的隧道权限管理
+
+
+
+
+
+
+
+
+
+
+ setTunnelForm(prev => ({ ...prev, flow: Number(e.target.value) || 0 }))}
+ />
+
+ setTunnelForm(prev => ({ ...prev, num: Number(e.target.value) || 0 }))}
+ />
+
+
+
+ {
+ if (date) {
+ const jsDate = new Date(date.year, date.month - 1, date.day);
+ setTunnelForm(prev => ({ ...prev, expTime: jsDate }));
+ } else {
+ setTunnelForm(prev => ({ ...prev, expTime: null }));
+ }
+ }}
+ showMonthAndYearPickers
+ className="cursor-pointer"
+ />
+
+
+
+
+
+
+
+
+
+ 隧道名称
+ 流量统计
+ 转发数量
+ 限速规则
+ 重置时间
+ 到期时间
+ 操作
+
+ }
+ emptyContent="暂无隧道权限"
+ >
+ {(userTunnel) => (
+
+ {userTunnel.tunnelName}
+
+
+
+ 限制:
+ {formatFlow(userTunnel.flow, 'gb')}
+
+
+ 已用:
+
+ {formatFlow(calculateTunnelUsedFlow(userTunnel))}
+
+
+
+
+ {userTunnel.num}
+
+
+ {userTunnel.speedLimitName || '不限速'}
+
+
+ 每月{userTunnel.flowResetTime}号
+ {formatDate(userTunnel.expTime)}
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ {/* 编辑隧道权限模态框 */}
+
+
+
+ 编辑隧道权限 - {editTunnelForm?.tunnelName}
+
+
+ {editTunnelForm && (
+
+ setEditTunnelForm(prev => prev ? { ...prev, flow: Number(e.target.value) || 0 } : null)}
+ />
+
+ setEditTunnelForm(prev => prev ? { ...prev, num: Number(e.target.value) || 0 } : null)}
+ />
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/vite-frontend/src/provider.tsx b/vite-frontend/src/provider.tsx
index 73855bd..ba941b7 100644
--- a/vite-frontend/src/provider.tsx
+++ b/vite-frontend/src/provider.tsx
@@ -5,6 +5,7 @@ import { HeroUIProvider } from "@heroui/system";
import { useHref, useNavigate } from "react-router-dom";
import { Toaster } from 'react-hot-toast';
import { ThemeProvider } from '@/components/theme-provider';
+import { I18nProvider } from "@react-aria/i18n";
declare module "@react-types/shared" {
interface RouterConfig {
@@ -20,34 +21,36 @@ export function Provider({ children }: ProvidersProps) {
const navigate = useNavigate();
return (
-
-
- {children}
-
+
+
+ {children}
+
-
-
+ error: {
+ style: {
+ background: '#ef4444',
+ color: '#ffffff',
+ },
+ },
+ }}
+ />
+
+
+
);
}
diff --git a/vite-frontend/src/types/index.ts b/vite-frontend/src/types/index.ts
index cece4a4..d42edf7 100644
--- a/vite-frontend/src/types/index.ts
+++ b/vite-frontend/src/types/index.ts
@@ -3,3 +3,82 @@ import { SVGProps } from "react";
export type IconSvgProps = SVGProps & {
size?: number;
};
+
+// 用户管理相关类型
+export interface User {
+ id: number;
+ name?: string;
+ user: string;
+ pwd?: string;
+ status: number; // 1-正常, 0-禁用
+ flow: number; // 流量限制(GB)
+ num: number; // 转发数量
+ expTime?: number; // 过期时间戳
+ flowResetTime?: number; // 流量重置日期(1-31号)
+ createdTime?: number; // 创建时间戳
+ inFlow?: number; // 下载流量(字节)
+ outFlow?: number; // 上传流量(字节)
+}
+
+export interface UserForm {
+ id?: number;
+ name?: string;
+ user: string;
+ pwd?: string;
+ status: number;
+ flow: number;
+ num: number;
+ expTime: Date | null;
+ flowResetTime: number;
+}
+
+export interface UserTunnel {
+ id: number;
+ userId: number;
+ tunnelId: number;
+ tunnelName: string;
+ status: number; // 1-正常, 0-禁用
+ flow: number; // 流量限制(GB)
+ num: number; // 转发数量
+ expTime: number; // 过期时间戳
+ flowResetTime: number; // 流量重置日期
+ speedId?: number | null; // 限速规则ID
+ speedLimitName?: string; // 限速规则名称
+ inFlow?: number; // 下载流量(字节)
+ outFlow?: number; // 上传流量(字节)
+ tunnelFlow?: number; // 隧道流量计算类型(1-单向, 2-双向)
+}
+
+export interface UserTunnelForm {
+ tunnelId: number | null;
+ flow: number;
+ num: number;
+ expTime: Date | null;
+ flowResetTime: number;
+ speedId: number | null;
+}
+
+export interface Tunnel {
+ id: number;
+ name: string;
+ entryNodeId: number;
+ exitNodeId: number;
+ entryNodeName?: string;
+ exitNodeName?: string;
+ status?: number;
+ flow?: number; // 流量计算类型
+}
+
+export interface SpeedLimit {
+ id: number;
+ name: string;
+ tunnelId: number;
+ uploadSpeed: number;
+ downloadSpeed: number;
+}
+
+export interface Pagination {
+ current: number;
+ size: number;
+ total: number;
+}
diff --git a/vue-frontend/nginx.conf b/vue-frontend/nginx.conf
index 51a0831..e384fe0 100644
--- a/vue-frontend/nginx.conf
+++ b/vue-frontend/nginx.conf
@@ -76,12 +76,5 @@ http {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
-
- # config.js 文件已经在容器启动时生成,这里只需要设置缓存策略
- location /config.js {
- add_header Cache-Control "no-cache, no-store, must-revalidate";
- add_header Pragma "no-cache";
- add_header Expires "0";
- }
}
}
\ No newline at end of file