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() && (
)}
{/* 操作按钮 */}
{!isRemoteNode && (
)}
{!isRemoteNode && (
)}
)}
);
})}
)}
{/* 新增/编辑节点对话框 */}
setDialogVisible(false)}
>
{dialogTitle}
{/* 屏蔽协议 */}
屏蔽协议
开启开关以屏蔽对应协议
{protocolDisabled && (
)}
{/* HTTP tile */}
禁用/启用
setForm((prev) => ({
...prev,
http: v ? 1 : 0,
}))
}
/>
{form.http === 1 ? "已开启" : "已关闭"}
{/* TLS tile */}
禁用/启用
setForm((prev) => ({ ...prev, tls: v ? 1 : 0 }))
}
/>
{form.tls === 1 ? "已开启" : "已关闭"}
{/* SOCKS tile */}
禁用/启用
setForm((prev) => ({
...prev,
socks: v ? 1 : 0,
}))
}
/>
{form.socks === 1 ? "已开启" : "已关闭"}
{/* 删除确认模态框 */}
{(onClose) => (
<>
确认删除
确定要删除节点{" "}
"{nodeToDelete?.name}" 吗?
此操作不可恢复,请谨慎操作。
>
)}
{(onClose) => (
<>
选择安装通道
{installTargetNode ? ` - ${installTargetNode.name}` : ""}
>
)}
{/* 安装命令模态框 */}
setInstallCommandModal(false)}
>
安装命令 - {currentNodeName}
请复制以下安装命令到服务器上执行:
💡 提示:如果复制按钮失效,请手动选择上方文本进行复制
{/* 版本选择升级模态框 */}
{(onClose) => (
<>
{upgradeTarget === "batch"
? `批量升级 (${selectedIds.size} 个节点)`
: "升级节点"}
{releasesLoading ? (
) : (
{selectedVersion
? `将升级到版本 ${selectedVersion}`
: `未选择版本,将自动使用最新${releaseChannel === "stable" ? "正式" : "测试"}版`}
)}
>
)}
{/* 批量删除确认模态框 */}
{(onClose) => (
<>
确认删除
确定要删除选中的 {selectedIds.size}{" "}
个节点吗?
此操作不可恢复,请谨慎操作。
>
)}
{(onClose) => (
<>
筛选条件
按到期状态筛选
>
)}
);
}