Compare commits
2 Commits
1e63da95ec
...
91e20c3a81
| Author | SHA1 | Date | |
|---|---|---|---|
| 91e20c3a81 | |||
| 5ed396aea4 |
@@ -6,6 +6,7 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
@@ -15,6 +16,7 @@ from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .admin import admin_login, admin_logout, admin_page
|
||||
from .config import settings
|
||||
@@ -120,6 +122,52 @@ async def download_installer():
|
||||
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 即可) ----------
|
||||
|
||||
@app.get("/{path:path}", include_in_schema=False)
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+15
-1
@@ -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');
|
||||
|
||||
@@ -105,6 +105,19 @@
|
||||
</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-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>
|
||||
@@ -190,6 +203,16 @@
|
||||
</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-title">媒体轮播</div>
|
||||
|
||||
Binary file not shown.
@@ -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",
|
||||
|
||||
+49
-4
@@ -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<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(最高优先)
|
||||
@@ -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!())
|
||||
|
||||
@@ -141,9 +141,19 @@ export default function PageHeader() {
|
||||
{/* <span className="bd-logo">
|
||||
<Image src="/logo.png" alt="Logo" />
|
||||
</span> */}
|
||||
<Avatar size="lg">
|
||||
<AvatarImage src="/logo.png" alt="" />
|
||||
</Avatar>
|
||||
<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">
|
||||
<AvatarImage src="/logo.png" alt="" />
|
||||
</Avatar>
|
||||
</span>
|
||||
<div className="bd-header-titles">
|
||||
<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>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Outlet } from 'react-router-dom';
|
||||
import PageHeader from './PageHeader';
|
||||
import DpmOverlays from './DpmOverlays';
|
||||
import ToolStatusToast from './ToolStatusToast';
|
||||
import UpdateNotify from './UpdateNotify';
|
||||
import GlobalVision from './GlobalVision';
|
||||
import { usePageNav } from '../utils/pageNav';
|
||||
import { useMqttControl } from '../utils/useMqttControl';
|
||||
@@ -29,6 +30,7 @@ export default function ScreenLayout() {
|
||||
<GlobalVision />
|
||||
<DpmOverlays />
|
||||
<ToolStatusToast />
|
||||
<UpdateNotify />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
</span>
|
||||
<span>
|
||||
{vision.gesture === 'raise' && (
|
||||
<b style={{ color: '#2f6bff' }}>您好呀 · {live ? '开始对话' : '结束对话'}</b>
|
||||
<b style={{ color: '#2f6bff' }}>{live ? '开始对话' : '结束对话'}</b>
|
||||
)}
|
||||
{visionStatusText}
|
||||
</span>
|
||||
|
||||
@@ -2201,6 +2201,86 @@
|
||||
color: #fff;
|
||||
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 {
|
||||
position: fixed;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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 || {} }));
|
||||
|
||||
Reference in New Issue
Block a user