diff --git a/ThingHK/HardwareManager.cs b/ThingHK/HardwareManager.cs index 1b9fae9..96a7e13 100644 --- a/ThingHK/HardwareManager.cs +++ b/ThingHK/HardwareManager.cs @@ -131,6 +131,11 @@ internal sealed class HardwareManager : IDisposable // 重新遍历以读取最新传感器值(visitor 缓存的是 hardware 引用,sensor 值实时) var groups = new Dictionary(); + + // 为已启用但 LHB 未枚举到的硬件类型预创建空分组, + // 确保前端能显示"已启用但无数据"的硬件(而不是直接隐藏,让用户误以为配置未生效) + EnsureGroupsForEnabledHardware(groups, snap); + foreach (var hw in _visitor.AllHardware) { string groupId = hw.HardwareType.ToString().ToLowerInvariant(); @@ -164,6 +169,40 @@ internal sealed class HardwareManager : IDisposable return snap; } + /// + /// 为已启用但 LHB 未枚举到的硬件类型预创建空分组。 + /// 场景:用户在设置中勾选了主板/电池/电源等,但 LHB 在当前权限或机型下检测不到对应硬件, + /// 此时仍创建空分组让前端显示"已启用但无数据",避免用户误以为配置未生效。 + /// GPU 特殊处理:LHB 会枚举到 GpuIntel/GpuAmd/GpuNvidia 之一,不预创建通用分组。 + /// + private void EnsureGroupsForEnabledHardware(Dictionary groups, SensorSnapshot snap) + { + // (configKey, groupId, groupName) + // 注意:controller 对应 SuperIO 和 EmbeddedController 两个 HardwareType + var mapping = new (string, string, string)[] + { + ("cpu", "cpu", "CPU"), + ("memory", "memory", "Memory"), + ("storage", "storage", "Storage"), + ("motherboard", "motherboard", "Motherboard"), + ("controller", "superio", "SuperIO"), + ("controller", "embeddedcontroller", "EmbeddedController"), + ("battery", "battery", "Battery"), + ("network", "network", "Network"), + ("psu", "psu", "Psu"), + }; + + foreach (var (key, groupId, groupName) in mapping) + { + if (_config.IsHardwareEnabled(key) && !groups.ContainsKey(groupId)) + { + var g = new SensorGroup { Id = groupId, Name = groupName }; + groups[groupId] = g; + snap.Groups.Add(g); + } + } + } + /// /// 判断硬件是否属于慢通道(低频 Update 即可)。 /// 快通道:CPU/GPU/Memory/Network(变化快、查询轻量) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0ea73fe..a1ea7ba 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -37,7 +37,16 @@ sysinfo = "0.32" [target.'cfg(windows)'.dependencies] winreg = "0.52" -windows-sys = { version = "0.52", features = ["Win32_Networking_WinInet", "Win32_Foundation", "Win32_Security", "Win32_System_Threading"] } +windows-sys = { version = "0.52", features = [ + "Win32_Networking_WinInet", + "Win32_Foundation", + "Win32_Security", + "Win32_System_Threading", + "Win32_UI_WindowsAndMessaging", + "Win32_UI_HiDpi", + "Win32_UI_Input_KeyboardAndMouse", + "Win32_Graphics_Gdi", +] } [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-autostart = "2" diff --git a/src-tauri/binaries/ThingHK.exe b/src-tauri/binaries/ThingHK.exe index 79fcfb0..6b9e513 100644 Binary files a/src-tauri/binaries/ThingHK.exe and b/src-tauri/binaries/ThingHK.exe differ diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 7cf204d..7c974f6 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -20,6 +20,12 @@ "core:window:allow-set-effects", "core:window:allow-set-background-color", "core:window:allow-set-theme", + "core:webview:allow-create-webview-window", + "core:window:allow-set-position", + "core:window:allow-set-size", + "core:window:allow-set-always-on-top", + "core:window:allow-set-skip-taskbar", + "core:window:allow-set-decorations", "snap-layout:default" ] } \ No newline at end of file diff --git a/src-tauri/capabilities/osd.json b/src-tauri/capabilities/osd.json new file mode 100644 index 0000000..6424c16 --- /dev/null +++ b/src-tauri/capabilities/osd.json @@ -0,0 +1,23 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "osd", + "description": "Capability for OSD overlay window", + "windows": ["osd-overlay"], + "permissions": [ + "core:default", + "core:window:allow-hide", + "core:window:allow-show", + "core:window:allow-set-focus", + "core:window:allow-start-dragging", + "core:window:allow-set-position", + "core:window:allow-set-size", + "core:window:allow-set-always-on-top", + "core:window:allow-set-skip-taskbar", + "core:window:allow-set-decorations", + "core:window:allow-set-ignore-cursor-events", + "core:window:allow-close", + "core:event:allow-emit", + "core:event:allow-listen", + "snap-layout:default" + ] +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0043f92..049ced3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ mod logger; mod mihomo_manager; mod monitor_kernel; mod network_monitor; +mod osd_window; mod process_manager; use download_engine::{ @@ -31,6 +32,10 @@ use monitor_kernel::{ monitor_stop, MonitorKernel, }; use network_monitor::{network_monitor_status, NetworkMonitor}; +use osd_window::{ + osd_apply_overlay_style, osd_begin_drag, osd_set_click_through, osd_set_topmost, + osd_start_drag_watch, osd_start_topmost_watch, osd_stop_watch, +}; use process_manager::{ get_all_process_status, get_process_status, start_monitoring_thread, start_process, stop_all_processes, stop_process, ProcessManager, @@ -122,6 +127,13 @@ pub fn run() { monitor_get_hardware_config, monitor_set_hardware_config, network_monitor_status, + osd_apply_overlay_style, + osd_begin_drag, + osd_set_click_through, + osd_set_topmost, + osd_start_drag_watch, + osd_start_topmost_watch, + osd_stop_watch, downloader_get_tasks, downloader_add_task, downloader_pause_task, diff --git a/src-tauri/src/osd_window.rs b/src-tauri/src/osd_window.rs new file mode 100644 index 0000000..e5e76c3 --- /dev/null +++ b/src-tauri/src/osd_window.rs @@ -0,0 +1,366 @@ +//! OSD 悬浮窗 Windows 原生窗口管理 +//! +//! 提供: +//! - NoActivate(不获取焦点):设置 WS_EX_NOACTIVATE 扩展样式 +//! - 右键长按拖动:轮询 GetAsyncKeyState 检测右键长按,触发拖动事件 +//! - 任务栏覆盖检测:轮询 GetForegroundWindow,检测系统 UI 出现时暂时取消置顶 + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, OnceLock}; +use std::thread; +use std::time::{Duration, Instant}; +use tauri::{AppHandle, Emitter}; + +/// 右键拖动监视线程停止标志(全局,仅一个 OSD 悬浮窗) +static DRAG_STOP: OnceLock> = OnceLock::new(); +/// 任务栏覆盖监视线程停止标志 +static TOPMOST_STOP: OnceLock> = OnceLock::new(); + +fn drag_stop() -> &'static Arc { + DRAG_STOP.get_or_init(|| Arc::new(AtomicBool::new(true))) +} + +fn topmost_stop() -> &'static Arc { + TOPMOST_STOP.get_or_init(|| Arc::new(AtomicBool::new(true))) +} + +#[cfg(windows)] +mod win_api { + use tauri::{AppHandle, Manager}; + use windows_sys::Win32::Foundation::{POINT, RECT}; + use windows_sys::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_RBUTTON}; + use windows_sys::Win32::UI::WindowsAndMessaging::{ + GetClassNameW, GetCursorPos, GetForegroundWindow, GetWindowLongPtrW, + GetWindowRect, SendMessageW, SetWindowLongPtrW, SetWindowPos, + GWL_EXSTYLE, HTCAPTION, HWND_NOTOPMOST, HWND_TOPMOST, SWP_NOACTIVATE, SWP_NOMOVE, + SWP_NOSIZE, SWP_SHOWWINDOW, WM_NCLBUTTONDOWN, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, + WS_EX_TRANSPARENT, + }; + + /// windows-sys 的 HWND 类型别名(isize) + pub type Hwnd = isize; + + /// 从 Tauri 窗口标签获取原生 HWND + pub fn get_hwnd(label: &str, app: &AppHandle) -> Option { + let win = app.get_webview_window(label)?; + let hwnd = win.hwnd().ok()?; + // Tauri 的 hwnd() 返回 windows::Win32::Foundation::HWND(pub *mut c_void) + // 转为 isize 用于 windows-sys 调用 + Some(hwnd.0 as isize) + } + + /// 设置窗口扩展样式:WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW + pub fn apply_no_activate(hwnd: Hwnd) { + unsafe { + let ex = GetWindowLongPtrW(hwnd, GWL_EXSTYLE); + let new_ex = ex | (WS_EX_NOACTIVATE as isize) | (WS_EX_TOOLWINDOW as isize); + SetWindowLongPtrW(hwnd, GWL_EXSTYLE, new_ex); + } + } + + /// 设置点击穿透(WS_EX_TRANSPARENT) + pub fn set_click_through(hwnd: Hwnd, enabled: bool) { + unsafe { + let ex = GetWindowLongPtrW(hwnd, GWL_EXSTYLE); + let new_ex = if enabled { + ex | (WS_EX_TRANSPARENT as isize) + } else { + ex & !(WS_EX_TRANSPARENT as isize) + }; + SetWindowLongPtrW(hwnd, GWL_EXSTYLE, new_ex); + } + } + + /// 获取鼠标光标位置 + pub fn get_cursor_pos() -> Option { + let mut pt = POINT { x: 0, y: 0 }; + unsafe { + if GetCursorPos(&mut pt) != 0 { + Some(pt) + } else { + None + } + } + } + + /// 获取窗口矩形 + pub fn get_window_rect(hwnd: Hwnd) -> Option { + let mut rect = RECT { + left: 0, + top: 0, + right: 0, + bottom: 0, + }; + unsafe { + if GetWindowRect(hwnd, &mut rect) != 0 { + Some(rect) + } else { + None + } + } + } + + /// 判断点是否在窗口矩形内 + pub fn point_in_rect(pt: POINT, rect: RECT) -> bool { + pt.x >= rect.left && pt.x <= rect.right && pt.y >= rect.top && pt.y <= rect.bottom + } + + /// 判断右键是否按下 + pub fn is_rbutton_down() -> bool { + // GetAsyncKeyState 返回 i16,最高位为 1 表示按下 + // 转 u16 后与 0x8000 进行按位与,避免 i16 字面量溢出 + unsafe { (GetAsyncKeyState(VK_RBUTTON as i32) as u16 & 0x8000) != 0 } + } + + /// 获取前景窗口 + pub fn get_foreground_window() -> Hwnd { + unsafe { GetForegroundWindow() } + } + + /// 获取窗口类名 + pub fn get_class_name(hwnd: Hwnd) -> Option { + let mut buf = [0u16; 256]; + unsafe { + let len = GetClassNameW(hwnd, buf.as_mut_ptr(), buf.len() as i32); + if len > 0 { + Some(String::from_utf16_lossy(&buf[..len as usize])) + } else { + None + } + } + } + + /// 设置窗口置顶状态 + pub fn set_topmost(hwnd: Hwnd, topmost: bool) { + unsafe { + let insert_after = if topmost { + HWND_TOPMOST + } else { + HWND_NOTOPMOST + }; + SetWindowPos( + hwnd, + insert_after, + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW, + ); + } + } + + /// 启动原生拖动:发送 WM_NCLBUTTONDOWN(HTCAPTION) 进入标题栏拖动 + /// + /// 调用前应已关闭 WS_EX_TRANSPARENT(点击穿透),否则窗口无法接收后续鼠标事件。 + /// SendMessageW 会阻塞直到拖动结束,应在独立线程调用。 + pub fn begin_drag(hwnd: Hwnd) { + unsafe { + // 获取当前鼠标位置,编码为 LPARAM(低字 x,高字 y) + let pt = get_cursor_pos().unwrap_or(POINT { x: 0, y: 0 }); + let lparam = ((pt.y as isize) << 16) | (pt.x as isize & 0xFFFF); + SendMessageW(hwnd, WM_NCLBUTTONDOWN, HTCAPTION as usize, lparam); + } + } + + /// 判断窗口类名是否为系统 UI(任务栏、开始菜单、通知区域等) + pub fn is_system_ui_class(class_name: &str) -> bool { + matches!( + class_name, + "Shell_TrayWnd" // 任务栏 + | "Shell_SecondaryTrayWnd" // 副任务栏 + | "Windows.UI.Core.CoreWindow" // UWP 系统弹窗(开始菜单、通知中心、日历等) + | "XamlExplorerHostIslandWindow" // Win11 系统弹窗容器 + | "TopLevelWindowForOverflowSink" // Win11 系统托盘溢出 + | "TopLevelWindowForHiddenRegion" // Win11 系统隐藏区域 + | "Windows.UI.Shell.ShellFlyoutWindow" // Win11 Shell 弹出 + ) + } +} + +/// 应用 OSD 悬浮窗的原生样式(NoActivate + ToolWindow) +#[tauri::command] +pub fn osd_apply_overlay_style(label: String, app: AppHandle) -> Result<(), String> { + #[cfg(windows)] + { + let hwnd = win_api::get_hwnd(&label, &app) + .ok_or_else(|| format!("窗口 {} 不存在", label))?; + win_api::apply_no_activate(hwnd); + eprintln!("[osd] 已应用 NoActivate 样式到窗口 {}", label); + } + Ok(()) +} + +/// 启动右键长按拖动监视 +/// +/// 轮询检测右键按下,持续超过 400ms 且鼠标在 OSD 窗口上方时, +/// 发出 `osd-start-drag` 事件通知前端临时关闭点击穿透并开始拖动。 +/// 右键释放后发出 `osd-end-drag` 事件。 +#[tauri::command] +pub fn osd_start_drag_watch(label: String, app: AppHandle) -> Result<(), String> { + // 停止旧线程,等待退出 + drag_stop().store(true, Ordering::SeqCst); + thread::sleep(Duration::from_millis(50)); + let stop_flag = drag_stop().clone(); + stop_flag.store(false, Ordering::SeqCst); + + let app_handle = app.clone(); + + thread::spawn(move || { + let mut rbutton_was_down = false; + let mut press_start: Option = None; + let mut drag_emitted = false; + + loop { + if stop_flag.load(Ordering::SeqCst) { + break; + } + + #[cfg(windows)] + { + let rbutton_down = win_api::is_rbutton_down(); + + if rbutton_down && !rbutton_was_down { + // 右键刚按下 + press_start = Some(Instant::now()); + drag_emitted = false; + } else if rbutton_down && rbutton_was_down { + // 右键持续按住,检测长按 + if !drag_emitted { + if let Some(start) = press_start { + if start.elapsed() >= Duration::from_millis(400) { + if let Some(hwnd) = win_api::get_hwnd(&label, &app_handle) { + if let (Some(pt), Some(rect)) = + (win_api::get_cursor_pos(), win_api::get_window_rect(hwnd)) + { + if win_api::point_in_rect(pt, rect) { + let _ = app_handle.emit("osd-start-drag", ()); + drag_emitted = true; + } + } + } + } + } + } + } else if !rbutton_down && rbutton_was_down { + // 右键释放 + if drag_emitted { + let _ = app_handle.emit("osd-end-drag", ()); + } + press_start = None; + drag_emitted = false; + } + + rbutton_was_down = rbutton_down; + } + + thread::sleep(Duration::from_millis(30)); + } + }); + + Ok(()) +} + +/// 启动任务栏覆盖监视 +/// +/// 轮询检测前景窗口变化,当系统 UI(任务栏弹窗、开始菜单等)变为前景时, +/// 发出 `osd-system-ui-active` 事件通知前端暂时取消置顶; +/// 系统 UI 关闭后发出 `osd-system-ui-inactive` 事件恢复置顶。 +#[tauri::command] +pub fn osd_start_topmost_watch(app: AppHandle) -> Result<(), String> { + topmost_stop().store(true, Ordering::SeqCst); + thread::sleep(Duration::from_millis(50)); + let stop_flag = topmost_stop().clone(); + stop_flag.store(false, Ordering::SeqCst); + + let app_handle = app.clone(); + + thread::spawn(move || { + let mut last_foreground: isize = 0; + let mut system_ui_active = false; + + loop { + if stop_flag.load(Ordering::SeqCst) { + break; + } + + #[cfg(windows)] + { + let fg = win_api::get_foreground_window(); + if fg != last_foreground { + last_foreground = fg; + if let Some(class) = win_api::get_class_name(fg) { + if win_api::is_system_ui_class(&class) { + if !system_ui_active { + system_ui_active = true; + let _ = app_handle.emit("osd-system-ui-active", ()); + } + } else if system_ui_active { + system_ui_active = false; + let _ = app_handle.emit("osd-system-ui-inactive", ()); + } + } else if system_ui_active { + system_ui_active = false; + let _ = app_handle.emit("osd-system-ui-inactive", ()); + } + } + } + + thread::sleep(Duration::from_millis(150)); + } + }); + + Ok(()) +} + +/// 停止所有 OSD 监视线程 +#[tauri::command] +pub fn osd_stop_watch() { + drag_stop().store(true, Ordering::SeqCst); + topmost_stop().store(true, Ordering::SeqCst); +} + +/// 设置点击穿透(Rust 侧原生 WS_EX_TRANSPARENT,比 JS setIgnoreCursorEvents 更可靠) +#[tauri::command] +pub fn osd_set_click_through(label: String, enabled: bool, app: AppHandle) -> Result<(), String> { + #[cfg(windows)] + { + let hwnd = win_api::get_hwnd(&label, &app) + .ok_or_else(|| format!("窗口 {} 不存在", label))?; + win_api::set_click_through(hwnd, enabled); + } + Ok(()) +} + +/// 设置窗口置顶状态(Rust 侧原生 SetWindowPos) +#[tauri::command] +pub fn osd_set_topmost(label: String, topmost: bool, app: AppHandle) -> Result<(), String> { + #[cfg(windows)] + { + let hwnd = win_api::get_hwnd(&label, &app) + .ok_or_else(|| format!("窗口 {} 不存在", label))?; + win_api::set_topmost(hwnd, topmost); + } + Ok(()) +} + +/// 启动原生拖动(右键长按触发) +/// +/// 同步关闭点击穿透(WS_EX_TRANSPARENT),然后在独立线程中调用 +/// `SendMessage(WM_NCLBUTTONDOWN, HTCAPTION)` 进入原生标题栏拖动模式。 +/// 相比前端 `startDragging()`,此方式在 WS_EX_NOACTIVATE 窗口上更可靠。 +#[tauri::command] +pub fn osd_begin_drag(label: String, app: AppHandle) -> Result<(), String> { + #[cfg(windows)] + { + let hwnd = win_api::get_hwnd(&label, &app) + .ok_or_else(|| format!("窗口 {} 不存在", label))?; + // 先同步关闭穿透,确保窗口能接收后续鼠标事件 + win_api::set_click_through(hwnd, false); + // SendMessageW 会阻塞直到拖动结束,在独立线程中调用避免阻塞 IPC + thread::spawn(move || { + win_api::begin_drag(hwnd); + }); + } + Ok(()) +} diff --git a/src/main.ts b/src/main.ts index 5c45166..d05d3ce 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,12 +1,8 @@ import { createApp } from 'vue' import { createPinia } from 'pinia' -import App from './App.vue' import './style.css' import 'vue-sonner/style.css' -// 导入模块注册入口 —— 副作用导入,注册所有模块到 moduleRegistry -import './modules' - import { createLogger } from './lib/logger' const logger = createLogger('main') @@ -19,31 +15,50 @@ window.addEventListener('unhandledrejection', (event) => { logger.error(`未处理的Promise拒绝: ${event.reason}`) }) -logger.info('Thing 应用启动') +// ===== OSD 窗口模式检测 ===== +// 通过 URL hash 识别:#osd-overlay(悬浮窗) +// OSD 窗口是精简的独立 Vue 应用,不加载主应用的 store 和模块 +const osdHash = window.location.hash +if (osdHash === '#osd-overlay') { + logger.info(`OSD 窗口启动: ${osdHash}`) + void import('./modules/monitor/OsdWindow.vue').then(({ default: OsdWindow }) => { + const app = createApp(OsdWindow) + app.mount('#app') + }) +} else { + // ===== 主应用模式 ===== + void import('./App.vue').then(async ({ default: App }) => { + // 导入模块注册入口 —— 副作用导入,注册所有模块到 moduleRegistry + await import('./modules') -const app = createApp(App) -const pinia = createPinia() + logger.info('Thing 应用启动') -app.use(pinia) + const app = createApp(App) + const pinia = createPinia() -app.mount('#app') + app.use(pinia) -// 应用挂载后初始化搜索索引和进程监听(不阻塞首屏渲染) -void import('./stores/searchStore').then(({ useSearchStore }) => { - const searchStore = useSearchStore() - searchStore.initGlobalIndex() + app.mount('#app') - // 移除已禁用模块的搜索项 - void import('./stores/appStore').then(({ useAppStore }) => { - const appStore = useAppStore() - appStore.modules.forEach(m => { - if (!m.enabled) { - searchStore.unregisterModule(m.id) - } + // 应用挂载后初始化搜索索引和进程监听(不阻塞首屏渲染) + void import('./stores/searchStore').then(({ useSearchStore }) => { + const searchStore = useSearchStore() + searchStore.initGlobalIndex() + + // 移除已禁用模块的搜索项 + void import('./stores/appStore').then(({ useAppStore }) => { + const appStore = useAppStore() + appStore.modules.forEach(m => { + if (!m.enabled) { + searchStore.unregisterModule(m.id) + } + }) + }) + }) + + void import('./stores/processStore').then(({ useProcessStore }) => { + useProcessStore().initListener().catch(e => console.error('Process listener init error:', e)) }) }) -}) +} -void import('./stores/processStore').then(({ useProcessStore }) => { - useProcessStore().initListener().catch(e => console.error('Process listener init error:', e)) -}) diff --git a/src/modules/monitor/MonitorModule.vue b/src/modules/monitor/MonitorModule.vue index 83b92d6..8fdad25 100644 --- a/src/modules/monitor/MonitorModule.vue +++ b/src/modules/monitor/MonitorModule.vue @@ -3,12 +3,18 @@ import { Activity, Play, Square, RefreshCw, Loader2, Cpu, MemoryStick, Gauge, HardDrive, Settings as SettingsIcon, AlertTriangle, ShieldCheck, ShieldOff, Zap, Thermometer, Clock, ChevronDown, - KeyRound, ArrowDown, ArrowUp, Wifi, FolderOpen, Copy, ListChecks, + ArrowDown, ArrowUp, Wifi, FolderOpen, Copy, ListChecks, + Monitor as MonitorIcon, GripVertical, SlidersHorizontal, + Eye, EyeOff, MousePointerClick, } from '@lucide/vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { toast } from 'vue-sonner' +import { VueDraggable } from 'vue-draggable-plus' import { appDataDir } from '@tauri-apps/api/path' import { revealItemInDir } from '@tauri-apps/plugin-opener' +import { WebviewWindow } from '@tauri-apps/api/webviewWindow' +import { emit, type UnlistenFn } from '@tauri-apps/api/event' +import { currentMonitor, LogicalPosition, LogicalSize } from '@tauri-apps/api/window' import { useMonitorStore, type SensorEntry, type SensorGroup, type ConnectionState } from '@/stores/monitorStore' import { useModuleTabs } from '@/lib/useModuleTabs' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' @@ -22,6 +28,10 @@ import { Separator } from '@/components/ui/separator' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog' import { Checkbox } from '@/components/ui/checkbox' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' +import { Switch } from '@/components/ui/switch' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Label } from '@/components/ui/label' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' const store = useMonitorStore() @@ -30,6 +40,7 @@ const activeTab = ref('overview') const tabsListRef = useModuleTabs(activeTab, [ { value: 'overview', label: '概览' }, { value: 'details', label: '详细' }, + { value: 'osd', label: 'OSD 显示' }, { value: 'settings', label: '设置' }, ]) @@ -309,36 +320,26 @@ async function handleRefresh() { } } -/** 提权启动 Kernel(弹 UAC)。 - * 普通权限下 CPU 温度/时钟/存储等传感器不可读,提权后可获取完整数据。 - */ -async function handleStartElevated() { - await store.startElevated() - if (store.status?.elevated) { - toast.success('Kernel 已提权运行') - } else if (store.errorMsg) { - toast.error('提权失败', { description: store.errorMsg }) - } -} - -/** 永久提权:以管理员权限重启 Thing 自身,并持久化标志使后续启动自动提权。 +/** 提权:以管理员权限重启 Thing 自身,并持久化标志使后续启动自动提权。 + * Thing 以管理员权限运行时,ThingHK 子进程继承权限,ProcessManager 可直接管控, + * ThingHK 崩溃会自动重启,避免数据停止后 Thing 不感知。 * 非管理员时进程退出;已是管理员时仅设置标志并返回。 */ async function handleElevateSelf() { await store.elevateSelf() // 非管理员时进程已退出,不会走到这里 if (store.errorMsg) { - toast.error('永久提权失败', { description: store.errorMsg }) + toast.error('提权失败', { description: store.errorMsg }) } else if (store.elevateOnLaunch) { - toast.success('已启用永久提权', { description: '后续启动将自动以管理员权限运行' }) + toast.success('已启用提权', { description: '后续启动将自动以管理员权限运行' }) } } -/** 取消永久提权:清除标志,下次启动不再触发 UAC(当前会话权限不变) */ +/** 取消提权:清除标志,下次启动不再触发 UAC(当前会话权限不变) */ async function handleCancelElevation() { await store.cancelElevation() if (!store.errorMsg) { - toast.success('已取消永久提权', { description: '下次启动将以普通权限运行' }) + toast.success('已取消提权', { description: '下次启动将以普通权限运行' }) } } @@ -415,7 +416,7 @@ async function handleSaveConfig() { if (needsAdmin && !isElevated) { toast.warning('部分硬件需要管理员权限', { - description: '主板/存储等硬件需提权才能读取完整数据,建议永久提权', + description: '主板/存储等硬件需提权才能读取完整数据,建议提权', duration: 6000, }) } else { @@ -450,15 +451,1175 @@ async function handleSaveConfig() { } } +// ===== OSD 显示配置 ===== +// OSD(On-Screen Display)配置:控制传感器数据在桌面悬浮窗中的显示。 +// 配置持久化到 localStorage,由独立 OsdWindow.vue 消费。 + +/** OSD 显示项:从可用传感器中选取并排序 */ +interface OsdItem { + /** 唯一 key:{groupId}/{hardwareName}/{sensorName}/{type} 小写化,或 special 项的固定 key */ + key: string + groupId: string + sensorName: string + hardwareName: string + type: string + unit: string + /** 特殊项标记:非 Kernel 传感器,由前端直接计算(如网速) */ + special?: 'net-up' | 'net-down' +} + +/** 颜色主题:按硬件/传感器类型着色(类似小飞机风格) */ +interface ColorTheme { + /** 按 groupId 着色:cpu/gpu/memory/storage/... */ + hardware: Record + /** 按 sensor type 着色:temperature/load/power/... */ + sensor: Record +} + +/** 警告色配置:阈值百分比 + 警告/严重颜色 */ +interface AlertConfig { + /** 警告色开关 */ + enabled: boolean + /** 警告阈值百分比(达到即变警告色,如 80) */ + warnThreshold: number + /** 严重阈值百分比(达到即变严重色,如 90) */ + criticalThreshold: number + /** 警告色(淡红,hex) */ + warnColor: string + /** 严重色(大红,hex) */ + criticalColor: string + /** 各硬件类型的最大值(用于将温度等非百分比值转为百分比) + * CPU 温度墙默认 100,GPU 默认 85 */ + maxValues: Record +} + +/** OSD 配置结构 */ +interface OsdConfig { + overlayEnabled: boolean + overlayItems: OsdItem[] + /** 悬浮窗位置 X 百分比(0=最左,50=居中,100=最右) */ + positionXPct: number + /** 悬浮窗位置 Y 百分比(0=最上,50=居中,100=最下) */ + positionYPct: number + fontSize: number + showUnit: boolean + showLabel: boolean + /** 标题语言:'zh' 中文 / 'en' 英文(原始传感器名) */ + labelLanguage: 'zh' | 'en' + /** 布局:'single' 单行分组式(组间用 | 分隔,固定宽度), + * 'group' 分组横排(标题在上+数据列在下),'multiline' 多行(每组一行,左对齐,类小飞机) */ + layout: 'single' | 'group' | 'multiline' + updateIntervalMs: number + /** 鼠标穿透:true 时窗口不接收鼠标事件(需关闭穿透才能左键拖动) */ + clickThrough: boolean + /** 默认文字颜色(hex),颜色主题关闭时使用 */ + fontColor: string + /** 字体不透明度 0-100 */ + fontOpacity: number + /** 悬浮窗背景色(CSS 颜色字符串,如 rgba(0,0,0,0.55)) */ + bgColor: string + /** 启用颜色主题(按硬件/传感器类型着色) */ + colorThemeEnabled: boolean + /** 颜色主题配置 */ + colorTheme: ColorTheme + /** 字体描边开关(默认关闭) */ + fontStrokeEnabled: boolean + /** 字体描边厚度(px,默认 1) */ + fontStrokeWidth: number + /** 字体描边颜色(hex,默认 #000000) */ + fontStrokeColor: string + /** 警告色配置 */ + alert: AlertConfig + /** 悬浮窗窗口保存位置(null=使用百分比计算默认位置) */ + overlayX?: number | null + overlayY?: number | null +} + +const OSD_STORAGE_KEY = 'thing_monitor_osd_config' +const OSD_CONFIG_VERSION = 11 + +/** 默认颜色主题(小飞机风格:不同硬件不同颜色,不同传感器不同颜色) */ +const DEFAULT_COLOR_THEME: ColorTheme = { + hardware: { + cpu: '#4A9EFF', + gpuintel: '#9D4EFF', + gpuamd: '#9D4EFF', + gpunvidia: '#9D4EFF', + memory: '#FF9F4A', + storage: '#4AFF9F', + motherboard: '#FFD700', + superio: '#B0B0B0', + embeddedcontroller: '#B0B0B0', + battery: '#FF4A9F', + network: '#4AFFFF', + psu: '#FF4A4A', + }, + sensor: { + temperature: '#FF6B6B', + load: '#4A9EFF', + power: '#FFD700', + voltage: '#9D4EFF', + fan: '#B0B0B0', + clock: '#4AFF9F', + data: '#FF9F4A', + smalldata: '#FF9F4A', + throughput: '#4AFFFF', + level: '#FF4A9F', + control: '#FFA500', + frequency: '#4AFF9F', + factor: '#FF4A4A', + timespan: '#B0B0B0', + energy: '#FFD700', + noise: '#B0B0B0', + conductivity: '#4AFFFF', + humidity: '#4A9EFF', + flow: '#4AFFFF', + }, +} + +/** 默认警告色配置:CPU 温度墙 100°C,GPU 85°C;百分比类直接用值 */ +const DEFAULT_ALERT_CONFIG: AlertConfig = { + enabled: true, + warnThreshold: 80, + criticalThreshold: 90, + warnColor: '#FF6B6B', + criticalColor: '#FF0000', + maxValues: { + cpu: 100, + gpu: 85, + gpuintel: 85, + gpuamd: 85, + gpunvidia: 85, + }, +} + +function defaultOsdConfig(): OsdConfig { + return { + overlayEnabled: false, + overlayItems: [], + // 默认顶部居中(top 0):水平 50%,垂直 0% + positionXPct: 50, + positionYPct: 0, + fontSize: 14, + showUnit: true, + showLabel: true, + labelLanguage: 'zh', + layout: 'single', + updateIntervalMs: 1000, + // 默认关闭点击穿透:关闭后左键可直接拖动悬浮窗 + clickThrough: false, + fontColor: '#ffffff', + fontOpacity: 100, + bgColor: 'transparent', + colorThemeEnabled: true, + colorTheme: { ...DEFAULT_COLOR_THEME }, + fontStrokeEnabled: false, + fontStrokeWidth: 1, + fontStrokeColor: '#000000', + alert: { ...DEFAULT_ALERT_CONFIG, maxValues: { ...DEFAULT_ALERT_CONFIG.maxValues } }, + overlayX: null, + overlayY: null, + } +} + +function loadOsdConfig(): OsdConfig { + try { + const saved = localStorage.getItem(OSD_STORAGE_KEY) + if (!saved) return defaultOsdConfig() + const parsed = JSON.parse(saved) + if (parsed.version !== OSD_CONFIG_VERSION) return defaultOsdConfig() + // 合并默认值,确保新增字段有默认值 + const def = defaultOsdConfig() + return { ...def, ...parsed.config } + } catch { + return defaultOsdConfig() + } +} + +function saveOsdConfig(cfg: OsdConfig) { + try { + localStorage.setItem(OSD_STORAGE_KEY, JSON.stringify({ + version: OSD_CONFIG_VERSION, + config: cfg, + })) + } catch { + /* 忽略 localStorage 写入失败 */ + } +} + +const osdConfig = ref(loadOsdConfig()) + +/** 传感器名称中英文字典(覆盖常见 LHB 传感器名 + 硬件名) */ +const SENSOR_NAME_ZH: Record = { + // CPU + 'CPU Package': 'CPU 封装', + 'CPU Total': 'CPU 总负载', + 'CPU Core Average': 'CPU 平均', + 'CPU Graphics': 'CPU 核显', + 'CPU DRAM': 'CPU 内存', + 'CPU Cores': 'CPU 核心', + 'CPU Bus': 'CPU 总线', + 'CPU Core': 'CPU 核心', + // GPU + 'GPU Core': 'GPU 核心', + 'GPU Memory': 'GPU 显存', + 'GPU Memory Controller': 'GPU 显存控制器', + 'GPU Video Engine': 'GPU 视频引擎', + 'GPU Power': 'GPU 功耗', + 'GPU Fan': 'GPU 风扇', + 'GPU Temperature': 'GPU 温度', + 'GPU PCIe': 'GPU PCIe', + 'GPU Memory Total': '总显存', + 'GPU Memory Used': '已用显存', + 'D3D 3D': '3D 引擎', + // 内存 + 'Memory': '内存', + 'Memory Used': '已用', + 'Memory Available': '可用', + 'Virtual Memory': '虚拟内存', + 'Virtual Memory Used': '虚拟已用', + 'Virtual Memory Available': '虚拟可用', + // 存储 + 'Used Space': '已用空间', + 'Free Space': '可用空间', + 'Total Space': '总空间', + 'Read Speed': '读取速度', + 'Write Speed': '写入速度', + 'Read Rate': '读取速率', + 'Write Rate': '写入速率', + // 主板/SuperIO + 'CPU Fan': 'CPU 风扇', + 'System Fan': '系统风扇', + 'Motherboard': '主板', + 'Motherboard Temperature': '主板温度', + 'CPU Socket': 'CPU 插槽', + // 电池 + 'Battery Level': '电量', + 'Battery Charge': '充电功率', + 'Battery Discharge': '放电功率', + 'Battery Voltage': '电池电压', + 'Battery Capacity': '电池容量', + 'Battery Wear Level': '电池损耗', +} + +/** 硬件名中英文(用于分组标题等) */ +const HW_NAME_ZH: Record = { + 'Total Memory': '总内存', + 'Virtual Memory': '虚拟内存', +} + +/** 网速特殊项的中文/英文名 */ +const SPECIAL_SENSOR_META: Record = { + 'net-up': { zh: '上传速度', en: 'Upload', unit: '', type: 'throughput' }, + 'net-down': { zh: '下载速度', en: 'Download', unit: '', type: 'throughput' }, +} + +/** 通俗类型后缀(OSD 双行标题用),如 温度 / 使用率 / 功耗 */ +function colloquialTypeLabel(item: OsdItem): string { + if (item.special === 'net-up') return '上传速度' + if (item.special === 'net-down') return '下载速度' + switch (item.type) { + case 'temperature': return '温度' + case 'load': return '使用率' + case 'power': return '功耗' + case 'voltage': return '电压' + case 'fan': return '风扇' + case 'clock': return '频率' + case 'data': + case 'smalldata': return '容量' + case 'throughput': return '速率' + case 'level': return '等级' + case 'frequency': return '频率' + case 'control': return '控制' + case 'factor': return '因子' + case 'timespan': return '时长' + case 'energy': return '能量' + default: return '' + } +} + +/** 硬件前缀(简洁),如 CPU / GPU / 内存 / 主板 */ +function shortHardwareLabel(item: OsdItem): string { + if (item.special === 'net-up') return '上传' + if (item.special === 'net-down') return '下载' + switch (item.groupId) { + case 'cpu': return 'CPU' + case 'gpuintel': + case 'gpuamd': + case 'gpunvidia': return 'GPU' + case 'memory': return '内存' + case 'motherboard': return '主板' + case 'battery': return '电池' + case 'network': return '网络' + case 'psu': return '电源' + default: return '' + } +} + +function truncateHwName(name: string, maxLen = 10): string { + return name.length > maxLen ? name.slice(0, maxLen) + '…' : name +} + +/** 完整通俗标题(双行模式第一行 + 设置页显示),如 "CPU温度"、"GPU功耗"、"内存使用率" */ +function fullColloquialLabel(item: OsdItem): string { + if (item.special === 'net-up') return '上传速度' + if (item.special === 'net-down') return '下载速度' + if (osdConfig.value.labelLanguage === 'en') return item.sensorName + const type = colloquialTypeLabel(item) + // 存储类用硬件名(硬盘型号)+ 类型 + if (item.groupId === 'storage') { + return `${truncateHwName(item.hardwareName)}${type}` + } + const hw = shortHardwareLabel(item) + if (hw && type) return `${hw}${type}` + return hw || type || (SENSOR_NAME_ZH[item.sensorName] ?? item.sensorName) +} + +/** 传感器显示名翻译:根据 labelLanguage 返回中文或英文 */ +function sensorLabel(item: OsdItem): string { + // 特殊项 + if (item.special) { + const meta = SPECIAL_SENSOR_META[item.special] + if (!meta) return item.sensorName + return osdConfig.value.labelLanguage === 'zh' ? meta.zh : meta.en + } + if (osdConfig.value.labelLanguage === 'en') return item.sensorName + // 使用通俗描述:CPU温度、CPU功耗、CPU使用率 等 + return fullColloquialLabel(item) +} + +/** 硬件名翻译(标签辅助显示) */ +function hwLabel(item: OsdItem): string { + if (osdConfig.value.labelLanguage === 'en') return item.hardwareName + return HW_NAME_ZH[item.hardwareName] ?? item.hardwareName +} + +/** AvailableSensor 的传感器名翻译(用于选择 Dialog) + * 使用更明显的名字:负载→使用率,温度/功率→XXX温度/XXX功率 + * 容量类(data/smalldata)用字典翻译区分已用/可用,避免重名 */ +function sensorLabelAvail(s: AvailableSensor): string { + if (s.special) { + const meta = SPECIAL_SENSOR_META[s.special] + if (!meta) return s.sensorName + return osdConfig.value.labelLanguage === 'zh' ? meta.zh : meta.en + } + if (osdConfig.value.labelLanguage === 'en') return s.sensorName + // 容量类(data/smalldata):用字典翻译 + 硬件名区分(已用内存/可用内存/已用空间/可用空间) + if (s.type === 'data' || s.type === 'smalldata') { + const dictName = SENSOR_NAME_ZH[s.sensorName] + if (dictName) { + const hw = shortHardwareLabelAvail(s) + // 存储类用硬盘型号 + const prefix = s.groupId === 'storage' ? truncateHwName(s.hardwareName) : hw + return prefix ? `${prefix}${dictName}` : dictName + } + return SENSOR_NAME_ZH[s.sensorName] ?? s.sensorName + } + // 中文:用 通俗类型后缀 生成更明显的名字(如 CPU温度、CPU使用率、GPU功耗) + const type = colloquialTypeLabel({ type: s.type } as OsdItem) + const hw = shortHardwareLabelAvail(s) + // 存储类用硬件名(硬盘型号)+ 类型 + if (s.groupId === 'storage') { + return `${truncateHwName(s.hardwareName)}${type}` + } + if (hw && type) return `${hw}${type}` + // 回退到字典翻译 + return SENSOR_NAME_ZH[s.sensorName] ?? s.sensorName +} + +/** AvailableSensor 的硬件短名(用于 Dialog 显示) */ +function shortHardwareLabelAvail(s: AvailableSensor): string { + if (s.special === 'net-up') return '上传' + if (s.special === 'net-down') return '下载' + switch (s.groupId) { + case 'cpu': return 'CPU' + case 'gpuintel': + case 'gpuamd': + case 'gpunvidia': return 'GPU' + case 'memory': return '内存' + case 'motherboard': return '主板' + case 'battery': return '电池' + case 'network': return '网络' + case 'psu': return '电源' + default: return '' + } +} + +/** AvailableSensor 的硬件名翻译(用于选择 Dialog) */ +function hwLabelAvail(s: AvailableSensor): string { + if (osdConfig.value.labelLanguage === 'en') return s.hardwareName + return HW_NAME_ZH[s.hardwareName] ?? s.hardwareName +} + +/** + * 判断传感器是否为"常用项"。 + * 规则:基于 name + type + hardwareName 模式匹配,挑选日常监控最关注的指标。 + * 其余归入"详细项"(如 CPU 分核负载、各路电压、各时钟等)。 + */ +function isCommon(s: { sensorName: string; hardwareName: string; type: string; special?: string }): boolean { + // 特殊项(网速)算常用 + if (s.special) return true + const name = s.sensorName + const type = s.type + const hw = s.hardwareName.toLowerCase() + const nameLc = name.toLowerCase() + + // CPU 常用:Package 温度 / Total 负载 / Package 功耗 / Graphics 核显 / Core Average 温度 + // 其余(分核负载、分核温度、时钟、总线、各路功耗)归详细 + if (hw.includes('cpu') || nameLc.startsWith('cpu')) { + if (name === 'CPU Package' && type === 'temperature') return true + if (name === 'CPU Total' && type === 'load') return true + if (name === 'CPU Package' && type === 'power') return true + if (name === 'CPU Graphics' && (type === 'load' || type === 'temperature')) return true + if (name === 'CPU Core Average' && type === 'temperature') return true + return false + } + + // GPU 常用:核心温度 / 核心负载 / 功耗 / 风扇 / 显存负载 + if (hw.includes('gpu') || nameLc.startsWith('gpu') || name === 'D3D 3D') { + if (type === 'temperature' && (name === 'GPU Core' || name === 'GPU Temperature')) return true + if (type === 'load' && (name === 'GPU Core' || name === 'D3D 3D' || name === '3D')) return true + if (type === 'power' && name === 'GPU Power') return true + if (type === 'fan' && name === 'GPU Fan') return true + if (type === 'load' && name === 'GPU Memory') return true + if (type === 'smalldata' && (name === 'GPU Memory Total' || name === 'GPU Memory Used')) return true + return false + } + + // 内存:Total Memory 常用 / Virtual Memory 详细 + if (s.hardwareName === 'Total Memory') { + if (name === 'Memory' && type === 'load') return true + if (name === 'Memory Used' && type === 'data') return true + if (name === 'Memory Available' && type === 'data') return true + return false + } + if (s.hardwareName === 'Virtual Memory') return false + + // 存储:使用率 + 温度 常用;其余(Total/Free/Read/Write/Throughput)详细 + if (type === 'load' && name === 'Used Space') return true + if (type === 'temperature' && (nameLc.includes('temperature') || nameLc.includes('temp'))) return true + + // 风扇类(所有 fan 类型,覆盖主板/SuperIO/GPU 多风扇) + if (type === 'fan') return true + + // 电池 + if (name === 'Battery Level' && type === 'level') return true + if (name === 'Battery Charge' && type === 'power') return true + if (name === 'Battery Discharge' && type === 'power') return true + + // 主板温度 + if (type === 'temperature' && (nameLc.includes('motherboard') || nameLc.includes('cpu socket'))) return true + + // 电源 + if (type === 'level' && nameLc.includes('psu')) return true + + return false +} + +/** 当前快照中所有可用传感器(供"显示项"选择),按分组聚合 */ +interface AvailableSensor { + key: string + groupId: string + groupName: string + sensorName: string + hardwareName: string + type: string + unit: string + special?: 'net-up' | 'net-down' +} +const availableSensors = computed(() => { + const list: AvailableSensor[] = [] + // 网络特殊项:始终前置(即使 Kernel 未就绪也可选) + list.push({ + key: 'special/net-up', + groupId: 'network', + groupName: '网络', + sensorName: 'Upload', + hardwareName: 'Network', + type: 'throughput', + unit: '', + special: 'net-up', + }) + list.push({ + key: 'special/net-down', + groupId: 'network', + groupName: '网络', + sensorName: 'Download', + hardwareName: 'Network', + type: 'throughput', + unit: '', + special: 'net-down', + }) + for (const g of store.snapshot?.groups ?? []) { + const groupName = groupMeta[g.id]?.name ?? g.name + for (const s of g.sensors) { + const key = `${g.id}/${s.hardwareName}/${s.name}/${s.type}`.replace(/\s+/g, '_').toLowerCase() + list.push({ + key, + groupId: g.id, + groupName, + sensorName: s.name, + hardwareName: s.hardwareName, + type: s.type, + unit: s.unit, + }) + } + } + return list +}) + +/** 按分组聚合 + 常用/详细分类的可用传感器(用于 OSD 选择 Dialog 展示) */ +interface GroupedSensors { + groupId: string + groupName: string + common: AvailableSensor[] + detailed: AvailableSensor[] +} +const groupedAvailableSensors = computed(() => { + const map = new Map() + for (const s of availableSensors.value) { + if (!map.has(s.groupId)) { + map.set(s.groupId, { + groupId: s.groupId, + groupName: s.groupName, + common: [], + detailed: [], + }) + } + const grp = map.get(s.groupId)! + if (isCommon(s)) { + grp.common.push(s) + } else { + grp.detailed.push(s) + } + } + // 按默认顺序排序:CPU → GPU → 内存 → 网络 → 存储 → 其余 + const groupOrder = (id: string): number => { + if (id === 'cpu') return 0 + if (id.startsWith('gpu')) return 1 + if (id === 'memory') return 2 + if (id === 'network') return 3 + if (id === 'storage') return 4 + return 9 + } + // 过滤掉空分组(理论上不会出现) + return Array.from(map.values()) + .filter(g => g.common.length || g.detailed.length) + .sort((a, b) => groupOrder(a.groupId) - groupOrder(b.groupId)) +}) + +/** Dialog 中各分组"详细项"折叠状态:groupId → 是否展开 */ +const detailedExpanded = ref>({}) + +/** 显示项选择 Dialog(仅悬浮窗) */ +const osdPickDialogOpen = ref(false) +/** Dialog 中勾选状态(key → 是否选中) */ +const osdPickSelected = ref>({}) + +function openOsdPickDialog() { + const items = osdConfig.value.overlayItems + const selected: Record = {} + for (const it of items) selected[it.key] = true + osdPickSelected.value = selected + osdPickDialogOpen.value = true +} + +function confirmOsdPick() { + // 收集所有勾选项(保留原有项对象,新增项从 availableSensors 构造) + const oldItems = osdConfig.value.overlayItems + const newItems: OsdItem[] = [] + // 保留原有项 + for (const old of oldItems) { + if (osdPickSelected.value[old.key]) { + newItems.push(old) + } + } + // 追加新增项 + for (const s of availableSensors.value) { + if (osdPickSelected.value[s.key] && !newItems.some(it => it.key === s.key)) { + newItems.push({ + key: s.key, + groupId: s.groupId, + sensorName: s.sensorName, + hardwareName: s.hardwareName, + type: s.type, + unit: s.unit, + special: s.special, + }) + } + } + // 按默认顺序排序:CPU → GPU → 内存 → 网络 → 存储 → 其余 + const groupOrder = (id: string): number => { + if (id === 'cpu') return 0 + if (id.startsWith('gpu')) return 1 + if (id === 'memory') return 2 + if (id === 'network') return 3 + if (id === 'storage') return 4 + return 9 + } + newItems.sort((a, b) => groupOrder(a.groupId) - groupOrder(b.groupId)) + osdConfig.value.overlayItems = newItems + saveOsdConfig(osdConfig.value) + osdPickDialogOpen.value = false + toast.success('悬浮窗显示项已更新') +} + +/** 拖动排序结束回调 */ +function onOsdDragEnd() { + saveOsdConfig(osdConfig.value) +} + +/** 移除单个显示项 */ +function removeOsdItem(key: string) { + const items = osdConfig.value.overlayItems + const idx = items.findIndex(it => it.key === key) + if (idx >= 0) { + items.splice(idx, 1) + saveOsdConfig(osdConfig.value) + } +} + +/** OSD 配置项变更时自动保存 */ +function updateOsdConfig(field: keyof OsdConfig, value: unknown) { + ;(osdConfig.value as Record)[field] = value + saveOsdConfig(osdConfig.value) +} + +/** 解析背景色字符串为 hex + alpha(0-100) */ +function parseBgColor(bg: string): { hex: string; alpha: number } { + // rgba(r,g,b,a) 或 #RRGGBBAA + const rgbaMatch = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)/i) + if (rgbaMatch) { + const r = parseInt(rgbaMatch[1]) + const g = parseInt(rgbaMatch[2]) + const b = parseInt(rgbaMatch[3]) + const a = rgbaMatch[4] != null ? parseFloat(rgbaMatch[4]) : 1 + const hex = '#' + [r, g, b].map(n => n.toString(16).padStart(2, '0')).join('') + return { hex, alpha: Math.round(a * 100) } + } + let h = bg.replace('#', '').trim() + if (h.length === 3) h = h.split('').map(c => c + c).join('') + if (h.length === 8) { + return { hex: '#' + h.slice(0, 6), alpha: Math.round(parseInt(h.slice(6, 8), 16) / 255 * 100) } + } + if (h.length === 6) { + return { hex: '#' + h, alpha: 100 } + } + return { hex: '#000000', alpha: 55 } +} + +/** 背景色 hex 部分(用于 color picker 绑定) */ +const osdBgHex = computed(() => parseBgColor(osdConfig.value.bgColor).hex) +/** 背景色 alpha 部分(0-100,用于透明度滑块) */ +const osdBgAlpha = computed(() => parseBgColor(osdConfig.value.bgColor).alpha) + +/** 背景颜色选择器变化:保持原 alpha,更新 hex */ +function onBgColorInput(hex: string) { + const { alpha } = parseBgColor(osdConfig.value.bgColor) + const a = (alpha / 100).toFixed(2) + // 解析 hex 为 rgb + let h = hex.replace('#', '') + if (h.length === 3) h = h.split('').map(c => c + c).join('') + const r = parseInt(h.slice(0, 2), 16) + const g = parseInt(h.slice(2, 4), 16) + const b = parseInt(h.slice(4, 6), 16) + updateOsdConfig('bgColor', `rgba(${r}, ${g}, ${b}, ${a})`) +} + +/** 背景透明度滑块变化:保持原 hex,更新 alpha */ +function onBgAlphaInput(alpha: number) { + const { hex } = parseBgColor(osdConfig.value.bgColor) + const a = (alpha / 100).toFixed(2) + let h = hex.replace('#', '') + if (h.length === 3) h = h.split('').map(c => c + c).join('') + const r = parseInt(h.slice(0, 2), 16) + const g = parseInt(h.slice(2, 4), 16) + const b = parseInt(h.slice(4, 6), 16) + updateOsdConfig('bgColor', `rgba(${r}, ${g}, ${b}, ${a})`) +} + +/** 根据 OSD item 查找当前快照中的传感器值(支持特殊项) */ +function getOsdItemValue(item: OsdItem): number | null { + // 特殊项:网速 + if (item.special === 'net-up') return store.networkSpeed?.uploadBps ?? null + if (item.special === 'net-down') return store.networkSpeed?.downloadBps ?? null + const g = store.groupById[item.groupId] + if (!g) return null + const s = g.sensors.find(s => + s.hardwareName === item.hardwareName && s.name === item.sensorName + ) + return s?.value ?? null +} + +/** 格式化 OSD 显示值(简洁模式,不含单位后缀;网速已含单位字符串) */ +function fmtOsdValue(v: number | null, type: string, _unit: string, special?: string): string { + if (v == null || !isFinite(v)) return '--' + // 网速特殊项:自适应 KB/s 或 MB/s(值已含单位字符串) + if (special === 'net-up' || special === 'net-down') { + if (v >= 1_048_576) return (v / 1_048_576).toFixed(2) + ' MB/s' + if (v >= 1024) return (v / 1024).toFixed(1) + ' KB/s' + return v.toFixed(0) + ' B/s' + } + const digits = (type === 'voltage' || type === 'power') ? 2 + : (type === 'temperature' || type === 'load' || type === 'level') ? 0 + : 1 + return v.toFixed(digits) +} + +/** 获取单位后缀(简洁模式,网速已含单位返回空) */ +function unitSuffix(item: OsdItem): string { + if (item.special === 'net-up' || item.special === 'net-down') return '' + if (!osdConfig.value.showUnit) return '' + switch (item.type) { + case 'temperature': return '°C' + case 'load': return '%' + case 'power': return 'W' + case 'voltage': return 'V' + case 'fan': return 'RPM' + case 'clock': + case 'frequency': return 'MHz' + case 'data': + case 'smalldata': return 'GB' + case 'level': return '%' + default: return item.unit || '' + } +} + +/** 将 hex 颜色 + 不透明度(0-100) 转为 rgba 字符串 */ +function withOpacity(hex: string, opacityPct: number): string { + const a = Math.max(0, Math.min(100, opacityPct)) / 100 + let h = hex.replace('#', '').trim() + if (h.length === 3) h = h.split('').map(c => c + c).join('') + if (h.length === 8) h = h.slice(0, 6) + if (h.length !== 6 || /[^0-9a-fA-F]/.test(h)) return hex + const r = parseInt(h.slice(0, 2), 16) + const g = parseInt(h.slice(2, 4), 16) + const b = parseInt(h.slice(4, 6), 16) + return `rgba(${r}, ${g}, ${b}, ${a})` +} + +/** 获取 OSD 项颜色(按颜色主题着色,应用字体透明度) */ +function osdItemColor(item: OsdItem): string { + const opacity = osdConfig.value.fontOpacity ?? 100 + if (!osdConfig.value.colorThemeEnabled) return withOpacity(osdConfig.value.fontColor, opacity) + const theme = osdConfig.value.colorTheme ?? DEFAULT_COLOR_THEME + const hwColor = theme.hardware[item.groupId] + if (hwColor) return withOpacity(hwColor, opacity) + const sensorColor = theme.sensor[item.type] + if (sensorColor) return withOpacity(sensorColor, opacity) + return withOpacity(osdConfig.value.fontColor, opacity) +} + +/** 预览用分组(group / multiline 布局) */ +const osdPreviewGroups = computed(() => { + const items = osdConfig.value.overlayItems + if (!items?.length) return [] + const groups: { key: string; label: string; items: OsdItem[] }[] = [] + const map = new Map() + const isEn = osdConfig.value.labelLanguage === 'en' + for (const item of items) { + let gkey: string + if (item.special === 'net-up' || item.special === 'net-down') gkey = 'network' + else if (item.groupId.startsWith('gpu')) gkey = 'gpu' + else gkey = item.groupId + let g = map.get(gkey) + if (!g) { + const label = isEn + ? ({ cpu: 'CPU', gpu: 'GPU', memory: 'RAM', storage: 'DISK', network: 'NET' }[gkey] ?? gkey.toUpperCase().slice(0, 6)) + : ({ cpu: 'CPU', gpu: 'GPU', memory: '内存', storage: '存储', network: '网络' }[gkey] ?? gkey) + g = { key: gkey, label, items: [] } + map.set(gkey, g) + groups.push(g) + } + g.items.push(item) + } + return groups +}) + +/** 预览用布局标签 */ +const osdLayoutLabel = computed(() => { + switch (osdConfig.value.layout) { + case 'single': return '单行' + case 'group': return '分组横排' + case 'multiline': return '多行' + default: return '' + } +}) + +// ===== OSD 窗口管理(实际创建/隐藏 Tauri 窗口并推送数据) ===== +const OSD_OVERLAY_LABEL = 'osd-overlay' +/** 抑制百分比 watch 的程序定位标志:拖动 onMoved 更新百分比时置 true,避免触发 resetOverlayPosition 循环 */ +let suppressPercentWatch = false + +/** 构建用于 OSD 窗口的 URL(基于当前页面 URL 替换 hash) */ +function osdUrl(hash: string): string { + const base = window.location.href.split('#')[0] + return `${base}#${hash}` +} + +/** 推送当前 OSD 状态到所有 OSD 窗口 */ +async function pushOsdState() { + const payload = { + config: osdConfig.value, + snapshot: store.snapshot, + networkSpeed: store.networkSpeed, + } + try { + await emit('osd-state-update', payload) + } catch (e) { + console.error('[OSD] 推送状态失败:', e) + } +} + +/** 根据百分比位置计算窗口坐标 */ +function computePositionFromPct(screenW: number, screenH: number, w: number, h: number, xPct: number, yPct: number): { x: number; y: number } { + // 百分比基于可用空间(屏幕尺寸 - 窗口尺寸),确保窗口不会被定位到屏幕外 + const availW = Math.max(0, screenW - w) + const availH = Math.max(0, screenH - h) + return { + x: Math.round((availW * xPct) / 100), + y: Math.round((availH * yPct) / 100), + } +} + +/** 根据显示项估算悬浮窗窗口尺寸(逻辑像素) + * single: 单行分组式,组间用 | 分隔,固定宽度数据列 + * group: 分组横排,标题在上 + 数据列在下 + * multiline: 多行,每组一行,标题 + 固定宽度数据列 */ +function computeOsdWindowSize( + _itemCount: number, + layout: 'single' | 'group' | 'multiline', + fontSize: number, + _hasNetItem = false, + items?: OsdItem[], +): { w: number; h: number } { + const charW = fontSize * 0.62 + const barHPad = 8 // osd-bar 左右 padding 4*2 + + // 按硬件类型分组(与渲染逻辑一致) + const groupMap = new Map() + if (items?.length) { + for (const item of items) { + let gkey: string + if (item.special === 'net-up' || item.special === 'net-down') gkey = 'network' + else if (item.groupId.startsWith('gpu')) gkey = 'gpu' + else gkey = item.groupId + if (!groupMap.has(gkey)) groupMap.set(gkey, []) + groupMap.get(gkey)!.push(item) + } + } + const groupCount = Math.max(1, groupMap.size) + + // 每组数据列宽度:标签(6ch) + 各项(数值+单位+箭头/gap) + const groupWidths: number[] = [] + for (const [, groupItems] of groupMap) { + const labelW = 6 + const dataW = groupItems.reduce((sum, item) => { + const isNet = item.special === 'net-up' || item.special === 'net-down' + return sum + (isNet ? 11 : 8) + 1 + }, 0) + groupWidths.push(labelW + dataW) + } + + if (layout === 'multiline') { + // 多行:取最宽行 + const maxLineW = groupWidths.length ? Math.max(...groupWidths) : 10 + const w = Math.ceil(maxLineW * charW + barHPad) + const lineH = Math.ceil(fontSize + 2) + const h = Math.ceil(groupCount * lineH + 6) + return { w: Math.max(120, w), h: Math.max(28, h) } + } + + if (layout === 'group') { + // 分组横排:各组横排 + 标题行 + const totalW = groupWidths.reduce((s, w) => s + w + 4, 0) + (groupCount - 1) * 4 + const w = Math.ceil(totalW * charW + barHPad) + const titleH = Math.ceil(fontSize * 0.85) + 2 + const dataH = Math.ceil(fontSize) + 2 + const h = Math.ceil(titleH + dataH + 10) + return { w: Math.max(120, w), h: Math.max(40, h) } + } + + // single:单行分组式,各组横排 + 组间 | 分隔符(1ch) + const sepW = (groupCount - 1) * 1 + const totalW = groupWidths.reduce((s, w) => s + w, 0) + sepW + const w = Math.ceil(totalW * charW + barHPad) + const h = Math.ceil(fontSize + 8) + return { w: Math.max(120, w), h: Math.max(28, h) } +} + +/** 创建/显示悬浮窗窗口(默认置顶 + NoActivate + 点击穿透) */ +async function ensureOverlayWindow() { + const existing = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL) + if (existing) { + // 窗口已存在,仅显示并推送最新状态 + await existing.show() + await updateOsdWindowSize() + await pushOsdState() + return + } + + // 获取屏幕尺寸用于定位 + const monitor = await currentMonitor() + const screenW = monitor?.size.width ?? 1920 + const screenH = monitor?.size.height ?? 1080 + const scale = monitor?.scaleFactor ?? 1 + const logicalW = screenW / scale + const logicalH = screenH / scale + const hasNetItem = osdConfig.value.overlayItems.some(i => i.special === 'net-up' || i.special === 'net-down') + const { w, h } = computeOsdWindowSize( + osdConfig.value.overlayItems.length, + osdConfig.value.layout, + osdConfig.value.fontSize, + hasNetItem, + osdConfig.value.overlayItems, + ) + + // 优先使用保存的像素位置;否则根据百分比计算默认位置 + let x: number, y: number + if (osdConfig.value.overlayX != null && osdConfig.value.overlayY != null) { + x = osdConfig.value.overlayX + y = osdConfig.value.overlayY + } else { + const pos = computePositionFromPct(logicalW, logicalH, w, h, osdConfig.value.positionXPct, osdConfig.value.positionYPct) + x = pos.x + y = pos.y + } + + const win = new WebviewWindow(OSD_OVERLAY_LABEL, { + url: osdUrl('osd-overlay'), + title: 'OSD 悬浮窗', + width: w, + height: h, + x, + y, + decorations: false, + transparent: true, + // 关闭窗口阴影:Win11 默认会画一圈阴影光晕,透明窗口上表现为可见的"外部框" + shadow: false, + alwaysOnTop: true, + skipTaskbar: true, + // 禁用调整大小:移除 Windows 隐形 resize 边框(该边框会拦截鼠标事件导致穿透/拖动失效) + resizable: false, + visible: true, + // 不获取焦点(NoActivate 由 Rust 后端 osd_apply_overlay_style 进一步保证) + focus: false, + }) + + win.once('tauri://created', async () => { + // 等待 webview 加载后推送初始状态 + setTimeout(() => pushOsdState(), 300) + // 监听窗口移动,保存像素位置并同步更新百分比(拖动结束后触发) + try { + const winInstance = await win + const unlisten = await winInstance.onMoved(async ({ payload }) => { + osdConfig.value.overlayX = payload.x + osdConfig.value.overlayY = payload.y + // 反算百分比:xPct = x / availW * 100,availW = screenW - windowW + // 置 suppressPercentWatch=true 避免百分比变化触发 resetOverlayPosition 循环 + suppressPercentWatch = true + try { + const monitor = await currentMonitor() + const screenW = (monitor?.size.width ?? 1920) / (monitor?.scaleFactor ?? 1) + const screenH = (monitor?.size.height ?? 1080) / (monitor?.scaleFactor ?? 1) + const size = await winInstance.outerSize() + const scale = monitor?.scaleFactor ?? 1 + const winW = size.width / scale + const winH = size.height / scale + const availW = Math.max(1, screenW - winW) + const availH = Math.max(1, screenH - winH) + osdConfig.value.positionXPct = Math.round((payload.x / availW) * 100) + osdConfig.value.positionYPct = Math.round((payload.y / availH) * 100) + } catch { /* 忽略百分比反算失败 */ } + saveOsdConfig(osdConfig.value) + // 下一个微任务后解除抑制(让本次 watch 回调跳过即可) + queueMicrotask(() => { suppressPercentWatch = false }) + }) + osdEventUnlisteners.push(unlisten) + } catch { /* 忽略 */ } + }) + win.once('tauri://error', (e: unknown) => { + console.error('[OSD] 悬浮窗创建失败:', e) + toast.error('悬浮窗创建失败') + }) +} + +/** 隐藏悬浮窗 */ +async function hideOverlayWindow() { + const existing = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL) + if (existing) { + await existing.hide() + } +} + +/** 根据当前配置更新悬浮窗窗口尺寸(显示项数量/布局/字号变化时调用) */ +async function updateOsdWindowSize() { + try { + const existing = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL) + if (!existing) return + const hasNetItem = osdConfig.value.overlayItems.some(i => i.special === 'net-up' || i.special === 'net-down') + const { w, h } = computeOsdWindowSize( + osdConfig.value.overlayItems.length, + osdConfig.value.layout, + osdConfig.value.fontSize, + hasNetItem, + osdConfig.value.overlayItems, + ) + await existing.setSize(new LogicalSize(w, h)) + } catch { /* 忽略 */ } +} + +/** 重置悬浮窗位置到默认(百分比位置),清除保存的像素位置 + * 仅重新定位,不改变尺寸——尺寸由悬浮窗内容实际测量上报维持 */ +async function resetOverlayPosition() { + osdConfig.value.overlayX = null + osdConfig.value.overlayY = null + saveOsdConfig(osdConfig.value) + // 重新定位窗口 + try { + const existing = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL) + if (existing) { + const monitor = await currentMonitor() + const screenW = (monitor?.size.width ?? 1920) / (monitor?.scaleFactor ?? 1) + const screenH = (monitor?.size.height ?? 1080) / (monitor?.scaleFactor ?? 1) + // 读取窗口当前实际尺寸用于定位计算,不调用 setSize(避免覆盖实际测量值) + const size = await existing.outerSize() + const scale = monitor?.scaleFactor ?? 1 + const w = size.width / scale + const h = size.height / scale + const pos = computePositionFromPct(screenW, screenH, w, h, osdConfig.value.positionXPct, osdConfig.value.positionYPct) + await existing.setPosition(new LogicalPosition(pos.x, pos.y)) + } + } catch { /* 忽略 */ } +} + +/** 关闭所有 OSD 窗口(组件卸载时调用) */ +async function closeAllOsdWindows() { + try { + const w = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL) + if (w) await w.close() + } catch { + /* 忽略 */ + } +} + +// ===== OSD 窗口事件监听 ===== +let osdEventUnlisteners: UnlistenFn[] = [] + +async function setupOsdEventListeners() { + const { listen: tauriListen } = await import('@tauri-apps/api/event') + // 监听悬浮窗上报的实际内容尺寸,按内容调整窗口大小(替代不准确的估算) + // 仅当尺寸变化超过 1px 时才 setSize,避免无意义的频繁调用 + let lastW = 0 + let lastH = 0 + const unlisten = await tauriListen<{ width: number; height: number }>('osd-content-size', async (e) => { + const { width, height } = e.payload + if (Math.abs(width - lastW) < 1 && Math.abs(height - lastH) < 1) return + lastW = width + lastH = height + try { + const w = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL) + if (w) await w.setSize(new LogicalSize(width, height)) + } catch { /* 忽略 */ } + }) + osdEventUnlisteners.push(unlisten) +} + +// ===== 颜色主题编辑 Dialog ===== +const colorThemeDialogOpen = ref(false) +/** 编辑中的颜色主题(深拷贝) */ +const editingColorTheme = ref({ hardware: {}, sensor: {} }) + +function openColorThemeDialog() { + editingColorTheme.value = JSON.parse(JSON.stringify(osdConfig.value.colorTheme ?? DEFAULT_COLOR_THEME)) + colorThemeDialogOpen.value = true +} + +function saveColorTheme() { + osdConfig.value.colorTheme = editingColorTheme.value + saveOsdConfig(osdConfig.value) + colorThemeDialogOpen.value = false + toast.success('颜色主题已保存') +} + +function resetColorTheme() { + editingColorTheme.value = JSON.parse(JSON.stringify(DEFAULT_COLOR_THEME)) +} + +// ===== 警告色配置更新辅助 ===== +/** 更新 alert 配置字段(支持嵌套 maxValues) */ +function updateAlertConfig(field: keyof AlertConfig | 'maxValues', value: unknown, maxKey?: string) { + if (field === 'maxValues' && maxKey) { + osdConfig.value.alert.maxValues[maxKey] = Number(value) + } else { + ;(osdConfig.value.alert as Record)[field] = value + } + saveOsdConfig(osdConfig.value) +} + +/** 警告色配置中需配置最大值的硬件类型列表(温度墙) */ +const ALERT_MAX_VALUE_LIST: { key: string; name: string }[] = [ + { key: 'cpu', name: 'CPU' }, + { key: 'gpu', name: 'GPU' }, +] + +/** 颜色主题中硬件类型列表(含中文名) */ +const COLOR_THEME_HARDWARE_LIST: { key: string; name: string }[] = [ + { key: 'cpu', name: 'CPU' }, + { key: 'gpuintel', name: 'GPU (Intel)' }, + { key: 'gpuamd', name: 'GPU (AMD)' }, + { key: 'gpunvidia', name: 'GPU (NVIDIA)' }, + { key: 'memory', name: '内存' }, + { key: 'storage', name: '存储' }, + { key: 'motherboard', name: '主板' }, + { key: 'superio', name: '超级 IO' }, + { key: 'embeddedcontroller', name: '嵌入式控制器' }, + { key: 'battery', name: '电池' }, + { key: 'network', name: '网络' }, + { key: 'psu', name: '电源' }, +] + +/** 颜色主题中传感器类型列表(含中文名) */ +const COLOR_THEME_SENSOR_LIST: { key: string; name: string }[] = [ + { key: 'temperature', name: '温度' }, + { key: 'load', name: '使用率' }, + { key: 'power', name: '功耗' }, + { key: 'voltage', name: '电压' }, + { key: 'fan', name: '风扇' }, + { key: 'clock', name: '时钟' }, + { key: 'frequency', name: '频率' }, + { key: 'data', name: '容量' }, + { key: 'smalldata', name: '小容量' }, + { key: 'throughput', name: '吞吐' }, + { key: 'level', name: '等级' }, + { key: 'control', name: '控制' }, + { key: 'factor', name: '因子' }, + { key: 'timespan', name: '时长' }, + { key: 'energy', name: '能量' }, +] + // 生命周期 onMounted(async () => { // 获取 appData 路径,用于将 Kernel 路径替换为 %APPDATA% 形式 try { appDataPath.value = await appDataDir() } catch { /* 忽略 */ } store.init() + + // 注册 OSD 窗口事件监听 + setupOsdEventListeners().catch(e => console.error('[OSD] 事件监听注册失败:', e)) + + // 初始化悬浮窗(如果开关已开启) + if (osdConfig.value.overlayEnabled) { + ensureOverlayWindow().catch(e => console.error('[OSD] 初始化悬浮窗失败:', e)) + } }) onUnmounted(() => { store.dispose() + // 清理 OSD 事件监听 + osdEventUnlisteners.forEach(fn => fn()) + osdEventUnlisteners = [] + // 关闭悬浮窗(切走监控模块时释放 OSD 窗口) + closeAllOsdWindows().catch(e => console.error('[OSD] 关闭窗口失败:', e)) }) // 当 Kernel 从未就绪变成就绪时,主动拉一次快照填充 UI @@ -467,15 +1628,82 @@ watch(() => store.status?.ready, (ready, prev) => { store.fetchSnapshot() } }) + +// ===== OSD 开关变化时创建/隐藏悬浮窗 ===== +watch(() => osdConfig.value.overlayEnabled, (enabled) => { + if (enabled) { + // 开启时若显示项为空则不创建窗口 + if (osdConfig.value.overlayItems.length === 0) return + ensureOverlayWindow().catch(e => console.error('[OSD] 创建悬浮窗失败:', e)) + } else { + hideOverlayWindow().catch(e => console.error('[OSD] 隐藏悬浮窗失败:', e)) + } +}) + +// ===== 显示项变化时:无项则隐藏窗口,有项且开关开则确保窗口存在 ===== +watch(() => osdConfig.value.overlayItems.length, (len) => { + if (!osdConfig.value.overlayEnabled) return + if (len === 0) { + hideOverlayWindow().catch(e => console.error('[OSD] 显示项为空,隐藏悬浮窗失败:', e)) + } else { + ensureOverlayWindow().catch(e => console.error('[OSD] 显示项恢复,创建悬浮窗失败:', e)) + } +}) + +// ===== 位置百分比变化时重新定位窗口(清除已保存像素位置) ===== +// 拖动 OSD 触发的 onMoved 会反算更新百分比,此时 suppressPercentWatch=true 跳过,避免循环 +watch(() => [osdConfig.value.positionXPct, osdConfig.value.positionYPct], () => { + if (suppressPercentWatch) return + // 清除保存的像素位置,让窗口使用百分比重新定位 + osdConfig.value.overlayX = null + osdConfig.value.overlayY = null + saveOsdConfig(osdConfig.value) + // 如果窗口已存在,重新定位 + resetOverlayPosition().catch(() => {}) +}) + +// ===== 数据变化时推送状态到 OSD 窗口 ===== +// 快照变化(Kernel SSE 推送)→ 推送到 OSD 窗口 +watch(() => store.snapshot, () => { + if (osdConfig.value.overlayEnabled) { + pushOsdState() + } +}, { deep: false }) + +// 网速变化 → 推送到 OSD 窗口 +watch(() => store.networkSpeed, () => { + if (osdConfig.value.overlayEnabled) { + pushOsdState() + } +}, { deep: false }) + +// OSD 配置变化 → 推送到 OSD 窗口(位置/字体/显示项等) +watch(osdConfig, () => { + if (osdConfig.value.overlayEnabled) { + pushOsdState() + } +}, { deep: true }) + +// 显示项数量/布局/字号变化 → 更新悬浮窗窗口尺寸(自适应内容) +watch([ + () => osdConfig.value.overlayItems.length, + () => osdConfig.value.layout, + () => osdConfig.value.fontSize, +], () => { + if (osdConfig.value.overlayEnabled) { + updateOsdWindowSize().catch(() => {}) + } +}) + + diff --git a/src/modules/monitor/OsdWindow.vue b/src/modules/monitor/OsdWindow.vue new file mode 100644 index 0000000..1d99119 --- /dev/null +++ b/src/modules/monitor/OsdWindow.vue @@ -0,0 +1,775 @@ + + + + + diff --git a/src/stores/monitorStore.ts b/src/stores/monitorStore.ts index b790dec..a859495 100644 --- a/src/stores/monitorStore.ts +++ b/src/stores/monitorStore.ts @@ -44,7 +44,7 @@ export interface MonitorStatus { restartCount: number /** 是否为提权模式(通过 UAC 以管理员权限启动) */ elevated: boolean - /** Thing 自身是否以管理员权限运行(永久提权模式) */ + /** Thing 自身是否以管理员权限运行(提权模式) */ thingElevated: boolean } @@ -122,7 +122,7 @@ export const useMonitorStore = defineStore('monitor', () => { /** 是否已完成首次加载(避免初始 null/false 导致 UI 闪烁误导状态) */ const initialized = ref(false) - /** 永久提权标志是否已启用(后续启动自动触发 UAC) */ + /** 提权标志是否已启用(后续启动自动触发 UAC) */ const elevateOnLaunch = ref(false) /** 硬件监控配置(可用硬件 + 传感器类型清单 + 当前启用状态) */ @@ -138,8 +138,8 @@ export const useMonitorStore = defineStore('monitor', () => { if (!status.value) return 'idle' if (!status.value.running) return 'idle' if (!status.value.ready) return 'loading' - if (eventCount.value === 0) return 'loading' - if (Date.now() - lastEventTime.value > STALE_TIMEOUT_MS) return 'disconnected' + // Kernel 已 ready 即视为已连接(避免 SSE 首事件延迟导致一直显示"启动中") + if (Date.now() - lastEventTime.value > STALE_TIMEOUT_MS && eventCount.value > 0) return 'disconnected' return 'connected' }) @@ -197,6 +197,14 @@ export const useMonitorStore = defineStore('monitor', () => { try { await invoke('monitor_start') await refreshStatus() + // 进程拉起可能存在竞态(spawn 后状态未立即更新),短暂重试确保 running=true + if (!status.value?.running) { + for (let i = 0; i < 6; i++) { + await new Promise(r => setTimeout(r, 500)) + await refreshStatus() + if (status.value?.running) break + } + } } catch (e) { errorMsg.value = String(e) logger.error('启动失败: ' + e) @@ -222,26 +230,8 @@ export const useMonitorStore = defineStore('monitor', () => { } } - /** 以管理员权限重启 Kernel(弹 UAC)。 - * 停止当前 Kernel → ShellExecute "runas" → 等待 ready → 重新订阅 SSE。 - * 提权后进程不归 ProcessManager 管,停止走 /shutdown 接口。 - */ - async function startElevated() { - if (starting.value) return - starting.value = true - errorMsg.value = null - try { - await invoke('monitor_start_elevated') - await refreshStatus() - } catch (e) { - errorMsg.value = String(e) - logger.error('提权启动失败: ' + e) - } finally { - starting.value = false - } - } - - /** 永久提权:以管理员权限重启 Thing 自身,ThingHK 子进程会继承管理员权限。 + /** 提权:以管理员权限重启 Thing 自身,ThingHK 子进程会继承管理员权限。 + * Thing 以管理员权限运行时,ProcessManager 可直接管控 ThingHK,崩溃自动重启。 * 非管理员时进程退出;已是管理员时仅设置标志并返回。 */ async function elevateSelf() { if (starting.value) return @@ -253,13 +243,13 @@ export const useMonitorStore = defineStore('monitor', () => { await refreshElevateOnLaunch() } catch (e) { errorMsg.value = String(e) - logger.error('永久提权失败: ' + e) + logger.error('提权失败: ' + e) } finally { starting.value = false } } - /** 刷新永久提权标志状态 */ + /** 刷新提权标志状态 */ async function refreshElevateOnLaunch() { try { elevateOnLaunch.value = await invoke('monitor_get_elevate_on_launch') @@ -269,7 +259,7 @@ export const useMonitorStore = defineStore('monitor', () => { return elevateOnLaunch.value } - /** 取消永久提权:清除标志,下次启动不再触发 UAC(当前会话权限不变) */ + /** 取消提权:清除标志,下次启动不再触发 UAC(当前会话权限不变) */ async function cancelElevation() { try { await invoke('monitor_set_elevate_on_launch', { enabled: false }) @@ -337,6 +327,8 @@ export const useMonitorStore = defineStore('monitor', () => { })) unlistenFns.push(await listen('monitor-ready', () => { refreshStatus() + // Kernel 就绪后主动拉取一次快照,避免等待 SSE 首事件导致 UI 空白 + fetchSnapshot() })) unlistenFns.push(await listen('monitor-loading', () => { // 后端正在等待 Kernel ready,刷新状态以反映 running=true @@ -416,7 +408,6 @@ export const useMonitorStore = defineStore('monitor', () => { refreshKernelInfo, refreshElevateOnLaunch, start, - startElevated, elevateSelf, cancelElevation, fetchHardwareConfig,