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,
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 { 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";
interface Node {
id: number;
inx?: number;
name: string;
ip: string;
serverIp: string;
serverIpV4?: string;
serverIpV6?: string;
port: string;
tcpListenAddr?: string;
udpListenAddr?: 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;
serverHost: string;
serverIpV4: string;
serverIpV6: string;
port: string;
tcpListenAddr: string;
udpListenAddr: string;
interfaceName: string;
http: number; // 0 关 1 开
tls: number; // 0 关 1 开
socks: number; // 0 关 1 开
}
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 [searchKeyword, setSearchKeyword] = useLocalStorageState(
"node-search-keyword",
"",
);
const [isSearchVisible, setIsSearchVisible] = 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: "",
serverHost: "",
serverIpV4: "",
serverIpV6: "",
port: "1000-65535",
tcpListenAddr: "[::]",
udpListenAddr: "[::]",
interfaceName: "",
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 loadNodes = useCallback(async () => {
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(nodesData);
// 优先使用数据库中的 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 {
toast.error(res.msg || "加载节点列表失败");
}
} catch {
toast.error("网络错误,请重试");
} finally {
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 } = useNodeRealtime({
onMessage: handleWebSocketMessage,
});
useEffect(() => {
loadNodes();
}, [loadNodes]);
// 格式化速度
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 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位";
}
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,
serverHost: normalizedHost,
serverIpV4: normalizedV4,
serverIpV6: normalizedV6,
port: node.port || "1000-65535",
tcpListenAddr: node.tcpListenAddr || "[::]",
udpListenAddr: node.udpListenAddr || "[::]",
interfaceName: (node as any).interfaceName || "",
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") {
setBatchUpgradeLoading(true);
setUpgradeModalOpen(false);
try {
const res = await batchUpgradeNodes(
Array.from(selectedIds),
version,
releaseChannel,
);
if (res.code === 0) {
toast.success(`批量升级命令已发送到 ${selectedIds.size} 个节点`);
} 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,
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,
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: "",
serverHost: "",
serverIpV4: "",
serverIpV6: "",
port: "1000-65535",
tcpListenAddr: "[::]",
udpListenAddr: "[::]",
interfaceName: "",
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 oldIndex = nodeOrder.indexOf(activeId);
const newIndex = nodeOrder.indexOf(overId);
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
const newOrder = arrayMove(nodeOrder, oldIndex, newIndex);
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(sortedNodes.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 sortedNodes = useMemo((): Node[] => {
if (!nodeList || nodeList.length === 0) return [];
let filteredNodes = nodeList;
if (searchKeyword.trim()) {
const lowerKeyword = searchKeyword.toLowerCase();
filteredNodes = filteredNodes.filter(
(n) =>
(n.name && n.name.toLowerCase().includes(lowerKeyword)) ||
(n.serverIp && n.serverIp.toLowerCase().includes(lowerKeyword)) ||
(n.serverIpV4 && n.serverIpV4.toLowerCase().includes(lowerKeyword)) ||
(n.serverIpV6 && n.serverIpV6.toLowerCase().includes(lowerKeyword)),
);
}
const sortedByDb = [...filteredNodes].sort((a, b) => {
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(filteredNodes.map((n) => [n.id, n] as const));
const localSorted: Node[] = [];
nodeOrder.forEach((id) => {
const node = nodeMap.get(id);
if (node) localSorted.push(node);
});
filteredNodes.forEach((node) => {
if (!nodeOrder.includes(node.id)) {
localSorted.push(node);
}
});
return localSorted;
}
return sortedByDb;
}, [nodeList, nodeOrder, searchKeyword]);
const sortableNodeIds = useMemo(
() => sortedNodes.map((n) => n.id),
[sortedNodes],
);
return (
setIsSearchVisible(false)}
onOpen={() => setIsSearchVisible(true)}
/>
{selectMode ? (
<>
已选 {selectedIds.size} 项
>
) : (
<>
>
)}
{!wsConnected && (
)}
{/* 节点列表 */}
{loading ? (
) : nodeList.length === 0 ? (
) : (
{sortedNodes.map((node) => {
const isRemoteNode = node.isRemote === 1;
return (
{(listeners) => (
{selectMode && (
toggleSelect(node.id)}
/>
)}
{node.name}
{isRemoteNode && (
远程
)}
{(() => {
const connectionStatusMeta =
getConnectionStatusMeta(
node.connectionStatus,
);
return (
{connectionStatusMeta.text}
);
})()}
{isRemoteNode && node.syncError && (
{getRemoteSyncErrorMessage(node.syncError)}
)}
{/* 基础信息 */}
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 && (
<>
{/* 系统监控 */}
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,
)
: "-"}
>
)}
{/* 操作按钮 */}
{!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}{" "}
个节点吗?
此操作不可恢复,请谨慎操作。
>
)}
);
}