托盘调整
This commit is contained in:
@@ -25,7 +25,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.5" />
|
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.7-pre716" />
|
||||||
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
|
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
Generated
+97
@@ -2392,6 +2392,20 @@ dependencies = [
|
|||||||
"pkg-config",
|
"pkg-config",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mac-notification-sys"
|
||||||
|
version = "0.6.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"log",
|
||||||
|
"objc2",
|
||||||
|
"objc2-foundation",
|
||||||
|
"time",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markup5ever"
|
name = "markup5ever"
|
||||||
version = "0.38.0"
|
version = "0.38.0"
|
||||||
@@ -2529,6 +2543,20 @@ version = "1.0.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "notify-rust"
|
||||||
|
version = "4.18.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891"
|
||||||
|
dependencies = [
|
||||||
|
"futures-lite",
|
||||||
|
"log",
|
||||||
|
"mac-notification-sys",
|
||||||
|
"serde",
|
||||||
|
"tauri-winrt-notification",
|
||||||
|
"zbus",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ntapi"
|
name = "ntapi"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
@@ -3061,6 +3089,15 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ppv-lite86"
|
||||||
|
version = "0.2.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "precomputed-hash"
|
name = "precomputed-hash"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -3165,6 +3202,35 @@ version = "6.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||||
|
dependencies = [
|
||||||
|
"rand_chacha",
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_chacha"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||||
|
dependencies = [
|
||||||
|
"ppv-lite86",
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "raw-window-handle"
|
name = "raw-window-handle"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -4313,6 +4379,25 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-notification"
|
||||||
|
version = "2.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"notify-rust",
|
||||||
|
"rand",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_repr",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"time",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-opener"
|
name = "tauri-plugin-opener"
|
||||||
version = "2.5.4"
|
version = "2.5.4"
|
||||||
@@ -4451,6 +4536,17 @@ dependencies = [
|
|||||||
"toml 1.1.2+spec-1.1.0",
|
"toml 1.1.2+spec-1.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-winrt-notification"
|
||||||
|
version = "0.7.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"windows 0.61.3",
|
||||||
|
"windows-version",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.27.0"
|
version = "3.27.0"
|
||||||
@@ -4495,6 +4591,7 @@ dependencies = [
|
|||||||
"tauri-plugin-autostart",
|
"tauri-plugin-autostart",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-global-shortcut",
|
"tauri-plugin-global-shortcut",
|
||||||
|
"tauri-plugin-notification",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-snap-layout",
|
"tauri-plugin-snap-layout",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ tauri-plugin-opener = "2"
|
|||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
tauri-plugin-snap-layout = "1"
|
tauri-plugin-snap-layout = "1"
|
||||||
tauri-plugin-global-shortcut = "2"
|
tauri-plugin-global-shortcut = "2"
|
||||||
|
tauri-plugin-notification = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
|
|||||||
Binary file not shown.
@@ -9,6 +9,7 @@
|
|||||||
"main"
|
"main"
|
||||||
],
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"autostart:default"
|
"autostart:default",
|
||||||
|
"notification:default"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "tray-menu",
|
||||||
|
"description": "Capability for custom tray menu popup window",
|
||||||
|
"windows": ["tray-menu"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"core:window:allow-hide",
|
||||||
|
"core:window:allow-show",
|
||||||
|
"core:window:allow-set-focus",
|
||||||
|
"core:window:allow-close",
|
||||||
|
"core:window:allow-set-theme",
|
||||||
|
"core:window:allow-set-effects",
|
||||||
|
"core:window:allow-set-background-color",
|
||||||
|
"core:window:allow-set-size",
|
||||||
|
"core:window:allow-set-position",
|
||||||
|
"core:event:allow-emit",
|
||||||
|
"core:event:allow-listen"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -73,7 +73,7 @@ pub fn unregister_shortcut(app: &AppHandle) {
|
|||||||
/// 窗口不存在则创建(隐藏状态,等前端挂载后调用 show_window 显示)。
|
/// 窗口不存在则创建(隐藏状态,等前端挂载后调用 show_window 显示)。
|
||||||
/// 窗口已存在则移动到鼠标位置并显示。
|
/// 窗口已存在则移动到鼠标位置并显示。
|
||||||
pub fn show_popup(app: &AppHandle) {
|
pub fn show_popup(app: &AppHandle) {
|
||||||
// 获取鼠标位置(屏幕坐标)
|
// 获取鼠标位置(物理像素,Per-Monitor DPI V2)
|
||||||
let (mx, my) = match get_cursor_pos() {
|
let (mx, my) = match get_cursor_pos() {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => return,
|
None => return,
|
||||||
@@ -83,16 +83,30 @@ pub fn show_popup(app: &AppHandle) {
|
|||||||
let w = 380.0_f64;
|
let w = 380.0_f64;
|
||||||
let h = 460.0_f64;
|
let h = 460.0_f64;
|
||||||
|
|
||||||
// 获取屏幕工作区以避免弹出位置超出屏幕
|
// 获取屏幕工作区(物理像素,Per-Monitor DPI V2)
|
||||||
let (screen_w, screen_h) = get_work_area().unwrap_or((1920.0, 1080.0));
|
let (screen_w, screen_h) = get_work_area().unwrap_or((1920.0, 1080.0));
|
||||||
|
|
||||||
// 计算窗口位置:鼠标位置作为左上角,超出屏幕则调整
|
// 获取 scale factor,将窗口逻辑尺寸换算为物理像素用于边界裁剪
|
||||||
let x = (mx as f64).min(screen_w - w).max(0.0);
|
let scale = app
|
||||||
let y = (my as f64).min(screen_h - h).max(0.0);
|
.get_webview_window(POPUP_LABEL)
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|w| w.scale_factor().ok())
|
||||||
|
.or_else(|| {
|
||||||
|
app.get_webview_window("main")
|
||||||
|
.and_then(|w| w.scale_factor().ok())
|
||||||
|
})
|
||||||
|
.unwrap_or(1.0);
|
||||||
|
|
||||||
|
let w_phys = (w * scale) as i32;
|
||||||
|
let h_phys = (h * scale) as i32;
|
||||||
|
|
||||||
|
// 物理坐标 clamping
|
||||||
|
let x = mx.max(0).min(screen_w as i32 - w_phys);
|
||||||
|
let y = my.max(0).min(screen_h as i32 - h_phys);
|
||||||
|
|
||||||
// 窗口已存在:移动 + 显示 + 请求焦点
|
// 窗口已存在:移动 + 显示 + 请求焦点
|
||||||
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
|
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
|
||||||
let _ = win.set_position(tauri::Position::Logical(tauri::LogicalPosition {
|
let _ = win.set_position(tauri::Position::Physical(tauri::PhysicalPosition {
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
}));
|
}));
|
||||||
@@ -112,7 +126,7 @@ pub fn show_popup(app: &AppHandle) {
|
|||||||
)
|
)
|
||||||
.title("剪贴板")
|
.title("剪贴板")
|
||||||
.inner_size(w, h)
|
.inner_size(w, h)
|
||||||
.position(x, y)
|
.position(x as f64 / scale, y as f64 / scale)
|
||||||
.decorations(false)
|
.decorations(false)
|
||||||
.transparent(true)
|
.transparent(true)
|
||||||
.shadow(true)
|
.shadow(true)
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ use clipboard::{
|
|||||||
clipboard_set_pinned, clipboard_show_popup, clipboard_show_window, clipboard_start,
|
clipboard_set_pinned, clipboard_show_popup, clipboard_show_window, clipboard_start,
|
||||||
clipboard_status, clipboard_stop, clipboard_unregister_shortcut,
|
clipboard_status, clipboard_stop, clipboard_unregister_shortcut,
|
||||||
};
|
};
|
||||||
|
use tray_menu::{tray_menu_action, tray_menu_hide, tray_menu_show_window};
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn greet(name: &str) -> String {
|
fn greet(name: &str) -> String {
|
||||||
@@ -87,6 +88,7 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||||
|
.plugin(tauri_plugin_notification::init())
|
||||||
.plugin(
|
.plugin(
|
||||||
tauri_plugin_snap_layout::init()
|
tauri_plugin_snap_layout::init()
|
||||||
.button_id("titlebar-maximize")
|
.button_id("titlebar-maximize")
|
||||||
@@ -181,6 +183,9 @@ pub fn run() {
|
|||||||
clipboard_show_window,
|
clipboard_show_window,
|
||||||
clipboard_hide_popup,
|
clipboard_hide_popup,
|
||||||
clipboard_paste_to_target,
|
clipboard_paste_to_target,
|
||||||
|
tray_menu_action,
|
||||||
|
tray_menu_hide,
|
||||||
|
tray_menu_show_window,
|
||||||
snap_fix::fix_snap_background
|
snap_fix::fix_snap_background
|
||||||
])
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
|
|||||||
+348
-374
@@ -1,46 +1,53 @@
|
|||||||
//! tray_menu.rs — 自定义系统托盘右键菜单
|
//! tray_menu.rs — 自定义系统托盘菜单(WebviewWindow 弹窗)
|
||||||
//!
|
//!
|
||||||
//! 菜单结构:
|
//! 使用独立 WebviewWindow 替代原生菜单,实现与应用一致的视觉风格(Mica/Acrylic + 深色主题)。
|
||||||
//! 代理 ▸
|
//! - 右键托盘:显示自定义菜单窗口(在鼠标位置弹出)
|
||||||
//! 开启代理 (启动mihomo + 择优选节点 + 系统代理 + Windows通知)
|
//! - 左键托盘:显示主窗口
|
||||||
//! 关闭代理
|
//! - 菜单窗口失焦:自动隐藏
|
||||||
//! ---
|
//!
|
||||||
//! 选择代理 ▸
|
//! 前端通过监听 `tray-menu-show` 事件获取菜单状态并渲染,
|
||||||
//! 刷新节点列表
|
//! 点击菜单项时调用 `tray_menu_action` 命令执行对应操作。
|
||||||
//! ---
|
|
||||||
//! 节点1 (120ms)
|
|
||||||
//! 节点2 (超时)
|
|
||||||
//! ...
|
|
||||||
//! ---
|
|
||||||
//! 切换 OSD
|
|
||||||
//! 重启 Kernel
|
|
||||||
//! ---
|
|
||||||
//! 新建下载
|
|
||||||
//! ---
|
|
||||||
//! 常规设置
|
|
||||||
//! 退出
|
|
||||||
|
|
||||||
use std::sync::{Mutex, OnceLock};
|
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
use tauri::{
|
use tauri::{
|
||||||
AppHandle, Emitter, Manager,
|
AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder,
|
||||||
menu::{IsMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu},
|
tray::{MouseButton, TrayIconBuilder, TrayIconEvent},
|
||||||
tray::{MouseButton, TrayIconBuilder, TrayIconEvent, TrayIconId},
|
window::{Effect, EffectsBuilder},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// 记录窗口最后显示时间,用于失焦防抖(避免显示瞬间因焦点未稳定而被立即隐藏)
|
||||||
|
static LAST_SHOW_TIME: Mutex<Option<Instant>> = Mutex::new(None);
|
||||||
|
|
||||||
|
use crate::clipboard::popup::{get_cursor_pos, get_work_area};
|
||||||
use crate::clipboard::ClipboardManager;
|
use crate::clipboard::ClipboardManager;
|
||||||
use crate::download_engine::DownloadEngine;
|
use crate::download_engine::DownloadEngine;
|
||||||
use crate::mihomo_manager::MihomoManager;
|
use crate::mihomo_manager::MihomoManager;
|
||||||
use crate::monitor_kernel::MonitorKernel;
|
use crate::monitor_kernel::MonitorKernel;
|
||||||
use crate::process_manager::{ProcessManager, ProcessStatus};
|
use crate::process_manager::{ProcessManager, ProcessStatus};
|
||||||
|
|
||||||
// ===== 全局状态:代理节点菜单项映射 =====
|
/// 托盘菜单窗口标签
|
||||||
|
pub const TRAY_MENU_LABEL: &str = "tray-menu";
|
||||||
|
|
||||||
/// menu_id → (group, node_name)
|
// ===== 状态数据结构(发送给前端) =====
|
||||||
static PROXY_NODE_MAP: OnceLock<Mutex<Vec<(String, String)>>> = OnceLock::new();
|
|
||||||
|
|
||||||
fn proxy_node_map() -> &'static Mutex<Vec<(String, String)>> {
|
#[derive(Serialize, Clone, Debug)]
|
||||||
PROXY_NODE_MAP.get_or_init(|| Mutex::new(Vec::new()))
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ProxyNodeInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub delay: Option<u32>,
|
||||||
|
pub is_current: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone, Debug)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct TrayMenuState {
|
||||||
|
pub proxy_running: bool,
|
||||||
|
pub monitor_running: bool,
|
||||||
|
pub proxy_group: Option<String>,
|
||||||
|
pub proxy_nodes: Vec<ProxyNodeInfo>,
|
||||||
|
pub proxy_current: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 伪节点过滤(与前端 ProxyModule 保持一致) =====
|
// ===== 伪节点过滤(与前端 ProxyModule 保持一致) =====
|
||||||
@@ -107,7 +114,6 @@ fn is_monitor_running(app: &AppHandle) -> bool {
|
|||||||
fn parse_main_group(proxies_response: &serde_json::Value) -> Option<(String, Vec<String>, String)> {
|
fn parse_main_group(proxies_response: &serde_json::Value) -> Option<(String, Vec<String>, String)> {
|
||||||
let proxies = proxies_response.get("proxies")?.as_object()?;
|
let proxies = proxies_response.get("proxies")?.as_object()?;
|
||||||
|
|
||||||
// 收集所有可选择组
|
|
||||||
let selector_groups: Vec<&str> = proxies
|
let selector_groups: Vec<&str> = proxies
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, v)| {
|
.filter(|(_, v)| {
|
||||||
@@ -120,7 +126,6 @@ fn parse_main_group(proxies_response: &serde_json::Value) -> Option<(String, Vec
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优先选择名为 PROXY / Proxy / 节点选择 / 代理 的组
|
|
||||||
let preferred = selector_groups
|
let preferred = selector_groups
|
||||||
.iter()
|
.iter()
|
||||||
.find(|name| matches!(**name, "PROXY" | "Proxy" | "节点选择" | "代理"))
|
.find(|name| matches!(**name, "PROXY" | "Proxy" | "节点选择" | "代理"))
|
||||||
@@ -156,9 +161,8 @@ fn parse_node_delay(proxies_response: &serde_json::Value, name: &str) -> Option<
|
|||||||
.map(|d| d as u32)
|
.map(|d| d as u32)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 菜单构建 =====
|
// ===== 获取代理节点数据(异步,调用 mihomo API) =====
|
||||||
|
|
||||||
/// 获取代理节点数据(异步,调用 mihomo API)
|
|
||||||
async fn fetch_proxy_nodes(app: &AppHandle) -> Option<(String, Vec<(String, Option<u32>)>, String)> {
|
async fn fetch_proxy_nodes(app: &AppHandle) -> Option<(String, Vec<(String, Option<u32>)>, String)> {
|
||||||
let mihomo = app.state::<MihomoManager>();
|
let mihomo = app.state::<MihomoManager>();
|
||||||
let proxies = mihomo.get_proxies().await.ok()?;
|
let proxies = mihomo.get_proxies().await.ok()?;
|
||||||
@@ -170,224 +174,226 @@ async fn fetch_proxy_nodes(app: &AppHandle) -> Option<(String, Vec<(String, Opti
|
|||||||
Some((group, node_delays, now))
|
Some((group, node_delays, now))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_menu(
|
// ===== 获取菜单状态 =====
|
||||||
app: &AppHandle,
|
|
||||||
proxy_running: bool,
|
|
||||||
monitor_running: bool,
|
|
||||||
proxy_nodes: Option<(String, Vec<(String, Option<u32>)>, String)>,
|
|
||||||
) -> Result<Menu<tauri::Wry>, tauri::Error> {
|
|
||||||
// ---------- 代理子菜单 ----------
|
|
||||||
let enable_proxy = MenuItem::with_id(
|
|
||||||
app,
|
|
||||||
"proxy_enable",
|
|
||||||
"开启代理",
|
|
||||||
!proxy_running,
|
|
||||||
None::<&str>,
|
|
||||||
)?;
|
|
||||||
let disable_proxy = MenuItem::with_id(
|
|
||||||
app,
|
|
||||||
"proxy_disable",
|
|
||||||
"关闭代理",
|
|
||||||
proxy_running,
|
|
||||||
None::<&str>,
|
|
||||||
)?;
|
|
||||||
let proxy_sep = PredefinedMenuItem::separator(app)?;
|
|
||||||
|
|
||||||
// 选择代理子菜单
|
pub async fn get_tray_menu_state(app: &AppHandle) -> TrayMenuState {
|
||||||
let (node_menu_items, node_sep) = build_proxy_select_items(app, proxy_running, &proxy_nodes)?;
|
|
||||||
|
|
||||||
// "刷新节点列表"项(仅代理运行时创建,提前声明以保证引用生命周期)
|
|
||||||
let refresh = if proxy_running {
|
|
||||||
Some(MenuItem::with_id(app, "proxy_refresh", "🔄 刷新节点列表", true, None::<&str>)?)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
// 组装选择代理子菜单
|
|
||||||
let mut select_refs: Vec<&dyn IsMenuItem<tauri::Wry>> = Vec::new();
|
|
||||||
if !proxy_running {
|
|
||||||
// 只有一个 hint 项
|
|
||||||
for item in &node_menu_items {
|
|
||||||
select_refs.push(item);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if let Some(ref r) = refresh {
|
|
||||||
select_refs.push(r);
|
|
||||||
}
|
|
||||||
if let Some(ref sep) = node_sep {
|
|
||||||
select_refs.push(sep);
|
|
||||||
}
|
|
||||||
for item in &node_menu_items {
|
|
||||||
select_refs.push(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let select_proxy = Submenu::with_items(app, "选择代理", proxy_running, &select_refs)?;
|
|
||||||
|
|
||||||
// 组装代理子菜单
|
|
||||||
let proxy_refs: Vec<&dyn IsMenuItem<tauri::Wry>> = vec![&enable_proxy, &disable_proxy, &proxy_sep, &select_proxy];
|
|
||||||
let proxy_submenu = Submenu::with_items(app, "代理", true, &proxy_refs)?;
|
|
||||||
|
|
||||||
// ---------- 分隔线 ----------
|
|
||||||
let sep1 = PredefinedMenuItem::separator(app)?;
|
|
||||||
|
|
||||||
// ---------- OSD + Kernel ----------
|
|
||||||
let osd_toggle = MenuItem::with_id(app, "osd_toggle", "切换 OSD", true, None::<&str>)?;
|
|
||||||
let kernel_restart = MenuItem::with_id(
|
|
||||||
app,
|
|
||||||
"kernel_restart",
|
|
||||||
"重启 Kernel",
|
|
||||||
monitor_running,
|
|
||||||
None::<&str>,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// ---------- 分隔线 ----------
|
|
||||||
let sep2 = PredefinedMenuItem::separator(app)?;
|
|
||||||
|
|
||||||
// ---------- 新建下载 ----------
|
|
||||||
let download_new = MenuItem::with_id(app, "download_new", "新建下载", true, None::<&str>)?;
|
|
||||||
|
|
||||||
// ---------- 分隔线 ----------
|
|
||||||
let sep3 = PredefinedMenuItem::separator(app)?;
|
|
||||||
|
|
||||||
// ---------- 系统菜单 ----------
|
|
||||||
let settings = MenuItem::with_id(app, "settings", "常规设置", true, None::<&str>)?;
|
|
||||||
let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
|
|
||||||
|
|
||||||
// ---------- 组装主菜单 ----------
|
|
||||||
let main_refs: Vec<&dyn IsMenuItem<tauri::Wry>> = vec![
|
|
||||||
&proxy_submenu,
|
|
||||||
&sep1,
|
|
||||||
&osd_toggle,
|
|
||||||
&kernel_restart,
|
|
||||||
&sep2,
|
|
||||||
&download_new,
|
|
||||||
&sep3,
|
|
||||||
&settings,
|
|
||||||
&quit,
|
|
||||||
];
|
|
||||||
Menu::with_items(app, &main_refs)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 构建代理节点菜单项(返回 MenuItem 列表 + 可选分隔线)
|
|
||||||
fn build_proxy_select_items(
|
|
||||||
app: &AppHandle,
|
|
||||||
proxy_running: bool,
|
|
||||||
proxy_nodes: &Option<(String, Vec<(String, Option<u32>)>, String)>,
|
|
||||||
) -> Result<(Vec<MenuItem<tauri::Wry>>, Option<PredefinedMenuItem<tauri::Wry>>), tauri::Error> {
|
|
||||||
if !proxy_running {
|
|
||||||
let hint = MenuItem::with_id(app, "proxy_noop", "代理未运行", false, None::<&str>)?;
|
|
||||||
return Ok((vec![hint], None));
|
|
||||||
}
|
|
||||||
|
|
||||||
let (group, nodes, now) = match proxy_nodes {
|
|
||||||
Some(data) => data,
|
|
||||||
None => {
|
|
||||||
let hint = MenuItem::with_id(app, "proxy_loading", "加载中…", false, None::<&str>)?;
|
|
||||||
return Ok((vec![hint], None));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if nodes.is_empty() {
|
|
||||||
let hint = MenuItem::with_id(app, "proxy_noop", "无可用节点", false, None::<&str>)?;
|
|
||||||
return Ok((vec![hint], None));
|
|
||||||
}
|
|
||||||
|
|
||||||
let sep = PredefinedMenuItem::separator(app)?;
|
|
||||||
|
|
||||||
// 清空并重建全局映射
|
|
||||||
{
|
|
||||||
let mut map = proxy_node_map().lock().unwrap();
|
|
||||||
map.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut items: Vec<MenuItem<tauri::Wry>> = Vec::new();
|
|
||||||
|
|
||||||
// 限制节点数量避免菜单过长
|
|
||||||
for (idx, (name, delay)) in nodes.iter().take(30).enumerate() {
|
|
||||||
let delay_text = match delay {
|
|
||||||
Some(0) => "(超时)".to_string(),
|
|
||||||
Some(d) => format!("({}ms)", d),
|
|
||||||
None => "(未测试)".to_string(),
|
|
||||||
};
|
|
||||||
let is_current = name == now;
|
|
||||||
let prefix = if is_current { "✓ " } else { " " };
|
|
||||||
let text = format!("{}{} {}", prefix, name, delay_text);
|
|
||||||
|
|
||||||
let menu_id = format!("proxy_node_{}", idx);
|
|
||||||
let item = MenuItem::with_id(app, &menu_id, &text, true, None::<&str>)?;
|
|
||||||
|
|
||||||
// 记录映射
|
|
||||||
{
|
|
||||||
let mut map = proxy_node_map().lock().unwrap();
|
|
||||||
map.push((group.clone(), name.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
items.push(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((items, Some(sep)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 异步菜单重建 =====
|
|
||||||
|
|
||||||
async fn rebuild_menu(app: &AppHandle) {
|
|
||||||
let proxy_running = is_proxy_running(app);
|
let proxy_running = is_proxy_running(app);
|
||||||
let monitor_running = is_monitor_running(app);
|
let monitor_running = is_monitor_running(app);
|
||||||
let proxy_nodes = if proxy_running {
|
|
||||||
fetch_proxy_nodes(app).await
|
let (proxy_group, proxy_nodes, proxy_current) = if proxy_running {
|
||||||
|
match fetch_proxy_nodes(app).await {
|
||||||
|
Some((group, nodes, now)) => {
|
||||||
|
let node_infos: Vec<ProxyNodeInfo> = nodes
|
||||||
|
.iter()
|
||||||
|
.map(|(name, delay)| ProxyNodeInfo {
|
||||||
|
name: name.clone(),
|
||||||
|
delay: *delay,
|
||||||
|
is_current: name == &now,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
(Some(group), node_infos, if now.is_empty() { None } else { Some(now) })
|
||||||
|
}
|
||||||
|
None => (None, vec![], None),
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
None
|
(None, vec![], None)
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Ok(menu) = build_menu(app, proxy_running, monitor_running, proxy_nodes) {
|
TrayMenuState {
|
||||||
if let Some(tray) = app.tray_by_id(&TrayIconId::new("main")) {
|
proxy_running,
|
||||||
let _ = tray.set_menu(Some(menu));
|
monitor_running,
|
||||||
}
|
proxy_group,
|
||||||
|
proxy_nodes,
|
||||||
|
proxy_current,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 菜单事件处理 =====
|
// ===== 显示/隐藏托盘菜单窗口 =====
|
||||||
|
|
||||||
fn handle_menu_event(app: &AppHandle, event: tauri::menu::MenuEvent) {
|
/// 托盘菜单窗口尺寸(逻辑像素)
|
||||||
let id = event.id().as_ref().to_string();
|
const MENU_W: f64 = 260.0;
|
||||||
match id.as_str() {
|
const MENU_H: f64 = 420.0;
|
||||||
"proxy_enable" => {
|
|
||||||
let app = app.clone();
|
/// 预创建隐藏的托盘菜单窗口(在应用启动时调用)。
|
||||||
|
/// 窗口隐藏渲染,前端 onMounted 应用主题后保持隐藏状态,右键托盘时直接 show 即无延迟。
|
||||||
|
pub fn precreate_tray_menu_window(app: &AppHandle) {
|
||||||
|
if app.get_webview_window(TRAY_MENU_LABEL).is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认放在屏幕右下角(托盘常见位置附近),首次显示时会重新定位
|
||||||
|
let (screen_w, screen_h) = get_work_area().unwrap_or((1920.0, 1080.0));
|
||||||
|
let x = (screen_w as f64) - MENU_W - 20.0;
|
||||||
|
let y = (screen_h as f64) - MENU_H - 60.0;
|
||||||
|
|
||||||
|
let win = match WebviewWindowBuilder::new(
|
||||||
|
app,
|
||||||
|
TRAY_MENU_LABEL,
|
||||||
|
WebviewUrl::App("index.html#tray-menu".into()),
|
||||||
|
)
|
||||||
|
.title("Thing 菜单")
|
||||||
|
.inner_size(MENU_W, MENU_H)
|
||||||
|
.position(x, y)
|
||||||
|
.decorations(false)
|
||||||
|
.transparent(true)
|
||||||
|
.shadow(true)
|
||||||
|
.always_on_top(true)
|
||||||
|
.skip_taskbar(true)
|
||||||
|
.resizable(false)
|
||||||
|
.visible(false)
|
||||||
|
.focused(false)
|
||||||
|
.effects(EffectsBuilder::new().effects(vec![Effect::Mica]).build())
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(w) => w,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[tray-menu] 预创建菜单窗口失败: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听窗口失焦:自动隐藏(带 300ms 防抖,避免显示瞬间焦点未稳定而被立即隐藏)
|
||||||
|
let win_handle = win.clone();
|
||||||
|
win.on_window_event(move |event| {
|
||||||
|
if let tauri::WindowEvent::Focused(false) = event {
|
||||||
|
let should_hide = {
|
||||||
|
let t = LAST_SHOW_TIME.lock().unwrap();
|
||||||
|
match *t {
|
||||||
|
Some(time) => time.elapsed() > Duration::from_millis(300),
|
||||||
|
None => true,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if should_hide {
|
||||||
|
let _ = win_handle.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
eprintln!("[tray-menu] 菜单窗口已预创建(隐藏渲染)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在鼠标当前位置显示托盘菜单窗口。
|
||||||
|
/// 窗口不存在则创建并显示,已存在则移动到鼠标位置并显示。
|
||||||
|
pub fn show_tray_menu(app: &AppHandle) {
|
||||||
|
let (mx, my) = match get_cursor_pos() {
|
||||||
|
Some(p) => p,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
// GetCursorPos 在 Per-Monitor DPI V2 下返回物理像素,直接用作 PhysicalPosition
|
||||||
|
let win = app.get_webview_window(TRAY_MENU_LABEL);
|
||||||
|
let scale = win.as_ref()
|
||||||
|
.and_then(|w| w.scale_factor().ok())
|
||||||
|
.unwrap_or(1.0);
|
||||||
|
|
||||||
|
let w_phys = (MENU_W * scale) as i32;
|
||||||
|
let h_phys = (MENU_H * scale) as i32;
|
||||||
|
|
||||||
|
// 托盘位于屏幕右下角,菜单始终出现在鼠标左上方
|
||||||
|
let x = (mx - w_phys).max(0);
|
||||||
|
let y = (my - h_phys - 8).max(0);
|
||||||
|
|
||||||
|
// 记录显示时间,用于失焦防抖
|
||||||
|
{
|
||||||
|
let mut t = LAST_SHOW_TIME.lock().unwrap();
|
||||||
|
*t = Some(Instant::now());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 窗口已存在(预创建或之前显示过):移动 + 显示 + 发送状态
|
||||||
|
if let Some(win) = win {
|
||||||
|
let _ = win.set_position(tauri::Position::Physical(tauri::PhysicalPosition { x, y }));
|
||||||
|
let _ = win.show();
|
||||||
|
let _ = win.set_focus();
|
||||||
|
let app_clone = app.clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let state = get_tray_menu_state(&app_clone).await;
|
||||||
|
let _ = app_clone.emit("tray-menu-show", state);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底:窗口未预创建(理论上不会走到,因为 create_tray_menu 已预创建)
|
||||||
|
precreate_tray_menu_window(app);
|
||||||
|
if let Some(win) = app.get_webview_window(TRAY_MENU_LABEL) {
|
||||||
|
let _ = win.set_position(tauri::Position::Physical(tauri::PhysicalPosition { x, y }));
|
||||||
|
let _ = win.show();
|
||||||
|
let _ = win.set_focus();
|
||||||
|
let app_clone = app.clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let state = get_tray_menu_state(&app_clone).await;
|
||||||
|
let _ = app_clone.emit("tray-menu-show", state);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预创建窗口完成后由前端调用(仅触发状态推送,不显示窗口——预创建模式下窗口保持隐藏)。
|
||||||
|
pub fn show_window(app: &AppHandle) {
|
||||||
|
if app.get_webview_window(TRAY_MENU_LABEL).is_some() {
|
||||||
|
let app_clone = app.clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let state = get_tray_menu_state(&app_clone).await;
|
||||||
|
let _ = app_clone.emit("tray-menu-show", state);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 隐藏托盘菜单窗口(不销毁,保留复用)
|
||||||
|
pub fn hide_tray_menu(app: &AppHandle) {
|
||||||
|
if let Some(win) = app.get_webview_window(TRAY_MENU_LABEL) {
|
||||||
|
let _ = win.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 刷新菜单状态并发送给前端
|
||||||
|
async fn refresh_and_emit_state(app: &AppHandle) {
|
||||||
|
let state = get_tray_menu_state(app).await;
|
||||||
|
let _ = app.emit("tray-menu-state-updated", state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Tauri 命令 =====
|
||||||
|
|
||||||
|
/// 执行菜单项动作(统一入口)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn tray_menu_action(
|
||||||
|
action: String,
|
||||||
|
payload: Option<serde_json::Value>,
|
||||||
|
app: AppHandle,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
match action.as_str() {
|
||||||
|
"proxy_enable" => {
|
||||||
if let Err(e) = enable_proxy(&app).await {
|
if let Err(e) = enable_proxy(&app).await {
|
||||||
eprintln!("[tray] 开启代理失败: {}", e);
|
eprintln!("[tray] 开启代理失败: {}", e);
|
||||||
send_notification("代理启动失败", &e);
|
send_notification(&app, "代理启动失败", &e);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
"proxy_disable" => {
|
"proxy_disable" => {
|
||||||
let app = app.clone();
|
|
||||||
tauri::async_runtime::spawn(async move {
|
|
||||||
if let Err(e) = disable_proxy(&app).await {
|
if let Err(e) = disable_proxy(&app).await {
|
||||||
eprintln!("[tray] 关闭代理失败: {}", e);
|
eprintln!("[tray] 关闭代理失败: {}", e);
|
||||||
send_notification("代理关闭失败", &e);
|
send_notification(&app, "代理关闭失败", &e);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
"proxy_refresh" => {
|
"proxy_refresh" => {
|
||||||
let app = app.clone();
|
test_all_delays(&app).await;
|
||||||
tauri::async_runtime::spawn(async move {
|
}
|
||||||
// 先重建菜单(保留旧数据),然后并行测试延迟并再次重建
|
"proxy_select_node" => {
|
||||||
rebuild_menu(&app).await;
|
if let Some(name) = payload
|
||||||
test_all_delays_and_rebuild(&app).await;
|
.as_ref()
|
||||||
});
|
.and_then(|p| p.get("name"))
|
||||||
|
.and_then(|n| n.as_str())
|
||||||
|
{
|
||||||
|
if let Some(group) = get_proxy_group(&app).await {
|
||||||
|
select_proxy_node(&app, &group, name).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"osd_toggle" => {
|
"osd_toggle" => {
|
||||||
let _ = app.emit("tray:toggle-osd", ());
|
let _ = app.emit("tray:toggle-osd", ());
|
||||||
}
|
}
|
||||||
"kernel_restart" => {
|
"kernel_restart" => {
|
||||||
let app = app.clone();
|
|
||||||
tauri::async_runtime::spawn(async move {
|
|
||||||
if let Err(e) = restart_kernel(&app).await {
|
if let Err(e) = restart_kernel(&app).await {
|
||||||
eprintln!("[tray] 重启 Kernel 失败: {}", e);
|
eprintln!("[tray] 重启 Kernel 失败: {}", e);
|
||||||
send_notification("Kernel 重启失败", &e);
|
send_notification(&app, "Kernel 重启失败", &e);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
"download_new" => {
|
"download_new" => {
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
@@ -395,6 +401,7 @@ fn handle_menu_event(app: &AppHandle, event: tauri::menu::MenuEvent) {
|
|||||||
window.set_focus().ok();
|
window.set_focus().ok();
|
||||||
}
|
}
|
||||||
let _ = app.emit("tray:new-download", ());
|
let _ = app.emit("tray:new-download", ());
|
||||||
|
hide_tray_menu(&app);
|
||||||
}
|
}
|
||||||
"settings" => {
|
"settings" => {
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
@@ -402,58 +409,43 @@ fn handle_menu_event(app: &AppHandle, event: tauri::menu::MenuEvent) {
|
|||||||
window.set_focus().ok();
|
window.set_focus().ok();
|
||||||
}
|
}
|
||||||
let _ = app.emit("tray:open-settings", ());
|
let _ = app.emit("tray:open-settings", ());
|
||||||
|
hide_tray_menu(&app);
|
||||||
}
|
}
|
||||||
"quit" => {
|
"quit" => {
|
||||||
quit_cleanup(app);
|
hide_tray_menu(&app);
|
||||||
}
|
quit_cleanup(&app);
|
||||||
id_str if id_str.starts_with("proxy_node_") => {
|
|
||||||
let idx: usize = id_str
|
|
||||||
.strip_prefix("proxy_node_")
|
|
||||||
.and_then(|s| s.parse().ok())
|
|
||||||
.unwrap_or(usize::MAX);
|
|
||||||
let app = app.clone();
|
|
||||||
tauri::async_runtime::spawn(async move {
|
|
||||||
if let Some((group, name)) = group_name_from_map(idx) {
|
|
||||||
select_proxy_node(&app, &group, &name).await;
|
|
||||||
} else {
|
|
||||||
eprintln!("[tray] 节点索引无效: {}", idx);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 刷新状态并发送给前端(quit 除外,quit 后进程已退出)
|
||||||
|
if action != "quit" {
|
||||||
|
refresh_and_emit_state(&app).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从全局映射中获取 (group, name)
|
/// 隐藏托盘菜单窗口(前端调用)
|
||||||
fn group_name_from_map(idx: usize) -> Option<(String, String)> {
|
#[tauri::command]
|
||||||
let map = proxy_node_map().lock().unwrap();
|
pub async fn tray_menu_hide(app: AppHandle) -> Result<(), String> {
|
||||||
map.get(idx).cloned()
|
hide_tray_menu(&app);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 选择代理节点并测试延迟
|
/// 显示已创建的菜单窗口(前端 onMounted 后调用)
|
||||||
async fn select_proxy_node(app: &AppHandle, group: &str, name: &str) {
|
#[tauri::command]
|
||||||
|
pub async fn tray_menu_show_window(app: AppHandle) -> Result<(), String> {
|
||||||
|
show_window(&app);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取当前代理组名
|
||||||
|
async fn get_proxy_group(app: &AppHandle) -> Option<String> {
|
||||||
let mihomo = app.state::<MihomoManager>();
|
let mihomo = app.state::<MihomoManager>();
|
||||||
match mihomo.select_proxy(group, name).await {
|
let proxies = mihomo.get_proxies().await.ok()?;
|
||||||
Ok(()) => {
|
let (group, _, _) = parse_main_group(&proxies)?;
|
||||||
// 测试延迟
|
Some(group)
|
||||||
let delay = mihomo
|
|
||||||
.test_delay(name, "https://www.gstatic.com/generate_204", 5000)
|
|
||||||
.await
|
|
||||||
.ok();
|
|
||||||
let delay_text = match delay {
|
|
||||||
Some(0) => "超时".to_string(),
|
|
||||||
Some(d) => format!("{}ms", d),
|
|
||||||
None => "未测速".to_string(),
|
|
||||||
};
|
|
||||||
send_notification("节点已切换", &format!("{}\n延迟: {}", name, delay_text));
|
|
||||||
// 重建菜单
|
|
||||||
rebuild_menu(app).await;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("[tray] 切换节点失败: {}", e);
|
|
||||||
send_notification("切换节点失败", &e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 代理开启/关闭 =====
|
// ===== 代理开启/关闭 =====
|
||||||
@@ -485,24 +477,22 @@ async fn enable_proxy(app: &AppHandle) -> Result<(), String> {
|
|||||||
match &best {
|
match &best {
|
||||||
Some((name, delay)) => {
|
Some((name, delay)) => {
|
||||||
send_notification(
|
send_notification(
|
||||||
|
app,
|
||||||
"代理已开启",
|
"代理已开启",
|
||||||
&format!("当前节点: {} ({}ms)", name, delay),
|
&format!("当前节点: {} ({}ms)", name, delay),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
send_notification("代理已开启", "所有节点均超时,未自动选择");
|
send_notification(app, "代理已开启", "所有节点均超时,未自动选择");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
send_notification("代理已开启", "无可用节点");
|
send_notification(app, "代理已开启", "无可用节点");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 开启系统代理
|
// 5. 开启系统代理
|
||||||
mihomo.enable_system_proxy()?;
|
mihomo.enable_system_proxy()?;
|
||||||
|
|
||||||
// 6. 重建菜单
|
|
||||||
rebuild_menu(app).await;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,10 +506,7 @@ async fn disable_proxy(app: &AppHandle) -> Result<(), String> {
|
|||||||
// 停止 mihomo
|
// 停止 mihomo
|
||||||
pm.stop("proxy")?;
|
pm.stop("proxy")?;
|
||||||
|
|
||||||
send_notification("代理已关闭", "mihomo 已停止,系统代理已清除");
|
send_notification(app, "代理已关闭", "mihomo 已停止,系统代理已清除");
|
||||||
|
|
||||||
// 重建菜单
|
|
||||||
rebuild_menu(app).await;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -547,14 +534,12 @@ async fn test_and_select_best(
|
|||||||
|
|
||||||
let results = join_all(futures).await;
|
let results = join_all(futures).await;
|
||||||
|
|
||||||
// 找出有效延迟中最低的
|
|
||||||
let best = results
|
let best = results
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|(name, delay)| delay.map(|d| (name, d)))
|
.filter_map(|(name, delay)| delay.map(|d| (name, d)))
|
||||||
.filter(|(_, d)| *d > 0)
|
.filter(|(_, d)| *d > 0)
|
||||||
.min_by_key(|(_, d)| *d);
|
.min_by_key(|(_, d)| *d);
|
||||||
|
|
||||||
// 选择最优节点
|
|
||||||
if let Some((ref name, _)) = best {
|
if let Some((ref name, _)) = best {
|
||||||
let _ = mihomo.select_proxy(group, name).await;
|
let _ = mihomo.select_proxy(group, name).await;
|
||||||
}
|
}
|
||||||
@@ -562,21 +547,18 @@ async fn test_and_select_best(
|
|||||||
best
|
best
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试所有节点延迟后重建菜单
|
/// 并行测试所有节点延迟(更新 mihomo 内部 history)
|
||||||
async fn test_all_delays_and_rebuild(app: &AppHandle) {
|
async fn test_all_delays(app: &AppHandle) {
|
||||||
let mihomo = app.state::<MihomoManager>();
|
let mihomo = app.state::<MihomoManager>();
|
||||||
let proxies = match mihomo.get_proxies().await {
|
let proxies = match mihomo.get_proxies().await {
|
||||||
Ok(p) => p,
|
Ok(p) => p,
|
||||||
Err(_) => {
|
Err(_) => return,
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let (_group, nodes, _) = match parse_main_group(&proxies) {
|
let (_group, nodes, _now) = match parse_main_group(&proxies) {
|
||||||
Some(g) => g,
|
Some(g) => g,
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 并行测试延迟(更新 mihomo 内部 history)
|
|
||||||
use futures_util::future::join_all;
|
use futures_util::future::join_all;
|
||||||
let futures: Vec<_> = nodes
|
let futures: Vec<_> = nodes
|
||||||
.iter()
|
.iter()
|
||||||
@@ -587,9 +569,30 @@ async fn test_all_delays_and_rebuild(app: &AppHandle) {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
join_all(futures).await;
|
join_all(futures).await;
|
||||||
|
}
|
||||||
|
|
||||||
// 再次获取 proxies(此时 history 已更新)并重建菜单
|
// ===== 选择代理节点并测试延迟 =====
|
||||||
rebuild_menu(app).await;
|
|
||||||
|
async fn select_proxy_node(app: &AppHandle, group: &str, name: &str) {
|
||||||
|
let mihomo = app.state::<MihomoManager>();
|
||||||
|
match mihomo.select_proxy(group, name).await {
|
||||||
|
Ok(()) => {
|
||||||
|
let delay = mihomo
|
||||||
|
.test_delay(name, "https://www.gstatic.com/generate_204", 5000)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
let delay_text = match delay {
|
||||||
|
Some(0) => "超时".to_string(),
|
||||||
|
Some(d) => format!("{}ms", d),
|
||||||
|
None => "未测速".to_string(),
|
||||||
|
};
|
||||||
|
send_notification(app, "节点已切换", &format!("{}\n延迟: {}", name, delay_text));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[tray] 切换节点失败: {}", e);
|
||||||
|
send_notification(app, "切换节点失败", &e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 等待 mihomo API 就绪 =====
|
// ===== 等待 mihomo API 就绪 =====
|
||||||
@@ -621,103 +624,25 @@ async fn restart_kernel(app: &AppHandle) -> Result<(), String> {
|
|||||||
// 重新启动
|
// 重新启动
|
||||||
monitor.start_with_subscription(app).await?;
|
monitor.start_with_subscription(app).await?;
|
||||||
|
|
||||||
send_notification("Kernel 已重启", "ThingHK 监控内核已重新启动");
|
send_notification(app, "Kernel 已重启", "ThingHK 监控内核已重新启动");
|
||||||
rebuild_menu(app).await;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Windows 通知 =====
|
// ===== Windows 通知 =====
|
||||||
|
|
||||||
#[cfg(windows)]
|
fn send_notification(app: &AppHandle, title: &str, message: &str) {
|
||||||
fn send_notification(title: &str, message: &str) {
|
use tauri_plugin_notification::NotificationExt;
|
||||||
use std::os::windows::process::CommandExt;
|
let _ = app
|
||||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
.notification()
|
||||||
|
.builder()
|
||||||
// 转义 PowerShell 单引号
|
.title(title)
|
||||||
let title_escaped = title.replace('\'', "''");
|
.body(message)
|
||||||
let msg_escaped = message.replace('\'', "''").replace('\n', "`n");
|
.show();
|
||||||
|
|
||||||
let script = format!(
|
|
||||||
"[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms')|Out-Null;\
|
|
||||||
$n=New-Object System.Windows.Forms.NotifyIcon;\
|
|
||||||
$n.Icon=[System.Drawing.SystemIcons]::Information;\
|
|
||||||
$n.Visible=$true;\
|
|
||||||
$n.BalloonTipTitle='{}';\
|
|
||||||
$n.BalloonTipText='{}';\
|
|
||||||
$n.ShowBalloonTip(5000);\
|
|
||||||
Start-Sleep -Seconds 6;\
|
|
||||||
$n.Dispose()",
|
|
||||||
title_escaped, msg_escaped
|
|
||||||
);
|
|
||||||
|
|
||||||
let _ = std::process::Command::new("powershell")
|
|
||||||
.args([
|
|
||||||
"-NoProfile",
|
|
||||||
"-NonInteractive",
|
|
||||||
"-WindowStyle",
|
|
||||||
"Hidden",
|
|
||||||
"-Command",
|
|
||||||
&script,
|
|
||||||
])
|
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
|
||||||
.spawn();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(windows))]
|
// ===== 退出清理 =====
|
||||||
fn send_notification(_title: &str, _message: &str) {
|
|
||||||
// 非 Windows 平台不显示通知
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 创建托盘 =====
|
|
||||||
|
|
||||||
/// 创建自定义托盘菜单(在 setup 中调用)
|
|
||||||
pub fn create_tray_menu(app: &AppHandle) -> Result<(), tauri::Error> {
|
|
||||||
let proxy_running = is_proxy_running(app);
|
|
||||||
let monitor_running = is_monitor_running(app);
|
|
||||||
|
|
||||||
// 初始菜单(不含代理节点,异步获取后替换)
|
|
||||||
let menu = build_menu(app, proxy_running, monitor_running, None)?;
|
|
||||||
|
|
||||||
let _tray = TrayIconBuilder::with_id("main")
|
|
||||||
.icon(app.default_window_icon().unwrap().clone())
|
|
||||||
.tooltip("Thing")
|
|
||||||
.menu(&menu)
|
|
||||||
.show_menu_on_left_click(false)
|
|
||||||
.on_menu_event(handle_menu_event)
|
|
||||||
.on_tray_icon_event(|tray, event| {
|
|
||||||
let app = tray.app_handle().clone();
|
|
||||||
match event {
|
|
||||||
TrayIconEvent::Click {
|
|
||||||
button: MouseButton::Left,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
|
||||||
window.show().ok();
|
|
||||||
window.set_focus().ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TrayIconEvent::Enter { .. } => {
|
|
||||||
// 鼠标进入托盘区域时预刷新菜单
|
|
||||||
tauri::async_runtime::spawn(async move {
|
|
||||||
rebuild_menu(&app).await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.build(app)?;
|
|
||||||
|
|
||||||
// 异步获取代理节点并重建菜单
|
|
||||||
let app_clone = app.clone();
|
|
||||||
tauri::async_runtime::spawn(async move {
|
|
||||||
rebuild_menu(&app_clone).await;
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 退出应用时调用(与 quit_app 命令共享清理逻辑)
|
|
||||||
pub fn quit_cleanup(app: &AppHandle) {
|
pub fn quit_cleanup(app: &AppHandle) {
|
||||||
if let Some(mihomo) = app.try_state::<MihomoManager>() {
|
if let Some(mihomo) = app.try_state::<MihomoManager>() {
|
||||||
mihomo.cleanup_on_exit();
|
mihomo.cleanup_on_exit();
|
||||||
@@ -725,8 +650,18 @@ pub fn quit_cleanup(app: &AppHandle) {
|
|||||||
if let Some(engine) = app.try_state::<DownloadEngine>() {
|
if let Some(engine) = app.try_state::<DownloadEngine>() {
|
||||||
engine.cleanup_on_exit();
|
engine.cleanup_on_exit();
|
||||||
}
|
}
|
||||||
|
// monitor.cleanup_on_exit 是 async,在 tokio worker 线程中直接 block_on 会 panic,
|
||||||
|
// 放到独立 OS 线程执行 block_on,避免嵌套 runtime。
|
||||||
if let Some(monitor) = app.try_state::<MonitorKernel>() {
|
if let Some(monitor) = app.try_state::<MonitorKernel>() {
|
||||||
tauri::async_runtime::block_on(monitor.cleanup_on_exit(app));
|
let app_clone = app.clone();
|
||||||
|
let monitor_clone = monitor.inner().clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
tauri::async_runtime::block_on(async move {
|
||||||
|
monitor_clone.cleanup_on_exit(&app_clone).await;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.join()
|
||||||
|
.ok();
|
||||||
}
|
}
|
||||||
if let Some(clip) = app.try_state::<ClipboardManager>() {
|
if let Some(clip) = app.try_state::<ClipboardManager>() {
|
||||||
clip.stop();
|
clip.stop();
|
||||||
@@ -736,3 +671,42 @@ pub fn quit_cleanup(app: &AppHandle) {
|
|||||||
}
|
}
|
||||||
app.exit(0);
|
app.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 创建托盘 =====
|
||||||
|
|
||||||
|
/// 创建自定义托盘(在 setup 中调用)
|
||||||
|
/// 不使用原生菜单,改用 WebviewWindow 弹窗实现自定义风格。
|
||||||
|
pub fn create_tray_menu(app: &AppHandle) -> Result<(), tauri::Error> {
|
||||||
|
let _tray = TrayIconBuilder::with_id("main")
|
||||||
|
.icon(app.default_window_icon().unwrap().clone())
|
||||||
|
.tooltip("Thing")
|
||||||
|
.on_tray_icon_event(|tray, event| {
|
||||||
|
let app = tray.app_handle().clone();
|
||||||
|
match event {
|
||||||
|
TrayIconEvent::Click {
|
||||||
|
button: MouseButton::Left,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
// 左键:显示主窗口
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
window.show().ok();
|
||||||
|
window.set_focus().ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TrayIconEvent::Click {
|
||||||
|
button: MouseButton::Right,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
// 右键:显示自定义菜单窗口
|
||||||
|
show_tray_menu(&app);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build(app)?;
|
||||||
|
|
||||||
|
// 预创建隐藏的菜单窗口,让前端提前渲染主题和内容,右键时直接 show 无延迟
|
||||||
|
precreate_tray_menu_window(app);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
+7
-1
@@ -16,7 +16,7 @@ window.addEventListener('unhandledrejection', (event) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// ===== OSD 窗口模式检测 =====
|
// ===== OSD 窗口模式检测 =====
|
||||||
// 通过 URL hash 识别:#osd-overlay(悬浮窗)/ #clipboard-popup(剪贴板快捷弹窗)
|
// 通过 URL hash 识别:#osd-overlay(悬浮窗)/ #clipboard-popup(剪贴板快捷弹窗)/ #tray-menu(托盘菜单)
|
||||||
// 这些窗口是精简的独立 Vue 应用,不加载主应用的 store 和模块
|
// 这些窗口是精简的独立 Vue 应用,不加载主应用的 store 和模块
|
||||||
const winHash = window.location.hash
|
const winHash = window.location.hash
|
||||||
if (winHash === '#osd-overlay') {
|
if (winHash === '#osd-overlay') {
|
||||||
@@ -31,6 +31,12 @@ if (winHash === '#osd-overlay') {
|
|||||||
const app = createApp(ClipboardPopup)
|
const app = createApp(ClipboardPopup)
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
})
|
})
|
||||||
|
} else if (winHash === '#tray-menu') {
|
||||||
|
logger.info(`托盘菜单窗口启动: ${winHash}`)
|
||||||
|
void import('./modules/tray/TrayMenu.vue').then(({ default: TrayMenu }) => {
|
||||||
|
const app = createApp(TrayMenu)
|
||||||
|
app.mount('#app')
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
// ===== 主应用模式 =====
|
// ===== 主应用模式 =====
|
||||||
void import('./App.vue').then(async ({ default: App }) => {
|
void import('./App.vue').then(async ({ default: App }) => {
|
||||||
|
|||||||
@@ -44,20 +44,6 @@ const tabsListRef = useModuleTabs(activeTab, [
|
|||||||
{ value: 'settings', label: '设置' },
|
{ value: 'settings', label: '设置' },
|
||||||
])
|
])
|
||||||
|
|
||||||
// ===== 关键指标历史 buffer(用于概览页 sparkline,不引入外部图表库) =====
|
|
||||||
// 容量 30 点,约对应 30 秒(fast 通道 1s 推送一次)
|
|
||||||
const HISTORY_LEN = 30
|
|
||||||
const cpuTempHistory = ref<number[]>([])
|
|
||||||
const gpuTempHistory = ref<number[]>([])
|
|
||||||
const netDownHistory = ref<number[]>([])
|
|
||||||
const netUpHistory = ref<number[]>([])
|
|
||||||
|
|
||||||
function pushHistory(buf: typeof cpuTempHistory, v: number | null) {
|
|
||||||
if (v == null || !isFinite(v)) return
|
|
||||||
buf.value.push(v)
|
|
||||||
if (buf.value.length > HISTORY_LEN) buf.value.shift()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 关键指标 computed =====
|
// ===== 关键指标 computed =====
|
||||||
|
|
||||||
/** GPU 分组 id(兼容 intel/amd/nvidia) */
|
/** GPU 分组 id(兼容 intel/amd/nvidia) */
|
||||||
@@ -69,9 +55,34 @@ const gpuGroupId = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const cpuModel = computed(() => store.groupById['cpu']?.sensors[0]?.hardwareName ?? null)
|
const cpuModel = computed(() => store.groupById['cpu']?.sensors[0]?.hardwareName ?? null)
|
||||||
const cpuTemp = computed(() => store.findSensorValue('cpu', { name: 'CPU Package', type: 'temperature' }))
|
|
||||||
|
/** 取首个 >0 的传感器值:用于过滤 AMD Ryzen 上 LHB 返回 0 的无效读数(温度/功耗/时钟为 0 不可信) */
|
||||||
|
function findPositiveValue(groupId: string, matchers: Array<{ name?: string; hardwareName?: string; type?: string }>): number | null {
|
||||||
|
for (const m of matchers) {
|
||||||
|
const v = store.findSensorValue(groupId, m)
|
||||||
|
if (v != null && v > 0 && isFinite(v)) return v
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// CPU 温度:Intel 命名 "CPU Package",AMD Ryzen 命名 "Core (Tctl/Tdie)";
|
||||||
|
// LHB 0.9.5 对 Ryzen 9000 系列支持不全,可能返回 0,用 >0 守卫过滤。
|
||||||
|
const cpuTemp = computed(() =>
|
||||||
|
findPositiveValue('cpu', [
|
||||||
|
{ name: 'CPU Package', type: 'temperature' }, // Intel
|
||||||
|
{ name: 'Core (Tctl/Tdie)', type: 'temperature' }, // AMD Ryzen
|
||||||
|
{ type: 'temperature' }, // 兜底:任意温度
|
||||||
|
])
|
||||||
|
)
|
||||||
const cpuLoad = computed(() => store.findSensorValue('cpu', { name: 'CPU Total', type: 'load' }))
|
const cpuLoad = computed(() => store.findSensorValue('cpu', { name: 'CPU Total', type: 'load' }))
|
||||||
const cpuPower = computed(() => store.findSensorValue('cpu', { name: 'CPU Package', type: 'power' }))
|
// CPU 功耗:Intel "CPU Package",AMD Ryzen "Package"
|
||||||
|
const cpuPower = computed(() =>
|
||||||
|
findPositiveValue('cpu', [
|
||||||
|
{ name: 'CPU Package', type: 'power' }, // Intel
|
||||||
|
{ name: 'Package', type: 'power' }, // AMD Ryzen
|
||||||
|
{ type: 'power' }, // 兜底:任意功耗
|
||||||
|
])
|
||||||
|
)
|
||||||
|
|
||||||
const gpuModel = computed(() => {
|
const gpuModel = computed(() => {
|
||||||
const gid = gpuGroupId.value
|
const gid = gpuGroupId.value
|
||||||
@@ -121,7 +132,10 @@ const memModuleModels = computed(() => {
|
|||||||
return Array.from(models)
|
return Array.from(models)
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 存储硬盘列表(按 hardwareName 分组,提取温度/使用率/容量) */
|
/** 存储硬盘列表(按 hardwareName 分组,提取温度/使用率/容量)
|
||||||
|
* LHB 0.9.5 已知 bug:MBR + XINT13 Extended 分区布局下,Free Space 可能返回
|
||||||
|
* ≈ 2^64/1e9 GB(无符号回绕),Used Space load 也可能为巨大负值。
|
||||||
|
* 这里对 LHB 原始值做有效性校验,异常时降级为 null(UI 显示 "--")。 */
|
||||||
interface StorageDrive {
|
interface StorageDrive {
|
||||||
name: string
|
name: string
|
||||||
temp: number | null
|
temp: number | null
|
||||||
@@ -139,28 +153,34 @@ const storageDrives = computed<StorageDrive[]>(() => {
|
|||||||
}
|
}
|
||||||
return Array.from(byHw.entries()).map(([hw, sensors]) => {
|
return Array.from(byHw.entries()).map(([hw, sensors]) => {
|
||||||
const temp = sensors.find(s => s.type === 'temperature')?.value ?? null
|
const temp = sensors.find(s => s.type === 'temperature')?.value ?? null
|
||||||
const usedPct = sensors.find(s => s.name === 'Used Space')?.value ?? null
|
// Used Space 是 load 百分比,理论上 [0,100];LHB 在 MBR+Extended 布局下可能返回 -922153000% 等
|
||||||
const totalGB = sensors.find(s => s.name === 'Total Space')?.value ?? null
|
const rawUsedPct = sensors.find(s => s.name === 'Used Space' && s.type === 'load')?.value ?? null
|
||||||
const freeGB = sensors.find(s => s.name === 'Free Space')?.value ?? null
|
const usedPct = rawUsedPct != null && isFinite(rawUsedPct) && rawUsedPct >= 0 && rawUsedPct <= 100
|
||||||
const usedGB = totalGB != null && freeGB != null ? totalGB - freeGB : null
|
? rawUsedPct
|
||||||
|
: null
|
||||||
|
// Total Space 在 LHB 中通常正确(即便 Free Space 异常)
|
||||||
|
const rawTotal = sensors.find(s => s.name === 'Total Space' && s.type === 'data')?.value ?? null
|
||||||
|
const totalGB = rawTotal != null && isFinite(rawTotal) && rawTotal > 0 && rawTotal < 1e6
|
||||||
|
? rawTotal
|
||||||
|
: null
|
||||||
|
// Free Space 在 MBR+Extended 布局下可能返回 ≈ 1.8446744e10 GB(UINT64_MAX / 1e9)
|
||||||
|
const rawFree = sensors.find(s => s.name === 'Free Space' && s.type === 'data')?.value ?? null
|
||||||
|
const freeGB = rawFree != null && isFinite(rawFree) && rawFree >= 0 && totalGB != null && rawFree <= totalGB
|
||||||
|
? rawFree
|
||||||
|
: null
|
||||||
|
// 优先用 total - free 计算 usedGB;free 异常时回退用 usedPct * total / 100
|
||||||
|
let usedGB: number | null = null
|
||||||
|
if (totalGB != null) {
|
||||||
|
if (freeGB != null) {
|
||||||
|
usedGB = totalGB - freeGB
|
||||||
|
} else if (usedPct != null) {
|
||||||
|
usedGB = totalGB * usedPct / 100
|
||||||
|
}
|
||||||
|
}
|
||||||
return { name: hw, temp, usedPct, totalGB, usedGB }
|
return { name: hw, temp, usedPct, totalGB, usedGB }
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// 监听快照变化,提取温度历史(用于 CPU/GPU 温度 sparkline)
|
|
||||||
watch(() => store.snapshot, () => {
|
|
||||||
pushHistory(cpuTempHistory, cpuTemp.value)
|
|
||||||
pushHistory(gpuTempHistory, gpuTemp.value)
|
|
||||||
}, { deep: false })
|
|
||||||
|
|
||||||
// 监听网速事件,记录历史(用于网速 sparkline)
|
|
||||||
watch(() => store.networkSpeed, (v) => {
|
|
||||||
if (v) {
|
|
||||||
pushHistory(netDownHistory, v.downloadBps)
|
|
||||||
pushHistory(netUpHistory, v.uploadBps)
|
|
||||||
}
|
|
||||||
}, { deep: false })
|
|
||||||
|
|
||||||
// 网速格式化(computed 避免模板中重复调用)
|
// 网速格式化(computed 避免模板中重复调用)
|
||||||
const downSpeed = computed(() => fmtSpeed(store.networkSpeed?.downloadBps ?? null))
|
const downSpeed = computed(() => fmtSpeed(store.networkSpeed?.downloadBps ?? null))
|
||||||
const upSpeed = computed(() => fmtSpeed(store.networkSpeed?.uploadBps ?? null))
|
const upSpeed = computed(() => fmtSpeed(store.networkSpeed?.uploadBps ?? null))
|
||||||
@@ -258,22 +278,6 @@ const stateMeta: Record<ConnectionState, { text: string; class: string }> = {
|
|||||||
error: { text: '错误', class: 'bg-red-500/15 text-red-600 dark:text-red-400' },
|
error: { text: '错误', class: 'bg-red-500/15 text-red-600 dark:text-red-400' },
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== sparkline 路径生成(无外部依赖) =====
|
|
||||||
function sparklinePath(data: number[], opts: { min?: number; max?: number; w?: number; h?: number } = {}): string {
|
|
||||||
if (data.length < 2) return ''
|
|
||||||
const w = opts.w ?? 100
|
|
||||||
const h = opts.h ?? 28
|
|
||||||
const min = opts.min ?? Math.min(...data)
|
|
||||||
const max = opts.max ?? Math.max(...data)
|
|
||||||
const range = max - min || 1
|
|
||||||
const step = w / (data.length - 1)
|
|
||||||
return data.map((v, i) => {
|
|
||||||
const x = i * step
|
|
||||||
const y = h - ((v - min) / range) * h
|
|
||||||
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`
|
|
||||||
}).join(' ')
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 分组列表(详细页用) =====
|
// ===== 分组列表(详细页用) =====
|
||||||
const groups = computed<SensorGroup[]>(() => store.snapshot?.groups ?? [])
|
const groups = computed<SensorGroup[]>(() => store.snapshot?.groups ?? [])
|
||||||
|
|
||||||
@@ -651,15 +655,20 @@ const osdConfig = ref<OsdConfig>(loadOsdConfig())
|
|||||||
|
|
||||||
/** 传感器名称中英文字典(覆盖常见 LHB 传感器名 + 硬件名) */
|
/** 传感器名称中英文字典(覆盖常见 LHB 传感器名 + 硬件名) */
|
||||||
const SENSOR_NAME_ZH: Record<string, string> = {
|
const SENSOR_NAME_ZH: Record<string, string> = {
|
||||||
// CPU
|
// CPU 通用(Intel 命名)
|
||||||
'CPU Package': 'CPU 封装',
|
'CPU Total': '总使用率',
|
||||||
'CPU Total': 'CPU 总负载',
|
'CPU Core Average': '平均温度',
|
||||||
'CPU Core Average': 'CPU 平均',
|
'CPU Core Max': '最大使用率',
|
||||||
'CPU Graphics': 'CPU 核显',
|
'CPU Graphics': '核显',
|
||||||
'CPU DRAM': 'CPU 内存',
|
'CPU DRAM': '内存功耗',
|
||||||
'CPU Cores': 'CPU 核心',
|
'CPU Cores': '核心功耗',
|
||||||
'CPU Bus': 'CPU 总线',
|
'CPU Bus': '总线功耗',
|
||||||
'CPU Core': 'CPU 核心',
|
'Bus Speed': '总线频率',
|
||||||
|
'Distance to TjMax': 'TjMax 距离',
|
||||||
|
// AMD Ryzen
|
||||||
|
'Core (Tctl/Tdie)': '封装温度',
|
||||||
|
'Cores (Average)': '平均频率',
|
||||||
|
'Cores (Average Effective)': '平均有效频率',
|
||||||
// GPU
|
// GPU
|
||||||
'GPU Core': 'GPU 核心',
|
'GPU Core': 'GPU 核心',
|
||||||
'GPU Memory': 'GPU 显存',
|
'GPU Memory': 'GPU 显存',
|
||||||
@@ -672,11 +681,13 @@ const SENSOR_NAME_ZH: Record<string, string> = {
|
|||||||
'GPU Memory Total': '总显存',
|
'GPU Memory Total': '总显存',
|
||||||
'GPU Memory Used': '已用显存',
|
'GPU Memory Used': '已用显存',
|
||||||
'D3D 3D': '3D 引擎',
|
'D3D 3D': '3D 引擎',
|
||||||
|
'D3D Copy': '拷贝引擎',
|
||||||
|
'D3D VideoDecode': '视频解码',
|
||||||
|
'D3D VideoProcessing': '视频处理',
|
||||||
// 内存
|
// 内存
|
||||||
'Memory': '内存',
|
|
||||||
'Memory Used': '已用',
|
'Memory Used': '已用',
|
||||||
'Memory Available': '可用',
|
'Memory Available': '可用',
|
||||||
'Virtual Memory': '虚拟内存',
|
'Virtual Memory': '虚拟内存使用率',
|
||||||
'Virtual Memory Used': '虚拟已用',
|
'Virtual Memory Used': '虚拟已用',
|
||||||
'Virtual Memory Available': '虚拟可用',
|
'Virtual Memory Available': '虚拟可用',
|
||||||
// 存储
|
// 存储
|
||||||
@@ -692,7 +703,7 @@ const SENSOR_NAME_ZH: Record<string, string> = {
|
|||||||
'System Fan': '系统风扇',
|
'System Fan': '系统风扇',
|
||||||
'Motherboard': '主板',
|
'Motherboard': '主板',
|
||||||
'Motherboard Temperature': '主板温度',
|
'Motherboard Temperature': '主板温度',
|
||||||
'CPU Socket': 'CPU 插槽',
|
'CPU Socket': 'CPU 插槽温度',
|
||||||
// 电池
|
// 电池
|
||||||
'Battery Level': '电量',
|
'Battery Level': '电量',
|
||||||
'Battery Charge': '充电功率',
|
'Battery Charge': '充电功率',
|
||||||
@@ -702,6 +713,23 @@ const SENSOR_NAME_ZH: Record<string, string> = {
|
|||||||
'Battery Wear Level': '电池损耗',
|
'Battery Wear Level': '电池损耗',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 同名传感器按 type 消歧的字典(key 格式:sensorName|type)。
|
||||||
|
* 用于解决 Intel/AMD 同一传感器名在不同 type 下含义不同的问题。
|
||||||
|
* 例如 'CPU Package' 在 temperature 是封装温度,在 power 是封装功耗;
|
||||||
|
* 'Memory' 在 load 是使用率,在 data 是内存容量。 */
|
||||||
|
const SENSOR_NAME_ZH_TYPED: Record<string, string> = {
|
||||||
|
'CPU Package|temperature': '封装温度',
|
||||||
|
'CPU Package|power': '封装功耗',
|
||||||
|
'Package|power': '封装功耗',
|
||||||
|
'Package|temperature': '封装温度',
|
||||||
|
'Memory|load': '内存使用率',
|
||||||
|
'Memory|data': '内存',
|
||||||
|
'Memory|smalldata': '内存',
|
||||||
|
'Cores (Average)|clock': '平均频率',
|
||||||
|
'Cores (Average)|power': '核心平均功耗',
|
||||||
|
'Cores (Average Effective)|clock': '平均有效频率',
|
||||||
|
}
|
||||||
|
|
||||||
/** 硬件名中英文(用于分组标题等) */
|
/** 硬件名中英文(用于分组标题等) */
|
||||||
const HW_NAME_ZH: Record<string, string> = {
|
const HW_NAME_ZH: Record<string, string> = {
|
||||||
'Total Memory': '总内存',
|
'Total Memory': '总内存',
|
||||||
@@ -775,7 +803,8 @@ function fullColloquialLabel(item: OsdItem): string {
|
|||||||
return hw || type || (SENSOR_NAME_ZH[item.sensorName] ?? item.sensorName)
|
return hw || type || (SENSOR_NAME_ZH[item.sensorName] ?? item.sensorName)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 传感器显示名翻译:根据 labelLanguage 返回中文或英文 */
|
/** 传感器显示名翻译:根据 labelLanguage 返回中文或英文
|
||||||
|
* 中文优先智能"按词对应"翻译,找不到时回退到通俗组合 */
|
||||||
function sensorLabel(item: OsdItem): string {
|
function sensorLabel(item: OsdItem): string {
|
||||||
// 特殊项
|
// 特殊项
|
||||||
if (item.special) {
|
if (item.special) {
|
||||||
@@ -784,7 +813,10 @@ function sensorLabel(item: OsdItem): string {
|
|||||||
return osdConfig.value.labelLanguage === 'zh' ? meta.zh : meta.en
|
return osdConfig.value.labelLanguage === 'zh' ? meta.zh : meta.en
|
||||||
}
|
}
|
||||||
if (osdConfig.value.labelLanguage === 'en') return item.sensorName
|
if (osdConfig.value.labelLanguage === 'en') return item.sensorName
|
||||||
// 使用通俗描述:CPU温度、CPU功耗、CPU使用率 等
|
// 优先:智能精准翻译(与 Dialog 保持一致)
|
||||||
|
const smart = smartSensorLabelZh(item.sensorName, item.type)
|
||||||
|
if (smart) return smart
|
||||||
|
// 回退:通俗描述
|
||||||
return fullColloquialLabel(item)
|
return fullColloquialLabel(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -795,8 +827,7 @@ function hwLabel(item: OsdItem): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** AvailableSensor 的传感器名翻译(用于选择 Dialog)
|
/** AvailableSensor 的传感器名翻译(用于选择 Dialog)
|
||||||
* 使用更明显的名字:负载→使用率,温度/功率→XXX温度/XXX功率
|
* 优先智能"按词对应"翻译(精准且唯一),找不到时回退到通俗组合。 */
|
||||||
* 容量类(data/smalldata)用字典翻译区分已用/可用,避免重名 */
|
|
||||||
function sensorLabelAvail(s: AvailableSensor): string {
|
function sensorLabelAvail(s: AvailableSensor): string {
|
||||||
if (s.special) {
|
if (s.special) {
|
||||||
const meta = SPECIAL_SENSOR_META[s.special]
|
const meta = SPECIAL_SENSOR_META[s.special]
|
||||||
@@ -804,26 +835,28 @@ function sensorLabelAvail(s: AvailableSensor): string {
|
|||||||
return osdConfig.value.labelLanguage === 'zh' ? meta.zh : meta.en
|
return osdConfig.value.labelLanguage === 'zh' ? meta.zh : meta.en
|
||||||
}
|
}
|
||||||
if (osdConfig.value.labelLanguage === 'en') return s.sensorName
|
if (osdConfig.value.labelLanguage === 'en') return s.sensorName
|
||||||
|
|
||||||
|
// 优先:智能精准翻译(typed 字典 + 字典 + 模式匹配)
|
||||||
|
const smart = smartSensorLabelZh(s.sensorName, s.type)
|
||||||
|
if (smart) return smart
|
||||||
|
|
||||||
// 容量类(data/smalldata):用字典翻译 + 硬件名区分(已用内存/可用内存/已用空间/可用空间)
|
// 容量类(data/smalldata):用字典翻译 + 硬件名区分(已用内存/可用内存/已用空间/可用空间)
|
||||||
if (s.type === 'data' || s.type === 'smalldata') {
|
if (s.type === 'data' || s.type === 'smalldata') {
|
||||||
const dictName = SENSOR_NAME_ZH[s.sensorName]
|
const dictName = SENSOR_NAME_ZH[s.sensorName]
|
||||||
if (dictName) {
|
if (dictName) {
|
||||||
const hw = shortHardwareLabelAvail(s)
|
const hw = shortHardwareLabelAvail(s)
|
||||||
// 存储类用硬盘型号
|
|
||||||
const prefix = s.groupId === 'storage' ? truncateHwName(s.hardwareName) : hw
|
const prefix = s.groupId === 'storage' ? truncateHwName(s.hardwareName) : hw
|
||||||
return prefix ? `${prefix}${dictName}` : dictName
|
return prefix ? `${prefix}${dictName}` : dictName
|
||||||
}
|
}
|
||||||
return SENSOR_NAME_ZH[s.sensorName] ?? s.sensorName
|
return SENSOR_NAME_ZH[s.sensorName] ?? s.sensorName
|
||||||
}
|
}
|
||||||
// 中文:用 通俗类型后缀 生成更明显的名字(如 CPU温度、CPU使用率、GPU功耗)
|
// 回退:通俗类型组合(短前缀 + 类型后缀)
|
||||||
const type = colloquialTypeLabel({ type: s.type } as OsdItem)
|
const type = colloquialTypeLabel({ type: s.type } as OsdItem)
|
||||||
const hw = shortHardwareLabelAvail(s)
|
const hw = shortHardwareLabelAvail(s)
|
||||||
// 存储类用硬件名(硬盘型号)+ 类型
|
|
||||||
if (s.groupId === 'storage') {
|
if (s.groupId === 'storage') {
|
||||||
return `${truncateHwName(s.hardwareName)}${type}`
|
return `${truncateHwName(s.hardwareName)}${type}`
|
||||||
}
|
}
|
||||||
if (hw && type) return `${hw}${type}`
|
if (hw && type) return `${hw}${type}`
|
||||||
// 回退到字典翻译
|
|
||||||
return SENSOR_NAME_ZH[s.sensorName] ?? s.sensorName
|
return SENSOR_NAME_ZH[s.sensorName] ?? s.sensorName
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -851,6 +884,76 @@ function hwLabelAvail(s: AvailableSensor): string {
|
|||||||
return HW_NAME_ZH[s.hardwareName] ?? s.hardwareName
|
return HW_NAME_ZH[s.hardwareName] ?? s.hardwareName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 类型后缀(中文,用于组合"核心 N 频率"、"封装温度"等精准翻译) */
|
||||||
|
function typeSuffixZh(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'temperature': return '温度'
|
||||||
|
case 'load': return '使用率'
|
||||||
|
case 'power': return '功耗'
|
||||||
|
case 'clock': return '频率'
|
||||||
|
case 'voltage': return '电压'
|
||||||
|
case 'fan': return '风扇'
|
||||||
|
case 'throughput': return '速率'
|
||||||
|
case 'level': return '等级'
|
||||||
|
case 'frequency': return '频率'
|
||||||
|
case 'control': return '控制'
|
||||||
|
case 'factor': return '因子'
|
||||||
|
case 'timespan': return '时长'
|
||||||
|
case 'energy': return '能量'
|
||||||
|
default: return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 智能"按词对应"翻译:根据 sensorName + type 生成精准中文标签
|
||||||
|
* 优先级:① typed 字典(同名不同义,如 CPU Package temperature vs power)
|
||||||
|
* ② 普通字典(固定短语)
|
||||||
|
* ③ 模式匹配(带核心序号、CCD、D3D 引擎等)
|
||||||
|
* ④ 返回 null 交由调用方回退
|
||||||
|
* 设计目标:每个传感器产出唯一中文标签,避免"CPU功耗 ×8"这种重复 */
|
||||||
|
function smartSensorLabelZh(name: string, type: string): string | null {
|
||||||
|
// ① typed 字典:同名 + type 消歧
|
||||||
|
const typedKey = `${name}|${type}`
|
||||||
|
if (SENSOR_NAME_ZH_TYPED[typedKey]) return SENSOR_NAME_ZH_TYPED[typedKey]
|
||||||
|
|
||||||
|
// ② 普通字典
|
||||||
|
if (SENSOR_NAME_ZH[name]) return SENSOR_NAME_ZH[name]
|
||||||
|
|
||||||
|
// ③ 模式匹配
|
||||||
|
let m: RegExpMatchArray | null
|
||||||
|
// CPU Core #N / Core #N → 核心 N + 类型后缀(load→使用率, clock→频率, power→功耗...)
|
||||||
|
if ((m = name.match(/^(?:CPU )?Core #(\d+)$/))) {
|
||||||
|
return `核心 ${m[1]}${typeSuffixZh(type)}`
|
||||||
|
}
|
||||||
|
// CPU Core #N (Effective) / Core #N (Effective) → 核心 N 有效频率
|
||||||
|
if ((m = name.match(/^(?:CPU )?Core #(\d+) \(Effective\)$/))) {
|
||||||
|
return `核心 ${m[1]} 有效${typeSuffixZh(type)}`
|
||||||
|
}
|
||||||
|
// Core #N (SMU) → 核心 N (SMU) 功耗
|
||||||
|
if ((m = name.match(/^Core #(\d+) \(SMU\)$/))) {
|
||||||
|
return `核心 ${m[1]} (SMU)${typeSuffixZh(type)}`
|
||||||
|
}
|
||||||
|
// CCD1 (Tdie) → CCD1 (Tdie) 温度
|
||||||
|
if ((m = name.match(/^CCD(\d+) \(Tdie\)$/))) {
|
||||||
|
return `CCD${m[1]} (Tdie) 温度`
|
||||||
|
}
|
||||||
|
// CCD#N (Tccd#N) → CCD#N (Tccd#N) 温度
|
||||||
|
if ((m = name.match(/^CCD(\d+) \(Tccd\d+\)$/))) {
|
||||||
|
return `CCD${m[1]} 温度`
|
||||||
|
}
|
||||||
|
// D3D 引擎族:D3D 3D / D3D Copy / D3D VideoDecode / D3D VideoProcessing
|
||||||
|
if ((m = name.match(/^D3D\s+(.+)$/))) {
|
||||||
|
const d3dMap: Record<string, string> = {
|
||||||
|
'3D': '3D 引擎',
|
||||||
|
'Copy': '拷贝引擎',
|
||||||
|
'VideoDecode': '视频解码',
|
||||||
|
'VideoProcessing': '视频处理',
|
||||||
|
}
|
||||||
|
if (d3dMap[m[1]]) return d3dMap[m[1]]
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断传感器是否为"常用项"。
|
* 判断传感器是否为"常用项"。
|
||||||
* 规则:基于 name + type + hardwareName 模式匹配,挑选日常监控最关注的指标。
|
* 规则:基于 name + type + hardwareName 模式匹配,挑选日常监控最关注的指标。
|
||||||
@@ -864,14 +967,14 @@ function isCommon(s: { sensorName: string; hardwareName: string; type: string; s
|
|||||||
const hw = s.hardwareName.toLowerCase()
|
const hw = s.hardwareName.toLowerCase()
|
||||||
const nameLc = name.toLowerCase()
|
const nameLc = name.toLowerCase()
|
||||||
|
|
||||||
// CPU 常用:Package 温度 / Total 负载 / Package 功耗 / Graphics 核显 / Core Average 温度
|
// CPU 常用:封装温度 / Total 负载 / 封装功耗 / Graphics 核显 / Core Average 温度
|
||||||
|
// 兼容 Intel ("CPU Package") 与 AMD Ryzen ("Core (Tctl/Tdie)" / "Package") 命名
|
||||||
// 其余(分核负载、分核温度、时钟、总线、各路功耗)归详细
|
// 其余(分核负载、分核温度、时钟、总线、各路功耗)归详细
|
||||||
if (hw.includes('cpu') || nameLc.startsWith('cpu')) {
|
if (hw.includes('cpu') || nameLc.startsWith('cpu') || hw.includes('amd ryzen')) {
|
||||||
if (name === 'CPU Package' && type === 'temperature') return true
|
if (type === 'temperature' && (name === 'CPU Package' || name === 'Core (Tctl/Tdie)' || name === 'CPU Core Average')) return true
|
||||||
if (name === 'CPU Total' && type === 'load') return true
|
if (name === 'CPU Total' && type === 'load') return true
|
||||||
if (name === 'CPU Package' && type === 'power') return true
|
if (type === 'power' && (name === 'CPU Package' || name === 'Package')) return true
|
||||||
if (name === 'CPU Graphics' && (type === 'load' || type === 'temperature')) return true
|
if (name === 'CPU Graphics' && (type === 'load' || type === 'temperature')) return true
|
||||||
if (name === 'CPU Core Average' && type === 'temperature') return true
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -951,6 +1054,8 @@ const availableSensors = computed<AvailableSensor[]>(() => {
|
|||||||
special: 'net-down',
|
special: 'net-down',
|
||||||
})
|
})
|
||||||
for (const g of store.snapshot?.groups ?? []) {
|
for (const g of store.snapshot?.groups ?? []) {
|
||||||
|
// 悬浮窗不显示存储分组(硬盘容量/温度等已在主界面监控,OSD 场景无需)
|
||||||
|
if (g.id === 'storage') continue
|
||||||
const groupName = groupMeta[g.id]?.name ?? g.name
|
const groupName = groupMeta[g.id]?.name ?? g.name
|
||||||
for (const s of g.sensors) {
|
for (const s of g.sensors) {
|
||||||
const key = `${g.id}/${s.hardwareName}/${s.name}/${s.type}`.replace(/\s+/g, '_').toLowerCase()
|
const key = `${g.id}/${s.hardwareName}/${s.name}/${s.type}`.replace(/\s+/g, '_').toLowerCase()
|
||||||
@@ -1019,7 +1124,11 @@ const osdPickSelected = ref<Record<string, boolean>>({})
|
|||||||
function openOsdPickDialog() {
|
function openOsdPickDialog() {
|
||||||
const items = osdConfig.value.overlayItems
|
const items = osdConfig.value.overlayItems
|
||||||
const selected: Record<string, boolean> = {}
|
const selected: Record<string, boolean> = {}
|
||||||
for (const it of items) selected[it.key] = true
|
// 存储分组已从 OSD 选择中移除,已选的存储项不预选(确认时自动清理)
|
||||||
|
for (const it of items) {
|
||||||
|
if (it.groupId === 'storage') continue
|
||||||
|
selected[it.key] = true
|
||||||
|
}
|
||||||
osdPickSelected.value = selected
|
osdPickSelected.value = selected
|
||||||
osdPickDialogOpen.value = true
|
osdPickDialogOpen.value = true
|
||||||
}
|
}
|
||||||
@@ -1028,8 +1137,9 @@ function confirmOsdPick() {
|
|||||||
// 收集所有勾选项(保留原有项对象,新增项从 availableSensors 构造)
|
// 收集所有勾选项(保留原有项对象,新增项从 availableSensors 构造)
|
||||||
const oldItems = osdConfig.value.overlayItems
|
const oldItems = osdConfig.value.overlayItems
|
||||||
const newItems: OsdItem[] = []
|
const newItems: OsdItem[] = []
|
||||||
// 保留原有项
|
// 保留原有项(存储项不再支持,直接丢弃)
|
||||||
for (const old of oldItems) {
|
for (const old of oldItems) {
|
||||||
|
if (old.groupId === 'storage') continue
|
||||||
if (osdPickSelected.value[old.key]) {
|
if (osdPickSelected.value[old.key]) {
|
||||||
newItems.push(old)
|
newItems.push(old)
|
||||||
}
|
}
|
||||||
@@ -1048,13 +1158,12 @@ function confirmOsdPick() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 按默认顺序排序:CPU → GPU → 内存 → 网络 → 存储 → 其余
|
// 按默认顺序排序:CPU → GPU → 内存 → 网络 → 其余
|
||||||
const groupOrder = (id: string): number => {
|
const groupOrder = (id: string): number => {
|
||||||
if (id === 'cpu') return 0
|
if (id === 'cpu') return 0
|
||||||
if (id.startsWith('gpu')) return 1
|
if (id.startsWith('gpu')) return 1
|
||||||
if (id === 'memory') return 2
|
if (id === 'memory') return 2
|
||||||
if (id === 'network') return 3
|
if (id === 'network') return 3
|
||||||
if (id === 'storage') return 4
|
|
||||||
return 9
|
return 9
|
||||||
}
|
}
|
||||||
newItems.sort((a, b) => groupOrder(a.groupId) - groupOrder(b.groupId))
|
newItems.sort((a, b) => groupOrder(a.groupId) - groupOrder(b.groupId))
|
||||||
@@ -1138,53 +1247,6 @@ function onBgAlphaInput(alpha: number) {
|
|||||||
updateOsdConfig('bgColor', `rgba(${r}, ${g}, ${b}, ${a})`)
|
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 字符串 */
|
/** 将 hex 颜色 + 不透明度(0-100) 转为 rgba 字符串 */
|
||||||
function withOpacity(hex: string, opacityPct: number): string {
|
function withOpacity(hex: string, opacityPct: number): string {
|
||||||
const a = Math.max(0, Math.min(100, opacityPct)) / 100
|
const a = Math.max(0, Math.min(100, opacityPct)) / 100
|
||||||
@@ -1210,42 +1272,6 @@ function osdItemColor(item: OsdItem): string {
|
|||||||
return withOpacity(osdConfig.value.fontColor, 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<string, { key: string; label: string; items: OsdItem[] }>()
|
|
||||||
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 窗口并推送数据) =====
|
// ===== OSD 窗口管理(实际创建/隐藏 Tauri 窗口并推送数据) =====
|
||||||
const OSD_OVERLAY_LABEL = 'osd-overlay'
|
const OSD_OVERLAY_LABEL = 'osd-overlay'
|
||||||
/** 抑制百分比 watch 的程序定位标志:拖动 onMoved 更新百分比时置 true,避免触发 resetOverlayPosition 循环 */
|
/** 抑制百分比 watch 的程序定位标志:拖动 onMoved 更新百分比时置 true,避免触发 resetOverlayPosition 循环 */
|
||||||
@@ -1732,7 +1758,7 @@ watch([
|
|||||||
|
|
||||||
<!-- 始终显示卡片网格,未启动时数据以占位符显示,保持画面完整 -->
|
<!-- 始终显示卡片网格,未启动时数据以占位符显示,保持画面完整 -->
|
||||||
<div v-else key="content" class="grid grid-cols-1 md:grid-cols-3 gap-2.5">
|
<div v-else key="content" class="grid grid-cols-1 md:grid-cols-3 gap-2.5">
|
||||||
<!-- CPU(温度 + 功耗 + 曲线图,未读数据以 -- 占位) -->
|
<!-- CPU(温度 + 功耗 + 频率,未读数据以 -- 占位) -->
|
||||||
<Card class="py-0 gap-0">
|
<Card class="py-0 gap-0">
|
||||||
<CardHeader class="pb-1.5 px-3.5 pt-2.5">
|
<CardHeader class="pb-1.5 px-3.5 pt-2.5">
|
||||||
<CardTitle class="flex items-center justify-between text-sm">
|
<CardTitle class="flex items-center justify-between text-sm">
|
||||||
@@ -1741,7 +1767,7 @@ watch([
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="px-3.5 pb-2.5 space-y-1.5">
|
<CardContent class="px-3.5 pb-2.5 space-y-1.5">
|
||||||
<!-- 温度 + 功耗 + 曲线图 -->
|
<!-- 温度 + 功耗 -->
|
||||||
<div class="flex items-end justify-between gap-2">
|
<div class="flex items-end justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-muted-foreground flex items-center gap-1"><Thermometer class="size-3" />封装温度</div>
|
<div class="text-xs text-muted-foreground flex items-center gap-1"><Thermometer class="size-3" />封装温度</div>
|
||||||
@@ -1753,9 +1779,6 @@ watch([
|
|||||||
<div class="text-xs text-muted-foreground flex items-center gap-1 justify-end"><Zap class="size-3" />功耗</div>
|
<div class="text-xs text-muted-foreground flex items-center gap-1 justify-end"><Zap class="size-3" />功耗</div>
|
||||||
<div class="text-base font-medium tabular-nums">{{ fmt(cpuPower, 1) }}<span class="text-xs text-muted-foreground ml-0.5">W</span></div>
|
<div class="text-base font-medium tabular-nums">{{ fmt(cpuPower, 1) }}<span class="text-xs text-muted-foreground ml-0.5">W</span></div>
|
||||||
</div>
|
</div>
|
||||||
<svg v-if="cpuTempHistory.length >= 2" :width="56" :height="24" class="opacity-70 shrink-0">
|
|
||||||
<path :d="sparklinePath(cpuTempHistory, { w: 56, h: 24 })" stroke="currentColor" :class="tempColor(cpuTemp)" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- 负载 -->
|
<!-- 负载 -->
|
||||||
<div>
|
<div>
|
||||||
@@ -1768,7 +1791,7 @@ watch([
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- GPU(结构与 CPU 一致:温度 + 功耗 + 曲线图,未读数据以 -- 占位) -->
|
<!-- GPU(结构与 CPU 一致:温度 + 功耗,未读数据以 -- 占位) -->
|
||||||
<Card class="py-0 gap-0">
|
<Card class="py-0 gap-0">
|
||||||
<CardHeader class="pb-1.5 px-3.5 pt-2.5">
|
<CardHeader class="pb-1.5 px-3.5 pt-2.5">
|
||||||
<CardTitle class="flex items-center justify-between text-sm">
|
<CardTitle class="flex items-center justify-between text-sm">
|
||||||
@@ -1777,7 +1800,7 @@ watch([
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="px-3.5 pb-2.5 space-y-1.5">
|
<CardContent class="px-3.5 pb-2.5 space-y-1.5">
|
||||||
<!-- 温度 + 功耗 + 曲线图(与 CPU 卡片结构一致) -->
|
<!-- 温度 + 功耗(与 CPU 卡片结构一致) -->
|
||||||
<div class="flex items-end justify-between gap-2">
|
<div class="flex items-end justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-muted-foreground flex items-center gap-1"><Thermometer class="size-3" />核心温度</div>
|
<div class="text-xs text-muted-foreground flex items-center gap-1"><Thermometer class="size-3" />核心温度</div>
|
||||||
@@ -1789,9 +1812,6 @@ watch([
|
|||||||
<div class="text-xs text-muted-foreground flex items-center gap-1 justify-end"><Zap class="size-3" />功耗</div>
|
<div class="text-xs text-muted-foreground flex items-center gap-1 justify-end"><Zap class="size-3" />功耗</div>
|
||||||
<div class="text-base font-medium tabular-nums">{{ fmt(gpuPower, 1) }}<span class="text-xs text-muted-foreground ml-0.5">W</span></div>
|
<div class="text-base font-medium tabular-nums">{{ fmt(gpuPower, 1) }}<span class="text-xs text-muted-foreground ml-0.5">W</span></div>
|
||||||
</div>
|
</div>
|
||||||
<svg v-if="gpuTempHistory.length >= 2" :width="56" :height="24" class="opacity-70 shrink-0">
|
|
||||||
<path :d="sparklinePath(gpuTempHistory, { w: 56, h: 24 })" stroke="currentColor" :class="tempColor(gpuTemp)" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- 负载 -->
|
<!-- 负载 -->
|
||||||
<div>
|
<div>
|
||||||
@@ -1835,7 +1855,7 @@ watch([
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- 网络(跨3列,下载/上传 + 曲线图,独立于 Kernel 由 Tauri 后台推送) -->
|
<!-- 网络(跨3列,下载/上传速率,独立于 Kernel 由 Tauri 后台推送) -->
|
||||||
<Card class="md:col-span-3 py-0 gap-0">
|
<Card class="md:col-span-3 py-0 gap-0">
|
||||||
<CardHeader class="pb-1.5 px-3.5 pt-2.5">
|
<CardHeader class="pb-1.5 px-3.5 pt-2.5">
|
||||||
<CardTitle class="flex items-center gap-1.5 text-sm">
|
<CardTitle class="flex items-center gap-1.5 text-sm">
|
||||||
@@ -1847,26 +1867,16 @@ watch([
|
|||||||
<!-- 下载 -->
|
<!-- 下载 -->
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-muted-foreground flex items-center gap-1 mb-0.5"><ArrowDown class="size-3 text-sky-500" />下载</div>
|
<div class="text-xs text-muted-foreground flex items-center gap-1 mb-0.5"><ArrowDown class="size-3 text-sky-500" />下载</div>
|
||||||
<div class="flex items-end gap-2">
|
<div class="text-2xl font-bold tabular-nums leading-tight text-sky-500">
|
||||||
<div class="text-2xl font-bold tabular-nums leading-tight text-sky-500 shrink-0">
|
|
||||||
{{ downSpeed.value }}<span class="text-sm font-normal text-muted-foreground ml-0.5">{{ downSpeed.unit }}</span>
|
{{ downSpeed.value }}<span class="text-sm font-normal text-muted-foreground ml-0.5">{{ downSpeed.unit }}</span>
|
||||||
</div>
|
</div>
|
||||||
<svg v-if="netDownHistory.length >= 2" class="flex-1 h-7 opacity-70" viewBox="0 0 80 28" preserveAspectRatio="none">
|
|
||||||
<path :d="sparklinePath(netDownHistory, { w: 80, h: 28 })" stroke="currentColor" class="text-sky-500" fill="none" stroke-width="1.5" vector-effect="non-scaling-stroke" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- 上传 -->
|
<!-- 上传 -->
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-muted-foreground flex items-center gap-1 mb-0.5"><ArrowUp class="size-3 text-violet-500" />上传</div>
|
<div class="text-xs text-muted-foreground flex items-center gap-1 mb-0.5"><ArrowUp class="size-3 text-violet-500" />上传</div>
|
||||||
<div class="flex items-end gap-2">
|
<div class="text-2xl font-bold tabular-nums leading-tight text-violet-500">
|
||||||
<div class="text-2xl font-bold tabular-nums leading-tight text-violet-500 shrink-0">
|
|
||||||
{{ upSpeed.value }}<span class="text-sm font-normal text-muted-foreground ml-0.5">{{ upSpeed.unit }}</span>
|
{{ upSpeed.value }}<span class="text-sm font-normal text-muted-foreground ml-0.5">{{ upSpeed.unit }}</span>
|
||||||
</div>
|
</div>
|
||||||
<svg v-if="netUpHistory.length >= 2" class="flex-1 h-7 opacity-70" viewBox="0 0 80 28" preserveAspectRatio="none">
|
|
||||||
<path :d="sparklinePath(netUpHistory, { w: 80, h: 28 })" stroke="currentColor" class="text-violet-500" fill="none" stroke-width="1.5" vector-effect="non-scaling-stroke" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -2136,9 +2146,6 @@ watch([
|
|||||||
<span class="truncate">{{ sensorLabel(item) }}</span>
|
<span class="truncate">{{ sensorLabel(item) }}</span>
|
||||||
<span class="text-xs text-muted-foreground truncate">/ {{ hwLabel(item) }}</span>
|
<span class="text-xs text-muted-foreground truncate">/ {{ hwLabel(item) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="font-mono text-xs tabular-nums shrink-0 text-muted-foreground">
|
|
||||||
{{ fmtOsdValue(getOsdItemValue(item), item.type, item.unit, item.special) }}{{ unitSuffix(item) }}
|
|
||||||
</span>
|
|
||||||
<Button size="icon-xs" variant="ghost" class="text-muted-foreground hover:text-destructive shrink-0" @click="removeOsdItem(item.key)">
|
<Button size="icon-xs" variant="ghost" class="text-muted-foreground hover:text-destructive shrink-0" @click="removeOsdItem(item.key)">
|
||||||
<span class="text-lg leading-none">×</span>
|
<span class="text-lg leading-none">×</span>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -2651,78 +2658,7 @@ watch([
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- 预览 -->
|
<!-- 预览卡片已移除(用户可在桌面悬浮窗直接看实际效果) -->
|
||||||
<Card v-if="osdConfig.overlayEnabled && osdConfig.overlayItems.length">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle class="text-base flex items-center gap-2">
|
|
||||||
<Eye class="size-4 text-primary" />预览
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div class="text-xs text-muted-foreground mb-1.5">悬浮窗({{ osdLayoutLabel }})</div>
|
|
||||||
<div class="rounded-md bg-muted/30 border p-3 font-mono">
|
|
||||||
<!-- 单行预览:分组式,组间用 | 分隔 -->
|
|
||||||
<div
|
|
||||||
v-if="osdConfig.layout === 'single'"
|
|
||||||
class="osd-preview-single"
|
|
||||||
:style="{ background: osdConfig.bgColor, fontSize: osdConfig.fontSize + 'px' }"
|
|
||||||
>
|
|
||||||
<template v-for="(g, gi) in osdPreviewGroups" :key="g.key">
|
|
||||||
<span v-if="gi > 0" class="osd-preview-sg-sep">|</span>
|
|
||||||
<span class="osd-preview-sg-group">
|
|
||||||
<span v-if="osdConfig.showLabel" class="osd-preview-sg-label">{{ g.label }}</span>
|
|
||||||
<span
|
|
||||||
v-for="item in g.items"
|
|
||||||
:key="item.key"
|
|
||||||
:style="{ color: osdItemColor(item) }"
|
|
||||||
>
|
|
||||||
{{ fmtOsdValue(getOsdItemValue(item), item.type, item.unit, item.special) }}{{ unitSuffix(item) }}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 分组横排预览 -->
|
|
||||||
<div
|
|
||||||
v-else-if="osdConfig.layout === 'group'"
|
|
||||||
class="osd-preview-group"
|
|
||||||
:style="{ background: osdConfig.bgColor, fontSize: osdConfig.fontSize + 'px' }"
|
|
||||||
>
|
|
||||||
<div v-for="g in osdPreviewGroups" :key="g.key" class="osd-preview-group-item">
|
|
||||||
<div class="osd-preview-group-header">{{ g.label }}</div>
|
|
||||||
<div class="osd-preview-group-data">
|
|
||||||
<span
|
|
||||||
v-for="item in g.items"
|
|
||||||
:key="item.key"
|
|
||||||
class="osd-preview-group-val"
|
|
||||||
:style="{ color: osdItemColor(item) }"
|
|
||||||
>
|
|
||||||
{{ fmtOsdValue(getOsdItemValue(item), item.type, item.unit, item.special) }}{{ unitSuffix(item) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 多行预览 -->
|
|
||||||
<div
|
|
||||||
v-else-if="osdConfig.layout === 'multiline'"
|
|
||||||
class="osd-preview-multiline"
|
|
||||||
:style="{ background: osdConfig.bgColor, fontSize: osdConfig.fontSize + 'px' }"
|
|
||||||
>
|
|
||||||
<div v-for="g in osdPreviewGroups" :key="g.key" class="osd-preview-line">
|
|
||||||
<span class="osd-preview-line-label">{{ g.label }}</span>
|
|
||||||
<span
|
|
||||||
v-for="item in g.items"
|
|
||||||
:key="item.key"
|
|
||||||
:style="{ color: osdItemColor(item) }"
|
|
||||||
>
|
|
||||||
{{ fmtOsdValue(getOsdItemValue(item), item.type, item.unit, item.special) }}{{ unitSuffix(item) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -0,0 +1,726 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||||
|
import { getCurrentWindow, LogicalSize, PhysicalPosition } from '@tauri-apps/api/window'
|
||||||
|
import { Effect, EffectState } from '@tauri-apps/api/window'
|
||||||
|
import {
|
||||||
|
Globe, Power, PowerOff, RefreshCw, Check, Monitor, Download,
|
||||||
|
Settings, LogOut,
|
||||||
|
} from '@lucide/vue'
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
|
||||||
|
// ===== 类型定义(与 Rust 端对应) =====
|
||||||
|
interface ProxyNodeInfo {
|
||||||
|
name: string
|
||||||
|
delay: number | null
|
||||||
|
isCurrent: boolean
|
||||||
|
}
|
||||||
|
interface TrayMenuState {
|
||||||
|
proxyRunning: boolean
|
||||||
|
monitorRunning: boolean
|
||||||
|
proxyGroup: string | null
|
||||||
|
proxyNodes: ProxyNodeInfo[]
|
||||||
|
proxyCurrent: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 状态 =====
|
||||||
|
const state = reactive<TrayMenuState>({
|
||||||
|
proxyRunning: false,
|
||||||
|
monitorRunning: false,
|
||||||
|
proxyGroup: null,
|
||||||
|
proxyNodes: [],
|
||||||
|
proxyCurrent: null,
|
||||||
|
})
|
||||||
|
const loading = ref(false)
|
||||||
|
const refreshing = ref(false)
|
||||||
|
let unlistenFns: UnlistenFn[] = []
|
||||||
|
|
||||||
|
// ===== 节点名缩写(badge 显示) =====
|
||||||
|
// 地区映射
|
||||||
|
const REGION_MAP: Record<string, string> = {
|
||||||
|
'日本': 'JP', '香港': 'HK', '美国': 'US', '新加坡': 'SG',
|
||||||
|
'台湾': 'TW', '韩国': 'KR', '印度': 'IN', '英国': 'UK',
|
||||||
|
'德国': 'DE', '法国': 'FR', '荷兰': 'NL', '加拿大': 'CA',
|
||||||
|
'澳大利亚': 'AU', '巴西': 'BR', '俄罗斯': 'RU', '泰国': 'TH',
|
||||||
|
'越南': 'VN', '马来西亚': 'MY', '菲律宾': 'PH', '印尼': 'ID',
|
||||||
|
'日本东京': 'JP', '东京': 'JP', '大阪': 'JP',
|
||||||
|
'Japan': 'JP', 'Tokyo': 'JP', 'Osaka': 'JP',
|
||||||
|
'Hong Kong': 'HK', 'Kong': 'HK',
|
||||||
|
'USA': 'US', 'United States': 'US', 'America': 'US',
|
||||||
|
'Singapore': 'SG', 'Taiwan': 'TW', 'Korea': 'KR', 'India': 'IN',
|
||||||
|
'UK': 'UK', 'London': 'UK',
|
||||||
|
'Germany': 'DE', 'France': 'FR', 'Netherlands': 'NL',
|
||||||
|
'Canada': 'CA', 'Australia': 'AU', 'Brazil': 'BR',
|
||||||
|
'Russia': 'RU', 'Thailand': 'TH', 'Vietnam': 'VN',
|
||||||
|
'Malaysia': 'MY', 'Philippines': 'PH', 'Indonesia': 'ID',
|
||||||
|
'Italy': 'IT', 'Spain': 'ES', 'Sweden': 'SE', 'Switzerland': 'CH',
|
||||||
|
'Poland': 'PL', 'Turkey': 'TR', 'Finland': 'FI', 'Norway': 'NO',
|
||||||
|
}
|
||||||
|
|
||||||
|
// 订阅/服务商模式
|
||||||
|
const PROVIDER_PATTERNS: { regex: RegExp; label: string }[] = [
|
||||||
|
{ regex: /\bAWS\b/i, label: 'AWS' },
|
||||||
|
{ regex: /\bGCP\b/i, label: 'GCP' },
|
||||||
|
{ regex: /\bAzure\b/i, label: 'AZ' },
|
||||||
|
{ regex: /\bVultr\b/i, label: 'VUL' },
|
||||||
|
{ regex: /\bDigitalOcean\b/i, label: 'DO' },
|
||||||
|
{ regex: /\bLinode\b/i, label: 'LND' },
|
||||||
|
{ regex: /\bOracle\b/i, label: 'OCI' },
|
||||||
|
{ regex: /\bAlibaba\b/i, label: 'ALI' },
|
||||||
|
{ regex: /\bTencent\b/i, label: 'TENC' },
|
||||||
|
{ regex: /\bHetzner\b/i, label: 'HZ' },
|
||||||
|
{ regex: /\bOVH\b/i, label: 'OVH' },
|
||||||
|
{ regex: /\bBuyVM\b/i, label: 'BUY' },
|
||||||
|
{ regex: /\bRackNerd\b/i, label: 'RN' },
|
||||||
|
{ regex: /\bGreenCloud\b/i, label: 'GC' },
|
||||||
|
{ regex: /\bDMIT\b/i, label: 'DMT' },
|
||||||
|
{ regex: /\b搬瓦工\b/i, label: 'BWG' },
|
||||||
|
{ regex: /\bBandwagon\b/i, label: 'BWG' },
|
||||||
|
{ regex: /\bV.PS\b/i, label: 'VPS' },
|
||||||
|
{ regex: /\bCloudflare\b/i, label: 'CF' },
|
||||||
|
{ regex: /\biQ\b/i, label: 'iQ' },
|
||||||
|
{ regex: /\bCN2\b/i, label: 'CN2' },
|
||||||
|
{ regex: /\bGIA\b/i, label: 'GIA' },
|
||||||
|
{ regex: /\bIPLC\b/i, label: 'IP' },
|
||||||
|
{ regex: /\bIEPL\b/i, label: 'IE' },
|
||||||
|
{ regex: /\b9929\b/, label: '9929' },
|
||||||
|
{ regex: /\b4837\b/, label: '4837' },
|
||||||
|
{ regex: /\bCMI\b/i, label: 'CMI' },
|
||||||
|
{ regex: /\bBGP\b/i, label: 'BGP' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function abbreviateNodeName(name: string): string {
|
||||||
|
// 检测服务商
|
||||||
|
let provider = ''
|
||||||
|
for (const p of PROVIDER_PATTERNS) {
|
||||||
|
if (p.regex.test(name)) {
|
||||||
|
provider = p.label
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测地区代码
|
||||||
|
let region = ''
|
||||||
|
for (const [key, code] of Object.entries(REGION_MAP)) {
|
||||||
|
if (name.includes(key)) {
|
||||||
|
region = code
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取数字部分(节点编号)
|
||||||
|
const numMatch = name.match(/(\d{2,3})/)
|
||||||
|
const num = numMatch ? numMatch[1] : ''
|
||||||
|
|
||||||
|
// 组合缩写
|
||||||
|
const parts: string[] = []
|
||||||
|
if (provider) parts.push(provider)
|
||||||
|
if (region) parts.push(region)
|
||||||
|
if (num) {
|
||||||
|
// 有服务商时加点分隔,如 JP-AWS-01
|
||||||
|
parts.push(num)
|
||||||
|
} else if (parts.length === 0) {
|
||||||
|
// 没有匹配到任何模式,兜底
|
||||||
|
const cnMatch = name.match(/^([\u4e00-\u9fa5]{2,4})\s*(\d{2,3})/)
|
||||||
|
if (cnMatch) return `${cnMatch[1]}${cnMatch[2]}`
|
||||||
|
if (/^[A-Za-z]/.test(name)) return name.slice(0, 6)
|
||||||
|
return name.slice(0, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join('-')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前选中节点的缩写
|
||||||
|
const currentNodeAbbr = computed(() => {
|
||||||
|
const node = state.proxyNodes.find(n => n.isCurrent)
|
||||||
|
return node ? abbreviateNodeName(node.name) : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// 当前选中节点的延迟
|
||||||
|
const currentNodeDelay = computed(() => {
|
||||||
|
const node = state.proxyNodes.find(n => n.isCurrent)
|
||||||
|
return node?.delay ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
// 按延迟排序的节点列表(有延迟的按升序在前,无延迟的在后)
|
||||||
|
const sortedNodes = computed(() => {
|
||||||
|
const arr = [...state.proxyNodes]
|
||||||
|
arr.sort((a, b) => {
|
||||||
|
const da = a.delay ?? Number.MAX_SAFE_INTEGER
|
||||||
|
const db = b.delay ?? Number.MAX_SAFE_INTEGER
|
||||||
|
if (da === 0 && db === 0) return 0
|
||||||
|
if (da === 0) return 1
|
||||||
|
if (db === 0) return -1
|
||||||
|
return da - db
|
||||||
|
})
|
||||||
|
return arr
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== 菜单动作(所有按钮点击后立即关闭菜单,任务在后台执行) =====
|
||||||
|
async function handleAction(action: string, payload?: Record<string, unknown>) {
|
||||||
|
// 立即关闭菜单窗口
|
||||||
|
try { await invoke('tray_menu_hide') } catch { /* 忽略 */ }
|
||||||
|
|
||||||
|
// 后台执行任务(不 await)
|
||||||
|
if (action === 'proxy_refresh') refreshing.value = true
|
||||||
|
loading.value = true
|
||||||
|
invoke('tray_menu_action', { action, payload: payload ?? null })
|
||||||
|
.catch((e) => console.error('[tray-menu] 动作失败:', e))
|
||||||
|
.finally(() => {
|
||||||
|
loading.value = false
|
||||||
|
refreshing.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 代理开关按钮文案
|
||||||
|
const proxyButtonLabel = computed(() => state.proxyRunning ? '关闭代理' : '开启代理')
|
||||||
|
const proxyButtonIcon = computed(() => state.proxyRunning ? PowerOff : Power)
|
||||||
|
|
||||||
|
function handleProxyToggle() {
|
||||||
|
handleAction(state.proxyRunning ? 'proxy_disable' : 'proxy_enable')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 节点选择
|
||||||
|
function handleSelectNode(name: unknown) {
|
||||||
|
if (typeof name === 'string') {
|
||||||
|
handleAction('proxy_select_node', { name })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 延迟显示辅助 =====
|
||||||
|
function delayText(delay: number | null): string {
|
||||||
|
if (delay === null) return '未测试'
|
||||||
|
if (delay === 0) return '超时'
|
||||||
|
return `${delay}ms`
|
||||||
|
}
|
||||||
|
function delayClass(delay: number | null): string {
|
||||||
|
if (delay === null) return 'delay-none'
|
||||||
|
if (delay === 0) return 'delay-timeout'
|
||||||
|
if (delay <= 100) return 'delay-fast'
|
||||||
|
if (delay <= 300) return 'delay-medium'
|
||||||
|
return 'delay-slow'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 键盘 =====
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
invoke('tray_menu_hide').catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 窗口高度自适应(底部锚定,防止内容变化导致位置偏移) =====
|
||||||
|
const MENU_WIDTH = 260
|
||||||
|
const MENU_MIN_HEIGHT = 200
|
||||||
|
const MENU_MAX_HEIGHT = 520
|
||||||
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
|
async function adjustWindowHeight() {
|
||||||
|
try {
|
||||||
|
const root = document.querySelector('.tray-menu-root') as HTMLElement | null
|
||||||
|
if (!root) return
|
||||||
|
const contentHeight = root.scrollHeight
|
||||||
|
const height = Math.min(Math.max(contentHeight, MENU_MIN_HEIGHT), MENU_MAX_HEIGHT)
|
||||||
|
const tauriWin = getCurrentWindow()
|
||||||
|
|
||||||
|
// 记录当前窗口底部在屏幕上的物理位置(用于锚定底部,避免高度变化后偏移)
|
||||||
|
const oldPos = await tauriWin.outerPosition()
|
||||||
|
const oldSize = await tauriWin.outerSize()
|
||||||
|
const oldBottom = oldPos.y + oldSize.height
|
||||||
|
|
||||||
|
await tauriWin.setSize(new LogicalSize(MENU_WIDTH, height))
|
||||||
|
|
||||||
|
// 根据新的物理高度,调整 y 坐标使得窗口底部保持在原位
|
||||||
|
const newSize = await tauriWin.outerSize()
|
||||||
|
const newY = Math.max(0, oldBottom - newSize.height)
|
||||||
|
if (Math.abs(newY - oldPos.y) > 2) {
|
||||||
|
await tauriWin.setPosition(new PhysicalPosition(oldPos.x, newY))
|
||||||
|
}
|
||||||
|
} catch { /* 忽略 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 主题应用(与主应用同步,复用 ClipboardPopup 逻辑) =====
|
||||||
|
function readMainTheme(): { theme: string; effect: string } {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('thing_app_settings')
|
||||||
|
if (raw) {
|
||||||
|
const s = JSON.parse(raw)
|
||||||
|
return { theme: s.theme ?? 'system', effect: s.effect ?? 'mica' }
|
||||||
|
}
|
||||||
|
} catch { /* 忽略 */ }
|
||||||
|
return { theme: 'system', effect: 'mica' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveIsDark(theme: string): boolean {
|
||||||
|
if (theme === 'dark') return true
|
||||||
|
if (theme === 'light') return false
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyTheme() {
|
||||||
|
const root = document.documentElement
|
||||||
|
const { theme, effect } = readMainTheme()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tauriWin = getCurrentWindow()
|
||||||
|
if (theme === 'system') {
|
||||||
|
await tauriWin.setTheme(null)
|
||||||
|
} else {
|
||||||
|
await tauriWin.setTheme(theme as 'dark' | 'light')
|
||||||
|
}
|
||||||
|
} catch { /* 非 Tauri 环境忽略 */ }
|
||||||
|
|
||||||
|
const isDark = resolveIsDark(theme)
|
||||||
|
|
||||||
|
root.classList.remove('dark', 'effect-mica', 'effect-acrylic', 'effect-normal')
|
||||||
|
root.classList.add(`effect-${effect}`)
|
||||||
|
if (isDark) root.classList.add('dark')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tauriWin = getCurrentWindow()
|
||||||
|
await tauriWin.clearEffects()
|
||||||
|
if (effect === 'mica') {
|
||||||
|
await tauriWin.setEffects({
|
||||||
|
effects: [Effect.Mica],
|
||||||
|
state: EffectState.FollowsWindowActiveState,
|
||||||
|
color: isDark ? [30, 41, 59, 0] : [248, 250, 252, 0],
|
||||||
|
})
|
||||||
|
await tauriWin.setBackgroundColor('#00000000')
|
||||||
|
root.style.setProperty('--menu-bg', 'transparent')
|
||||||
|
} else if (effect === 'acrylic') {
|
||||||
|
await tauriWin.setEffects({
|
||||||
|
effects: [Effect.Acrylic],
|
||||||
|
state: EffectState.FollowsWindowActiveState,
|
||||||
|
color: isDark ? [30, 41, 59, 0] : [248, 250, 252, 0],
|
||||||
|
})
|
||||||
|
await tauriWin.setBackgroundColor('#00000000')
|
||||||
|
root.style.setProperty('--menu-bg', 'transparent')
|
||||||
|
} else {
|
||||||
|
await tauriWin.setBackgroundColor(isDark ? '#0f172a' : '#ffffff')
|
||||||
|
root.style.setProperty('--menu-bg', isDark ? '#0f172a' : '#ffffff')
|
||||||
|
}
|
||||||
|
} catch { /* 非 Tauri 环境忽略 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await applyTheme()
|
||||||
|
|
||||||
|
// 监听系统主题变化
|
||||||
|
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
const onThemeChange = () => applyTheme()
|
||||||
|
mq.addEventListener('change', onThemeChange)
|
||||||
|
unlistenFns.push(() => mq.removeEventListener('change', onThemeChange))
|
||||||
|
|
||||||
|
// 监听菜单显示事件:每次显示时重新同步主题 + 刷新状态 + 调整高度
|
||||||
|
unlistenFns.push(await listen<TrayMenuState>('tray-menu-show', async (event) => {
|
||||||
|
await applyTheme()
|
||||||
|
Object.assign(state, event.payload)
|
||||||
|
await nextTick()
|
||||||
|
await adjustWindowHeight()
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 监听状态更新事件(动作执行后刷新 + 调整高度)
|
||||||
|
unlistenFns.push(await listen<TrayMenuState>('tray-menu-state-updated', async (event) => {
|
||||||
|
Object.assign(state, event.payload)
|
||||||
|
await nextTick()
|
||||||
|
await adjustWindowHeight()
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 使用 ResizeObserver 监听内容尺寸变化,自动调整窗口高度
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
adjustWindowHeight()
|
||||||
|
})
|
||||||
|
const root = document.querySelector('.tray-menu-root') as HTMLElement | null
|
||||||
|
if (root) resizeObserver.observe(root)
|
||||||
|
|
||||||
|
// 预创建模式下:通知 Rust 端前端已就绪,触发初始状态推送(窗口保持隐藏)
|
||||||
|
try {
|
||||||
|
await invoke('tray_menu_show_window')
|
||||||
|
} catch { /* 忽略 */ }
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
unlistenFns.forEach((fn) => fn())
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tray-menu-root flex flex-col" @keydown="onKeydown" tabindex="0">
|
||||||
|
<!-- 菜单内容 -->
|
||||||
|
<div class="tray-menu-scroll flex-1 min-h-0 overflow-y-auto">
|
||||||
|
<!-- ===== 代理 section ===== -->
|
||||||
|
<div class="tray-section">
|
||||||
|
<div class="tray-section-header">
|
||||||
|
<Globe class="h-4 w-4 text-primary" />
|
||||||
|
<span class="tray-section-title">代理</span>
|
||||||
|
<span v-if="state.proxyRunning" class="tray-status-dot running"></span>
|
||||||
|
<span v-else class="tray-status-dot stopped"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tray-section-body">
|
||||||
|
<!-- 开启/关闭代理(单按钮切换) -->
|
||||||
|
<button class="tray-item" :disabled="loading" @click="handleProxyToggle">
|
||||||
|
<component :is="proxyButtonIcon" class="h-4 w-4" />
|
||||||
|
<span>{{ proxyButtonLabel }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 刷新节点列表 -->
|
||||||
|
<button
|
||||||
|
v-if="state.proxyRunning"
|
||||||
|
class="tray-item"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="handleAction('proxy_refresh')"
|
||||||
|
>
|
||||||
|
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': refreshing }" />
|
||||||
|
<span>刷新节点列表</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 节点选择 Select -->
|
||||||
|
<div v-if="state.proxyRunning && state.proxyNodes.length > 0" class="tray-node-select">
|
||||||
|
<label class="tray-node-label">节点</label>
|
||||||
|
<Select
|
||||||
|
:model-value="state.proxyCurrent ?? ''"
|
||||||
|
:disabled="loading"
|
||||||
|
@update:model-value="handleSelectNode"
|
||||||
|
@update:open="(open: boolean) => { if (open) invoke('tray_menu_action', { action: 'proxy_refresh', payload: null }) }"
|
||||||
|
>
|
||||||
|
<SelectTrigger class="tray-select-trigger" size="sm">
|
||||||
|
<span v-if="currentNodeAbbr" class="tray-select-badge">{{ currentNodeAbbr }}</span>
|
||||||
|
<span v-if="currentNodeDelay !== null" class="tray-select-delay-inline" :class="delayClass(currentNodeDelay)">
|
||||||
|
{{ delayText(currentNodeDelay) }}
|
||||||
|
</span>
|
||||||
|
<SelectValue v-else placeholder="选择节点" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent class="tray-select-content">
|
||||||
|
<SelectItem
|
||||||
|
v-for="node in sortedNodes"
|
||||||
|
:key="node.name"
|
||||||
|
:value="node.name"
|
||||||
|
class="tray-select-option"
|
||||||
|
>
|
||||||
|
<Check v-if="node.isCurrent" class="h-3 w-3 text-primary flex-shrink-0" />
|
||||||
|
<span class="tray-select-badge-option">{{ abbreviateNodeName(node.name) }}</span>
|
||||||
|
<span class="tray-select-delay" :class="delayClass(node.delay)">
|
||||||
|
{{ delayText(node.delay) }}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tray-separator"></div>
|
||||||
|
|
||||||
|
<!-- ===== OSD + Kernel ===== -->
|
||||||
|
<button class="tray-item" :disabled="loading" @click="handleAction('osd_toggle')">
|
||||||
|
<Monitor class="h-4 w-4" />
|
||||||
|
<span>切换 OSD</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tray-item"
|
||||||
|
:disabled="!state.monitorRunning || loading"
|
||||||
|
@click="handleAction('kernel_restart')"
|
||||||
|
>
|
||||||
|
<RefreshCw class="h-4 w-4" />
|
||||||
|
<span>重启 Kernel</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="tray-separator"></div>
|
||||||
|
|
||||||
|
<!-- ===== 新建下载 ===== -->
|
||||||
|
<button class="tray-item" :disabled="loading" @click="handleAction('download_new')">
|
||||||
|
<Download class="h-4 w-4" />
|
||||||
|
<span>新建下载</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="tray-separator"></div>
|
||||||
|
|
||||||
|
<!-- ===== 设置 + 退出 ===== -->
|
||||||
|
<button class="tray-item" :disabled="loading" @click="handleAction('settings')">
|
||||||
|
<Settings class="h-4 w-4" />
|
||||||
|
<span>常规设置</span>
|
||||||
|
</button>
|
||||||
|
<button class="tray-item tray-quit" :disabled="loading" @click="handleAction('quit')">
|
||||||
|
<LogOut class="h-4 w-4" />
|
||||||
|
<span>退出</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tray-menu-root {
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', 'PingFang SC', sans-serif;
|
||||||
|
background: var(--menu-bg, transparent);
|
||||||
|
color: var(--foreground);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动区域(高度由内容决定,不用 flex-1 撑满) */
|
||||||
|
.tray-menu-scroll {
|
||||||
|
padding: 6px;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动条 */
|
||||||
|
.tray-menu-scroll::-webkit-scrollbar {
|
||||||
|
width: 5px;
|
||||||
|
}
|
||||||
|
.tray-menu-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--muted-foreground);
|
||||||
|
border-radius: 3px;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
.tray-menu-scroll::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Section ===== */
|
||||||
|
.tray-section {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tray-section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 10px;
|
||||||
|
color: var(--foreground);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tray-section-title {
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tray-section-body {
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 状态指示点 */
|
||||||
|
.tray-status-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.tray-status-dot.running {
|
||||||
|
background: #22c55e;
|
||||||
|
box-shadow: 0 0 4px rgba(34, 197, 94, 0.5);
|
||||||
|
}
|
||||||
|
.tray-status-dot.stopped {
|
||||||
|
background: var(--muted-foreground);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 菜单项 ===== */
|
||||||
|
.tray-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.08s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tray-item:hover:not(:disabled) {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tray-item:active:not(:disabled) {
|
||||||
|
background: var(--secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tray-item:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tray-item svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 退出项特殊样式 */
|
||||||
|
.tray-quit {
|
||||||
|
color: var(--destructive);
|
||||||
|
}
|
||||||
|
.tray-quit svg {
|
||||||
|
color: var(--destructive);
|
||||||
|
}
|
||||||
|
.tray-quit:hover:not(:disabled) {
|
||||||
|
background: color-mix(in oklch, var(--destructive) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 分隔线 ===== */
|
||||||
|
.tray-separator {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
margin: 4px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 节点选择 ===== */
|
||||||
|
.tray-node-select {
|
||||||
|
padding: 4px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tray-node-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Select trigger 样式覆盖 */
|
||||||
|
.tray-select-trigger {
|
||||||
|
width: 100%;
|
||||||
|
height: 30px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tray-select-trigger:hover {
|
||||||
|
background: var(--secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 选中节点的 badge 缩写 */
|
||||||
|
.tray-select-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 5px;
|
||||||
|
height: 18px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: color-mix(in oklch, var(--primary) 12%, transparent);
|
||||||
|
color: var(--primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SelectTrigger 内的延迟 badge */
|
||||||
|
.tray-select-delay-inline {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1px 5px;
|
||||||
|
height: 16px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tray-select-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 下拉选项中每个节点的 badge */
|
||||||
|
.tray-select-badge-option {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 4px;
|
||||||
|
height: 16px;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: color-mix(in oklch, var(--primary) 10%, transparent);
|
||||||
|
color: var(--primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tray-select-delay {
|
||||||
|
font-size: 10px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 延迟配色 */
|
||||||
|
.delay-fast {
|
||||||
|
background: rgba(34, 197, 94, 0.12);
|
||||||
|
color: rgb(22, 163, 74);
|
||||||
|
}
|
||||||
|
.delay-medium {
|
||||||
|
background: rgba(234, 179, 8, 0.12);
|
||||||
|
color: rgb(202, 138, 4);
|
||||||
|
}
|
||||||
|
.delay-slow {
|
||||||
|
background: rgba(249, 115, 22, 0.12);
|
||||||
|
color: rgb(234, 88, 12);
|
||||||
|
}
|
||||||
|
.delay-timeout {
|
||||||
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
color: rgb(220, 38, 38);
|
||||||
|
}
|
||||||
|
.delay-none {
|
||||||
|
background: var(--secondary);
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 深色主题下调整延迟配色 */
|
||||||
|
.dark .delay-fast {
|
||||||
|
color: rgb(74, 222, 128);
|
||||||
|
}
|
||||||
|
.dark .delay-medium {
|
||||||
|
color: rgb(250, 204, 21);
|
||||||
|
}
|
||||||
|
.dark .delay-slow {
|
||||||
|
color: rgb(251, 146, 60);
|
||||||
|
}
|
||||||
|
.dark .delay-timeout {
|
||||||
|
color: rgb(248, 113, 113);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- 全局样式:SelectContent 通过 Portal 渲染到 body 外部,scoped 样式不生效 -->
|
||||||
|
<style>
|
||||||
|
.tray-select-content[data-slot="select-content"] {
|
||||||
|
/* 固定宽度等于 trigger 宽度,防止长节点名撑宽下拉框 */
|
||||||
|
width: var(--reka-select-trigger-width);
|
||||||
|
min-width: var(--reka-select-trigger-width);
|
||||||
|
max-width: var(--reka-select-trigger-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tray-select-option[data-slot="select-item"] {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
{"error":"internal","message":".NET number values such as positive and negative infinity cannot be written as valid JSON. To make it work when using 'JsonSerializer', consider specifying 'JsonNumberHandling.AllowNamedFloatingPointLiterals' (see https://docs.microsoft.com/dotnet/api/system.text.json.serialization.jsonnumberhandling)."}
|
||||||
Reference in New Issue
Block a user