From e72442dae010e5bd770c7a52c16b62a16f93f6b7 Mon Sep 17 00:00:00 2001 From: Pine Date: Wed, 19 Aug 2026 14:19:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E6=B3=A8=E5=86=8C=E5=92=8C=E7=B2=BE=E5=87=86=E6=8E=A7=E5=88=B6?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81=E5=8F=8C=E5=B1=8F?= =?UTF-8?q?=E7=AE=A1=E7=90=86=EF=BC=9B=E6=9B=B4=E6=96=B0=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E9=80=BB=E8=BE=91=E4=BB=A5=E4=BC=98=E5=8C=96=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E8=B7=9F=E8=B8=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/mqtt.py | 120 ++++++++++++++++++++++++---- backend/app/routers.py | 19 ++++- backend/static/admin.js | 69 ++++++++++++---- backend/templates/admin.html | 13 +-- index.html | 5 ++ package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/capabilities/default.json | 1 + src-tauri/src/lib.rs | 7 +- src-tauri/tauri.conf.json | 2 +- src/main.jsx | 17 ++++ src/utils/mqtt.js | 4 +- src/utils/updateCheck.js | 17 +++- src/utils/useMqttControl.js | 37 +++++++-- 15 files changed, 264 insertions(+), 53 deletions(-) diff --git a/backend/app/mqtt.py b/backend/app/mqtt.py index 78201f5..81c5690 100644 --- a/backend/app/mqtt.py +++ b/backend/app/mqtt.py @@ -33,7 +33,7 @@ class MqttHub: self.client = None self.connected = False self._lock = threading.RLock() - self._screens = {} # client_id -> last_seen_ts + self.devices = {} # device_id(client_id) -> {role, parent_id, last_seen} self.last_command = None # {action, params, ts, published} # ---------- 生命周期 ---------- @@ -81,30 +81,45 @@ class MqttHub: log.warning("MQTT 断开(rc=%s),自动重连中...", rc) def _on_message(self, client, userdata, msg): - """接收大屏心跳:记录 client_id 与时间""" + """接收大屏心跳:更新设备表在线时间(设备由注册表登记 role/parent)""" 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} + cur = self.devices.get(cid, {"role": "", "parent_id": ""}) + cur["last_seen"] = time.time() + self.devices[cid] = cur + self._prune() except Exception: # noqa: BLE001 pass + def _prune(self): + """剔除超时未上报的设备""" + cutoff = time.time() - settings.SCREEN_TTL + self.devices = {k: v for k, v in self.devices.items() if v.get("last_seen", 0) > cutoff} + # ---------- 状态查询 ---------- 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) + return sum(1 for v in self.devices.values() if v.get("last_seen", 0) > time.time() - settings.SCREEN_TTL) def status(self): + with self._lock: + self._prune() + devices = [ + { + "device_id": cid, + "role": v.get("role", ""), + "parent_id": v.get("parent_id", ""), + } + for cid, v in self.devices.items() + ] return { "mqtt_connected": self.connected, "mqtt_host": f"{settings.MQTT_HOST}:{settings.MQTT_PORT}", - "screens_online": self.screens_online(), + "screens_online": len(devices), + "devices": devices, "last_command": self.last_command, } @@ -120,26 +135,103 @@ class MqttHub: log.warning("MQTT 发布失败: %s", e) return False - def publish_command(self, action, params=None): - """统一指令信封:{cmd_id, ts, action, params, published} - MQTT 广播 + SSE 兼容推送;published 标记是否真正发布成功""" + def register_device(self, device_id, role="", parent_id=""): + """前端上报设备:唯一 device_id + 角色(main/secondary) + 从属主屏 parent_id。""" + device_id = (device_id or "").strip() + if not device_id: + return + with self._lock: + cur = self.devices.get(device_id, {}) + if role: + cur["role"] = role + if parent_id: + cur["parent_id"] = parent_id + cur["last_seen"] = time.time() + self.devices[device_id] = cur + self._prune() + log.info("mqtt: 设备注册 id=%s role=%s parent=%s", device_id, role or cur.get("role"), parent_id or cur.get("parent_id")) + + def publish_command(self, action, params=None, screen_id=None, screen_role=None): + """按客户端(设备)路由下发指令: + · screen_id 精确到某台设备 → 只发到该设备独有 topic + · screen_role(main/secondary) → 解析到该角色的设备,发到其独有 topic + · 两者皆空(全部)→ 遍历在线设备列表,依次下发到各自独有 topic + 客户端只订阅自己的独有频道,因此定向指令绝不会被其它屏幕收到。""" payload = { "cmd_id": new_cmd_id(), "ts": int(time.time() * 1000), "action": action, "params": params or {}, } - ok = self.publish(settings.TOPIC_COMMAND, payload) + target = None + requested = bool(screen_id or screen_role in ("main", "secondary")) + if screen_id: + target = (screen_id or "").strip() + elif screen_role in ("main", "secondary"): + with self._lock: + for cid, v in self.devices.items(): + if v.get("role") == screen_role: + target = cid + break + if target: + # 定向:只发到目标设备独有频道 + return self._publish_to(payload, target, screen_role) + if requested: + # 指定了目标但未找到对应设备:不广播,避免误发到全部 + log.warning("mqtt: 目标设备未找到(id=%s role=%s),未下发", screen_id, screen_role) + payload["published"] = False + with self._lock: + self.last_command = {"action": action, "params": params or {}, "ts": payload["ts"], "published": False, "topic": "none"} + return payload + + # 全部:遍历在线设备列表,依次下发到各自独有频道 + with self._lock: + cutoff = time.time() - settings.SCREEN_TTL + online = [cid for cid, v in self.devices.items() if v.get("last_seen", 0) > cutoff] + ok = True + published_topics = [] + for cid in online: + p = dict(payload) + p["screen_id"] = cid + with self._lock: + p["screen_role"] = self.devices.get(cid, {}).get("role", "") + if self.publish(f"{settings.TOPIC_COMMAND}/{cid}", p): + bus.emit(p) + published_topics.append(cid) + else: + ok = False payload["published"] = ok + payload["screen_id"] = "" + payload["screen_role"] = "both" with self._lock: self.last_command = { "action": action, "params": params or {}, "ts": payload["ts"], "published": ok, + "topic": "all", + } + log.info("mqtt: 全部下发 %s 到 %d 台设备", action, len(published_topics)) + return payload + + def _publish_to(self, payload, target, screen_role=""): + """发到指定设备独有频道,并记录 last_command。""" + topic = f"{settings.TOPIC_COMMAND}/{target}" + payload["screen_id"] = target + with self._lock: + payload["screen_role"] = self.devices.get(target, {}).get("role", screen_role or "") + ok = self.publish(topic, payload) + payload["published"] = ok + with self._lock: + self.last_command = { + "action": payload["action"], + "params": payload.get("params") or {}, + "ts": payload["ts"], + "published": ok, + "topic": topic, } if ok: - bus.emit(payload) # SSE 兼容通道(仅发布成功时推送) + bus.emit(payload) return payload def publish_tick(self, snapshot): diff --git a/backend/app/routers.py b/backend/app/routers.py index 86a470d..86c434c 100644 --- a/backend/app/routers.py +++ b/backend/app/routers.py @@ -73,6 +73,8 @@ class StateBody(BaseModel): class DisplayCommandBody(BaseModel): action: str params: dict = Field(default_factory=dict) + screen_id: str = "" # 目标屏幕唯一 client_id(精确到某台) + screen_role: str = "" # main | secondary(按角色定位) class ChatMessage(BaseModel): @@ -487,13 +489,28 @@ _VALID_DISPLAY_ACTIONS = { } +class RegisterBody(BaseModel): + device_id: str = "" # 唯一设备标识(MQTT client_id) + role: str = "" # main | secondary + parent_id: str = "" # 副屏从属的主屏 device_id;主屏为空 + + +@router.post("/api/display/register") +async def display_register(body: RegisterBody): + """设备注册:前端上报唯一 device_id + 角色 + 从属主屏,后端维护设备表供精准控制。""" + hub.register_device(body.device_id, body.role, body.parent_id) + return {"ok": True, "device_id": body.device_id, "role": body.role, "parent_id": body.parent_id} + + @router.post("/api/display/command") async def display_command_publish(body: DisplayCommandBody): """管理端/任意客户端通过 REST 发控制指令 → 后端转 MQTT 广播给所有大屏""" if body.action not in _VALID_DISPLAY_ACTIONS: return JSONResponse({"ok": False, "error": f"不支持的指令: {body.action}"}, status_code=400) - cmd = hub.publish_command(body.action, body.params) + # 按屏幕终端路由:screen_id 精确到某客户端;screen_role 按主/副定位;皆空 → 全局广播 + cmd = hub.publish_command(body.action, body.params, body.screen_id or None, body.screen_role or None) return {"ok": cmd.get("published", False), "cmd_id": cmd["cmd_id"], "action": body.action, + "screen_id": body.screen_id or "", "screen_role": body.screen_role or "both", "mqtt_connected": hub.connected} diff --git a/backend/static/admin.js b/backend/static/admin.js index d8df433..175bb8b 100644 --- a/backend/static/admin.js +++ b/backend/static/admin.js @@ -10,8 +10,46 @@ var fullscreen = !!init.fullscreen; var autostart = !!init.autostart; - /* 目标屏(双屏独立控制):both | main | secondary */ - var screenTarget = 'both'; + /* 目标屏幕(按 MQTT 客户端精准控制):client_id + role */ + var screenId = ''; + var screenRole = ''; + + function applyScreenTarget(sel) { + sel = sel || document.getElementById('screenTarget'); + if (!sel) return; + var v = sel.value || ''; + screenId = v; + var opt = null; + if (v) { for (var i = 0; i < sel.options.length; i++) { if (sel.options[i].value === v) { opt = sel.options[i]; break; } } } + screenRole = opt ? (opt.getAttribute('data-role') || '') : ''; + } + + function loadScreens() { + fetch('/api/display/state', { cache: 'no-store' }) + .then(function (r) { return r.json(); }) + .then(function (d) { + var sel = document.getElementById('screenTarget'); + var cnt = document.getElementById('screenCount'); + if (!sel) return; + var devices = (d && d.devices) || []; + if (cnt) cnt.textContent = '在线设备:' + (devices.length || 0) + ' 台'; + var cur = sel.value; + sel.innerHTML = ''; + devices.forEach(function (dev) { + var role = dev.role || ''; + var name = role === 'main' ? '主屏' : role === 'secondary' ? '副屏' : (role || '屏幕'); + var parent = (dev.role === 'secondary' && dev.parent_id) ? '·属于:' + String(dev.parent_id).slice(0, 8) : ''; + var opt = document.createElement('option'); + opt.value = dev.device_id; + opt.setAttribute('data-role', role); + opt.textContent = name + parent + ' · ' + String(dev.device_id).slice(0, 16); + sel.appendChild(opt); + }); + if (cur) sel.value = cur; + applyScreenTarget(sel); + }) + .catch(function () {}); + } /* ---------- Toast ---------- */ var toastContainer = document.getElementById('toastContainer'); @@ -82,6 +120,7 @@ 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) + ' 台'); + loadScreens(); // 刷新目标屏幕下拉(按 MQTT 客户端) var lc = d.last_command; if (lc) { var names = { navigate: '页面切换', play: '播放', pause: '暂停', next: '下一项', prev: '上一项', @@ -98,6 +137,12 @@ }); } if (connEls.api) { pollStatus(); setInterval(pollStatus, 3000); } + // 目标屏幕下拉:变更时记录目标 client_id + role;初始加载一次 + var screenSel = document.getElementById('screenTarget'); + if (screenSel) { + screenSel.addEventListener('change', function () { applyScreenTarget(screenSel); }); + loadScreens(); + } /* ========================================================= 事件委托:页面切换 / 播放控制 / 媒体操作 @@ -110,19 +155,10 @@ }); document.addEventListener('click', function (e) { - var btn = e.target.closest ? e.target.closest('[data-nav], [data-mqtt], [data-control], [data-action], .copy-btn, [data-preview], .screen-target, #mqttAiSend, #mqttAlertSend') : null; + var btn = e.target.closest ? e.target.closest('[data-nav], [data-mqtt], [data-control], [data-action], .copy-btn, [data-preview], #mqttAiSend, #mqttAlertSend') : null; if (!btn) return; e.stopPropagation(); - // 目标屏选择(双屏独立控制) - if (btn.classList.contains('screen-target')) { - screenTarget = btn.getAttribute('data-screen') || 'both'; - var btns = document.querySelectorAll('.screen-target'); - for (var i = 0; i < btns.length; i++) btns[i].classList.toggle('active', btns[i] === btn); - toast('目标屏:' + ({ both: '全部(镜像)', main: '仅主屏', secondary: '仅副屏' }[screenTarget] || screenTarget)); - return; - } - // 预览 if (btn.hasAttribute('data-preview')) { openPreview(btn.closest('.media-card')); return; } @@ -141,10 +177,13 @@ if (btn.hasAttribute('data-mqtt')) { var cmd = null; try { cmd = JSON.parse(btn.getAttribute('data-mqtt')); } catch (err) { toast('指令 JSON 错误', 'error'); return; } - // 双屏独立控制:除「双屏开关」外,注入目标屏(main/secondary/both) - if (cmd.action !== 'dual_screen') cmd.screen = screenTarget; + // 屏幕终端精准控制:除「双屏开关」外,注入目标 client_id + role;全空=全局广播 + if (cmd.action !== 'dual_screen') { cmd.screen_id = screenId; cmd.screen_role = screenRole; } + var tgtTxt = screenId + ? (cmd.screen_role === 'main' ? '主屏' : cmd.screen_role === 'secondary' ? '副屏' : '屏幕') + '·' + String(screenId).slice(0, 10) + : '全部'; post('/api/display/command', cmd, function (d) { - toast(d && d.ok !== false ? '指令已发送:' + (cmd.action || '') : '发送失败(MQTT 未连接?)', + toast(d && d.ok !== false ? ('指令已发送:' + (cmd.action || '') + ' → ' + tgtTxt) : '发送失败(MQTT 未连接?)', d && d.ok !== false ? 'ok' : 'error'); }); return; diff --git a/backend/templates/admin.html b/backend/templates/admin.html index 816a935..b06f61a 100644 --- a/backend/templates/admin.html +++ b/backend/templates/admin.html @@ -108,14 +108,15 @@
-

目标屏(双屏独立控制)

+

目标屏幕(按 MQTT 客户端精准控制)

-
- - - +
+ 在线屏幕:加载中… +
-
选择后,下方命令只发送到对应屏幕;「全部」两边同步。
+
选择某台屏幕后,下方命令只发给它(按 client_id);「全部」则所有屏幕同步。
diff --git a/index.html b/index.html index 60a8fe3..adb1ce8 100644 --- a/index.html +++ b/index.html @@ -12,6 +12,11 @@
+ diff --git a/package.json b/package.json index 1e004a3..d846de7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dpm", "private": true, - "version": "0.1.0", + "version": "0.1.2", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index fdac5fe..55c09af 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5135,7 +5135,7 @@ dependencies = [ [[package]] name = "昆明大学生创业园展播系统" -version = "0.1.0" +version = "0.1.2" dependencies = [ "serde", "tauri", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index fb5a83f..29804a9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "昆明大学生创业园展播系统" -version = "0.1.0" +version = "0.1.2" description = "大屏媒体轮播系统 - Tauri App" authors = ["Pine"] edition = "2021" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 9d419d4..a5afe91 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -5,6 +5,7 @@ "windows": ["main", "secondary"], "permissions": [ "core:default", + "core:app:default", "core:window:default", "core:window:allow-minimize", "core:window:allow-set-fullscreen", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2f5ac4b..51f1b1c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,7 +9,7 @@ use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; /// 双屏控制:on=true 在第二块屏幕上新建一个全屏窗口(加载前端首页),on=false 关闭它。 /// 用于 admin 端「启动双屏」,让展播内容同时投到电脑的另一块显示器。 #[tauri::command] -fn set_dual_screen(app: AppHandle, on: bool) -> Result { +fn set_dual_screen(app: AppHandle, on: bool, parent_id: Option) -> Result { if !on { if let Some(w) = app.get_webview_window("secondary") { let _ = w.close(); @@ -52,6 +52,11 @@ fn set_dual_screen(app: AppHandle, on: bool) -> Result { .title("OPC 运营中心 · 双屏") .position(x as f64, y as f64) .inner_size(w as f64, h as f64) + // 由 Rust 明确告知副窗:它的身份是 secondary,且从属于主屏 device_id(parent_id) + .initialization_script(format!( + "window.__DPM_WINDOW_LABEL__='secondary';window.__DPM_PARENT_ID__='{}';", + parent_id.unwrap_or_default().replace('\'', "") + )) .build() .map_err(|e| format!("创建副窗失败: {e}"))?; // 等窗口内容就绪后再全屏(避免过早全屏导致白屏/布局异常) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 28fbc07..dd6b86a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "云超服昆创园OPC运营中心", - "version": "0.1.1", + "version": "0.1.2", "identifier": "com.pine.dpm", "build": { "beforeDevCommand": "yarn build && yarn dev", diff --git a/src/main.jsx b/src/main.jsx index f14ce1d..b6e0841 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -7,6 +7,23 @@ import { getApiBase } from "./config"; import { invoke } from "@tauri-apps/api/core"; import "./styles/global.css"; +/* 全局致命错误捕获:任何模块加载/运行/异步错误 → 显示到页面(替代白屏),便于副屏等定位 */ +function installFatalErrorDiagnostics() { + const show = (label, err) => { + const el = document.getElementById("fatal-error"); + if (!el || el.style.display === "block") return; + const text = document.getElementById("fatal-error-text"); + if (text) { + const msg = (err && (err.stack || err.message)) || String(err); + text.textContent = `${label}\n${msg}`; + } + el.style.display = "block"; + }; + window.addEventListener("error", (e) => show("加载错误:", e.error || e.message)); + window.addEventListener("unhandledrejection", (e) => show("未处理异常:", e.reason)); +} +installFatalErrorDiagnostics(); + /* ========================================================= 启动引导(打包部署关键)—— 地址解析优先级: 1. exe 旁 config.json(Tauri 命令读取,部署免重打包改 IP)★ diff --git a/src/utils/mqtt.js b/src/utils/mqtt.js index 8f814f7..79b2ff0 100644 --- a/src/utils/mqtt.js +++ b/src/utils/mqtt.js @@ -46,7 +46,9 @@ export function connectMqtt() { client.on('connect', () => { status = 'connected'; notifyStatus(); - client.subscribe([MQTT_TOPIC_COMMAND, MQTT_TOPIC_TICK], { qos: 1 }); + // 客户端只监听自己唯一的频道 opc/display/command/(+ 数据 tick)。 + // 不再订阅全局命令频道 → 后端按客户端路由时,他屏的指令绝收不到。 + client.subscribe([MQTT_TOPIC_TICK, `${MQTT_TOPIC_COMMAND}/${clientId}`], { qos: 1 }); }); client.on('reconnect', () => { status = 'connecting'; diff --git a/src/utils/updateCheck.js b/src/utils/updateCheck.js index 19c4730..916b76d 100644 --- a/src/utils/updateCheck.js +++ b/src/utils/updateCheck.js @@ -6,20 +6,29 @@ import { getApiBase } from '../config'; import { invoke } from '@tauri-apps/api/core'; -// 当前版本:发布新版本时同步 package.json 的 version -export const APP_VERSION = '0.1.0'; +/** 读取真实安装的应用版本(Tauri getVersion = package.json/tauri.conf.json version);失败时兜底。 */ +async function getCurrentVersion() { + try { + const { getVersion } = await import('@tauri-apps/api/app'); + const v = await getVersion(); + return v || '0.1.0'; + } catch { + return '0.1.0'; + } +} export async function checkForUpdate() { try { + const current = await getCurrentVersion(); const res = await fetch(`${getApiBase()}/api/update/check`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ current: APP_VERSION }), + body: JSON.stringify({ current }), }); const data = await res.json(); if (data && data.ok) { return { - current: APP_VERSION, + current, latest: data.latest_version, available: !!data.update_available, url: `${getApiBase()}${data.download_url || '/download'}`, diff --git a/src/utils/useMqttControl.js b/src/utils/useMqttControl.js index 39aa844..80849cb 100644 --- a/src/utils/useMqttControl.js +++ b/src/utils/useMqttControl.js @@ -1,16 +1,20 @@ import { useEffect } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import { onMqttMessage, connectMqtt, publishMqtt, getClientId, useMqttStatus } from './mqtt'; -import { MQTT_TOPIC_HEARTBEAT } from '../config'; +import { MQTT_TOPIC_HEARTBEAT, MQTT_TOPIC_COMMAND, getApiBase as API_BASE } from '../config'; 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'(浏览器开发环境回退 'main') +// 当前窗口标识:主屏 'main' / 双屏副窗 'secondary' +// 优先用 Rust 注入的 __DPM_WINDOW_LABEL__(副窗初始化脚本),再回退 getCurrentWindow().label const WINDOW_LABEL = (() => { - try { return getCurrentWindow().label || 'main'; } catch { return 'main'; } + try { + if (window.__DPM_WINDOW_LABEL__) return window.__DPM_WINDOW_LABEL__; + return getCurrentWindow().label || 'main'; + } catch { return 'main'; } })(); /* ========================================================= @@ -42,6 +46,20 @@ export function useMqttControl() { 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]); + // 大屏在线心跳(后端据此统计在线大屏数) useEffect(() => { connectMqtt(); @@ -75,9 +93,14 @@ export function useMqttControl() { useEffect(() => { connectMqtt(); const off = onMqttMessage((topic, cmd) => { - if (topic !== 'opc/display/command' || !cmd || !cmd.action) return; - // 双屏独立控制:cmd.screen = main|secondary|both(缺省视为 both) + // 接收全局控制 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) { @@ -97,10 +120,10 @@ export function useMqttControl() { break; } case 'dual_screen': - // 双屏:admin 控制第二块屏全屏展示(Tauri 命令)——仅主窗口执行 + // 双屏:仅主窗口执行(只有主屏能开启/关闭副屏),把主屏 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 }) + 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' });