2026-08-23 22:34:45 +08:00
|
|
|
|
import { useEffect } from 'react';
|
|
|
|
|
|
import { useNavigate, useLocation } from 'react-router-dom';
|
|
|
|
|
|
import { onMqttMessage, connectMqtt, publishMqtt, getClientId, useMqttStatus } from './mqtt';
|
2026-09-16 22:34:28 +08:00
|
|
|
|
import { MQTT_TOPIC_COMMAND, MQTT_TOPIC_BIND, getApiBase as API_BASE } from '../config';
|
2026-08-25 18:42:32 +08:00
|
|
|
|
import { getTenantId, clearBinding } from './tenant';
|
2026-08-23 22:34:45 +08:00
|
|
|
|
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',
|
2026-08-25 18:42:32 +08:00
|
|
|
|
tasks: '/tasks', '任务展示': '/tasks', task: '/tasks',
|
2026-08-23 22:34:45 +08:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-08-25 18:42:32 +08:00
|
|
|
|
// 后端主动解绑:监听绑定频道,收到 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;
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-09-16 22:34:28 +08:00
|
|
|
|
// 大屏在线心跳已由 mqtt.js 连接成功后自动周期上报(绑定页/主屏通用),此处不再重复发送
|
2026-08-23 22:34:45 +08:00
|
|
|
|
|
|
|
|
|
|
// 页面状态即时上报:路由变化即通知服务端当前所在页面
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
2026-08-25 18:42:32 +08:00
|
|
|
|
// ── 任务展示页操作(语音页已暂时关闭,voice_* 指令不再处理) ──
|
|
|
|
|
|
case 'task_refresh':
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent('dpm:task-control', {
|
|
|
|
|
|
detail: { action: 'refresh', ...(cmd.params || {}) },
|
2026-08-23 22:34:45 +08:00
|
|
|
|
}));
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|