feat: 实现设备注册和精准控制功能,支持双屏管理;更新相关逻辑以优化设备状态跟踪
This commit is contained in:
+106
-14
@@ -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):
|
||||
|
||||
+18
-1
@@ -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}
|
||||
|
||||
|
||||
|
||||
+54
-15
@@ -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 = '<option value="">全部屏幕</option>';
|
||||
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;
|
||||
|
||||
@@ -108,14 +108,15 @@
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#1D5DCE" stroke-width="1.6"><rect x="3" y="3" width="8" height="8" rx="1.5"/><rect x="13" y="3" width="8" height="8" rx="1.5"/><rect x="3" y="13" width="8" height="8" rx="1.5"/><rect x="13" y="13" width="8" height="8" rx="1.5"/></svg></div>
|
||||
<h3>目标屏(双屏独立控制)</h3>
|
||||
<h3>目标屏幕(按 MQTT 客户端精准控制)</h3>
|
||||
</div>
|
||||
<div class="page-switch-grid">
|
||||
<button class="page-switch-btn screen-target" data-screen="both">全部(镜像)</button>
|
||||
<button class="page-switch-btn screen-target" data-screen="main">仅主屏</button>
|
||||
<button class="page-switch-btn screen-target" data-screen="secondary">仅副屏</button>
|
||||
<div class="page-switch-grid" style="align-items:center;gap:8px;">
|
||||
<span id="screenCount" class="mqtt-hint" style="white-space:nowrap;">在线屏幕:加载中…</span>
|
||||
<select id="screenTarget" style="flex:1;min-width:0;padding:8px 10px;border:1px solid rgba(0,189,125,.2);border-radius:8px;background:#0e1a2b;color:#e8f0fa;font-size:12px;">
|
||||
<option value="">全部屏幕</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mqtt-hint">选择后,下方命令只发送到对应屏幕;「全部」两边同步。</div>
|
||||
<div class="mqtt-hint">选择某台屏幕后,下方命令只发给它(按 client_id);「全部」则所有屏幕同步。</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="fatal-error" style="display:none;position:fixed;inset:0;z-index:99999;background:#f7fafd;color:#1d2c44;padding:40px;font-family:system-ui,sans-serif;overflow:auto;">
|
||||
<h3 style="margin:0 0 12px;">页面加载出错</h3>
|
||||
<pre id="fatal-error-text" style="white-space:pre-wrap;font-size:12px;background:#fff;border:1px solid #e3ecf7;padding:14px;border-radius:8px;"></pre>
|
||||
<button onclick="location.reload()" style="margin-top:12px;padding:8px 18px;cursor:pointer;">重载</button>
|
||||
</div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dpm",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+1
-1
@@ -5135,7 +5135,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "昆明大学生创业园展播系统"
|
||||
version = "0.1.0"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"tauri",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "昆明大学生创业园展播系统"
|
||||
version = "0.1.0"
|
||||
version = "0.1.2"
|
||||
description = "大屏媒体轮播系统 - Tauri App"
|
||||
authors = ["Pine"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<String, String> {
|
||||
fn set_dual_screen(app: AppHandle, on: bool, parent_id: Option<String>) -> Result<String, String> {
|
||||
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<String, String> {
|
||||
.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}"))?;
|
||||
// 等窗口内容就绪后再全屏(避免过早全屏导致白屏/布局异常)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)★
|
||||
|
||||
+3
-1
@@ -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/<clientId>(+ 数据 tick)。
|
||||
// 不再订阅全局命令频道 → 后端按客户端路由时,他屏的指令绝收不到。
|
||||
client.subscribe([MQTT_TOPIC_TICK, `${MQTT_TOPIC_COMMAND}/${clientId}`], { qos: 1 });
|
||||
});
|
||||
client.on('reconnect', () => {
|
||||
status = 'connecting';
|
||||
|
||||
@@ -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'}`,
|
||||
|
||||
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user