diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index fa70ed5..50edc2c 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -6,6 +6,7 @@ on: - main paths: - 'vue-frontend/**' + - 'vite-frontend/**' - 'springboot-backend/**' - '.github/workflows/docker-build.yml' @@ -51,6 +52,47 @@ jobs: -t bqlpfy/vue-frontend \ ./vue-frontend + build-vite: + if: contains(github.event.head_commit.message, '[skip vite]') == false + name: Build & Push Vite Frontend + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Check for Vite changes + uses: dorny/paths-filter@v2 + id: vite-changes + with: + filters: | + vite: + - 'vite-frontend/**' + + - name: Set up Node.js + if: steps.vite-changes.outputs.vite == 'true' + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Set up Docker Buildx + if: steps.vite-changes.outputs.vite == 'true' + uses: docker/setup-buildx-action@v2 + + - name: Log in to DockerHub + if: steps.vite-changes.outputs.vite == 'true' + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} + + - name: Build and push Vite multi-arch image + if: steps.vite-changes.outputs.vite == 'true' + run: | + docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --push \ + -t bqlpfy/vite-frontend \ + ./vite-frontend + build-java: if: contains(github.event.head_commit.message, '[skip java]') == false name: Build & Push Spring Boot Backend diff --git a/go-gost/gost b/go-gost/gost index 1a88086..548d29f 100755 Binary files a/go-gost/gost and b/go-gost/gost differ diff --git a/go-gost/ss/config.json b/go-gost/ss/config.json new file mode 100644 index 0000000..7da7fde --- /dev/null +++ b/go-gost/ss/config.json @@ -0,0 +1,4 @@ +{ + "addr": "127.0.0.1:6365", + "secret": "0fb968b83a234441b290eb17e872b928" +} \ No newline at end of file diff --git a/go-gost/ss/gost b/go-gost/ss/gost new file mode 100755 index 0000000..548d29f Binary files /dev/null and b/go-gost/ss/gost differ diff --git a/go-gost/ss/gost.json b/go-gost/ss/gost.json new file mode 100644 index 0000000..8e75042 --- /dev/null +++ b/go-gost/ss/gost.json @@ -0,0 +1,3 @@ +{ + "services": null +} diff --git a/vite-frontend/Dockerfile b/vite-frontend/Dockerfile new file mode 100644 index 0000000..f6e5f14 --- /dev/null +++ b/vite-frontend/Dockerfile @@ -0,0 +1,21 @@ +# 多阶段构建 - 构建阶段 +FROM node:20.19.0 AS builder + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +# 生产阶段 +FROM nginx:stable-alpine AS production-stage + +COPY nginx.conf /etc/nginx/nginx.conf + +COPY --from=builder /app/dist /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/vite-frontend/nginx.conf b/vite-frontend/nginx.conf new file mode 100644 index 0000000..e384fe0 --- /dev/null +++ b/vite-frontend/nginx.conf @@ -0,0 +1,80 @@ +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # 日志格式 + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + error_log /var/log/nginx/error.log warn; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + + # gzip压缩 + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied any; + gzip_comp_level 6; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/json + application/javascript + application/xml+rss + application/atom+xml + image/svg+xml; + + server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # 静态文件缓存 + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Vue Router的history模式支持 + location / { + try_files $uri $uri/ /index.html; + } + + # API代理到后端 + location ^~ /api/v1/ { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://backend:6365/api/v1/; + } + + # WebSocket代理 - system-info + location /system-info { + proxy_pass http://backend:6365/system-info; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } +} \ No newline at end of file diff --git a/vite-frontend/package.json b/vite-frontend/package.json index 01d66d2..c4fbfb9 100644 --- a/vite-frontend/package.json +++ b/vite-frontend/package.json @@ -15,20 +15,27 @@ "@heroui/button": "2.2.22", "@heroui/card": "^2.2.21", "@heroui/chip": "^2.2.18", + "@heroui/date-picker": "^2.3.22", "@heroui/divider": "^2.2.15", "@heroui/dropdown": "^2.3.22", "@heroui/input": "2.4.22", "@heroui/link": "2.2.19", "@heroui/modal": "^2.2.19", "@heroui/navbar": "2.2.20", + "@heroui/pagination": "^2.2.20", + "@heroui/progress": "^2.2.18", + "@heroui/radio": "^2.3.21", "@heroui/select": "^2.4.22", "@heroui/spacer": "^2.2.16", "@heroui/spinner": "^2.2.19", "@heroui/switch": "2.2.20", "@heroui/system": "2.4.18", "@heroui/table": "^2.2.21", + "@heroui/tabs": "^2.2.19", "@heroui/theme": "2.4.17", + "@heroui/tooltip": "^2.2.19", "@heroui/use-theme": "2.1.9", + "@internationalized/date": "^3.8.2", "@react-aria/visually-hidden": "3.8.25", "@react-types/shared": "3.30.0", "axios": "^1.10.0", diff --git a/vite-frontend/src/App.tsx b/vite-frontend/src/App.tsx index 564a61e..2985b02 100644 --- a/vite-frontend/src/App.tsx +++ b/vite-frontend/src/App.tsx @@ -5,6 +5,9 @@ import IndexPage from "@/pages/index"; import ChangePasswordPage from "@/pages/change-password"; import DashboardPage from "@/pages/dashboard"; import ForwardPage from "@/pages/forward"; +import TunnelPage from "@/pages/tunnel"; +import NodePage from "@/pages/node"; +import UserPage from "@/pages/user"; import LimitPage from "@/pages/limit"; import PageWrapper from "@/components/page-wrapper"; import { isLoggedIn } from "@/utils/auth"; @@ -80,9 +83,7 @@ function App() { path="/tunnel" element={ - -
-
+
} /> @@ -90,9 +91,7 @@ function App() { path="/node" element={ - -
-
+
} /> @@ -100,9 +99,7 @@ function App() { path="/user" element={ - -
-
+
} /> diff --git a/vite-frontend/src/components/icons.tsx b/vite-frontend/src/components/icons.tsx index fe0dbfa..a597693 100644 --- a/vite-frontend/src/components/icons.tsx +++ b/vite-frontend/src/components/icons.tsx @@ -66,3 +66,196 @@ export const SunFilledIcon = ({ ); + +export const SearchIcon = ({ + size = 24, + width, + height, + ...props +}: IconSvgProps) => ( + +); + +export const PlusIcon = ({ + size = 24, + width, + height, + ...props +}: IconSvgProps) => ( + +); + +export const EditIcon = ({ + size = 24, + width, + height, + ...props +}: IconSvgProps) => ( + +); + +export const DeleteIcon = ({ + size = 24, + width, + height, + ...props +}: IconSvgProps) => ( + +); + +export const UserIcon = ({ + size = 24, + width, + height, + ...props +}: IconSvgProps) => ( + +); + +export const SettingsIcon = ({ + size = 24, + width, + height, + ...props +}: IconSvgProps) => ( + +); diff --git a/vite-frontend/src/pages/forward.tsx b/vite-frontend/src/pages/forward.tsx index 5469234..5cf4720 100644 --- a/vite-frontend/src/pages/forward.tsx +++ b/vite-frontend/src/pages/forward.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } 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 { Select, SelectItem } from "@heroui/select"; import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/modal"; import { Chip } from "@heroui/chip"; @@ -36,6 +37,7 @@ interface Forward { serviceRunning: boolean; createdTime: string; userName?: string; + userId?: number; } interface Tunnel { @@ -47,6 +49,7 @@ interface Tunnel { interface ForwardForm { id?: number; + userId?: number; name: string; tunnelId: number | null; inPort: number | null; @@ -190,6 +193,7 @@ export default function ForwardPage() { setIsEdit(true); setForm({ id: forward.id, + userId: forward.userId, name: forward.name, tunnelId: forward.tunnelId, inPort: forward.inPort, @@ -264,18 +268,27 @@ export default function ForwardPage() { let res; if (isEdit) { - res = await updateForward({ - ...form, + // 更新时确保包含必要字段 + const updateData = { + id: form.id, + userId: form.userId, + name: form.name, + tunnelId: form.tunnelId, + inPort: form.inPort, remoteAddr: processedRemoteAddr, strategy: addressCount > 1 ? form.strategy : 'fifo' - }); + }; + res = await updateForward(updateData); } else { - const { id, ...createData } = form; - res = await createForward({ - ...createData, + // 创建时不需要id和userId(后端会自动设置) + const createData = { + name: form.name, + tunnelId: form.tunnelId, + inPort: form.inPort, remoteAddr: processedRemoteAddr, strategy: addressCount > 1 ? form.strategy : 'fifo' - }); + }; + res = await createForward(createData); } if (res.code === 0) { @@ -545,14 +558,14 @@ export default function ForwardPage() { const strategyDisplay = getStrategyDisplay(forward.strategy); return ( - - + +
-

{forward.name}

-

{forward.tunnelName}

+

{forward.name}

+

{forward.tunnelName}

-
+
{statusDisplay.text}
- -
+ + +
{/* 地址信息 */} -
+
showAddressModal(forward.inIp, forward.inPort, '入口地址')} title={formatInAddress(forward.inIp, forward.inPort)} > -
入口地址
- +
+ 入口 + {hasMultipleAddresses(forward.inIp) && ( + + + + )} +
+ {formatInAddress(forward.inIp, forward.inPort)}
-
↓
+
+ + + +
showAddressModal(forward.remoteAddr, null, '目标地址')} title={formatRemoteAddress(forward.remoteAddr)} > -
目标地址
- +
+ 目标 + {hasMultipleAddresses(forward.remoteAddr) && ( + + + + )} +
+ {formatRemoteAddress(forward.remoteAddr)}
- {/* 策略和流量 */} -
-
-
策略
- + {/* 统计信息 */} +
+
+ 策略: + {strategyDisplay.text}
-
-
总流量
-
+
+
流量
+
{formatFlow((forward.inFlow || 0) + (forward.outFlow || 0))}
-
+
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} + +
+ setForm(prev => ({ ...prev, name: e.target.value }))} + isInvalid={!!errors.name} + errorMessage={errors.name} + variant="bordered" + /> + + setForm(prev => ({ ...prev, serverIp: e.target.value }))} + isInvalid={!!errors.serverIp} + errorMessage={errors.serverIp} + variant="bordered" + /> + +