优化 mqtt 退出路由 不订阅消息

This commit is contained in:
mmc
2026-08-31 11:06:05 +08:00
parent a1922cdeaf
commit db539893ce
5 changed files with 200 additions and 62 deletions

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useMemo } from 'react'; import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { import {
Layout, Card, Row, Col, Statistic, Tag, Button, Table, Layout, Card, Row, Col, Statistic, Tag, Button, Table,
Typography, Space, Progress, Badge, Timeline, message, Typography, Space, Progress, Badge, Timeline, message,
@@ -291,7 +291,7 @@ export default function DeviceOverviewPage() {
}, [selectedDevice, uavState, persistentHostData, persistentDroneData?.wind_speed]); }, [selectedDevice, uavState, persistentHostData, persistentDroneData?.wind_speed]);
// MQTT消息处理 // MQTT消息处理
const handleMqttMessage = (topic, message) => { const handleMqttMessage = useCallback((topic, message) => {
try { try {
@@ -398,7 +398,7 @@ export default function DeviceOverviewPage() {
} catch (err) { } catch (err) {
console.error('MQTT解析失败', err); console.error('MQTT解析失败', err);
} }
}; }, []);
// MQTT订阅 // MQTT订阅
const mqttTopics = useMemo(() => { const mqttTopics = useMemo(() => {
@@ -417,6 +417,7 @@ export default function DeviceOverviewPage() {
url: envConfig.mqtt_url, url: envConfig.mqtt_url,
topic: mqttTopics, topic: mqttTopics,
onMessage: handleMqttMessage, onMessage: handleMqttMessage,
enabled: mqttTopics.length > 0,
}); });
// 机器人视频 // 机器人视频

View File

@@ -247,7 +247,7 @@ export default function DeviceStatusPage() {
}; };
// 当MQTT数据更新时,更新持久化数据(只在有新数据时更新) // 当MQTT数据更新时,更新持久化数据(只在有新数据时更新)
const handleMqttMessage = (topic, message) => { const handleMqttMessage = useCallback((topic, message) => {
try { try {
const parsedMessage = JSON.parse(message); const parsedMessage = JSON.parse(message);
@@ -355,7 +355,7 @@ export default function DeviceStatusPage() {
} catch (err) { } catch (err) {
console.error('DeviceStatusPage MQTT 消息解析失败:', err); console.error('DeviceStatusPage MQTT 消息解析失败:', err);
} }
}; }, []);
// 根据选中设备动态构建MQTT主题 // 根据选中设备动态构建MQTT主题
const mqttTopics = useMemo(() => { const mqttTopics = useMemo(() => {
@@ -377,6 +377,7 @@ export default function DeviceStatusPage() {
url: envConfig.mqtt_url, url: envConfig.mqtt_url,
topic: mqttTopics, topic: mqttTopics,
onMessage: handleMqttMessage, onMessage: handleMqttMessage,
enabled: mqttTopics.length > 0,
}); });
// 合并无人机和机场数据 // 合并无人机和机场数据

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { import {
Row, Col, Card, Button, Space, Table, Tag, Typography, Progress, Badge, Row, Col, Card, Button, Space, Table, Tag, Typography, Progress, Badge,
Tabs, Input, Switch, Divider, Tooltip, Timeline, Select, message, DatePicker, Tabs, Input, Switch, Divider, Tooltip, Timeline, Select, message, DatePicker,
@@ -93,7 +93,7 @@ export default function WayLinePage({ onRefresh }) {
// 当MQTT数据更新时,更新持久化数据(只在有新数据时更新) // 当MQTT数据更新时,更新持久化数据(只在有新数据时更新)
const handleMqttMessage = (topic, message) => { const handleMqttMessage = useCallback((topic, message) => {
try { try {
const parsedMessage = JSON.parse(message); const parsedMessage = JSON.parse(message);
@@ -201,7 +201,7 @@ export default function WayLinePage({ onRefresh }) {
} catch (err) { } catch (err) {
console.error('WayLinePage MQTT 消息解析失败:', err); console.error('WayLinePage MQTT 消息解析失败:', err);
} }
}; }, []);
// 根据选中设备动态构建MQTT主题 // 根据选中设备动态构建MQTT主题
const mqttTopics = React.useMemo(() => { const mqttTopics = React.useMemo(() => {
@@ -223,6 +223,7 @@ export default function WayLinePage({ onRefresh }) {
url: envConfig.mqtt_url, url: envConfig.mqtt_url,
topic: mqttTopics, topic: mqttTopics,
onMessage: handleMqttMessage, onMessage: handleMqttMessage,
enabled: mqttTopics.length > 0,
}); });
// 优先使用持久化数据,保持显示稳定 // 优先使用持久化数据,保持显示稳定

View File

@@ -1,53 +1,188 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef, useCallback, useState } from 'react';
import mqtt, { MqttClient } from 'mqtt'; import mqtt, { MqttClient } from 'mqtt';
interface Options { /* ============================================================
url: string; * MQTT 连接池 —— 同一 URL 共享一个连接,避免重复建连
topic: string | string[]; * ============================================================ */
onMessage: (topic: string, message: string) => void; const connectionPool = new Map<string, { client: MqttClient; refCount: number }>();
function getOrCreateClient(url: string, options?: mqtt.IClientOptions): MqttClient {
const entry = connectionPool.get(url);
if (entry) {
entry.refCount++;
return entry.client;
}
const client = mqtt.connect(url, {
reconnectPeriod: 3000,
clean: true,
...options,
});
connectionPool.set(url, { client, refCount: 1 });
// 连接断开后自动清理池条目
client.on('close', () => {
const cur = connectionPool.get(url);
if (cur && cur.client === client) {
connectionPool.delete(url);
}
});
return client;
} }
export function useMqtt({ url, topic, onMessage }: Options) { function releaseClient(url: string): void {
const clientRef = useRef<MqttClient | null>(null); const entry = connectionPool.get(url);
if (!entry) return;
entry.refCount--;
if (entry.refCount <= 0) {
entry.client.end(true);
connectionPool.delete(url);
}
}
/* ============================================================
* useMqtt Hook
* ============================================================ */
interface UseMqttOptions {
/** broker 地址,如 wss://xxx/mqtt */
url: string;
/** 要订阅的主题(字符串或数组),传空则不订阅 */
topic: string | string[];
/** 收到消息的回调 */
onMessage: (topic: string, message: string) => void;
/** 是否启用订阅,默认 true;设为 false 时不会建立连接 */
enabled?: boolean;
/** MQTT 连接额外配置(可选) */
connectOptions?: mqtt.IClientOptions;
}
interface UseMqttReturn {
/** 当前连接状态 */
connected: boolean;
/** 手动发布消息 */
publish: (topic: string, message: string) => void;
}
export function useMqtt({
url,
topic,
onMessage,
enabled = true,
connectOptions,
}: UseMqttOptions): UseMqttReturn {
const clientRef = useRef<MqttClient | null>(null);
const onMessageRef = useRef(onMessage);
const [connected, setConnected] = useState(false);
// 始终保持 onMessage 回调为最新引用,避免闭包过期
useEffect(() => { useEffect(() => {
const client = mqtt.connect(url, { onMessageRef.current = onMessage;
reconnectPeriod: 3000, }, [onMessage]);
// 将 topic 统一转为数组,方便做 diff
const topicList = (Array.isArray(topic) ? topic : [topic]).filter(Boolean);
const topicKey = topicList.join(',');
/* ---------- 核心:建连 / 订阅 / 清理 ---------- */
useEffect(() => {
if (!enabled || !url) return;
const client = getOrCreateClient(url, {
username: 'maibu', username: 'maibu',
password: 'jsmbzn520', password: 'jsmbzn520',
clean: true, ...connectOptions,
}); });
clientRef.current = client; clientRef.current = client;
client.on('connect', () => { /* --- 连接成功 → 订阅主题 --- */
console.log('MQTT connected'); const handleConnect = () => {
client.subscribe(topic); setConnected(true);
}); if (topicList.length > 0) {
client.subscribe(topicList, (err) => {
client.on('message', (t, payload) => { if (err) console.error('[useMqtt] subscribe failed:', err);
onMessage(t, payload.toString()); });
}); }
client.on('error', err => {
console.error('MQTT error', err);
});
return () => {
client.end(true);
}; };
}, [url, JSON.stringify(topic)]);
return clientRef; /* --- 收到消息 → 回调 --- */
const handleMessage = (t: string, payload: Buffer) => {
console.log(payload.toString(), "订阅无人机数据")
onMessageRef.current(t, payload.toString());
};
/* --- 连接状态变化 --- */
const handleReconnect = () => setConnected(false);
const handleOffline = () => setConnected(false);
const handleError = (err: Error) => {
console.error('[useMqtt] error:', err);
};
client.on('connect', handleConnect);
client.on('message', handleMessage);
client.on('reconnect', handleReconnect);
client.on('offline', handleOffline);
client.on('error', handleError);
// 如果连接已经建立,直接订阅
if (client.connected && topicList.length > 0) {
client.subscribe(topicList);
setConnected(true);
}
/* --- 清理:退出路由时取消订阅 & 释放连接 --- */
return () => {
// 取消当前 hook 订阅的主题
if (topicList.length > 0) {
client.unsubscribe(topicList);
}
// 移除本 hook 注册的事件监听
client.removeListener('connect', handleConnect);
client.removeListener('message', handleMessage);
client.removeListener('reconnect', handleReconnect);
client.removeListener('offline', handleOffline);
client.removeListener('error', handleError);
setConnected(false);
// 释放连接池引用(refCount=0 时自动断开)
releaseClient(url);
clientRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, url, topicKey]);
/* ---------- 主题热更新:topic 变化时 diff 订阅 ---------- */
const prevTopicKeyRef = useRef(topicKey);
useEffect(() => {
const client = clientRef.current;
if (!client || !client.connected) return;
const prevKey = prevTopicKeyRef.current;
prevTopicKeyRef.current = topicKey;
if (prevKey === topicKey) return;
// 取消旧主题
const prevTopics = prevKey ? prevKey.split(',').filter(Boolean) : [];
if (prevTopics.length > 0) {
client.unsubscribe(prevTopics);
}
// 订阅新主题
if (topicList.length > 0) {
client.subscribe(topicList);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [topicKey]);
/* ---------- 手动发布 ---------- */
const publish = useCallback((t: string, message: string) => {
const client = clientRef.current;
if (client && client.connected) {
client.publish(t, message);
} else {
console.warn('[useMqtt] publish skipped: not connected');
}
}, []);
return { connected, publish };
} }
/* 使用 */
// useMqtt({
// url: 'wss://broker.emqx.io:8084/mqtt',
// topic: 'robot/status',
// onMessage: (_, message) => {
// setMsg(message);
// },
// });

View File

@@ -489,8 +489,8 @@ const zh = {
controlLogTitle: '控制日志标题', controlLogTitle: '控制日志标题',
controlRequestContent: '控制请求内容', controlRequestContent: '控制请求内容',
countUnit: '数量单位', countUnit: '数量单位',
coverClosed: '覆盖已关闭', coverClosed: '已关闭',
coverOpen: '覆盖打开', coverOpen: '打开',
coverageEquipment: '覆盖设备', coverageEquipment: '覆盖设备',
coverageValue: '{{types}}/{{units}}', coverageValue: '{{types}}/{{units}}',
createSuccess: '创建成功', createSuccess: '创建成功',
@@ -586,7 +586,7 @@ const zh = {
headingAngle: '航向角度', headingAngle: '航向角度',
horizontalSpeed: '水平速度', horizontalSpeed: '水平速度',
hotspotSuspect: '热斑疑似', hotspotSuspect: '热斑疑似',
inDock: '内停靠', inDock: '舱内',
inProgressDesc: '进行中', inProgressDesc: '进行中',
indoor: '室内', indoor: '室内',
inputCustomAlias: '输入自定义别名', inputCustomAlias: '输入自定义别名',
@@ -673,7 +673,7 @@ const zh = {
optimize: '优化', optimize: '优化',
optionalEmpty: '可选空', optionalEmpty: '可选空',
otherWebControlling: '其他网页控制中', otherWebControlling: '其他网页控制中',
outDock: '外停靠', outDock: '舱外',
outdoor: '室外', outdoor: '室外',
panorama: '全景', panorama: '全景',
paramArmChannel: '参数布防渠道', paramArmChannel: '参数布防渠道',
@@ -841,18 +841,18 @@ const zh = {
stationStatus: '电站状态', stationStatus: '电站状态',
stationTopology: '电站拓扑', stationTopology: '电站拓扑',
stationVideo: '电站视频', stationVideo: '电站视频',
statusExecFailed: '状态执行失败', statusExecFailed: '执行失败',
statusExecSuccess: '状态执行成功', statusExecSuccess: '执行成功',
statusPendingApproval: '状态待处理审批', statusPendingApproval: '状态待处理审批',
statusPreparing: '状态准备中', statusPreparing: '准备中',
statusRestored: '状态已恢复', statusRestored: '已恢复',
statusStartFailed: '状态开始失败', statusStartFailed: '开始失败',
statusSuggestExec: '状态建议执行', statusSuggestExec: '建议执行',
statusTerminated: '状态已终止', statusTerminated: '已终止',
statusTimeout: '状态超时', statusTimeout: '超时',
statusUpdateSuccess: '状态更新成功', statusUpdateSuccess: '更新成功',
statusWaitExec: '状态等待执行', statusWaitExec: '等待执行',
statusWaitingStart: '状态等待中开始', statusWaitingStart: '等待中开始',
stopVideoBtn: '停止视频按钮', stopVideoBtn: '停止视频按钮',
suggestAction: '建议操作', suggestAction: '建议操作',
suggestTime: '建议时间', suggestTime: '建议时间',