From c55835fb7d310b8d7d78b2f71e0f7beb14ca5c31 Mon Sep 17 00:00:00 2001 From: Pine Date: Mon, 17 Aug 2026 21:54:23 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=AE=A1=E7=90=86=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E7=8A=B6=E6=80=81=20+=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=8C=89=E9=92=AE=E6=97=A0=E5=93=8D=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 连接状态(正式): - /api/display/state 扩展:screens_online(大屏心跳统计,opc/display/heartbeat)+ last_command(最近指令与发布结果) - 前端大屏每 10s 上报心跳(稳定 client_id),后端 30s TTL 统计在线大屏数 - 管理台新增「连接状态」卡片:后端/MQTT/在线大屏/最近指令,3s 轮询刷新 按钮无响应根治: - admin.js 改为 document 事件委托(data-nav/data-control/data-action/复制/预览),不再依赖逐个绑定 - /static 与 /admin 响应加 no-cache 头,杜绝旧 JS/CSS 缓存导致的按钮失效 - SSE 不再自动重载页面(避免与操作回调双重重载清掉反馈);立即播放改为 toast 不重载 --- backend/app/config.py | 2 + backend/app/main.py | 11 ++ backend/app/mqtt.py | 45 ++++++++- backend/app/routers.py | 6 +- backend/static/admin.css | 54 ++++++++++ backend/static/admin.js | 191 +++++++++++++++++++++-------------- backend/templates/admin.html | 30 ++++++ src/config.js | 1 + src/utils/mqtt.js | 20 +++- src/utils/useMqttControl.js | 23 ++++- 10 files changed, 297 insertions(+), 86 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index 09ee421..afb6a0f 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -57,6 +57,8 @@ class Settings: TOPIC_COMMAND = _env("DPM_TOPIC_COMMAND", "opc/display/command") TOPIC_TICK = _env("DPM_TOPIC_TICK", "opc/dashboard/tick") TOPIC_ACK = _env("DPM_TOPIC_ACK", "opc/display/ack") + TOPIC_HEARTBEAT = _env("DPM_TOPIC_HEARTBEAT", "opc/display/heartbeat") + SCREEN_TTL = float(_env("DPM_SCREEN_TTL", "30")) # 大屏心跳过期秒数 # ---- 阿里云 DashScope(LLM + 语音识别) ---- DASHSCOPE_API_KEY = _env("DASHSCOPE_API_KEY", "") diff --git a/backend/app/main.py b/backend/app/main.py index 013298e..5a97c35 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -69,6 +69,17 @@ app.add_middleware( allow_headers=["*"], ) + +@app.middleware("http") +async def no_cache_admin_static(request: Request, call_next): + """管理后台页面与静态资源禁用缓存,避免旧 JS/CSS 残留导致按钮无响应""" + response = await call_next(request) + if request.url.path.startswith(("/static/", "/admin")): + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + return response + app.include_router(router) # 媒体资源静态服务(上传/播放的文件) diff --git a/backend/app/mqtt.py b/backend/app/mqtt.py index 6f06f49..b485c76 100644 --- a/backend/app/mqtt.py +++ b/backend/app/mqtt.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -"""MQTT 发布中心 —— 后端 → 前端控制通道 - 页面控制 / 媒体控制 / 卡片展示指令统一走 opc/display/command - 数据快照按 DPM_MQTT_TICK_INTERVAL 推送 opc/dashboard/tick +"""MQTT 发布/订阅中心 —— 后端 ↔ 前端控制通道 + 发布:opc/display/command(页面/媒体/卡片控制)、opc/dashboard/tick(数据快照) + 订阅:opc/display/heartbeat(大屏在线心跳,统计在线大屏数) """ import json @@ -27,6 +27,8 @@ class MqttHub: self.client = None self.connected = False self._lock = threading.RLock() + self._screens = {} # client_id -> last_seen_ts + self.last_command = None # {action, params, ts, published} # ---------- 生命周期 ---------- def start(self): @@ -43,6 +45,7 @@ class MqttHub: self.client.username_pw_set(settings.MQTT_USERNAME, settings.MQTT_PASSWORD) self.client.on_connect = self._on_connect self.client.on_disconnect = self._on_disconnect + self.client.on_message = self._on_message self.client.connect_async(settings.MQTT_HOST, settings.MQTT_PORT, keepalive=30) self.client.loop_start() log.info("MQTT 连接中 %s:%s ...", settings.MQTT_HOST, settings.MQTT_PORT) @@ -62,6 +65,7 @@ class MqttHub: if rc == 0: self.connected = True log.info("MQTT 已连接 %s:%s", settings.MQTT_HOST, settings.MQTT_PORT) + client.subscribe(settings.TOPIC_HEARTBEAT, qos=0) else: log.warning("MQTT 连接失败 rc=%s", rc) @@ -70,6 +74,34 @@ class MqttHub: if rc != 0: log.warning("MQTT 断开(rc=%s),自动重连中...", rc) + def _on_message(self, client, userdata, msg): + """接收大屏心跳:记录 client_id 与时间""" + if msg.topic == settings.TOPIC_HEARTBEAT: + try: + payload = json.loads(msg.payload.decode("utf-8")) + cid = payload.get("client_id") or msg.topic + with self._lock: + self._screens[cid] = time.time() + # 清理过期 + cutoff = time.time() - settings.SCREEN_TTL + self._screens = {k: v for k, v in self._screens.items() if v > cutoff} + except Exception: # noqa: BLE001 + pass + + # ---------- 状态查询 ---------- + def screens_online(self): + with self._lock: + cutoff = time.time() - settings.SCREEN_TTL + return sum(1 for v in self._screens.values() if v > cutoff) + + def status(self): + return { + "mqtt_connected": self.connected, + "mqtt_host": f"{settings.MQTT_HOST}:{settings.MQTT_PORT}", + "screens_online": self.screens_online(), + "last_command": self.last_command, + } + # ---------- 发布 ---------- def publish(self, topic, payload, qos=1, retain=False): if not self.client or not self.connected: @@ -93,6 +125,13 @@ class MqttHub: } ok = self.publish(settings.TOPIC_COMMAND, payload) payload["published"] = ok + with self._lock: + self.last_command = { + "action": action, + "params": params or {}, + "ts": payload["ts"], + "published": ok, + } if ok: bus.emit(payload) # SSE 兼容通道(仅发布成功时推送) return payload diff --git a/backend/app/routers.py b/backend/app/routers.py index bfc6904..8d1b1e9 100644 --- a/backend/app/routers.py +++ b/backend/app/routers.py @@ -364,11 +364,7 @@ async def display_command_publish(body: DisplayCommandBody): @router.get("/api/display/state") async def display_state(): - return { - "mqtt_connected": hub.connected, - "mqtt_host": f"{settings.MQTT_HOST}:{settings.MQTT_PORT}", - "page": None, # 前端可通过 MQTT 回执上报当前页(后续扩展) - } + return hub.status() # ==================== SSE 兼容通道(MQTT 不可用时前端回退) ==================== diff --git a/backend/static/admin.css b/backend/static/admin.css index 41c702e..4d66bc0 100644 --- a/backend/static/admin.css +++ b/backend/static/admin.css @@ -1187,3 +1187,57 @@ button { font-family: "Poppins", "PingFang SC", sans-serif; cursor: pointer; tra @media (max-width: 900px) { .page-switch-grid { grid-template-columns: repeat(2, 1fr); } } + +/* ========================================================= + 连接状态卡片 + ========================================================= */ +.conn-status { + display: flex; + flex-direction: column; + gap: 6px; +} +.conn-row { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 10px; + background: rgba(0, 189, 125, 0.04); + border: 1px solid rgba(0, 189, 125, 0.12); + border-radius: 8px; +} +.conn-dot { + width: 8px; + height: 8px; + flex-shrink: 0; + border-radius: 50%; + background: #d1d5db; +} +.conn-dot.on { + background: #16A34A; + box-shadow: 0 0 6px rgba(22, 163, 74, 0.7); + animation: connPulse 1.6s ease-in-out infinite; +} +.conn-dot.off { + background: #DC2626; + box-shadow: 0 0 6px rgba(220, 38, 38, 0.6); +} +@keyframes connPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} +.conn-label { + flex-shrink: 0; + font-size: 12px; + color: var(--text-secondary, #4b5563); + width: 72px; +} +.conn-value { + flex: 1; + min-width: 0; + font-size: 12px; + font-weight: 600; + color: var(--text, #111827); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/backend/static/admin.js b/backend/static/admin.js index 3cd0861..eba9d69 100644 --- a/backend/static/admin.js +++ b/backend/static/admin.js @@ -1,6 +1,7 @@ /* ========================================================= Admin 管理后台前端逻辑(Jinja 页面配套) - 动态操作调用 FastAPI REST,完成后整页刷新(服务端重渲染) + 事件采用 document 委托绑定,保证按钮点击稳定响应; + 连接状态每 3s 轮询 /api/display/state 刷新 ========================================================= */ (function () { 'use strict'; @@ -34,12 +35,12 @@ }) .catch(function () { toast('网络错误', 'error'); }); } - function reload(msg) { + function reload(msg, delay) { if (msg) toast(msg); - setTimeout(function () { location.reload(); }, 350); + setTimeout(function () { location.reload(); }, delay || 600); } - /* ---------- 播放状态(SSE) ---------- */ + /* ---------- 播放状态(SSE,仅更新状态文字,不自动重载页面) ---------- */ var statusText = document.getElementById('statusText'); try { var es = new EventSource('/api/events'); @@ -51,33 +52,118 @@ else if (s.status === 'playing') statusText.textContent = '正在播放 — ' + s.name; else if (s.status === 'paused') statusText.textContent = '已暂停 — ' + s.name; } - if (msg.action === 'playlist_changed') reload(); }; } catch (err) { /* ignore */ } - /* ---------- 页面切换(MQTT 控制大屏) ---------- */ - document.querySelectorAll('[data-nav]').forEach(function (btn) { - btn.addEventListener('click', function () { + /* ========================================================= + 连接状态轮询(/api/display/state) + ========================================================= */ + var connEls = { + api: document.getElementById('connApi'), + mqtt: document.getElementById('connMqtt'), + screens: document.getElementById('connScreens'), + lastCmd: document.getElementById('connLastCmd'), + }; + function setConn(id, ok, value) { + var el = connEls[id]; + if (!el) return; + var dot = el.querySelector('.conn-dot'); + var val = el.querySelector('.conn-value'); + if (dot) dot.className = 'conn-dot ' + (ok ? 'on' : 'off'); + if (val) val.textContent = value; + } + function pollStatus() { + fetch('/api/display/state', { cache: 'no-store' }) + .then(function (r) { return r.json(); }) + .then(function (d) { + setConn('api', true, '在线'); + setConn('mqtt', !!d.mqtt_connected, d.mqtt_connected ? '已连接(' + (d.mqtt_host || '') + ')' : '未连接'); + setConn('screens', (d.screens_online || 0) > 0, (d.screens_online || 0) + ' 台'); + var lc = d.last_command; + if (lc) { + var names = { navigate: '页面切换', play: '播放', pause: '暂停', next: '下一项', prev: '上一项', + 'play_target': '指定播放', alert: '通知', show_card: '卡片', set_mode: '播放模式' }; + var when = new Date(lc.ts).toLocaleTimeString('zh-CN', { hour12: false }); + setConn('lastCmd', !!lc.published, + (names[lc.action] || lc.action) + ' · ' + (lc.published ? '已广播' : '未广播') + ' · ' + when); + } + }) + .catch(function () { + setConn('api', false, '离线'); + setConn('mqtt', false, '未知'); + setConn('screens', false, '未知'); + }); + } + if (connEls.api) { pollStatus(); setInterval(pollStatus, 3000); } + + /* ========================================================= + 事件委托:页面切换 / 播放控制 / 媒体操作 + ========================================================= */ + document.addEventListener('click', function (e) { + var btn = e.target.closest ? e.target.closest('[data-nav], [data-control], [data-action], .copy-btn, [data-preview]') : null; + if (!btn) return; + e.stopPropagation(); + + // 预览 + if (btn.hasAttribute('data-preview')) { openPreview(btn.closest('.media-card')); return; } + + // 复制 URL + if (btn.classList.contains('copy-btn')) { + var url = btn.closest('.media-url').getAttribute('data-url') || ''; + var full = /^https?:\/\//.test(url) ? url : window.location.origin + url; + var done = function () { toast('链接已复制'); }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(full).then(done).catch(function () { fallbackCopy(full); done(); }); + } else { fallbackCopy(full); done(); } + return; + } + + // 页面切换 + if (btn.hasAttribute('data-nav')) { var page = btn.getAttribute('data-nav'); post('/api/display/command', { action: 'navigate', params: { page: page } }, function () { toast('已切换大屏至「' + btn.textContent.trim() + '」'); }); - }); - }); + return; + } - /* ---------- 播放控制 ---------- */ - document.querySelectorAll('[data-control]').forEach(function (btn) { - btn.addEventListener('click', function () { + // 播放控制 + if (btn.hasAttribute('data-control')) { var action = btn.getAttribute('data-control'); - if (action === 'minimize') { - post('/api/display/command', { action: 'minimize', params: {} }); - } else { - post('/api/control', { action: action }); + if (action === 'minimize') post('/api/display/command', { action: 'minimize', params: {} }); + else post('/api/control', { action: action }); + return; + } + + // 媒体操作 + if (btn.hasAttribute('data-action')) { + var card = btn.closest('.media-card'); + var path = card.getAttribute('data-path'); + var act = btn.getAttribute('data-action'); + if (act === 'play-now') { + post('/api/playlist/play', { path: path }, function () { toast('已发送播放指令,大屏即将播放'); }); + } else if (act === 'add-playlist') { + post('/api/playlist/add', { path: path }, function () { reload('已加入播放列表'); }); + } else if (act === 'remove-playlist') { + post('/api/playlist/remove', { path: path }, function () { reload('已移出播放列表'); }); + } else if (act === 'delete') { + if (!window.confirm('确定删除该文件?')) return; + post('/api/delete', { path: path }, function () { reload('已删除'); }); } - }); + } }); - /* ---------- 播放设置 ---------- */ + function fallbackCopy(text) { + var ta = document.createElement('textarea'); + ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; + document.body.appendChild(ta); ta.select(); + try { document.execCommand('copy'); } catch (err) { /* ignore */ } + document.body.removeChild(ta); + } + + /* ========================================================= + 播放设置(change/input 事件) + ========================================================= */ var playMode = document.getElementById('playMode'); if (playMode) playMode.addEventListener('change', function () { post('/api/settings', { play_mode: playMode.value }, function () { toast('播放模式已更新'); }); @@ -105,20 +191,17 @@ /* ---------- 显示控制 ---------- */ var btnFullscreen = document.getElementById('btnFullscreen'); + var btnAutostart = document.getElementById('btnAutostart'); function renderDisplay() { if (btnFullscreen) btnFullscreen.textContent = fullscreen ? '切换窗口模式' : '切换大屏模式'; - var btnAutostart = document.getElementById('btnAutostart'); if (btnAutostart) { btnAutostart.textContent = autostart ? '已开启' : '已关闭'; btnAutostart.classList.toggle('active', autostart); } } if (btnFullscreen) btnFullscreen.addEventListener('click', function () { - fullscreen = !fullscreen; - renderDisplay(); + fullscreen = !fullscreen; renderDisplay(); post('/api/settings', { fullscreen: fullscreen }); }); - var btnAutostart = document.getElementById('btnAutostart'); if (btnAutostart) btnAutostart.addEventListener('click', function () { - autostart = !autostart; - renderDisplay(); + autostart = !autostart; renderDisplay(); post('/api/settings', { autostart: autostart }); }); renderDisplay(); @@ -129,12 +212,10 @@ var inp = document.getElementById('fileInput'); var fileList = Array.from(inp.files || []); if (fileList.length === 0) { toast('请选择文件', 'error'); return; } - var prog = document.getElementById('uploadProgress'); var fill = document.getElementById('progressFill'); var text = document.getElementById('progressText'); prog.style.display = 'block'; - var done = 0; fileList.forEach(function (file) { var fd = new FormData(); @@ -167,7 +248,7 @@ if (!url) { toast('请输入 URL', 'error'); return; } post('/api/media/add-url', { url: url }, function (d) { if (play) { - post('/api/playlist/play', { path: url }, function () { reload('已加入并开始播放'); }); + post('/api/playlist/play', { path: url }, function () { toast('已加入并发送播放指令'); }); } else { reload(d && d.duplicate ? 'URL 已存在于媒体库' : '已添加到媒体库'); } @@ -176,54 +257,15 @@ if (btnAddUrl) btnAddUrl.addEventListener('click', function () { addUrlThen(false); }); if (btnAddUrlPlay) btnAddUrlPlay.addEventListener('click', function () { addUrlThen(true); }); - /* ---------- 复制资源 URL(FastAPI 输出) ---------- */ - document.querySelectorAll('.copy-btn').forEach(function (btn) { - btn.addEventListener('click', function (e) { - e.stopPropagation(); - var url = btn.closest('.media-url').getAttribute('data-url') || ''; - var full = /^https?:\/\//.test(url) ? url : window.location.origin + url; - var done = function () { toast('链接已复制'); }; - if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard.writeText(full).then(done).catch(function () { fallbackCopy(full); done(); }); - } else { - fallbackCopy(full); done(); - } - }); - }); - function fallbackCopy(text) { - var ta = document.createElement('textarea'); - ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; - document.body.appendChild(ta); ta.select(); - try { document.execCommand('copy'); } catch (err) { /* ignore */ } - document.body.removeChild(ta); - } - - /* ---------- 媒体操作(立即播放 / 加入播放 / 移出 / 删除) ---------- */ - document.querySelectorAll('[data-action]').forEach(function (btn) { - btn.addEventListener('click', function (e) { - e.stopPropagation(); - var card = btn.closest('.media-card'); - var path = card.getAttribute('data-path'); - var action = btn.getAttribute('data-action'); - if (action === 'play-now') { - post('/api/playlist/play', { path: path }, function () { toast('正在播放'); }); - } else if (action === 'add-playlist') { - post('/api/playlist/add', { path: path }, function () { reload('已加入播放列表'); }); - } else if (action === 'remove-playlist') { - post('/api/playlist/remove', { path: path }, function () { reload('已移出播放列表'); }); - } else if (action === 'delete') { - if (!window.confirm('确定删除该文件?')) return; - post('/api/delete', { path: path }, function () { reload('已删除'); }); - } - }); - }); - - /* ---------- 预览弹窗 ---------- */ + /* ========================================================= + 预览弹窗 + ========================================================= */ var modal = document.getElementById('previewModal'); var modalBody = document.getElementById('previewBody'); var current = null; function openPreview(card) { + if (!card || !modal) return; var path = card.getAttribute('data-path'); var type = card.getAttribute('data-type'); var source = card.getAttribute('data-source'); @@ -246,11 +288,8 @@ modal.style.display = 'none'; current = null; } - document.querySelectorAll('[data-preview]').forEach(function (el) { - el.addEventListener('click', function () { openPreview(el.closest('.media-card')); }); - }); document.getElementById('previewClose').addEventListener('click', closePreview); - modal.addEventListener('click', function (e) { + if (modal) modal.addEventListener('click', function (e) { if (e.target === modal) closePreview(); }); document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closePreview(); }); @@ -260,7 +299,7 @@ }); document.getElementById('previewPlay').addEventListener('click', function () { if (!current) return; - post('/api/playlist/play', { path: current.path }, function () { closePreview(); toast('正在播放'); }); + post('/api/playlist/play', { path: current.path }, function () { closePreview(); toast('已发送播放指令'); }); }); document.getElementById('previewDelete').addEventListener('click', function () { if (!current) return; diff --git a/backend/templates/admin.html b/backend/templates/admin.html index f415c8c..7da49ae 100644 --- a/backend/templates/admin.html +++ b/backend/templates/admin.html @@ -74,6 +74,36 @@
+ +
+
+
+

连接状态

+
+
+
+ + 后端服务 + 检测中... +
+
+ + MQTT Broker + 检测中... +
+
+ + 在线大屏 + 检测中... +
+
+ + 最近指令 + +
+
+
+
diff --git a/src/config.js b/src/config.js index 499fac3..66e9b4a 100644 --- a/src/config.js +++ b/src/config.js @@ -17,3 +17,4 @@ export const MQTT_PASSWORD = window.__DPM_MQTT_PASS__ || env.VITE_MQTT_PASSWORD export const MQTT_TOPIC_COMMAND = 'opc/display/command'; export const MQTT_TOPIC_TICK = 'opc/dashboard/tick'; +export const MQTT_TOPIC_HEARTBEAT = 'opc/display/heartbeat'; diff --git a/src/utils/mqtt.js b/src/utils/mqtt.js index dee8a36..5fdb2a5 100644 --- a/src/utils/mqtt.js +++ b/src/utils/mqtt.js @@ -10,6 +10,7 @@ import { MQTT_URL, MQTT_TOPIC_COMMAND, MQTT_TOPIC_TICK, MQTT_USERNAME, MQTT_PASS let client = null; let status = 'connecting'; // connecting | connected | offline +let clientId = null; const listeners = new Set(); function notifyStatus() { @@ -19,10 +20,11 @@ function notifyStatus() { export function connectMqtt() { if (client) return; try { + clientId = `dpm-screen-${Math.random().toString(16).slice(2, 8)}`; client = mqtt.connect(MQTT_URL, { reconnectPeriod: 3000, connectTimeout: 8000, - clientId: `dpm-screen-${Math.random().toString(16).slice(2, 8)}`, + clientId, clean: true, username: MQTT_USERNAME || undefined, password: MQTT_PASSWORD || undefined, @@ -63,6 +65,22 @@ export function getMqttStatus() { return status; } +/** 当前连接的稳定 client_id */ +export function getClientId() { + return clientId; +} + +/** 发布消息(大屏心跳等) */ +export function publishMqtt(topic, payload) { + if (!client || status !== 'connected') return false; + try { + client.publish(topic, JSON.stringify(payload), { qos: 1 }); + return true; + } catch (e) { + return false; + } +} + /** 订阅 MQTT 消息,返回取消订阅函数 */ export function onMqttMessage(fn) { listeners.add(fn); diff --git a/src/utils/useMqttControl.js b/src/utils/useMqttControl.js index 60badb3..af007b6 100644 --- a/src/utils/useMqttControl.js +++ b/src/utils/useMqttControl.js @@ -1,6 +1,7 @@ import { useEffect } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; -import { onMqttMessage, connectMqtt } from './mqtt'; +import { onMqttMessage, connectMqtt, publishMqtt, getClientId, useMqttStatus } from './mqtt'; +import { MQTT_TOPIC_HEARTBEAT } from '../config'; import { PAGE_ORDER } from './pageNav'; /* ========================================================= @@ -9,6 +10,7 @@ import { PAGE_ORDER } from './pageNav'; · play / pause / next / prev / set_mode:媒体控制 · alert:全局通知 · show_card:展示信息卡片(AI 工具调用 / 管理端指令) + · 心跳:定时上报 opc/display/heartbeat(后端统计在线大屏) ========================================================= */ const PAGE_ALIAS = { @@ -28,7 +30,26 @@ function resolvePage(page) { export function useMqttControl() { const navigate = useNavigate(); const location = useLocation(); + const mqttStatus = useMqttStatus(); + // 大屏在线心跳(后端据此统计在线大屏数) + useEffect(() => { + connectMqtt(); + const sendHeartbeat = () => { + if (mqttStatus === 'connected') { + publishMqtt(MQTT_TOPIC_HEARTBEAT, { + client_id: getClientId(), + page: location.pathname, + ts: Date.now(), + }); + } + }; + sendHeartbeat(); + const hb = setInterval(sendHeartbeat, 10000); + return () => clearInterval(hb); + }, [mqttStatus, location.pathname]); + + // 控制指令订阅 useEffect(() => { connectMqtt(); const off = onMqttMessage((topic, cmd) => {