import { useState, useEffect, useMemo, useCallback } from "react"; import toast from "react-hot-toast"; import { DndContext, KeyboardSensor, MouseSensor, TouchSensor, type DragEndEvent, useSensor, useSensors, } from "@dnd-kit/core"; import { SortableContext, arrayMove, rectSortingStrategy, sortableKeyboardCoordinates, useSortable, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { SearchBar } from "@/components/search-bar"; import { AnimatedPage } from "@/components/animated-page"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card"; import { Button } from "@/shadcn-bridge/heroui/button"; import { Input } from "@/shadcn-bridge/heroui/input"; import { Textarea } from "@/shadcn-bridge/heroui/input"; import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, } from "@/shadcn-bridge/heroui/modal"; import { Chip } from "@/shadcn-bridge/heroui/chip"; import { Switch } from "@/shadcn-bridge/heroui/switch"; import { Spinner } from "@/shadcn-bridge/heroui/spinner"; import { Alert } from "@/shadcn-bridge/heroui/alert"; import { Progress } from "@/shadcn-bridge/heroui/progress"; import { Accordion, AccordionItem } from "@/shadcn-bridge/heroui/accordion"; import { Select, SelectItem } from "@/shadcn-bridge/heroui/select"; import { Checkbox } from "@/shadcn-bridge/heroui/checkbox"; import { createNode, getNodeList, updateNode, deleteNode, getNodeInstallCommand, updateNodeOrder, batchDeleteNodes, upgradeNode, batchUpgradeNodes, getNodeReleases, rollbackNode, getPeerRemoteUsageList, type ReleaseChannel, } from "@/api"; import { PageEmptyState, PageLoadingState } from "@/components/page-state"; import { getConnectionStatusMeta, getRemoteSyncErrorMessage, } from "@/pages/node/display"; import { tryCopyInstallCommand } from "@/pages/node/install-command"; import { getNodeRenewalSnapshot, formatNodeRenewalTime, getNodeRenewalCycleLabel, type NodeRenewalCycle, } from "@/pages/node/renewal"; import { buildNodeSystemInfo } from "@/pages/node/system-info"; import { useNodeOfflineTimers } from "@/pages/node/use-node-offline-timers"; import { useNodeRealtime } from "@/pages/node/use-node-realtime"; import { useLocalStorageState } from "@/hooks/use-local-storage-state"; import { loadStoredOrder, saveOrder } from "@/utils/order-storage"; const NODE_FALLBACK_REFRESH_INTERVAL_MS = 15000; interface Node { id: number; inx?: number; name: string; remark?: string; expiryTime?: number; renewalCycle?: NodeRenewalCycle; ip: string; serverIp: string; serverIpV4?: string; serverIpV6?: string; port: string; tcpListenAddr?: string; udpListenAddr?: string; extraIPs?: string; version?: string; http?: number; // 0 关 1 开 tls?: number; // 0 关 1 开 socks?: number; // 0 关 1 开 status: number; isRemote?: number; remoteUrl?: string; syncError?: string; connectionStatus: "online" | "offline"; systemInfo?: { cpuUsage: number; memoryUsage: number; uploadTraffic: number; downloadTraffic: number; uploadSpeed: number; downloadSpeed: number; uptime: number; } | null; copyLoading?: boolean; upgradeLoading?: boolean; rollbackLoading?: boolean; } interface NodeForm { id: number | null; name: string; remark: string; expiryTime: number; renewalCycle: NodeRenewalCycle; serverHost: string; serverIpV4: string; serverIpV6: string; port: string; tcpListenAddr: string; udpListenAddr: string; interfaceName: string; extraIPs: string; http: number; // 0 关 1 开 tls: number; // 0 关 1 开 socks: number; // 0 关 1 开 } type NodeTab = "local" | "remote"; interface RemoteUsageBinding { bindingId: number; tunnelId: number; tunnelName: string; chainType: number; hopInx: number; allocatedPort: number; resourceKey: string; remoteBindingId: string; updatedTime: number; } interface RemoteUsageNode { nodeId: number; nodeName: string; remoteUrl: string; shareId: number; portRangeStart: number; portRangeEnd: number; maxBandwidth: number; currentFlow: number; usedPorts: number[]; bindings: RemoteUsageBinding[]; activeBindingNum: number; syncError?: string; } const EXPIRING_SOON_DAYS = 7; type NodeExpiryState = "permanent" | "healthy" | "expiringSoon" | "expired"; type NodeFilterMode = "all" | "expiringSoon" | "expired" | "withExpiry"; const getNodeReminderEnabled = (node: Node): boolean => { return !!node.expiryTime && node.expiryTime > 0 && !!node.renewalCycle; }; const getNodeExpiryMeta = (timestamp?: number, cycle?: NodeRenewalCycle) => { const renewal = getNodeRenewalSnapshot(timestamp, cycle, EXPIRING_SOON_DAYS); if (renewal.state === "unset") { return { state: "permanent" as NodeExpiryState, label: "未设置续费周期", tone: "default" as const, accentClassName: "", bannerClassName: "", isHighlighted: false, sortWeight: 3, nextDueTime: undefined, }; } if (renewal.state === "expired") { return { state: "expired" as NodeExpiryState, label: "已过期", tone: "danger" as const, accentClassName: "border-red-300/80 bg-red-50/70 shadow-red-100 dark:border-red-500/40 dark:bg-red-950/20", bannerClassName: "bg-red-50 text-red-700 dark:bg-red-950/30 dark:text-red-300", isHighlighted: true, sortWeight: 0, nextDueTime: renewal.nextDueTime, }; } if (renewal.state === "dueSoon") { return { state: "expiringSoon" as NodeExpiryState, label: renewal.label, tone: "warning" as const, accentClassName: "border-amber-300/80 bg-amber-50/80 shadow-amber-100 dark:border-amber-500/40 dark:bg-amber-950/20", bannerClassName: "bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300", isHighlighted: true, sortWeight: 1, nextDueTime: renewal.nextDueTime, }; } return { state: "healthy" as NodeExpiryState, label: renewal.label, tone: "success" as const, accentClassName: "", bannerClassName: "", isHighlighted: false, sortWeight: 2, nextDueTime: renewal.nextDueTime, }; }; const mergeNodeRealtimeState = ( incomingNode: Node, existingNode?: Node, ): Node => { return { ...incomingNode, systemInfo: existingNode?.systemInfo ?? incomingNode.systemInfo ?? null, copyLoading: existingNode?.copyLoading ?? incomingNode.copyLoading ?? false, upgradeLoading: existingNode?.upgradeLoading ?? incomingNode.upgradeLoading ?? false, rollbackLoading: existingNode?.rollbackLoading ?? incomingNode.rollbackLoading ?? false, }; }; const SortableItem = ({ id, children, }: { id: number; children: (listeners: any) => any; }) => { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id }); const style: React.CSSProperties = { transform: transform ? CSS.Transform.toString({ ...transform, x: Math.round(transform.x), y: Math.round(transform.y), }) : undefined, transition: isDragging ? undefined : transition || undefined, opacity: isDragging ? 0.5 : 1, willChange: isDragging ? "transform" : undefined, }; return (
{children(listeners)}
); }; export default function NodePage() { const [nodeList, setNodeList] = useState([]); const [nodeOrder, setNodeOrder] = useState([]); const [loading, setLoading] = useState(false); const [localSearchKeyword, setLocalSearchKeyword] = useLocalStorageState( "node-search-keyword-local", "", ); const [remoteSearchKeyword, setRemoteSearchKeyword] = useLocalStorageState( "node-search-keyword-remote", "", ); const [activeTab, setActiveTab] = useLocalStorageState( "node-active-tab", "local", ); const [remoteUsageMap, setRemoteUsageMap] = useState< Record >({}); const [nodeFilterMode, setNodeFilterMode, resetNodeFilterMode] = useLocalStorageState("node-expiry-filter-mode", "all"); const [isSearchVisible, setIsSearchVisible] = useState(false); const [isFilterModalOpen, setIsFilterModalOpen] = useState(false); const [dialogVisible, setDialogVisible] = useState(false); const [dialogTitle, setDialogTitle] = useState(""); const [isEdit, setIsEdit] = useState(false); const [submitLoading, setSubmitLoading] = useState(false); const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [deleteLoading, setDeleteLoading] = useState(false); const [nodeToDelete, setNodeToDelete] = useState(null); const [protocolDisabled, setProtocolDisabled] = useState(false); const [protocolDisabledReason, setProtocolDisabledReason] = useState(""); const [form, setForm] = useState({ id: null, name: "", remark: "", expiryTime: 0, renewalCycle: "", serverHost: "", serverIpV4: "", serverIpV6: "", port: "1000-65535", tcpListenAddr: "[::]", udpListenAddr: "[::]", interfaceName: "", extraIPs: "", http: 0, tls: 0, socks: 0, }); const [errors, setErrors] = useState>({}); const [selectMode, setSelectMode] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false); const [batchLoading, setBatchLoading] = useState(false); // 安装命令相关状态 const [installCommandModal, setInstallCommandModal] = useState(false); const [installCommand, setInstallCommand] = useState(""); const [currentNodeName, setCurrentNodeName] = useState(""); const [installSelectorOpen, setInstallSelectorOpen] = useState(false); const [installTargetNode, setInstallTargetNode] = useState(null); const [installChannel, setInstallChannel] = useState("stable"); // 升级相关状态 const [upgradeModalOpen, setUpgradeModalOpen] = useState(false); const [upgradeTarget, setUpgradeTarget] = useState<"single" | "batch">( "single", ); const [upgradeTargetNodeId, setUpgradeTargetNodeId] = useState( null, ); const [releases, setReleases] = useState< Array<{ version: string; name: string; publishedAt: string; prerelease: boolean; channel: ReleaseChannel; }> >([]); const [releasesLoading, setReleasesLoading] = useState(false); const [releaseChannel, setReleaseChannel] = useState("stable"); const [selectedVersion, setSelectedVersion] = useState(""); const [batchUpgradeLoading, setBatchUpgradeLoading] = useState(false); const [upgradeProgress, setUpgradeProgress] = useState< Record >({}); const handleNodeOffline = useCallback((nodeId: number) => { setNodeList((prev) => prev.map((node) => { if (node.id !== nodeId) return node; if (node.connectionStatus === "offline" && node.systemInfo === null) { return node; } return { ...node, connectionStatus: "offline", systemInfo: null }; }), ); }, []); const { clearOfflineTimer, scheduleNodeOffline } = useNodeOfflineTimers({ delayMs: 3000, onNodeOffline: handleNodeOffline, }); const loadRemoteUsage = useCallback(async () => { try { const res = await getPeerRemoteUsageList(); if (res.code === 0 && Array.isArray(res.data)) { const nextMap: Record = {}; (res.data as unknown as RemoteUsageNode[]).forEach((item) => { if (!item || typeof item.nodeId !== "number") return; nextMap[item.nodeId] = item; }); setRemoteUsageMap(nextMap); } } catch { // ignore remote usage errors in node page } }, []); // 加载节点列表 const loadNodes = useCallback(async (options?: { silent?: boolean }) => { const silent = options?.silent ?? false; if (!silent) { setLoading(true); } try { const res = await getNodeList(); if (res.code === 0) { const nodesData: Node[] = (res.data || []).map((node: any) => ({ ...node, inx: node.inx ?? 0, connectionStatus: node.syncError ? "offline" : node.status === 1 ? "online" : "offline", syncError: node.syncError || undefined, systemInfo: null, copyLoading: false, })); setNodeList((prev) => { const previousById = new Map(prev.map((node) => [node.id, node])); return nodesData.map((node) => mergeNodeRealtimeState(node, previousById.get(node.id)), ); }); // 优先使用数据库中的 inx 字段进行排序,否则回退到本地排序 const hasDbOrdering = nodesData.some( (n) => n.inx !== undefined && n.inx !== 0, ); if (hasDbOrdering) { const dbOrder = [...nodesData] .sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0)) .map((n) => n.id); setNodeOrder(dbOrder); } else { setNodeOrder( loadStoredOrder( "node-order", nodesData.map((n) => n.id), ), ); } } else { if (!silent) { toast.error(res.msg || "加载节点列表失败"); } } } catch { if (!silent) { toast.error("网络错误,请重试"); } } finally { if (!silent) { setLoading(false); } } }, []); // 处理WebSocket消息 const handleWebSocketMessage = (data: any) => { const { id, type, data: messageData } = data; const nodeId = Number(id); if (Number.isNaN(nodeId)) return; if (type === "status") { if (messageData === 1) { clearOfflineTimer(nodeId); setNodeList((prev) => prev.map((node) => { if (node.id !== nodeId) return node; if (node.connectionStatus === "online") return node; return { ...node, connectionStatus: "online" }; }), ); } else { // 离线事件做延迟处理,避免短抖动导致频繁闪烁 scheduleNodeOffline(nodeId); } } else if (type === "info") { clearOfflineTimer(nodeId); setNodeList((prev) => prev.map((node) => { if (node.id === nodeId) { const systemInfo = buildNodeSystemInfo( messageData, node.systemInfo, ); if (!systemInfo) { return node; } return { ...node, connectionStatus: "online", systemInfo, }; } return node; }), ); } else if (type === "upgrade_progress") { try { const progressData = typeof messageData === "string" ? JSON.parse(messageData) : messageData; if (progressData?.data) { setUpgradeProgress((prev) => ({ ...prev, [nodeId]: { stage: progressData.data.stage || "", percent: progressData.data.percent || 0, message: progressData.message || "", }, })); } } catch { // ignore parse errors } } }; const { wsConnected, wsConnecting, usingPollingFallback } = useNodeRealtime({ onMessage: handleWebSocketMessage, }); useEffect(() => { loadNodes(); loadRemoteUsage(); }, [loadNodes, loadRemoteUsage]); useEffect(() => { setSelectedIds(new Set()); }, [activeTab]); useEffect(() => { if (!usingPollingFallback) { return; } void loadNodes({ silent: true }); const interval = window.setInterval(() => { void loadNodes({ silent: true }); }, NODE_FALLBACK_REFRESH_INTERVAL_MS); return () => { window.clearInterval(interval); }; }, [loadNodes, usingPollingFallback]); // 格式化速度 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 formatUptime = (seconds: number): string => { if (seconds === 0) return "-"; const days = Math.floor(seconds / 86400); const hours = Math.floor((seconds % 86400) / 3600); const minutes = Math.floor((seconds % 3600) / 60); if (days > 0) { return `${days}天${hours}小时`; } else if (hours > 0) { return `${hours}小时${minutes}分钟`; } else { return `${minutes}分钟`; } }; // 格式化流量 const formatTraffic = (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 formatFlow = (bytes: number): string => { if (!Number.isFinite(bytes) || bytes <= 0) { return "0 B"; } if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`; if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; }; const formatChainType = (chainType: number, hopInx: number) => { if (chainType === 1) { return "入口节点"; } if (chainType === 2) { return `中继跳点 #${hopInx}`; } if (chainType === 3) { return "出口节点"; } return "未知链路"; }; // 获取进度条颜色 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"; }; // IPv4/IPv6 格式验证(仅用于判定地址族) 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]?)$/; 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]))$/; const validateIpv4Literal = (ip: string): boolean => ipv4Regex.test(ip.trim()); const validateIpv6Literal = (ip: string): boolean => ipv6Regex.test(ip.trim()); // Hostname/domain validation (no scheme/port) const hostnameRegex = /^(?=.{1,253}$)(?:[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 validateHostname = (host: string): boolean => { const v = host.trim(); if (!v) return false; if (v === "localhost") return true; return hostnameRegex.test(v); }; // 验证端口格式:支持 80,443,100-600 const validatePort = ( portStr: string, ): { valid: boolean; error?: string } => { if (!portStr || !portStr.trim()) { return { valid: false, error: "请输入端口" }; } const trimmed = portStr.trim(); const parts = trimmed .split(",") .map((p) => p.trim()) .filter((p) => p); if (parts.length === 0) { return { valid: false, error: "请输入有效的端口" }; } for (const part of parts) { // 检查是否是端口范围 (如 100-600) if (part.includes("-")) { const range = part.split("-").map((p) => p.trim()); if (range.length !== 2) { return { valid: false, error: `端口范围格式错误: ${part}` }; } const start = parseInt(range[0]); const end = parseInt(range[1]); if (isNaN(start) || isNaN(end)) { return { valid: false, error: `端口必须是数字: ${part}` }; } if (start < 1 || start > 65535 || end < 1 || end > 65535) { return { valid: false, error: `端口范围必须在 1-65535 之间: ${part}`, }; } if (start >= end) { return { valid: false, error: `起始端口必须小于结束端口: ${part}` }; } } else { // 单个端口 const port = parseInt(part); if (isNaN(port)) { return { valid: false, error: `端口必须是数字: ${part}` }; } if (port < 1 || port > 65535) { return { valid: false, error: `端口必须在 1-65535 之间: ${part}` }; } } } return { valid: true }; }; // 表单验证 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.renewalCycle && !form.expiryTime) || (!form.renewalCycle && form.expiryTime) ) { newErrors.expiryTime = "请同时设置续费周期和续费基准时间"; } const v4 = form.serverIpV4.trim(); const v6 = form.serverIpV6.trim(); const host = form.serverHost.trim(); if (!v4 && !v6 && !host) { const msg = "请至少填写一个 IPv4/IPv6 地址或域名"; newErrors.serverIpV4 = msg; newErrors.serverIpV6 = msg; newErrors.serverHost = msg; } else { if (v4 && !validateIpv4Literal(v4)) { newErrors.serverIpV4 = "请输入有效的IPv4地址"; } if (v6 && !validateIpv6Literal(v6)) { newErrors.serverIpV6 = "请输入有效的IPv6地址"; } if (host && !validateHostname(host)) { newErrors.serverHost = "请输入有效的域名/主机名"; } } const portValidation = validatePort(form.port); if (!portValidation.valid) { newErrors.port = portValidation.error || "端口格式错误"; } setErrors(newErrors); return Object.keys(newErrors).length === 0; }; // 新增节点 const handleAdd = () => { setDialogTitle("新增节点"); setIsEdit(false); setDialogVisible(true); resetForm(); setProtocolDisabled(true); setProtocolDisabledReason("节点未在线,等待节点上线后再设置"); }; // 编辑节点 const handleEdit = (node: Node) => { setDialogTitle("编辑节点"); setIsEdit(true); const legacy = (node.serverIp || "").trim(); const normalizedV4 = node.serverIpV4?.trim() || (validateIpv4Literal(legacy) ? legacy : ""); const normalizedV6 = node.serverIpV6?.trim() || (validateIpv6Literal(legacy) ? legacy : ""); const normalizedHost = !normalizedV4 && !normalizedV6 && legacy ? legacy : ""; setForm({ id: node.id, name: node.name, remark: node.remark || "", expiryTime: node.expiryTime || 0, renewalCycle: node.renewalCycle || "", serverHost: normalizedHost, serverIpV4: normalizedV4, serverIpV6: normalizedV6, port: node.port || "1000-65535", tcpListenAddr: node.tcpListenAddr || "[::]", udpListenAddr: node.udpListenAddr || "[::]", interfaceName: (node as any).interfaceName || "", extraIPs: node.extraIPs || "", http: typeof node.http === "number" ? node.http : 1, tls: typeof node.tls === "number" ? node.tls : 1, socks: typeof node.socks === "number" ? node.socks : 1, }); const offline = node.connectionStatus !== "online"; setProtocolDisabled(offline); setProtocolDisabledReason( offline ? "节点未在线,等待节点上线后再设置" : "", ); setDialogVisible(true); }; // 删除节点 const handleDelete = (node: Node) => { setNodeToDelete(node); setDeleteModalOpen(true); }; const confirmDelete = async () => { if (!nodeToDelete) return; setDeleteLoading(true); try { const res = await deleteNode(nodeToDelete.id); if (res.code === 0) { toast.success("删除成功"); setNodeList((prev) => prev.filter((n) => n.id !== nodeToDelete.id)); setDeleteModalOpen(false); setNodeToDelete(null); } else { toast.error(res.msg || "删除失败"); } } catch { toast.error("网络错误,请重试"); } finally { setDeleteLoading(false); } }; const openInstallSelector = (node: Node) => { setInstallTargetNode(node); setInstallChannel("stable"); setInstallSelectorOpen(true); }; // 复制安装命令 const handleCopyInstallCommand = async ( node: Node, channel: ReleaseChannel, ) => { setNodeList((prev) => prev.map((n) => (n.id === node.id ? { ...n, copyLoading: true } : n)), ); try { const res = await getNodeInstallCommand(node.id, channel); if (res.code === 0 && res.data) { const copied = await tryCopyInstallCommand(res.data); if (copied) { toast.success( `${channel === "stable" ? "正式版" : "测试版"}安装命令已复制到剪贴板`, ); } else { setInstallCommand(res.data); setCurrentNodeName(node.name); setInstallCommandModal(true); } } else { toast.error(res.msg || "获取安装命令失败"); } } catch { toast.error("获取安装命令失败"); } finally { setNodeList((prev) => prev.map((n) => (n.id === node.id ? { ...n, copyLoading: false } : n)), ); } }; const handleConfirmInstallCommand = async () => { if (!installTargetNode) return; setInstallSelectorOpen(false); await handleCopyInstallCommand(installTargetNode, installChannel); }; // 手动复制安装命令 const handleManualCopy = async () => { try { await navigator.clipboard.writeText(installCommand); toast.success("安装命令已复制到剪贴板"); setInstallCommandModal(false); } catch { toast.error("复制失败,请手动选择文本复制"); } }; const loadReleasesByChannel = useCallback(async (channel: ReleaseChannel) => { setReleasesLoading(true); try { const res = await getNodeReleases(channel); if (res.code === 0 && Array.isArray(res.data)) { setReleases(res.data); } else { toast.error(res.msg || "获取版本列表失败"); } } catch { toast.error("获取版本列表失败"); } finally { setReleasesLoading(false); } }, []); // 打开版本选择弹窗 const openUpgradeModal = async ( target: "single" | "batch", nodeId?: number, ) => { const defaultChannel: ReleaseChannel = "stable"; setUpgradeTarget(target); setUpgradeTargetNodeId(nodeId || null); setReleaseChannel(defaultChannel); setSelectedVersion(""); setUpgradeModalOpen(true); await loadReleasesByChannel(defaultChannel); }; // 确认升级(从版本弹窗) const handleConfirmUpgrade = async () => { const version = selectedVersion || undefined; if (upgradeTarget === "single" && upgradeTargetNodeId) { setUpgradeModalOpen(false); // Find the node const node = nodeList.find((n) => n.id === upgradeTargetNodeId); if (!node) return; setNodeList((prev) => prev.map((n) => n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: true } : n, ), ); try { const res = await upgradeNode( upgradeTargetNodeId, version, releaseChannel, ); if (res.code === 0) { toast.success(`节点升级命令已发送,节点将自动重启`); } else { toast.error(res.msg || "升级失败"); } } catch { toast.error("网络错误,请重试"); } finally { setNodeList((prev) => prev.map((n) => n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: false } : n, ), ); } } else if (upgradeTarget === "batch") { const selectedLocalIds = Array.from(selectedIds).filter((id) => { const matchedNode = nodeList.find((node) => node.id === id); return matchedNode?.isRemote !== 1; }); if (selectedLocalIds.length === 0) { toast.error("请选择本地节点进行升级"); setUpgradeModalOpen(false); return; } setBatchUpgradeLoading(true); setUpgradeModalOpen(false); try { const res = await batchUpgradeNodes( selectedLocalIds, version, releaseChannel, ); if (res.code === 0) { toast.success(`批量升级命令已发送到 ${selectedLocalIds.length} 个节点`); } else { toast.error(res.msg || "批量升级失败"); } } catch { toast.error("网络错误,请重试"); } finally { setBatchUpgradeLoading(false); } } }; // 回退节点 const handleRollbackNode = async (node: Node) => { setNodeList((prev) => prev.map((n) => (n.id === node.id ? { ...n, rollbackLoading: true } : n)), ); try { const res = await rollbackNode(node.id); if (res.code === 0) { toast.success(`节点 ${node.name} 回退命令已发送,节点将自动重启`); } else { toast.error(res.msg || "回退失败"); } } catch { toast.error("网络错误,请重试"); } finally { setNodeList((prev) => prev.map((n) => n.id === node.id ? { ...n, rollbackLoading: false } : n, ), ); } }; // 提交表单 const handleSubmit = async () => { if (!validateForm()) return; setSubmitLoading(true); try { const apiCall = isEdit ? updateNode : createNode; const { serverHost, ...rest } = form; const data = { ...rest, remark: form.remark.trim(), expiryTime: form.expiryTime, renewalCycle: form.renewalCycle, extraIPs: form.extraIPs, serverIp: form.serverIpV4?.trim() || form.serverIpV6?.trim() || serverHost?.trim() || "", }; 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, remark: form.remark.trim(), expiryTime: form.expiryTime, renewalCycle: form.renewalCycle, serverIp: form.serverIpV4?.trim() || form.serverIpV6?.trim() || form.serverHost?.trim() || "", serverIpV4: form.serverIpV4, serverIpV6: form.serverIpV6, port: form.port, tcpListenAddr: form.tcpListenAddr, udpListenAddr: form.udpListenAddr, interfaceName: form.interfaceName, http: form.http, tls: form.tls, socks: form.socks, } : n, ), ); } else { loadNodes(); } } else { toast.error(res.msg || (isEdit ? "更新失败" : "创建失败")); } } catch { toast.error("网络错误,请重试"); } finally { setSubmitLoading(false); } }; // 重置表单 const resetForm = () => { setForm({ id: null, name: "", remark: "", expiryTime: 0, renewalCycle: "", serverHost: "", serverIpV4: "", serverIpV6: "", port: "1000-65535", tcpListenAddr: "[::]", udpListenAddr: "[::]", interfaceName: "", extraIPs: "", http: 0, tls: 0, socks: 0, }); setErrors({}); }; // 处理拖拽结束 const handleDragEnd = async (event: DragEndEvent) => { const { active, over } = event; if (!active || !over || active.id === over.id) return; if (!nodeOrder || nodeOrder.length === 0) return; const activeId = Number(active.id); const overId = Number(over.id); if (isNaN(activeId) || isNaN(overId)) return; const displayNodeIds = displayNodes.map((node) => node.id); const oldIndex = displayNodeIds.indexOf(activeId); const newIndex = displayNodeIds.indexOf(overId); if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return; const reorderedDisplayIds = arrayMove(displayNodeIds, oldIndex, newIndex); const displayIdSet = new Set(displayNodeIds); let reorderedDisplayIndex = 0; const newOrder = nodeOrder.map((id) => { if (!displayIdSet.has(id)) { return id; } const nextId = reorderedDisplayIds[reorderedDisplayIndex]; reorderedDisplayIndex += 1; return nextId; }); setNodeOrder(newOrder); saveOrder("node-order", newOrder); // 持久化到数据库 try { const nodesToUpdate = newOrder.map((id, index) => ({ id, inx: index })); const response = await updateNodeOrder({ nodes: nodesToUpdate }); if (response.code === 0) { setNodeList((prev) => prev.map((node) => { const updated = nodesToUpdate.find((n) => n.id === node.id); return updated ? { ...node, inx: updated.inx } : node; }), ); } else { toast.error("保存排序失败:" + (response.msg || "未知错误")); } } catch { toast.error("保存排序失败,请重试"); } }; // 批量操作处理函数 const toggleSelectMode = () => { setSelectMode((prev) => { if (prev) { setSelectedIds(new Set()); } return !prev; }); }; const toggleSelect = (id: number) => { setSelectedIds((prev) => { const next = new Set(prev); if (next.has(id)) { next.delete(id); } else { next.add(id); } return next; }); }; const selectAll = () => { setSelectedIds(new Set(displayNodes.map((n) => n.id))); }; const deselectAll = () => { setSelectedIds(new Set()); }; const handleBatchDelete = async () => { if (selectedIds.size === 0) return; setBatchLoading(true); try { const res = await batchDeleteNodes(Array.from(selectedIds)); if (res.code === 0) { toast.success(`成功删除 ${selectedIds.size} 个节点`); setNodeList((prev) => prev.filter((n) => !selectedIds.has(n.id))); setSelectedIds(new Set()); setBatchDeleteModalOpen(false); setSelectMode(false); } else { toast.error(res.msg || "删除失败"); } } catch { toast.error("网络错误,请重试"); } finally { setBatchLoading(false); } }; // 传感器配置 const sensors = useSensors( useSensor(MouseSensor, { activationConstraint: { distance: 8, }, }), useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 8, }, }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), ); const nodeExpiryStats = useMemo(() => { return nodeList.reduce( (acc, node) => { if (node.isRemote === 1) { return acc; } const meta = getNodeExpiryMeta(node.expiryTime, node.renewalCycle); if (meta.state === "expired") acc.expired += 1; if (meta.state === "expiringSoon") acc.expiringSoon += 1; if (getNodeReminderEnabled(node)) { acc.withExpiry += 1; } return acc; }, { expired: 0, expiringSoon: 0, withExpiry: 0 }, ); }, [nodeList]); // 根据排序顺序获取节点列表 const sortedNodes = useMemo((): Node[] => { if (!nodeList || nodeList.length === 0) return []; const sortedByDb = [...nodeList].sort((a, b) => { const expiryDiff = getNodeExpiryMeta(a.expiryTime, a.renewalCycle).sortWeight - getNodeExpiryMeta(b.expiryTime, b.renewalCycle).sortWeight; if (expiryDiff !== 0) { return expiryDiff; } const aInx = a.inx ?? 0; const bInx = b.inx ?? 0; return aInx - bInx; }); // 如果数据库中没有排序信息,则使用本地存储的顺序 if ( nodeOrder && nodeOrder.length > 0 && sortedByDb.every((n) => n.inx === undefined || n.inx === 0) ) { const nodeMap = new Map(nodeList.map((n) => [n.id, n] as const)); const localSorted: Node[] = []; nodeOrder.forEach((id) => { const node = nodeMap.get(id); if (node) localSorted.push(node); }); nodeList.forEach((node) => { if (!nodeOrder.includes(node.id)) { localSorted.push(node); } }); return localSorted; } return sortedByDb; }, [nodeList, nodeOrder]); const filterNodesByKeyword = useCallback((nodes: Node[], keyword: string) => { const normalizedKeyword = keyword.trim().toLowerCase(); if (!normalizedKeyword) { return nodes; } return nodes.filter( (node) => (node.name && node.name.toLowerCase().includes(normalizedKeyword)) || (node.remark && node.remark.toLowerCase().includes(normalizedKeyword)) || (node.serverIp && node.serverIp.toLowerCase().includes(normalizedKeyword)) || (node.serverIpV4 && node.serverIpV4.toLowerCase().includes(normalizedKeyword)) || (node.serverIpV6 && node.serverIpV6.toLowerCase().includes(normalizedKeyword)), ); }, []); const localNodes = useMemo( () => sortedNodes.filter((node) => node.isRemote !== 1), [sortedNodes], ); const remoteNodes = useMemo( () => sortedNodes.filter((node) => node.isRemote === 1), [sortedNodes], ); const filteredLocalNodes = useMemo( () => { const keywordFiltered = filterNodesByKeyword(localNodes, localSearchKeyword); if (nodeFilterMode === "all") { return keywordFiltered; } return keywordFiltered.filter((node) => { const expiryMeta = getNodeExpiryMeta( node.expiryTime, node.renewalCycle, ); switch (nodeFilterMode) { case "expiringSoon": return expiryMeta.state === "expiringSoon"; case "expired": return expiryMeta.state === "expired"; case "withExpiry": return getNodeReminderEnabled(node); default: return true; } }); }, [filterNodesByKeyword, localNodes, localSearchKeyword, nodeFilterMode], ); const filteredRemoteNodes = useMemo( () => filterNodesByKeyword(remoteNodes, remoteSearchKeyword), [filterNodesByKeyword, remoteNodes, remoteSearchKeyword], ); const currentSearchKeyword = activeTab === "remote" ? remoteSearchKeyword : localSearchKeyword; const setCurrentSearchKeyword = activeTab === "remote" ? setRemoteSearchKeyword : setLocalSearchKeyword; const displayNodes = useMemo( () => (activeTab === "remote" ? filteredRemoteNodes : filteredLocalNodes), [activeTab, filteredLocalNodes, filteredRemoteNodes], ); const canBatchUpgrade = activeTab === "local"; const canUseExpiryFilter = activeTab === "local"; const hasKeywordSearch = currentSearchKeyword.trim().length > 0; const hasActiveFilters = nodeFilterMode !== "all"; const isDisplayFiltered = hasKeywordSearch || (canUseExpiryFilter && hasActiveFilters); const sortableNodeIds = useMemo( () => displayNodes.map((n) => n.id), [displayNodes], ); return (
setIsSearchVisible(false)} onOpen={() => setIsSearchVisible(true)} />
{selectMode ? ( <> 已选择 {selectedIds.size} 项 ) : ( <> {/* 筛选按钮 */} )}
{!wsConnected && ( )} {/* 节点列表 */} {loading ? ( ) : nodeList.length === 0 ? ( ) : displayNodes.length === 0 ? ( ) : (
{displayNodes.map((node) => { const isRemoteNode = node.isRemote === 1; const remoteUsage = isRemoteNode ? remoteUsageMap[node.id] : null; const expiryMeta = getNodeExpiryMeta( node.expiryTime, node.renewalCycle, ); return ( {(listeners) => (
{selectMode && ( toggleSelect(node.id)} /> )}

{node.name}

{isRemoteNode && ( 远程 )} {(() => { const connectionStatusMeta = getConnectionStatusMeta( node.connectionStatus, ); return ( {connectionStatusMeta.text} ); })()} {node.expiryTime && node.expiryTime > 0 && node.renewalCycle && ( {expiryMeta.label} )}
{isRemoteNode && node.syncError && (
{getRemoteSyncErrorMessage(node.syncError)}
)} {/* 基础信息 */}
{node.expiryTime && node.expiryTime > 0 && node.renewalCycle &&
}
IP
{node.serverIpV4?.trim() || node.serverIpV6?.trim() ? (
{node.serverIpV4?.trim() && ( {node.serverIpV4.trim()} )} {node.serverIpV6?.trim() && ( {node.serverIpV6.trim()} )}
) : ( {node.serverIp.trim()} )}
{!isRemoteNode && ( <>
版本 {node.version || "未知"}
{upgradeProgress[node.id] && upgradeProgress[node.id].percent < 100 && (
)}
开机时间 {node.connectionStatus === "online" && node.systemInfo ? formatUptime(node.systemInfo.uptime) : "-"}
)}
{isRemoteNode && (
{remoteUsage ? ( <>
远程地址 {remoteUsage.remoteUrl || node.remoteUrl || "-"}
共享ID #{remoteUsage.shareId}
流量 {formatFlow(remoteUsage.currentFlow)}
带宽上限 {remoteUsage.maxBandwidth > 0 ? formatSpeed(remoteUsage.maxBandwidth) : "不限"}
占用端口 {remoteUsage.usedPorts.length}/{Math.max( remoteUsage.portRangeEnd - remoteUsage.portRangeStart + 1, 0, )}
{remoteUsage.usedPorts.length > 0 ? (
{remoteUsage.usedPorts.map((port) => ( {port} ))}
) : (
暂无占用端口
)}
绑定明细 {remoteUsage.activeBindingNum}
{remoteUsage.bindings.length > 0 ? ( remoteUsage.bindings.map((binding) => (
{binding.tunnelName} #{binding.tunnelId}
{formatChainType(binding.chainType, binding.hopInx)} 端口 {binding.allocatedPort}
)) ) : (
暂无绑定明细
)}
) : (
暂未获取到远程占用数据
)}
)} {!isRemoteNode && ( <> {/* 系统监控 */}
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, ) : "-"}
{/* 流量统计 */}
↑ 上行流量
{node.connectionStatus === "online" && node.systemInfo ? formatTraffic( node.systemInfo.uploadTraffic, ) : "-"}
↓ 下行流量
{node.connectionStatus === "online" && node.systemInfo ? formatTraffic( node.systemInfo.downloadTraffic, ) : "-"}
)}
{node.remark?.trim() && (
{node.remark.trim()}
)} {/* 操作按钮 */}
{!isRemoteNode && (
)}
{!isRemoteNode && ( )}
)} ); })}
)} {/* 新增/编辑节点对话框 */} setDialogVisible(false)} > {dialogTitle}
setForm((prev) => ({ ...prev, name: e.target.value })) } />