Files
park-desktop/src/utils/useMqttControl.js
T
Pine 87e57ea486 fix(mqtt): MQTT 连接成功后自动周期上报心跳(绑定页/主屏通用)
- mqtt.js:connect 后立即+每10s 上报 opc/display/heartbeat(client_id/tenant_id/page),
  绑定页(BindScreen)不再只连不报——admin 端屏幕管理即可看到在线大屏;
  断线 close 时清定时器,重连后自动恢复
- useMqttControl.js:移除重复心跳(避免双发),保留注册/状态上报/订阅逻辑
2026-09-16 22:34:28 +08:00

218 lines
9.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { onMqttMessage, connectMqtt, publishMqtt, getClientId, useMqttStatus } from './mqtt';
import { MQTT_TOPIC_COMMAND, MQTT_TOPIC_BIND, getApiBase as API_BASE } from '../config';
import { getTenantId, clearBinding } from './tenant';
import { Sfx } from './sounds';
import { PAGE_ORDER } from './pageNav';
import { aiStatus } from './statusBus';
import { invoke } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window';
// 当前窗口标识:主屏 'main' / 双屏副窗 'secondary'
// 优先用 Rust 注入的 __DPM_WINDOW_LABEL__(副窗初始化脚本),再回退 getCurrentWindow().label
const WINDOW_LABEL = (() => {
try {
if (window.__DPM_WINDOW_LABEL__) return window.__DPM_WINDOW_LABEL__;
return getCurrentWindow().label || 'main';
} catch { return 'main'; }
})();
/* =========================================================
MQTT 控制 —— 后端通过 opc/display/command 控制大屏
· navigate / navigate_rel:页面切换(与方向键同一通道)
· play / pause / next / prev / set_mode:媒体控制
· alert:全局通知
· show_card:展示信息卡片(AI 工具调用 / 管理端指令)
· 心跳:定时上报 opc/display/heartbeat(后端统计在线大屏)
========================================================= */
const PAGE_ALIAS = {
home: '/', '数据大屏': '/', '首页': '/',
twin: '/twin', '数字孪生': '/twin', '3d': '/twin',
ai: '/ai', 'ai助手': '/ai',
tasks: '/tasks', '任务展示': '/tasks', task: '/tasks',
screen: '/screen', '媒体轮播': '/screen', media: '/screen',
wall: '/wall', '企业展示墙': '/wall', '企业墙': '/wall', '展示墙': '/wall',
};
function resolvePage(page) {
if (!page) return null;
if (PAGE_ALIAS[String(page).toLowerCase()] !== undefined) return PAGE_ALIAS[String(page).toLowerCase()];
if (PAGE_ORDER.some((p) => p.path === page)) return page;
return null;
}
export function useMqttControl() {
const navigate = useNavigate();
const location = useLocation();
const mqttStatus = useMqttStatus();
// 设备注册:MQTT 连接后向后端上报「唯一 device_id + 角色 + 从属主屏」,供后端精准控制
useEffect(() => {
if (mqttStatus !== 'connected' || !getClientId()) return;
fetch(`${API_BASE()}/api/display/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
device_id: getClientId(),
role: WINDOW_LABEL,
parent_id: window.__DPM_PARENT_ID__ || '', // 副屏标识从属的主屏 device_id
}),
}).catch(() => {});
}, [mqttStatus]);
// 后端主动解绑:监听绑定频道,收到 unbound 事件 → 清理本地绑定并退出到待绑定页
// (大屏已绑定后仍订阅 bind/<clientId>,见 mqtt.js subscribe;unbind 后 main.jsx 见
// isDeviceBound()=false 渲染 BindScreen)
useEffect(() => {
connectMqtt();
const off = onMqttMessage((topic, data) => {
if (topic === `${MQTT_TOPIC_BIND}/${getClientId()}` && data && data.event === 'unbound') {
clearBinding();
}
});
return off;
}, []);
// 大屏在线心跳已由 mqtt.js 连接成功后自动周期上报(绑定页/主屏通用),此处不再重复发送
// 页面状态即时上报:路由变化即通知服务端当前所在页面
useEffect(() => {
connectMqtt();
if (mqttStatus === 'connected') {
publishMqtt('opc/frontend/state', {
client_id: getClientId(),
page: location.pathname,
ts: Date.now(),
});
}
}, [mqttStatus, location.pathname]);
// 控制指令订阅
useEffect(() => {
connectMqtt();
const off = onMqttMessage((topic, cmd) => {
// 接收全局控制 topic 或本客户端独有 topic(后端按屏幕终端分别下发)
const isCmd = topic === MQTT_TOPIC_COMMAND || topic.startsWith(`${MQTT_TOPIC_COMMAND}/`);
if (!isCmd || !cmd || !cmd.action) return;
// 屏幕终端精准控制:后端按 client_id 路由到独有 topic。
// 兜底:命令若带 screen_id,只有本客户端 client_id 匹配才处理(防误收全局)。
const sc = cmd.screen;
console.warn(`[dual] 命令 action=${cmd.action} screen_id=${cmd.screen_id || '(无)'} screen_role=${cmd.screen_role || '(无)'} 本窗口=${WINDOW_LABEL} 本client=${getClientId()}`);
if (cmd.screen_id && cmd.screen_id !== getClientId()) return;
if (sc && sc !== 'both' && sc !== WINDOW_LABEL) return;
switch (cmd.action) {
case 'navigate': {
const path = resolvePage(cmd.params?.page);
if (path) { Sfx.page(); aiStatus('正在切换页面', { icon: 'page' }); navigate(path); }
break;
}
case 'navigate_rel': {
const delta = Number(cmd.params?.delta ?? 1) || 1;
const idx = PAGE_ORDER.findIndex((p) => p.path === location.pathname);
if (idx >= 0) {
const n = PAGE_ORDER.length;
aiStatus('正在切换页面', { icon: 'page' });
navigate(PAGE_ORDER[(idx + delta + n) % n].path);
}
break;
}
case 'dual_screen':
// 双屏:仅主窗口执行(只有主屏能开启/关闭副屏),把主屏 device_id 传给副窗作 parent_id
if (WINDOW_LABEL !== 'main') break;
aiStatus(cmd.params?.on === false ? '正在关闭双屏' : '正在启动双屏', { icon: 'page' });
invoke('set_dual_screen', { on: cmd.params?.on !== false, parentId: getClientId() })
.catch((err) => {
const msg = String((err && err.message) || err || '操作失败');
aiStatus(`双屏失败:${msg}`, { state: 'error', icon: 'error' });
console.error('[dual]', err);
});
break;
case 'alert':
// 通知本身即展示,无需再弹「正在发送通知」状态提示
window.dispatchEvent(new CustomEvent('dpm:alert', { detail: cmd.params || {} }));
break;
case 'show_card':
aiStatus('正在展示信息卡片', { icon: 'tool' });
window.dispatchEvent(new CustomEvent('dpm:show-card', { detail: cmd.params || {} }));
break;
case 'play':
case 'pause':
case 'next':
case 'prev':
Sfx.tick();
case 'set_mode':
case 'play_target':
case 'minimize':
case 'settings_changed':
case 'playlist_changed':
window.dispatchEvent(new CustomEvent('dpm:media-control', { detail: cmd }));
break;
// ── 全局人物识别开关 ──
case 'vision_set':
window.dispatchEvent(new CustomEvent('dpm:vision-control', {
detail: { enabled: Boolean(cmd.params?.enabled) },
}));
break;
// ── AI 助手页操作 ──
case 'ai_input':
case 'ai_preset':
case 'ai_company':
case 'ai_zone':
window.dispatchEvent(new CustomEvent('dpm:ai-action', {
detail: { type: cmd.action.replace('ai_', ''), ...(cmd.params || {}) },
}));
break;
// ── 任务展示页操作(语音页已暂时关闭,voice_* 指令不再处理) ──
case 'task_refresh':
window.dispatchEvent(new CustomEvent('dpm:task-control', {
detail: { action: 'refresh', ...(cmd.params || {}) },
}));
break;
// ── 企业展示墙(/wall)操作 ──
case 'wall_pause':
case 'wall_resume':
case 'wall_speed':
window.dispatchEvent(new CustomEvent('dpm:wall-control', {
detail: { action: cmd.action.replace('wall_', ''), ...(cmd.params || {}) },
}));
break;
default:
break;
}
});
// 离线兜底:AI 对话返回的工具调用在 MQTT 未连接时本地执行
const onLocal = (e) => {
const { action, params } = e.detail || {};
if (action === 'navigate') {
const path = resolvePage(params?.page);
if (path) { aiStatus('正在切换页面', { icon: 'page' }); navigate(path); }
} else if (action === 'control') {
aiStatus('正在控制媒体播放', { icon: 'tool' });
window.dispatchEvent(new CustomEvent('dpm:media-control', { detail: { action: params?.action } }));
} else if (action === 'alert') {
// 通知本身即展示,无需状态提示
window.dispatchEvent(new CustomEvent('dpm:alert', { detail: params || {} }));
} else if (action === 'show_card') {
aiStatus('正在展示信息卡片', { icon: 'tool' });
window.dispatchEvent(new CustomEvent('dpm:show-card', { detail: params || {} }));
}
};
window.addEventListener('dpm:mqtt-local', onLocal);
return () => {
off();
window.removeEventListener('dpm:mqtt-local', onLocal);
};
}, [navigate, location.pathname]);
return null;
}