Compare commits

...

2 Commits

15 changed files with 374 additions and 19 deletions
+48
View File
@@ -6,6 +6,7 @@
import asyncio import asyncio
import logging import logging
import re
import threading import threading
import time import time
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -15,6 +16,7 @@ from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from .admin import admin_login, admin_logout, admin_page from .admin import admin_login, admin_logout, admin_page
from .config import settings from .config import settings
@@ -120,6 +122,52 @@ async def download_installer():
return JSONResponse({"detail": "安装包不存在"}, status_code=404) return JSONResponse({"detail": "安装包不存在"}, status_code=404)
# ---------- 客户端更新检测(仅 Windows:比对版本 → 下发安装包下载地址) ----------
def _latest_installer():
"""返回 backend/ 下最新(按文件名排序取最大)的 *.setup.exe。"""
for f in sorted(_DOWNLOAD_ROOT.glob("*setup.exe"), reverse=True):
if f.is_file():
return f
return None
def _version_from_name(name: str) -> str:
"""从安装包文件名解析版本,如 `..._0.1.0_x64-setup.exe` → `0.1.0`。"""
m = re.search(r"_(\d+\.\d+(?:\.\d+)?)", name or "")
return m.group(1) if m else ""
def _version_key(v: str):
return tuple(int(x) for x in v.split(".") if x.isdigit())
class UpdateCheckBody(BaseModel):
current: str = "" # 前端上报的当前版本,如 0.1.0
@app.post("/api/update/check", include_in_schema=False)
async def update_check(body: UpdateCheckBody):
"""检测更新:返回最新版本号、安装包下载地址、是否需要更新(仅 Windows)。"""
inst = _latest_installer()
if not inst:
return {"ok": False, "error": "installer not found"}
latest = _version_from_name(inst.name)
current = (body.current or "").strip()
update_available = False
if latest and current and current != latest:
update_available = _version_key(latest) > _version_key(current)
elif latest and not current:
update_available = True
return {
"ok": True,
"latest_version": latest,
"installer_name": inst.name,
"download_url": "/download",
"update_available": update_available,
}
# ---------- 前端静态托管 + SPA 回退(浏览器直接访问 :8000 即可) ---------- # ---------- 前端静态托管 + SPA 回退(浏览器直接访问 :8000 即可) ----------
@app.get("/{path:path}", include_in_schema=False) @app.get("/{path:path}", include_in_schema=False)
+2 -4
View File
@@ -531,14 +531,12 @@ class VisionEventBody(BaseModel):
@router.post("/api/vision/event") @router.post("/api/vision/event")
async def vision_event(body: VisionEventBody): async def vision_event(body: VisionEventBody):
"""前端摄像头识别状态/触发上报;后端记录详细日志,triggered 时广播 alert 到全屏。""" """前端摄像头识别状态/触发上报;后端记录详细日志。
主动问候已关闭,故不再对 triggered 广播 alert(避免右上角弹「已主动问候」误导提示)。"""
log.info( log.info(
"vision event=%s faces=%d dwell_ms=%d detail=%s", "vision event=%s faces=%d dwell_ms=%d detail=%s",
body.event, body.faces, body.dwell_ms, body.detail, 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} return {"ok": True}
+7
View File
@@ -1147,6 +1147,13 @@ button { font-family: "Poppins", "PingFang SC", sans-serif; cursor: pointer; tra
background: rgba(0, 189, 125, 0.14); background: rgba(0, 189, 125, 0.14);
transform: translateY(-1px); 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 { .media-url {
display: flex; display: flex;
align-items: center; align-items: center;
+15 -1
View File
@@ -10,6 +10,9 @@
var fullscreen = !!init.fullscreen; var fullscreen = !!init.fullscreen;
var autostart = !!init.autostart; var autostart = !!init.autostart;
/* 目标屏(双屏独立控制):both | main | secondary */
var screenTarget = 'both';
/* ---------- Toast ---------- */ /* ---------- Toast ---------- */
var toastContainer = document.getElementById('toastContainer'); var toastContainer = document.getElementById('toastContainer');
function toast(msg, type) { function toast(msg, type) {
@@ -107,10 +110,19 @@
}); });
document.addEventListener('click', function (e) { 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; if (!btn) return;
e.stopPropagation(); 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; } if (btn.hasAttribute('data-preview')) { openPreview(btn.closest('.media-card')); return; }
@@ -129,6 +141,8 @@
if (btn.hasAttribute('data-mqtt')) { if (btn.hasAttribute('data-mqtt')) {
var cmd = null; var cmd = null;
try { cmd = JSON.parse(btn.getAttribute('data-mqtt')); } catch (err) { toast('指令 JSON 错误', 'error'); return; } 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) { post('/api/display/command', cmd, function (d) {
toast(d && d.ok !== false ? '指令已发送:' + (cmd.action || '') : '发送失败(MQTT 未连接?)', toast(d && d.ok !== false ? '指令已发送:' + (cmd.action || '') : '发送失败(MQTT 未连接?)',
d && d.ok !== false ? 'ok' : 'error'); d && d.ok !== false ? 'ok' : 'error');
+23
View File
@@ -105,6 +105,19 @@
</div> </div>
<!-- 页面切换 --> <!-- 页面切换 -->
<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>
</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>
<div class="mqtt-hint">选择后,下方命令只发送到对应屏幕;「全部」两边同步。</div>
</div>
<div class="card"> <div class="card">
<div class="card-header"> <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> <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>
@@ -190,6 +203,16 @@
</div> </div>
</div> </div>
<!-- 双屏 -->
<div class="mqtt-group">
<div class="mqtt-group-title">双屏控制</div>
<div class="mqtt-row">
<button class="page-switch-btn" data-mqtt='{"action":"dual_screen","params":{"on":true}}'>启动双屏</button>
<button class="page-switch-btn" data-mqtt='{"action":"dual_screen","params":{"on":false}}'>关闭双屏</button>
</div>
<div class="mqtt-hint">在电脑另一块屏幕全屏展示(需展播机连接显示器)</div>
</div>
<!-- 媒体控制 --> <!-- 媒体控制 -->
<div class="mqtt-group"> <div class="mqtt-group">
<div class="mqtt-group-title">媒体轮播</div> <div class="mqtt-group-title">媒体轮播</div>
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "Capability for the main window", "description": "Capability for the main window",
"windows": ["main"], "windows": ["main", "secondary"],
"permissions": [ "permissions": [
"core:default", "core:default",
"core:window:default", "core:window:default",
+47 -2
View File
@@ -4,7 +4,49 @@
use std::fs; use std::fs;
use std::path::PathBuf; 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<String, String> {
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(最高优先) /// 1) exe 同目录 config.json / dpm.config.json(最高优先)
@@ -47,12 +89,15 @@ pub fn run() {
tauri_plugin_autostart::MacosLauncher::LaunchAgent, tauri_plugin_autostart::MacosLauncher::LaunchAgent,
Some(vec![]), 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| { .on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event { if let tauri::WindowEvent::CloseRequested { api, .. } = event {
// 仅主窗口关闭=最小化(进程常驻);双屏副窗口可正常关闭
if window.label() == "main" {
api.prevent_close(); api.prevent_close();
let _ = window.minimize(); let _ = window.minimize();
} }
}
}) })
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
+10
View File
@@ -141,9 +141,19 @@ export default function PageHeader() {
{/* <span className="bd-logo"> {/* <span className="bd-logo">
<Image src="/logo.png" alt="Logo" /> <Image src="/logo.png" alt="Logo" />
</span> */} </span> */}
<span
className="bd-logo-update"
role="button"
tabIndex={0}
title="点击检测更新"
aria-label="点击检测更新"
onClick={() => window.dispatchEvent(new CustomEvent('dpm:check-update'))}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') window.dispatchEvent(new CustomEvent('dpm:check-update')); }}
>
<Avatar size="lg"> <Avatar size="lg">
<AvatarImage src="/logo.png" alt="" /> <AvatarImage src="/logo.png" alt="" />
</Avatar> </Avatar>
</span>
<div className="bd-header-titles"> <div className="bd-header-titles">
<h1 className="bd-header-title">昆明市大学生创业园 <span className="bd-header-title-accent">· OPC 智能园区数字运营中心</span></h1> <h1 className="bd-header-title">昆明市大学生创业园 <span className="bd-header-title-accent">· OPC 智能园区数字运营中心</span></h1>
<div className="bd-header-sub">云南省首家政府主办大学生创业孵化园区 · <span className="bd-header-content—highlights">空间+孵化+融资+政策+资源+AI赋能+综合服务</span></div> <div className="bd-header-sub">云南省首家政府主办大学生创业孵化园区 · <span className="bd-header-content—highlights">空间+孵化+融资+政策+资源+AI赋能+综合服务</span></div>
+2
View File
@@ -2,6 +2,7 @@ import { Outlet } from 'react-router-dom';
import PageHeader from './PageHeader'; import PageHeader from './PageHeader';
import DpmOverlays from './DpmOverlays'; import DpmOverlays from './DpmOverlays';
import ToolStatusToast from './ToolStatusToast'; import ToolStatusToast from './ToolStatusToast';
import UpdateNotify from './UpdateNotify';
import GlobalVision from './GlobalVision'; import GlobalVision from './GlobalVision';
import { usePageNav } from '../utils/pageNav'; import { usePageNav } from '../utils/pageNav';
import { useMqttControl } from '../utils/useMqttControl'; import { useMqttControl } from '../utils/useMqttControl';
@@ -29,6 +30,7 @@ export default function ScreenLayout() {
<GlobalVision /> <GlobalVision />
<DpmOverlays /> <DpmOverlays />
<ToolStatusToast /> <ToolStatusToast />
<UpdateNotify />
</div> </div>
); );
} }
+68
View File
@@ -0,0 +1,68 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import Icon from './Icons';
import { checkForUpdate, openUpdate } from '../utils/updateCheck';
/* =========================================================
UpdateNotify —— 检测新版本(Windows)
· 启动静默检查一次 + 每 30 分钟一次(仅在有更新时提示)
· 页眉 logo 点击 → dpm:check-update → 主动检测:
有更新 → 弹更新提示;无更新 → 临时提示「已是最新版本」
========================================================= */
export default function UpdateNotify() {
const [upd, setUpd] = useState(null); // 更新提示
const [msg, setMsg] = useState(null); // 临时提示(已是最新 / 失败)
const [dismissed, setDismissed] = useState(false);
const timerRef = useRef(null);
const run = useCallback(async (manual) => {
const u = await checkForUpdate();
if (u) {
if (u.available) {
setUpd(u);
setDismissed(false);
} else if (manual) {
setMsg(`已是最新版本 v${u.latest}`);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setMsg(null), 3000);
}
} else if (manual) {
setMsg('更新检测失败');
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setMsg(null), 3000);
}
}, []);
useEffect(() => {
const onCheck = () => run(true);
window.addEventListener('dpm:check-update', onCheck);
run(false); // 启动静默检测
const iv = setInterval(() => run(false), 30 * 60 * 1000);
return () => {
window.removeEventListener('dpm:check-update', onCheck);
clearInterval(iv);
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [run]);
return (
<>
{upd && !dismissed && (
<div className="upd-toast" role="status">
<div className="upd-ico"><Icon name="rocket" size={15} /></div>
<div className="upd-body">
<b>发现新版本 v{upd.latest}</b>
<span>当前 v{upd.current} · 点击下载更新安装包(Windows)</span>
</div>
<button className="upd-btn" onClick={() => openUpdate(upd.url)}>下载更新</button>
<button className="upd-close" onClick={() => setDismissed(true)} aria-label="关闭">×</button>
</div>
)}
{msg && (
<div className="upd-toast upd-msg" role="status">
<div className="upd-ico"><Icon name="check" size={15} /></div>
<div className="upd-body"><b>{msg}</b></div>
</div>
)}
</>
);
}
+10 -6
View File
@@ -480,10 +480,14 @@ export default function VoiceAssistant() {
}); });
// 手势 → 对话控制:举手 toggle —— 无对话则开始,正在对话则结束 // 手势 → 对话控制:举手 toggle —— 无对话则开始,正在对话则结束
// 【已按需求关闭】视觉手势识别误判会触发右上角弹窗/误开对话,故这里不再响应手势。 const onGesture = useCallback((g) => {
const onGesture = useCallback(() => { if (g !== 'raise') return;
// 视觉手势已关闭:识别到人脸/手势不再自动开始/结束对话 if (clientRef.current) {
}, []); void stop();
} else {
void start();
}
}, [start, stop]);
useEffect(() => { useEffect(() => {
gestureRef.current = onGesture; gestureRef.current = onGesture;
@@ -517,7 +521,7 @@ export default function VoiceAssistant() {
loading: '摄像头启动中…', 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)}` : ''}`, 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`, facing: `面向大屏 ${(vision.dwellMs / 1000).toFixed(1)}s / ${VISION_DWELL_MS / 1000}s`,
triggered: '已触发 · 主动问候', triggered: '已识别到访客(主动问候已关闭)',
silent: '静默中', silent: '静默中',
error: '摄像头不可用', error: '摄像头不可用',
}[vision.status] ?? ''; }[vision.status] ?? '';
@@ -629,7 +633,7 @@ export default function VoiceAssistant() {
</span> </span>
<span> <span>
{vision.gesture === 'raise' && ( {vision.gesture === 'raise' && (
<b style={{ color: '#2f6bff' }}>您好呀 · {live ? '开始对话' : '结束对话'}</b> <b style={{ color: '#2f6bff' }}>{live ? '开始对话' : '结束对话'}</b>
)} )}
{visionStatusText} {visionStatusText}
</span> </span>
+80
View File
@@ -2201,6 +2201,86 @@
color: #fff; color: #fff;
background: linear-gradient(140deg, var(--bd-blue), var(--bd-cyan)); background: linear-gradient(140deg, var(--bd-blue), var(--bd-cyan));
} }
/* ---- 页眉 logo:点击检测更新 ---- */
.bd-logo-update {
display: inline-flex;
cursor: pointer;
border-radius: 12px;
transition: transform 0.15s ease, box-shadow 0.15s ease;
-webkit-tap-highlight-color: transparent;
}
.bd-logo-update:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(29, 93, 206, 0.25);
}
.bd-logo-update:active { transform: translateY(0); }
.bd-logo-update:focus-visible { outline: 2px solid var(--bd-blue); outline-offset: 2px; }
/* ---- 底部:更新提示(Windows 检测到新版本) ---- */
.upd-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 3200;
display: flex;
align-items: center;
gap: 12px;
max-width: 540px;
padding: 12px 16px;
background: #fff;
border: 1px solid var(--bd-line);
border-radius: 12px;
box-shadow: 0 12px 30px rgba(29, 44, 68, 0.16);
animation: bdOverlayIn 0.3s ease;
}
.upd-ico {
width: 30px;
height: 30px;
flex-shrink: 0;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
background: linear-gradient(140deg, var(--bd-blue), var(--bd-cyan));
}
.upd-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.upd-body b { font-size: 13px; color: var(--bd-ink); }
.upd-body span { font-size: 11.5px; color: var(--bd-sub); }
.upd-btn {
flex-shrink: 0;
padding: 7px 15px;
border: none;
border-radius: 8px;
background: linear-gradient(140deg, var(--bd-blue), var(--bd-cyan));
color: #fff;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.upd-btn:hover { opacity: 0.9; }
.upd-msg .upd-ico { background: linear-gradient(140deg, var(--bd-green), #4ade80); }
.upd-close {
width: 24px;
height: 24px;
flex-shrink: 0;
border: none;
background: transparent;
color: var(--bd-sub);
font-size: 16px;
line-height: 1;
cursor: pointer;
border-radius: 6px;
}
.upd-close:hover { color: var(--bd-red); background: rgba(240, 68, 56, 0.1); }
/* ---- 右上角:工具 / 操作进行中状态提示 ---- */ /* ---- 右上角:工具 / 操作进行中状态提示 ---- */
.ts-toast { .ts-toast {
position: fixed; position: fixed;
+40
View File
@@ -0,0 +1,40 @@
/* =========================================================
客户端更新检测(仅 Windows)
后端 POST /api/update/check 返回最新版本 + 安装包下载地址;
前端比对后提示,并提供下载(opener 打开安装包下载链接)。
========================================================= */
import { getApiBase } from '../config';
import { openUrl } from '@tauri-apps/plugin-opener';
// 当前版本:发布新版本时同步 package.json 的 version
export const APP_VERSION = '0.1.0';
export async function checkForUpdate() {
try {
const res = await fetch(`${getApiBase()}/api/update/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current: APP_VERSION }),
});
const data = await res.json();
if (data && data.ok) {
return {
current: APP_VERSION,
latest: data.latest_version,
available: !!data.update_available,
url: `${getApiBase()}${data.download_url || '/download'}`,
name: data.installer_name || '',
};
}
} catch { /* 后端不可达时忽略 */ }
return null;
}
/** 打开安装包下载地址(优先系统浏览器下载)。 */
export function openUpdate(url) {
try {
openUrl(url);
} catch {
window.open(url, '_blank');
}
}
+16
View File
@@ -5,6 +5,13 @@ import { MQTT_TOPIC_HEARTBEAT } from '../config';
import { Sfx } from './sounds'; import { Sfx } from './sounds';
import { PAGE_ORDER } from './pageNav'; import { PAGE_ORDER } from './pageNav';
import { aiStatus } from './statusBus'; 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 控制大屏 MQTT 控制 —— 后端通过 opc/display/command 控制大屏
@@ -69,6 +76,9 @@ export function useMqttControl() {
connectMqtt(); connectMqtt();
const off = onMqttMessage((topic, cmd) => { const off = onMqttMessage((topic, cmd) => {
if (topic !== 'opc/display/command' || !cmd || !cmd.action) return; 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) { switch (cmd.action) {
case 'navigate': { case 'navigate': {
@@ -86,6 +96,12 @@ export function useMqttControl() {
} }
break; 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': case 'alert':
// 通知本身即展示,无需再弹「正在发送通知」状态提示 // 通知本身即展示,无需再弹「正在发送通知」状态提示
window.dispatchEvent(new CustomEvent('dpm:alert', { detail: cmd.params || {} })); window.dispatchEvent(new CustomEvent('dpm:alert', { detail: cmd.params || {} }));