重构前端ui

This commit is contained in:
qaq
2025-07-03 11:12:42 +08:00
parent f1cab0563a
commit 47696e928b
17 changed files with 3388 additions and 85 deletions
+78 -43
View File
@@ -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 (
<Card key={forward.id} className="shadow-sm border border-gray-200 dark:border-gray-700">
<CardHeader className="pb-3">
<Card key={forward.id} className="shadow-sm border border-divider hover:shadow-md transition-shadow duration-200">
<CardHeader className="pb-2">
<div className="flex justify-between items-start w-full">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground truncate">{forward.name}</h3>
<p className="text-sm text-default-500 truncate mt-1">{forward.tunnelName}</p>
<h3 className="font-semibold text-foreground truncate text-sm">{forward.name}</h3>
<p className="text-xs text-default-500 truncate">{forward.tunnelName}</p>
</div>
<div className="flex items-center gap-2 ml-2">
<div className="flex items-center gap-1.5 ml-2">
<Switch
size="sm"
isSelected={forward.serviceRunning}
@@ -563,71 +576,91 @@ export default function ForwardPage() {
color={statusDisplay.color as any}
variant="flat"
size="sm"
className="text-xs"
>
{statusDisplay.text}
</Chip>
</div>
</div>
</CardHeader>
<CardBody className="pt-0">
<div className="space-y-3">
<CardBody className="pt-0 pb-3">
<div className="space-y-2">
{/* 地址信息 */}
<div className="space-y-2">
<div className="space-y-1.5">
<div
className={`cursor-pointer p-2 bg-green-50 dark:bg-green-500/10 rounded border border-green-200 dark:border-green-500/20 ${
hasMultipleAddresses(forward.inIp) ? 'hover:bg-green-100 dark:hover:bg-green-500/20' : ''
className={`cursor-pointer p-2 bg-default-50 dark:bg-default-100/50 rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${
hasMultipleAddresses(forward.inIp) ? 'hover:bg-default-100 dark:hover:bg-default-200/50' : ''
}`}
onClick={() => showAddressModal(forward.inIp, forward.inPort, '入口地址')}
title={formatInAddress(forward.inIp, forward.inPort)}
>
<div className="text-xs text-green-600 dark:text-green-400 mb-1">入口地址</div>
<code className="text-xs font-mono text-green-700 dark:text-green-300 truncate block">
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-medium text-default-600">入口</span>
{hasMultipleAddresses(forward.inIp) && (
<svg className="w-3 h-3 text-default-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</div>
<code className="text-xs font-mono text-foreground block truncate">
{formatInAddress(forward.inIp, forward.inPort)}
</code>
</div>
<div className="text-center text-default-400 text-xs">↓</div>
<div className="text-center py-0.5">
<svg className="w-3 h-3 text-default-400 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</div>
<div
className={`cursor-pointer p-2 bg-blue-50 dark:bg-blue-500/10 rounded border border-blue-200 dark:border-blue-500/20 ${
hasMultipleAddresses(forward.remoteAddr) ? 'hover:bg-blue-100 dark:hover:bg-blue-500/20' : ''
className={`cursor-pointer p-2 bg-default-50 dark:bg-default-100/50 rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${
hasMultipleAddresses(forward.remoteAddr) ? 'hover:bg-default-100 dark:hover:bg-default-200/50' : ''
}`}
onClick={() => showAddressModal(forward.remoteAddr, null, '目标地址')}
title={formatRemoteAddress(forward.remoteAddr)}
>
<div className="text-xs text-blue-600 dark:text-blue-400 mb-1">目标地址</div>
<code className="text-xs font-mono text-blue-700 dark:text-blue-300 truncate block">
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-medium text-default-600">目标</span>
{hasMultipleAddresses(forward.remoteAddr) && (
<svg className="w-3 h-3 text-default-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</div>
<code className="text-xs font-mono text-foreground block truncate">
{formatRemoteAddress(forward.remoteAddr)}
</code>
</div>
</div>
{/* 策略和流量 */}
<div className="flex justify-between items-center pt-2 border-t border-gray-200 dark:border-default-200">
<div className="text-center flex-1">
<div className="text-xs text-default-500 mb-1">策略</div>
<Chip color={strategyDisplay.color as any} variant="flat" size="sm">
{/* 统计信息 */}
<div className="flex justify-between items-center pt-2 border-t border-divider">
<div className="flex items-center gap-1">
<span className="text-xs text-default-500">策略:</span>
<Chip color={strategyDisplay.color as any} variant="flat" size="sm" className="text-xs">
{strategyDisplay.text}
</Chip>
</div>
<div className="text-center flex-1">
<div className="text-xs text-default-500 mb-1">总流量</div>
<div className="text-xs font-medium text-primary">
<div className="text-right">
<div className="text-xs text-default-500">流量</div>
<div className="text-xs font-medium text-foreground">
{formatFlow((forward.inFlow || 0) + (forward.outFlow || 0))}
</div>
</div>
</div>
</div>
<div className="flex gap-2 mt-4">
<div className="flex gap-1.5 mt-3">
<Button
size="sm"
variant="flat"
color="primary"
onPress={() => handleEdit(forward)}
className="flex-1"
className="flex-1 min-h-8"
startContent={
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
}
@@ -639,9 +672,9 @@ export default function ForwardPage() {
variant="flat"
color="danger"
onPress={() => handleDelete(forward)}
className="flex-1"
className="flex-1 min-h-8"
startContent={
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" clipRule="evenodd" />
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z" clipRule="evenodd" />
</svg>
@@ -719,7 +752,7 @@ export default function ForwardPage() {
variant="bordered"
>
{tunnels.map((tunnel) => (
<SelectItem key={tunnel.id} value={tunnel.id}>
<SelectItem key={tunnel.id} >
{tunnel.name}
</SelectItem>
))}
@@ -744,15 +777,17 @@ export default function ForwardPage() {
}
/>
<Input
<Textarea
label="远程地址"
placeholder="请输入远程地址,多个地址用换行分隔"
placeholder="请输入远程地址,多个地址用换行分隔&#10;例如:&#10;192.168.1.100:8080&#10;example.com:3000"
value={form.remoteAddr}
onChange={(e) => setForm(prev => ({ ...prev, remoteAddr: e.target.value }))}
isInvalid={!!errors.remoteAddr}
errorMessage={errors.remoteAddr}
variant="bordered"
description="格式: IP:端口 或 域名:端口,支持多个地址(每行一个)"
minRows={3}
maxRows={6}
/>
{getAddressCount(form.remoteAddr) > 1 && (
@@ -767,9 +802,9 @@ export default function ForwardPage() {
variant="bordered"
description="多个目标地址的负载均衡策略"
>
<SelectItem key="fifo" value="fifo">主备模式 - 自上而下</SelectItem>
<SelectItem key="round" value="round">轮询模式 - 依次轮换</SelectItem>
<SelectItem key="rand" value="rand">随机模式 - 随机选择</SelectItem>
<SelectItem key="fifo" >主备模式 - 自上而下</SelectItem>
<SelectItem key="round" >轮询模式 - 依次轮换</SelectItem>
<SelectItem key="rand" >随机模式 - 随机选择</SelectItem>
</Select>
)}
</div>
+800
View File
@@ -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<Node[]>([]);
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<NodeForm>({
id: null,
name: '',
ipString: '',
serverIp: '',
portSta: 1000,
portEnd: 65535
});
const [errors, setErrors] = useState<Record<string, string>>({});
const websocketRef = useRef<WebSocket | null>(null);
const reconnectTimerRef = useRef<NodeJS.Timeout | null>(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<string, string> = {};
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 (
<AdminLayout>
<div className="px-3 lg:px-6 py-8">
{/* 页面头部 */}
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-foreground">节点管理</h1>
<Button
color="primary"
onPress={handleAdd}
startContent={
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" />
</svg>
}
>
新增节点
</Button>
</div>
{/* 节点列表 */}
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="flex items-center gap-3">
<Spinner size="sm" />
<span className="text-default-600">正在加载...</span>
</div>
</div>
) : nodeList.length === 0 ? (
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
<CardBody className="text-center py-16">
<div className="flex flex-col items-center gap-4">
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-default-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 12h14M5 12l4-4m-4 4l4 4" />
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-foreground">暂无节点配置</h3>
<p className="text-default-500 text-sm mt-1">还没有创建任何节点配置,点击上方按钮开始创建</p>
</div>
</div>
</CardBody>
</Card>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{nodeList.map((node) => (
<Card
key={node.id}
className="shadow-sm border border-divider hover:shadow-md transition-shadow duration-200"
>
<CardHeader className="pb-2">
<div className="flex justify-between items-start w-full">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground truncate text-sm">{node.name}</h3>
<p className="text-xs text-default-500 truncate">{node.serverIp}</p>
</div>
<div className="flex items-center gap-1.5 ml-2">
<Chip
color={node.connectionStatus === 'online' ? 'success' : 'danger'}
variant="flat"
size="sm"
className="text-xs"
>
{node.connectionStatus === 'online' ? '在线' : '离线'}
</Chip>
</div>
</div>
</CardHeader>
<CardBody className="pt-0 pb-3">
{/* 基础信息 */}
<div className="space-y-2 mb-4">
<div className="flex justify-between text-sm">
<span className="text-default-600">服务器</span>
<span className="font-mono text-xs">{node.serverIp}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-default-600">入口IP</span>
<div className="text-right text-xs">
{node.ip ? (
node.ip.split(',').length > 1 ? (
<span className="font-mono">{node.ip.split(',')[0].trim()} +{node.ip.split(',').length - 1}个</span>
) : (
<span className="font-mono">{node.ip.trim()}</span>
)
) : '-'}
</div>
</div>
<div className="flex justify-between text-sm">
<span className="text-default-600">端口</span>
<span className="text-xs">{node.portSta}-{node.portEnd}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-default-600">版本</span>
<span className="text-xs">{node.version || '未知'}</span>
</div>
</div>
{/* 系统监控 */}
<div className="space-y-3 mb-4">
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex justify-between text-xs mb-1">
<span>CPU</span>
<span className="font-mono">
{node.connectionStatus === 'online' && node.systemInfo
? `${node.systemInfo.cpuUsage.toFixed(1)}%`
: '-'
}
</span>
</div>
<Progress
value={node.connectionStatus === 'online' && node.systemInfo ? node.systemInfo.cpuUsage : 0}
color={getProgressColor(
node.connectionStatus === 'online' && node.systemInfo ? node.systemInfo.cpuUsage : 0,
node.connectionStatus !== 'online'
)}
size="sm"
aria-label="CPU使用率"
/>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span>内存</span>
<span className="font-mono">
{node.connectionStatus === 'online' && node.systemInfo
? `${node.systemInfo.memoryUsage.toFixed(1)}%`
: '-'
}
</span>
</div>
<Progress
value={node.connectionStatus === 'online' && node.systemInfo ? node.systemInfo.memoryUsage : 0}
color={getProgressColor(
node.connectionStatus === 'online' && node.systemInfo ? node.systemInfo.memoryUsage : 0,
node.connectionStatus !== 'online'
)}
size="sm"
aria-label="内存使用率"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="text-center p-2 bg-default-50 dark:bg-default-100 rounded">
<div className="text-default-600 mb-0.5">上传</div>
<div className="font-mono">
{node.connectionStatus === 'online' && node.systemInfo
? formatSpeed(node.systemInfo.uploadSpeed)
: '-'
}
</div>
</div>
<div className="text-center p-2 bg-default-50 dark:bg-default-100 rounded">
<div className="text-default-600 mb-0.5">下载</div>
<div className="font-mono">
{node.connectionStatus === 'online' && node.systemInfo
? formatSpeed(node.systemInfo.downloadSpeed)
: '-'
}
</div>
</div>
</div>
</div>
{/* 操作按钮 */}
<div className="flex gap-1.5">
<Button
size="sm"
variant="flat"
color="success"
onPress={() => handleCopyInstallCommand(node)}
isLoading={node.copyLoading}
className="flex-1 min-h-8"
>
复制
</Button>
<Button
size="sm"
variant="flat"
color="primary"
onPress={() => handleEdit(node)}
className="flex-1 min-h-8"
>
编辑
</Button>
<Button
size="sm"
variant="flat"
color="danger"
onPress={() => handleDelete(node)}
className="flex-1 min-h-8"
>
删除
</Button>
</div>
</CardBody>
</Card>
))}
</div>
)}
{/* 新增/编辑节点对话框 */}
<Modal
isOpen={dialogVisible}
onClose={() => setDialogVisible(false)}
size="2xl"
scrollBehavior="inside"
>
<ModalContent>
<ModalHeader>{dialogTitle}</ModalHeader>
<ModalBody>
<div className="space-y-4">
<Input
label="节点名称"
placeholder="请输入节点名称"
value={form.name}
onChange={(e) => setForm(prev => ({ ...prev, name: e.target.value }))}
isInvalid={!!errors.name}
errorMessage={errors.name}
variant="bordered"
/>
<Input
label="服务器IP"
placeholder="请输入服务器IP地址,如: 192.168.1.100 或 example.com"
value={form.serverIp}
onChange={(e) => setForm(prev => ({ ...prev, serverIp: e.target.value }))}
isInvalid={!!errors.serverIp}
errorMessage={errors.serverIp}
variant="bordered"
/>
<Textarea
label="入口IP"
placeholder="一行一个IP地址或域名,例如:&#10;192.168.1.100&#10;example.com"
value={form.ipString}
onChange={(e) => setForm(prev => ({ ...prev, ipString: e.target.value }))}
isInvalid={!!errors.ipString}
errorMessage={errors.ipString}
variant="bordered"
minRows={3}
maxRows={5}
description="支持多个IP,每行一个地址"
/>
<div className="grid grid-cols-2 gap-4">
<Input
label="起始端口"
type="number"
placeholder="1000"
value={form.portSta.toString()}
onChange={(e) => setForm(prev => ({ ...prev, portSta: parseInt(e.target.value) || 1000 }))}
isInvalid={!!errors.portSta}
errorMessage={errors.portSta}
variant="bordered"
min={1}
max={65535}
/>
<Input
label="结束端口"
type="number"
placeholder="65535"
value={form.portEnd.toString()}
onChange={(e) => setForm(prev => ({ ...prev, portEnd: parseInt(e.target.value) || 65535 }))}
isInvalid={!!errors.portEnd}
errorMessage={errors.portEnd}
variant="bordered"
min={1}
max={65535}
/>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button
variant="flat"
onPress={() => setDialogVisible(false)}
>
取消
</Button>
<Button
color="primary"
onPress={handleSubmit}
isLoading={submitLoading}
>
{submitLoading ? '提交中...' : '确定'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
</AdminLayout>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff