Files
park-desktop/src/utils/useMqttControl.js
T
Pine d61b59acf5 feat(bigscreen): 任务展示页 + 暂时关闭语音对话页 + 导航/文档同步
- App.jsx 移除 /voice、新增 /tasks→TaskDisplay。
- pageNav PAGE_ORDER 移除 voice、加 tasks(方向键/页眉/MQTT navigate_rel 自动跟随)。
- useMqttControl:PAGE_ALIAS 加 tasks;移除 voice_*、改 task_refresh。
- PageHeader STATUS_BY_PATH 加 /tasks。
- 新增 TaskDisplay.jsx + task-display.css:轮播卡(分类/状态/标题/摘要/标签/预算/任务ID+QRCodeSVG 扫码接单)、useTasks 轮询 /park/api/tasks、接受 MQTT task_refresh。
- 新增 utils/taskData.js(useTasks, tenantFetch 带租户身份)。
- docs/mqtt-commands.md:navigate 列表/语音章节标注语音页关闭、tasks 页指令。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-25 18:42:32 +08:00

234 lines
9.4 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_HEARTBEAT, 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;
}, []);
// 大屏在线心跳(后端据此统计在线大屏数)
useEffect(() => {
connectMqtt();
const sendHeartbeat = () => {
if (mqttStatus === 'connected') {
publishMqtt(MQTT_TOPIC_HEARTBEAT, {
client_id: getClientId(),
tenant_id: getTenantId(),
page: location.pathname,
ts: Date.now(),
});
}
};
sendHeartbeat();
const hb = setInterval(sendHeartbeat, 10000);
return () => clearInterval(hb);
}, [mqttStatus, location.pathname]);
// 页面状态即时上报:路由变化即通知服务端当前所在页面
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;
}