diff --git a/backend/app/routers.py b/backend/app/routers.py index 613fe83..53dd9ce 100644 --- a/backend/app/routers.py +++ b/backend/app/routers.py @@ -531,14 +531,12 @@ class VisionEventBody(BaseModel): @router.post("/api/vision/event") async def vision_event(body: VisionEventBody): - """前端摄像头识别状态/触发上报;后端记录详细日志,triggered 时广播 alert 到全屏。""" + """前端摄像头识别状态/触发上报;后端记录详细日志。 + 主动问候已关闭,故不再对 triggered 广播 alert(避免右上角弹「已主动问候」误导提示)。""" log.info( "vision event=%s faces=%d dwell_ms=%d detail=%s", body.event, body.faces, body.dwell_ms, body.detail, ) - if body.event == "triggered": - hub.publish_command("alert", {"text": "有访客正对屏幕,语音助手已主动问候", "faces": body.faces}) - log.info("vision triggered -> alert 已广播(faces=%d)", body.faces) return {"ok": True} diff --git a/backend/static/admin.css b/backend/static/admin.css index 6bd0cee..4188d79 100644 --- a/backend/static/admin.css +++ b/backend/static/admin.css @@ -1147,6 +1147,13 @@ button { font-family: "Poppins", "PingFang SC", sans-serif; cursor: pointer; tra background: rgba(0, 189, 125, 0.14); transform: translateY(-1px); } +.screen-target.active { + background: #1D5DCE; + border-color: #1D5DCE; + color: #fff; + box-shadow: 0 4px 12px rgba(29, 93, 206, 0.3); +} +.screen-target.active:hover { background: #174fb0; } .media-url { display: flex; align-items: center; diff --git a/backend/static/admin.js b/backend/static/admin.js index 274687e..d8df433 100644 --- a/backend/static/admin.js +++ b/backend/static/admin.js @@ -10,6 +10,9 @@ var fullscreen = !!init.fullscreen; var autostart = !!init.autostart; + /* 目标屏(双屏独立控制):both | main | secondary */ + var screenTarget = 'both'; + /* ---------- Toast ---------- */ var toastContainer = document.getElementById('toastContainer'); function toast(msg, type) { @@ -107,10 +110,19 @@ }); 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], #mqttAiSend, #mqttAlertSend') : null; + 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; 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; } @@ -129,6 +141,8 @@ 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; post('/api/display/command', cmd, function (d) { toast(d && d.ok !== false ? '指令已发送:' + (cmd.action || '') : '发送失败(MQTT 未连接?)', d && d.ok !== false ? 'ok' : 'error'); diff --git a/backend/templates/admin.html b/backend/templates/admin.html index 30dbb15..816a935 100644 --- a/backend/templates/admin.html +++ b/backend/templates/admin.html @@ -105,6 +105,19 @@ +
+
+
+

目标屏(双屏独立控制)

+
+
+ + + +
+
选择后,下方命令只发送到对应屏幕;「全部」两边同步。
+
+
@@ -190,6 +203,16 @@
+ +
+
双屏控制
+
+ + +
+
在电脑另一块屏幕全屏展示(需展播机连接显示器)
+
+
媒体轮播
diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 0657820..9d419d4 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Capability for the main window", - "windows": ["main"], + "windows": ["main", "secondary"], "permissions": [ "core:default", "core:window:default", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 36dffb0..ad3e6ae 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,7 +4,49 @@ use std::fs; use std::path::PathBuf; -use tauri::{AppHandle, Manager}; +use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; + +/// 双屏控制:on=true 在第二块屏幕上新建一个全屏窗口(加载前端首页),on=false 关闭它。 +/// 用于 admin 端「启动双屏」,让展播内容同时投到电脑的另一块显示器。 +#[tauri::command] +fn set_dual_screen(app: AppHandle, on: bool) -> Result { + if !on { + if let Some(w) = app.get_webview_window("secondary") { + let _ = w.close(); + } + return Ok("off".into()); + } + // 已存在则直接显示/全屏/聚焦 + if let Some(w) = app.get_webview_window("secondary") { + let _ = w.show(); + let _ = w.unminimize(); + let _ = w.set_fullscreen(true); + let _ = w.set_focus(); + return Ok("on".into()); + } + let mut builder = WebviewWindowBuilder::new(&app, "secondary", WebviewUrl::App("/".into())) + .title("OPC 运营中心 · 双屏"); + // 定位到第二块屏(非主屏;无第二屏则用第一块) + if let Ok(monitors) = app.available_monitors() { + let primary_pos = app + .primary_monitor() + .ok() + .flatten() + .map(|pm| { let p = pm.position(); (p.x, p.y) }); + let target = monitors + .iter() + .find(|m| { let p = m.position(); Some((p.x, p.y)) != primary_pos }) + .or_else(|| monitors.first()); + if let Some(mon) = target { + let p = mon.position(); + let s = mon.size(); + builder = builder.position(p.x as f64, p.y as f64).inner_size(s.width as f64, s.height as f64); + } + } + let win = builder.build().map_err(|e| e.to_string())?; + let _ = win.set_fullscreen(true); + Ok("on".into()) +} /// 读取运行时配置(部署用,免重新打包): /// 1) exe 同目录 config.json / dpm.config.json(最高优先) @@ -47,11 +89,14 @@ pub fn run() { tauri_plugin_autostart::MacosLauncher::LaunchAgent, Some(vec![]), )) - .invoke_handler(tauri::generate_handler![read_runtime_config]) + .invoke_handler(tauri::generate_handler![read_runtime_config, set_dual_screen]) .on_window_event(|window, event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { - api.prevent_close(); - let _ = window.minimize(); + // 仅主窗口关闭=最小化(进程常驻);双屏副窗口可正常关闭 + if window.label() == "main" { + api.prevent_close(); + let _ = window.minimize(); + } } }) .run(tauri::generate_context!()) diff --git a/src/pages/VoiceAssistant.jsx b/src/pages/VoiceAssistant.jsx index 209a3ff..17c85a2 100644 --- a/src/pages/VoiceAssistant.jsx +++ b/src/pages/VoiceAssistant.jsx @@ -480,10 +480,14 @@ export default function VoiceAssistant() { }); // 手势 → 对话控制:举手 toggle —— 无对话则开始,正在对话则结束 - // 【已按需求关闭】视觉手势识别误判会触发右上角弹窗/误开对话,故这里不再响应手势。 - const onGesture = useCallback(() => { - // 视觉手势已关闭:识别到人脸/手势不再自动开始/结束对话 - }, []); + const onGesture = useCallback((g) => { + if (g !== 'raise') return; + if (clientRef.current) { + void stop(); + } else { + void start(); + } + }, [start, stop]); useEffect(() => { gestureRef.current = onGesture; @@ -517,7 +521,7 @@ export default function VoiceAssistant() { loading: '摄像头启动中…', detecting: `识别中 · ${vision.faces} 人${vision.faces > 0 ? `(后端 ${vision.latency ?? '-'}ms)` : ' · 未检到人脸'} · 链路${vision.modelPhase === 'ok' ? 'OK' : vision.modelPhase === 'failed' ? `失败:${(vision.modelErr || '').slice(0, 40)}` : '连接中'} · 亮度${vision.frameLum ?? '-'}${vision.detectErr ? ` · 异常:${String(vision.detectErr).slice(0, 50)}` : ''}`, facing: `面向大屏 ${(vision.dwellMs / 1000).toFixed(1)}s / ${VISION_DWELL_MS / 1000}s`, - triggered: '已触发 · 主动问候', + triggered: '已识别到访客(主动问候已关闭)', silent: '静默中', error: '摄像头不可用', }[vision.status] ?? ''; @@ -629,7 +633,7 @@ export default function VoiceAssistant() { {vision.gesture === 'raise' && ( - 您好呀 · {live ? '开始对话' : '结束对话'} + {live ? '开始对话' : '结束对话'} )} {visionStatusText} diff --git a/src/utils/useMqttControl.js b/src/utils/useMqttControl.js index 3a4536b..46ce424 100644 --- a/src/utils/useMqttControl.js +++ b/src/utils/useMqttControl.js @@ -5,6 +5,13 @@ import { MQTT_TOPIC_HEARTBEAT } 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') +const WINDOW_LABEL = (() => { + try { return getCurrentWindow().label || 'main'; } catch { return 'main'; } +})(); /* ========================================================= MQTT 控制 —— 后端通过 opc/display/command 控制大屏 @@ -69,6 +76,9 @@ export function useMqttControl() { connectMqtt(); const off = onMqttMessage((topic, cmd) => { if (topic !== 'opc/display/command' || !cmd || !cmd.action) return; + // 双屏独立控制:cmd.screen = main|secondary|both(缺省视为 both) + const sc = cmd.screen; + if (sc && sc !== 'both' && sc !== WINDOW_LABEL) return; switch (cmd.action) { case 'navigate': { @@ -86,6 +96,12 @@ export function useMqttControl() { } break; } + case 'dual_screen': + // 双屏:admin 控制第二块屏全屏展示(Tauri 命令)——仅主窗口执行 + if (WINDOW_LABEL !== 'main') break; + aiStatus(cmd.params?.on === false ? '正在关闭双屏' : '正在启动双屏', { icon: 'page' }); + invoke('set_dual_screen', { on: cmd.params?.on !== false }).catch(() => {}); + break; case 'alert': // 通知本身即展示,无需再弹「正在发送通知」状态提示 window.dispatchEvent(new CustomEvent('dpm:alert', { detail: cmd.params || {} }));