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(() => {})
+ }
+})
@@ -676,10 +1904,7 @@ watch(() => store.status?.ready, (ready, prev) => {
{{ store.snapshot?.isAdmin ? '管理员' : '普通' }}
- 永久提权
-
-
- 提权
+ 提权
@@ -691,23 +1916,21 @@ watch(() => store.status?.ready, (ready, prev) => {
重启: {{ store.status?.restartCount ?? 0 }}
事件: {{ store.eventCount }}
-
-
+
+
+
+
未检测到传感器数据(可能需要管理员权限或硬件不支持)
+
{{ hwGroup.hardware }}
@@ -803,6 +2031,689 @@ watch(() => store.status?.ready, (ready, prev) => {
+
+
+
+
+
+
+
+
+
+ 桌面悬浮窗
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 悬浮窗
+ {{ osdConfig.overlayItems.length }} 项
+
+
桌面自定义显示监控悬浮窗
+
+
+
+
+
+
+
+
Kernel 未就绪,无法获取可用传感器列表。请先在概览页启动 Kernel。
+
+
+
+
+
+
+
+
+ 显示项
+
+ 选择项
+
+
+
+
+
+ 暂无显示项,点击"选择项"添加
+
+
+
+
+
+
+
+
+ {{ sensorLabel(item) }}
+ / {{ hwLabel(item) }}
+
+
+ {{ fmtOsdValue(getOsdItemValue(item), item.type, item.unit, item.special) }}{{ unitSuffix(item) }}
+
+
+ ×
+
+
+
+
+ 拖动图标可调整显示顺序
+
+
+
+
+
+
+
+
+ 显示设置
+
+
+
+
+
+
+
+
+ 重置为顶部居中
+
+
+
+
+
+ X(水平)
+ {{ osdConfig.positionXPct }}%
+
+
+
+
+
+ Y(垂直)
+ {{ osdConfig.positionYPct }}%
+
+
+
+
+
X=50% Y=0% 表示水平居中、垂直顶部。调整百分比会重置到对应位置。
+
+
+
+
+
+
+
+
+
+ 单行
+ CPU 50% 30°C | GPU 60°C
+
+
+ 分组横排
+
+
CPU GPU 网络
+
50% 60°C ↑1KB/s
+
+
+
+ 多行
+
+
CPU 50% 60°C
+
GPU 20% 40°C
+
+
+
+
+
+
+
+
+
+
+
+
+ 不同硬件/传感器不同颜色
+
+
+
+
+ 自定义颜色
+
+
+
+
+
+
+
+
+ 达到阈值时数值/标题变色
+
+
+
+
+
+
+
+
+ {{ osdConfig.alert.warnThreshold }}%
+
+
+
+
+
+
+ {{ osdConfig.alert.criticalThreshold }}%
+
+
+
+
+
+
+
+
+
+
+ {{ osdConfig.alert.warnColor }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ osdConfig.alert.criticalColor }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ °C
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ osdConfig.bgColor }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ osdConfig.fontSize }}px
+
+
+
+
+
+
+
+
+ {{ osdConfig.fontOpacity }}%
+
+
+
+
+
+
+
+
+
+
+
+ {{ osdConfig.fontColor }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ osdConfig.fontStrokeWidth }}px
+
+
+
+
+
+
+
+
+
+
+ {{ osdConfig.fontStrokeColor }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 开启后鼠标穿透悬浮窗;关闭后左键可拖动悬浮窗
+
+
+
+
+
+
+
+
+
+
+
+ {{ osdConfig.updateIntervalMs }}ms
+
+
+
+
+
+
+
+
+
+
+ 预览
+
+
+
+ 悬浮窗({{ osdLayoutLabel }})
+
+
+
+
+ |
+
+ {{ g.label }}
+
+ {{ fmtOsdValue(getOsdItemValue(item), item.type, item.unit, item.special) }}{{ unitSuffix(item) }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ fmtOsdValue(getOsdItemValue(item), item.type, item.unit, item.special) }}{{ unitSuffix(item) }}
+
+
+
+
+
+
+
+
+ {{ g.label }}
+
+ {{ fmtOsdValue(getOsdItemValue(item), item.type, item.unit, item.special) }}{{ unitSuffix(item) }}
+
+
+
+
+
+
+
+
+
+
@@ -906,10 +2817,7 @@ watch(() => store.status?.ready, (ready, prev) => {
{{ store.snapshot?.isAdmin ? '管理员' : '普通' }}
- 永久提权
-
-
- 提权模式
+ 提权
@@ -918,28 +2826,18 @@ watch(() => store.status?.ready, (ready, prev) => {
LibreHardwareMonitor 访问 CPU MSR(温度/时钟)、存储 SMART、SMBus、EC 传感器需要管理员权限。
普通权限下可读:CPU 负载/功率、内存、GPU 负载。不可读:CPU 温度/时钟、存储、主板/电压。
-
+
-
-
-
- 提权:仅以管理员权限启动 ThingHK(弹 UAC)。ThingHK 崩溃不自动重启,停止走 /shutdown 接口。
-
-
- 永久提权:以管理员权限重启 Thing 自身,ThingHK 子进程继承权限。标志持久化,后续每次启动自动触发 UAC;可在概览页点击"取消提权"关闭。
+ 提权:以管理员权限重启 Thing 自身,ThingHK 子进程继承权限。标志持久化,后续每次启动自动触发 UAC;可在概览页点击"取消提权"关闭。ThingHK 崩溃由 ProcessManager 自动重启,避免数据停止后 Thing 不感知。
- 永久提权模式:Thing 与 ThingHK 均以管理员权限运行,ProcessManager 可直接 kill,崩溃自动重启可用。
-
-
-
- 提权模式运行中:仅 ThingHK 管理员权限,进程不归 ProcessManager 管控,崩溃不自动重启;停止通过 /shutdown 接口优雅退出。
+ 提权模式:Thing 与 ThingHK 均以管理员权限运行,ProcessManager 可直接 kill,崩溃自动重启可用。
@@ -1040,4 +2938,238 @@ watch(() => store.status?.ready, (ready, prev) => {
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+ |
+
+ {{ g.label }}
+
+ {{ netArrow(item) }}
+ {{ fmtFixedValue(getOsdItemValue(item), item) }}
+ {{ fmtFixedUnit(item) }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ netArrow(item) }}
+ {{ fmtFixedValue(getOsdItemValue(item), item) }}
+ {{ fmtFixedUnit(item) }}
+
+
+
+
+
+
+
+
+ {{ g.label }}
+
+ {{ netArrow(item) }}
+ {{ fmtFixedValue(getOsdItemValue(item), item) }}
+ {{ fmtFixedUnit(item) }}
+
+
+
+
+
+
未配置显示项
+
+
+
+
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,