From fd6493f58288d8ff59f01a76fc8d53500e40d202 Mon Sep 17 00:00:00 2001 From: Pine Date: Wed, 19 Aug 2026 13:40:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E5=B9=B6=E5=AE=89=E8=A3=85=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=8C=85=EF=BC=9B=E4=BC=98=E5=8C=96=E5=8F=8C=E5=B1=8F=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E9=80=BB=E8=BE=91=EF=BC=8C=E5=A2=9E=E5=BC=BA=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/lib.rs | 77 ++++++++++++++++++++-------- src/components/UpdateNotify.jsx | 91 +++++++++++++++++++++------------ src/utils/updateCheck.js | 25 +++++---- src/utils/useMqttControl.js | 7 ++- 4 files changed, 132 insertions(+), 68 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 02acfd9..2f5ac4b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -24,29 +24,36 @@ fn set_dual_screen(app: AppHandle, on: bool) -> Result { let _ = w.set_focus(); return Ok("on".into()); } - let mut builder = WebviewWindowBuilder::new(&app, "secondary", WebviewUrl::default()) - .title("OPC 运营中心 · 双屏"); - // 定位到第二块屏(非主屏;无第二屏则用第一块) - if let Ok(monitors) = app.available_monitors() { - eprintln!("[dual] available_monitors={}", monitors.len()); - let primary_pos = app - .primary_monitor() - .ok() - .flatten() - .map(|pm| { let p = pm.position(); (p.x, p.y) }); - eprintln!("[dual] primary_pos={:?}", primary_pos); - 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(); - eprintln!("[dual] target {}x{} @ {},{}", s.width, s.height, p.x, p.y); - builder = builder.position(p.x as f64, p.y as f64).inner_size(s.width as f64, s.height as f64); + // 主屏位置(用于排除主屏;必须把副窗放到主屏之外) + let primary_pos = app + .primary_monitor() + .ok() + .flatten() + .map(|pm| { let p = pm.position(); (p.x, p.y) }); + // 获取全部显示器,寻找一块与主屏位置不同的副屏 + let monitors = app.available_monitors().map_err(|e| format!("读取显示器失败: {e}"))?; + eprintln!("[dual] monitors={} primary_pos={:?}", monitors.len(), primary_pos); + let mut target: Option<(i32, i32, u32, u32)> = None; // (x, y, w, h) + for m in &monitors { + let p = m.position(); + if Some((p.x, p.y)) != primary_pos { + let s = m.size(); + target = Some((p.x, p.y, s.width, s.height)); + break; } } - let win = builder.build().map_err(|e| e.to_string())?; + // 绝对不允许在主屏桌面上开启;没有副屏则拒绝 + let (x, y, w, h) = match target { + Some(t) => t, + None => return Err("未检测到副屏(需连接第二块显示器),无法开启双屏".into()), + }; + eprintln!("[dual] secondary {}x{} @ {},{}", w, h, x, y); + let win = WebviewWindowBuilder::new(&app, "secondary", WebviewUrl::default()) + .title("OPC 运营中心 · 双屏") + .position(x as f64, y as f64) + .inner_size(w as f64, h as f64) + .build() + .map_err(|e| format!("创建副窗失败: {e}"))?; // 等窗口内容就绪后再全屏(避免过早全屏导致白屏/布局异常) let win2 = win.clone(); std::thread::spawn(move || { @@ -57,6 +64,28 @@ fn set_dual_screen(app: AppHandle, on: bool) -> Result { Ok("on".into()) } +/// 安装更新:把前端已下载的安装包字节写入磁盘 → 启动安装程序 → 退出应用。 +/// bytes 由前端 fetch /download 得到;启动安装程序后进程独立,应用退出不影响它。 +#[tauri::command] +fn install_update(app: AppHandle, bytes: Vec, filename: String) -> Result { + if bytes.len() < 1000 { + return Err("下载内容无效或为空".into()); + } + let dest = std::env::temp_dir().join(filename); + std::fs::write(&dest, &bytes).map_err(|e| format!("写入安装包失败: {e}"))?; + // 启动安装程序(Windows 下 Command::spawn 为独立进程,应用退出后仍在运行) + std::process::Command::new(&dest) + .spawn() + .map_err(|e| format!("启动安装程序失败: {e}"))?; + // 稍后退出应用,让安装程序接管 + let app2 = app.clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(800)); + app2.exit(0); + }); + Ok(dest.to_string_lossy().into_owned()) +} + /// 读取运行时配置(部署用,免重新打包): /// 1) exe 同目录 config.json / dpm.config.json(最高优先) /// 2) 应用配置目录 config.json @@ -98,7 +127,11 @@ pub fn run() { tauri_plugin_autostart::MacosLauncher::LaunchAgent, Some(vec![]), )) - .invoke_handler(tauri::generate_handler![read_runtime_config, set_dual_screen]) + .invoke_handler(tauri::generate_handler![ + read_runtime_config, + set_dual_screen, + install_update + ]) .on_window_event(|window, event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { // 仅主窗口关闭=最小化(进程常驻);双屏副窗口可正常关闭 diff --git a/src/components/UpdateNotify.jsx b/src/components/UpdateNotify.jsx index a2004c0..79e61d3 100644 --- a/src/components/UpdateNotify.jsx +++ b/src/components/UpdateNotify.jsx @@ -1,41 +1,51 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import Icon from './Icons'; -import { checkForUpdate, openUpdate } from '../utils/updateCheck'; +import { checkForUpdate, downloadAndInstall } from '../utils/updateCheck'; /* ========================================================= UpdateNotify —— 检测新版本(Windows) · 启动静默检查一次 + 每 30 分钟一次(仅在有更新时提示) - · 页眉 logo 点击 → dpm:check-update → 主动检测: - 有更新 → 弹更新提示;无更新 → 临时提示「已是最新版本」 + · 页眉 logo 点击 → dpm:check-update → 主动检测 + · 「下载更新」→ 下载安装包 → 启动安装程序 → 应用退出 ========================================================= */ export default function UpdateNotify() { - const [upd, setUpd] = useState(null); // 更新提示 - const [msg, setMsg] = useState(null); // 临时提示(已是最新 / 失败) + const [upd, setUpd] = useState(null); // 更新提示 + const [msg, setMsg] = useState(null); // 临时提示(已是最新 / 失败) const [dismissed, setDismissed] = useState(false); + const [busy, setBusy] = useState(null); // 'downloading' | 'installing' + const [errText, setErrText] = useState(''); 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); - } + if (u.available) { setUpd(u); setDismissed(false); } + else if (manual) { setMsg(`已是最新版本 v${u.latest}`); flash(); } + } else if (manual) { setMsg('更新检测失败'); flash(); } }, []); + const flash = () => { + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => setMsg(null), 3000); + }; + + const handleDownload = useCallback(async () => { + if (!upd || busy) return; + setBusy('downloading'); + setErrText(''); + try { + await downloadAndInstall(upd.url, upd.name); + setBusy('installing'); // 应用即将退出 + } catch (e) { + setErrText(String((e && e.message) || e)); + setBusy(null); + } + }, [upd, busy]); + useEffect(() => { const onCheck = () => run(true); window.addEventListener('dpm:check-update', onCheck); - run(false); // 启动静默检测 + run(false); const iv = setInterval(() => run(false), 30 * 60 * 1000); return () => { window.removeEventListener('dpm:check-update', onCheck); @@ -44,25 +54,38 @@ export default function UpdateNotify() { }; }, [run]); - return ( - <> - {upd && !dismissed && ( -
-
-
- 发现新版本 v{upd.latest} - 当前 v{upd.current} · 点击下载更新安装包(Windows) -
- - -
- )} - {msg && ( + if (!upd || dismissed) { + if (msg) { + return (
{msg}
+ ); + } + return null; + } + + return ( +
+
+
+ {busy === 'downloading' && 正在下载更新 v{upd.latest}…} + {busy === 'installing' && 正在启动安装程序,应用即将退出…} + {!busy && 发现新版本 v{upd.latest}} + {!busy && 当前 v{upd.current} · 点击下载更新并安装(Windows)} + {errText && {errText}} +
+ + {!busy && ( + )} - +
); } diff --git a/src/utils/updateCheck.js b/src/utils/updateCheck.js index 63fa397..19c4730 100644 --- a/src/utils/updateCheck.js +++ b/src/utils/updateCheck.js @@ -1,10 +1,10 @@ /* ========================================================= - 客户端更新检测(仅 Windows) + 客户端更新检测与安装(仅 Windows) 后端 POST /api/update/check 返回最新版本 + 安装包下载地址; - 前端比对后提示,并提供下载(opener 打开安装包下载链接)。 + 前端 fetch 下载安装包字节 → invoke install_update(写盘 → 启动安装程序 → 退出应用)。 ========================================================= */ import { getApiBase } from '../config'; -import { openUrl } from '@tauri-apps/plugin-opener'; +import { invoke } from '@tauri-apps/api/core'; // 当前版本:发布新版本时同步 package.json 的 version export const APP_VERSION = '0.1.0'; @@ -23,18 +23,21 @@ export async function checkForUpdate() { latest: data.latest_version, available: !!data.update_available, url: `${getApiBase()}${data.download_url || '/download'}`, - name: data.installer_name || '', + name: data.installer_name || 'dpm_update_setup.exe', }; } } catch { /* 后端不可达时忽略 */ } return null; } -/** 打开安装包下载地址(优先系统浏览器下载)。 */ -export function openUpdate(url) { - try { - openUrl(url); - } catch { - window.open(url, '_blank'); - } +/** 下载安装包并交给后端安装(写盘 → 启动安装程序 → 退出应用)。 */ +export async function downloadAndInstall(url, filename) { + const res = await fetch(url); + if (!res.ok) throw new Error(`下载失败(HTTP ${res.status})`); + const buf = new Uint8Array(await res.arrayBuffer()); + if (buf.length < 1000) throw new Error('安装包内容无效'); + return invoke('install_update', { + bytes: Array.from(buf), + filename: filename || 'dpm_update_setup.exe', + }); } diff --git a/src/utils/useMqttControl.js b/src/utils/useMqttControl.js index 46ce424..39aa844 100644 --- a/src/utils/useMqttControl.js +++ b/src/utils/useMqttControl.js @@ -100,7 +100,12 @@ export function useMqttControl() { // 双屏:admin 控制第二块屏全屏展示(Tauri 命令)——仅主窗口执行 if (WINDOW_LABEL !== 'main') break; aiStatus(cmd.params?.on === false ? '正在关闭双屏' : '正在启动双屏', { icon: 'page' }); - invoke('set_dual_screen', { on: cmd.params?.on !== false }).catch(() => {}); + invoke('set_dual_screen', { on: cmd.params?.on !== false }) + .catch((err) => { + const msg = String((err && err.message) || err || '操作失败'); + aiStatus(`双屏失败:${msg}`, { state: 'error', icon: 'error' }); + console.error('[dual]', err); + }); break; case 'alert': // 通知本身即展示,无需再弹「正在发送通知」状态提示