托盘调整

This commit is contained in:
2026-07-30 18:54:45 +08:00
parent f452322aad
commit 9d8f963cc6
14 changed files with 1441 additions and 659 deletions
+1 -1
View File
@@ -25,7 +25,7 @@
</PropertyGroup>
<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" />
</ItemGroup>
+97
View File
@@ -2392,6 +2392,20 @@ dependencies = [
"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]]
name = "markup5ever"
version = "0.38.0"
@@ -2529,6 +2543,20 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "ntapi"
version = "0.4.3"
@@ -3061,6 +3089,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "precomputed-hash"
version = "0.1.1"
@@ -3165,6 +3202,35 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "raw-window-handle"
version = "0.6.2"
@@ -4313,6 +4379,25 @@ dependencies = [
"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]]
name = "tauri-plugin-opener"
version = "2.5.4"
@@ -4451,6 +4536,17 @@ dependencies = [
"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]]
name = "tempfile"
version = "3.27.0"
@@ -4495,6 +4591,7 @@ dependencies = [
"tauri-plugin-autostart",
"tauri-plugin-dialog",
"tauri-plugin-global-shortcut",
"tauri-plugin-notification",
"tauri-plugin-opener",
"tauri-plugin-snap-layout",
"tokio",
+1
View File
@@ -23,6 +23,7 @@ tauri-plugin-opener = "2"
tauri-plugin-dialog = "2"
tauri-plugin-snap-layout = "1"
tauri-plugin-global-shortcut = "2"
tauri-plugin-notification = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
Binary file not shown.
+2 -1
View File
@@ -9,6 +9,7 @@
"main"
],
"permissions": [
"autostart:default"
"autostart:default",
"notification:default"
]
}
+20
View File
@@ -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"
]
}
+21 -7
View File
@@ -73,7 +73,7 @@ pub fn unregister_shortcut(app: &AppHandle) {
/// 窗口不存在则创建(隐藏状态,等前端挂载后调用 show_window 显示)。
/// 窗口已存在则移动到鼠标位置并显示。
pub fn show_popup(app: &AppHandle) {
// 获取鼠标位置(屏幕坐标
// 获取鼠标位置(物理像素,Per-Monitor DPI V2
let (mx, my) = match get_cursor_pos() {
Some(p) => p,
None => return,
@@ -83,16 +83,30 @@ pub fn show_popup(app: &AppHandle) {
let w = 380.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 x = (mx as f64).min(screen_w - w).max(0.0);
let y = (my as f64).min(screen_h - h).max(0.0);
// 获取 scale factor,将窗口逻辑尺寸换算为物理像素用于边界裁剪
let scale = app
.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) {
let _ = win.set_position(tauri::Position::Logical(tauri::LogicalPosition {
let _ = win.set_position(tauri::Position::Physical(tauri::PhysicalPosition {
x,
y,
}));
@@ -112,7 +126,7 @@ pub fn show_popup(app: &AppHandle) {
)
.title("剪贴板")
.inner_size(w, h)
.position(x, y)
.position(x as f64 / scale, y as f64 / scale)
.decorations(false)
.transparent(true)
.shadow(true)
+5
View File
@@ -51,6 +51,7 @@ use clipboard::{
clipboard_set_pinned, clipboard_show_popup, clipboard_show_window, clipboard_start,
clipboard_status, clipboard_stop, clipboard_unregister_shortcut,
};
use tray_menu::{tray_menu_action, tray_menu_hide, tray_menu_show_window};
#[tauri::command]
fn greet(name: &str) -> String {
@@ -87,6 +88,7 @@ pub fn run() {
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_notification::init())
.plugin(
tauri_plugin_snap_layout::init()
.button_id("titlebar-maximize")
@@ -181,6 +183,9 @@ pub fn run() {
clipboard_show_window,
clipboard_hide_popup,
clipboard_paste_to_target,
tray_menu_action,
tray_menu_hide,
tray_menu_show_window,
snap_fix::fix_snap_background
])
.setup(|app| {
+348 -374
View File
@@ -1,46 +1,53 @@
//! tray_menu.rs — 自定义系统托盘右键菜单
//! tray_menu.rs — 自定义系统托盘菜单WebviewWindow 弹窗)
//!
//! 菜单结构:
//! 代理 ▸
//! 开启代理 (启动mihomo + 择优选节点 + 系统代理 + Windows通知)
//! 关闭代理
//! ---
//! 选择代理 ▸
//! 刷新节点列表
//! ---
//! 节点1 (120ms)
//! 节点2 (超时)
//! ...
//! ---
//! 切换 OSD
//! 重启 Kernel
//! ---
//! 新建下载
//! ---
//! 常规设置
//! 退出
use std::sync::{Mutex, OnceLock};
//! 使用独立 WebviewWindow 替代原生菜单,实现与应用一致的视觉风格(Mica/Acrylic + 深色主题)。
//! - 右键托盘:显示自定义菜单窗口(在鼠标位置弹出)
//! - 左键托盘:显示主窗口
//! - 菜单窗口失焦:自动隐藏
//!
//! 前端通过监听 `tray-menu-show` 事件获取菜单状态并渲染,
//! 点击菜单项时调用 `tray_menu_action` 命令执行对应操作。
use serde::Serialize;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use tauri::{
AppHandle, Emitter, Manager,
menu::{IsMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu},
tray::{MouseButton, TrayIconBuilder, TrayIconEvent, TrayIconId},
AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder,
tray::{MouseButton, TrayIconBuilder, TrayIconEvent},
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::download_engine::DownloadEngine;
use crate::mihomo_manager::MihomoManager;
use crate::monitor_kernel::MonitorKernel;
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)>> {
PROXY_NODE_MAP.get_or_init(|| Mutex::new(Vec::new()))
#[derive(Serialize, Clone, Debug)]
#[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 保持一致) =====
@@ -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)> {
let proxies = proxies_response.get("proxies")?.as_object()?;
// 收集所有可选择组
let selector_groups: Vec<&str> = proxies
.iter()
.filter(|(_, v)| {
@@ -120,7 +126,6 @@ fn parse_main_group(proxies_response: &serde_json::Value) -> Option<(String, Vec
return None;
}
// 优先选择名为 PROXY / Proxy / 节点选择 / 代理 的组
let preferred = selector_groups
.iter()
.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)
}
// ===== 菜单构建 =====
// ===== 获取代理节点数据(异步,调用 mihomo API) =====
/// 获取代理节点数据(异步,调用 mihomo API)
async fn fetch_proxy_nodes(app: &AppHandle) -> Option<(String, Vec<(String, Option<u32>)>, String)> {
let mihomo = app.state::<MihomoManager>();
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))
}
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)?;
// ===== 获取菜单状态 =====
// 选择代理子菜单
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) {
pub async fn get_tray_menu_state(app: &AppHandle) -> TrayMenuState {
let proxy_running = is_proxy_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 {
None
(None, vec![], None)
};
if let Ok(menu) = build_menu(app, proxy_running, monitor_running, proxy_nodes) {
if let Some(tray) = app.tray_by_id(&TrayIconId::new("main")) {
let _ = tray.set_menu(Some(menu));
}
TrayMenuState {
proxy_running,
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();
match id.as_str() {
"proxy_enable" => {
let app = app.clone();
/// 托盘菜单窗口尺寸(逻辑像素)
const MENU_W: f64 = 260.0;
const MENU_H: f64 = 420.0;
/// 预创建隐藏的托盘菜单窗口(在应用启动时调用)。
/// 窗口隐藏渲染,前端 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 {
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 {
eprintln!("[tray] 开启代理失败: {}", e);
send_notification("代理启动失败", &e);
send_notification(&app, "代理启动失败", &e);
}
});
}
"proxy_disable" => {
let app = app.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = disable_proxy(&app).await {
eprintln!("[tray] 关闭代理失败: {}", e);
send_notification("代理关闭失败", &e);
send_notification(&app, "代理关闭失败", &e);
}
});
}
"proxy_refresh" => {
let app = app.clone();
tauri::async_runtime::spawn(async move {
// 先重建菜单(保留旧数据),然后并行测试延迟并再次重建
rebuild_menu(&app).await;
test_all_delays_and_rebuild(&app).await;
});
test_all_delays(&app).await;
}
"proxy_select_node" => {
if let Some(name) = payload
.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" => {
let _ = app.emit("tray:toggle-osd", ());
}
"kernel_restart" => {
let app = app.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = restart_kernel(&app).await {
eprintln!("[tray] 重启 Kernel 失败: {}", e);
send_notification("Kernel 重启失败", &e);
send_notification(&app, "Kernel 重启失败", &e);
}
});
}
"download_new" => {
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();
}
let _ = app.emit("tray:new-download", ());
hide_tray_menu(&app);
}
"settings" => {
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();
}
let _ = app.emit("tray:open-settings", ());
hide_tray_menu(&app);
}
"quit" => {
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);
}
});
hide_tray_menu(&app);
quit_cleanup(&app);
}
_ => {}
}
// 刷新状态并发送给前端(quit 除外,quit 后进程已退出)
if action != "quit" {
refresh_and_emit_state(&app).await;
}
Ok(())
}
/// 从全局映射中获取 (group, name)
fn group_name_from_map(idx: usize) -> Option<(String, String)> {
let map = proxy_node_map().lock().unwrap();
map.get(idx).cloned()
/// 隐藏托盘菜单窗口(前端调用)
#[tauri::command]
pub async fn tray_menu_hide(app: AppHandle) -> Result<(), String> {
hide_tray_menu(&app);
Ok(())
}
/// 选择代理节点并测试延迟
async fn select_proxy_node(app: &AppHandle, group: &str, name: &str) {
/// 显示已创建的菜单窗口(前端 onMounted 后调用)
#[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>();
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("节点已切换", &format!("{}\n延迟: {}", name, delay_text));
// 重建菜单
rebuild_menu(app).await;
}
Err(e) => {
eprintln!("[tray] 切换节点失败: {}", e);
send_notification("切换节点失败", &e);
}
}
let proxies = mihomo.get_proxies().await.ok()?;
let (group, _, _) = parse_main_group(&proxies)?;
Some(group)
}
// ===== 代理开启/关闭 =====
@@ -485,24 +477,22 @@ async fn enable_proxy(app: &AppHandle) -> Result<(), String> {
match &best {
Some((name, delay)) => {
send_notification(
app,
"代理已开启",
&format!("当前节点: {} ({}ms)", name, delay),
);
}
None => {
send_notification("代理已开启", "所有节点均超时,未自动选择");
send_notification(app, "代理已开启", "所有节点均超时,未自动选择");
}
}
} else {
send_notification("代理已开启", "无可用节点");
send_notification(app, "代理已开启", "无可用节点");
}
// 5. 开启系统代理
mihomo.enable_system_proxy()?;
// 6. 重建菜单
rebuild_menu(app).await;
Ok(())
}
@@ -516,10 +506,7 @@ async fn disable_proxy(app: &AppHandle) -> Result<(), String> {
// 停止 mihomo
pm.stop("proxy")?;
send_notification("代理已关闭", "mihomo 已停止,系统代理已清除");
// 重建菜单
rebuild_menu(app).await;
send_notification(app, "代理已关闭", "mihomo 已停止,系统代理已清除");
Ok(())
}
@@ -547,14 +534,12 @@ async fn test_and_select_best(
let results = join_all(futures).await;
// 找出有效延迟中最低的
let best = results
.into_iter()
.filter_map(|(name, delay)| delay.map(|d| (name, d)))
.filter(|(_, d)| *d > 0)
.min_by_key(|(_, d)| *d);
// 选择最优节点
if let Some((ref name, _)) = best {
let _ = mihomo.select_proxy(group, name).await;
}
@@ -562,21 +547,18 @@ async fn test_and_select_best(
best
}
/// 测试所有节点延迟后重建菜单
async fn test_all_delays_and_rebuild(app: &AppHandle) {
/// 并行测试所有节点延迟(更新 mihomo 内部 history
async fn test_all_delays(app: &AppHandle) {
let mihomo = app.state::<MihomoManager>();
let proxies = match mihomo.get_proxies().await {
Ok(p) => p,
Err(_) => {
return;
}
Err(_) => return,
};
let (_group, nodes, _) = match parse_main_group(&proxies) {
let (_group, nodes, _now) = match parse_main_group(&proxies) {
Some(g) => g,
None => return,
};
// 并行测试延迟(更新 mihomo 内部 history
use futures_util::future::join_all;
let futures: Vec<_> = nodes
.iter()
@@ -587,9 +569,30 @@ async fn test_all_delays_and_rebuild(app: &AppHandle) {
})
.collect();
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 就绪 =====
@@ -621,103 +624,25 @@ async fn restart_kernel(app: &AppHandle) -> Result<(), String> {
// 重新启动
monitor.start_with_subscription(app).await?;
send_notification("Kernel 已重启", "ThingHK 监控内核已重新启动");
rebuild_menu(app).await;
send_notification(app, "Kernel 已重启", "ThingHK 监控内核已重新启动");
Ok(())
}
// ===== Windows 通知 =====
#[cfg(windows)]
fn send_notification(title: &str, message: &str) {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
// 转义 PowerShell 单引号
let title_escaped = title.replace('\'', "''");
let msg_escaped = message.replace('\'', "''").replace('\n', "`n");
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();
fn send_notification(app: &AppHandle, title: &str, message: &str) {
use tauri_plugin_notification::NotificationExt;
let _ = app
.notification()
.builder()
.title(title)
.body(message)
.show();
}
#[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) {
if let Some(mihomo) = app.try_state::<MihomoManager>() {
mihomo.cleanup_on_exit();
@@ -725,8 +650,18 @@ pub fn quit_cleanup(app: &AppHandle) {
if let Some(engine) = app.try_state::<DownloadEngine>() {
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>() {
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>() {
clip.stop();
@@ -736,3 +671,42 @@ pub fn quit_cleanup(app: &AppHandle) {
}
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
View File
@@ -16,7 +16,7 @@ window.addEventListener('unhandledrejection', (event) => {
})
// ===== OSD 窗口模式检测 =====
// 通过 URL hash 识别:#osd-overlay(悬浮窗)/ #clipboard-popup(剪贴板快捷弹窗)
// 通过 URL hash 识别:#osd-overlay(悬浮窗)/ #clipboard-popup(剪贴板快捷弹窗)/ #tray-menu(托盘菜单)
// 这些窗口是精简的独立 Vue 应用,不加载主应用的 store 和模块
const winHash = window.location.hash
if (winHash === '#osd-overlay') {
@@ -31,6 +31,12 @@ if (winHash === '#osd-overlay') {
const app = createApp(ClipboardPopup)
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 {
// ===== 主应用模式 =====
void import('./App.vue').then(async ({ default: App }) => {
+197 -261
View File
@@ -44,20 +44,6 @@ const tabsListRef = useModuleTabs(activeTab, [
{ 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 =====
/** GPU 分组 id(兼容 intel/amd/nvidia */
@@ -69,9 +55,34 @@ const gpuGroupId = computed(() => {
})
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 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 gid = gpuGroupId.value
@@ -121,7 +132,10 @@ const memModuleModels = computed(() => {
return Array.from(models)
})
/** 存储硬盘列表(按 hardwareName 分组,提取温度/使用率/容量) */
/** 存储硬盘列表(按 hardwareName 分组,提取温度/使用率/容量)
* LHB 0.9.5 已知 bugMBR + XINT13 Extended 分区布局下,Free Space 可能返回
* ≈ 2^64/1e9 GB(无符号回绕),Used Space load 也可能为巨大负值。
* 这里对 LHB 原始值做有效性校验,异常时降级为 null(UI 显示 "--")。 */
interface StorageDrive {
name: string
temp: number | null
@@ -139,28 +153,34 @@ const storageDrives = computed<StorageDrive[]>(() => {
}
return Array.from(byHw.entries()).map(([hw, sensors]) => {
const temp = sensors.find(s => s.type === 'temperature')?.value ?? null
const usedPct = sensors.find(s => s.name === 'Used Space')?.value ?? null
const totalGB = sensors.find(s => s.name === 'Total Space')?.value ?? null
const freeGB = sensors.find(s => s.name === 'Free Space')?.value ?? null
const usedGB = totalGB != null && freeGB != null ? totalGB - freeGB : null
// Used Space 是 load 百分比,理论上 [0,100]LHB 在 MBR+Extended 布局下可能返回 -922153000% 等
const rawUsedPct = sensors.find(s => s.name === 'Used Space' && s.type === 'load')?.value ?? null
const usedPct = rawUsedPct != null && isFinite(rawUsedPct) && rawUsedPct >= 0 && rawUsedPct <= 100
? 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 GBUINT64_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 计算 usedGBfree 异常时回退用 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 }
})
})
// 监听快照变化,提取温度历史(用于 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 避免模板中重复调用)
const downSpeed = computed(() => fmtSpeed(store.networkSpeed?.downloadBps ?? 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' },
}
// ===== 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 ?? [])
@@ -651,15 +655,20 @@ const osdConfig = ref<OsdConfig>(loadOsdConfig())
/** 传感器名称中英文字典(覆盖常见 LHB 传感器名 + 硬件名) */
const SENSOR_NAME_ZH: Record<string, string> = {
// CPU
'CPU Package': 'CPU 封装',
'CPU Total': 'CPU 总负载',
'CPU Core Average': 'CPU 平均',
'CPU Graphics': 'CPU 核显',
'CPU DRAM': 'CPU 内存',
'CPU Cores': 'CPU 核心',
'CPU Bus': 'CPU 总线',
'CPU Core': 'CPU 核心',
// CPU 通用(Intel 命名)
'CPU Total': '总使用率',
'CPU Core Average': '平均温度',
'CPU Core Max': '最大使用率',
'CPU Graphics': '核显',
'CPU DRAM': '内存功耗',
'CPU Cores': '核心功耗',
'CPU Bus': '总线功耗',
'Bus Speed': '总线频率',
'Distance to TjMax': 'TjMax 距离',
// AMD Ryzen
'Core (Tctl/Tdie)': '封装温度',
'Cores (Average)': '平均频率',
'Cores (Average Effective)': '平均有效频率',
// GPU
'GPU Core': 'GPU 核心',
'GPU Memory': 'GPU 显存',
@@ -672,11 +681,13 @@ const SENSOR_NAME_ZH: Record<string, string> = {
'GPU Memory Total': '总显存',
'GPU Memory Used': '已用显存',
'D3D 3D': '3D 引擎',
'D3D Copy': '拷贝引擎',
'D3D VideoDecode': '视频解码',
'D3D VideoProcessing': '视频处理',
// 内存
'Memory': '内存',
'Memory Used': '已用',
'Memory Available': '可用',
'Virtual Memory': '虚拟内存',
'Virtual Memory': '虚拟内存使用率',
'Virtual Memory Used': '虚拟已用',
'Virtual Memory Available': '虚拟可用',
// 存储
@@ -692,7 +703,7 @@ const SENSOR_NAME_ZH: Record<string, string> = {
'System Fan': '系统风扇',
'Motherboard': '主板',
'Motherboard Temperature': '主板温度',
'CPU Socket': 'CPU 插槽',
'CPU Socket': 'CPU 插槽温度',
// 电池
'Battery Level': '电量',
'Battery Charge': '充电功率',
@@ -702,6 +713,23 @@ const SENSOR_NAME_ZH: Record<string, string> = {
'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> = {
'Total Memory': '总内存',
@@ -775,7 +803,8 @@ function fullColloquialLabel(item: OsdItem): string {
return hw || type || (SENSOR_NAME_ZH[item.sensorName] ?? item.sensorName)
}
/** 传感器显示名翻译:根据 labelLanguage 返回中文或英文 */
/** 传感器显示名翻译:根据 labelLanguage 返回中文或英文
* 中文优先智能"按词对应"翻译,找不到时回退到通俗组合 */
function sensorLabel(item: OsdItem): string {
// 特殊项
if (item.special) {
@@ -784,7 +813,10 @@ function sensorLabel(item: OsdItem): string {
return osdConfig.value.labelLanguage === 'zh' ? meta.zh : meta.en
}
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)
}
@@ -795,8 +827,7 @@ function hwLabel(item: OsdItem): string {
}
/** AvailableSensor 的传感器名翻译(用于选择 Dialog)
* 使用更明显的名字:负载→使用率,温度/功率→XXX温度/XXX功率
* 容量类(data/smalldata)用字典翻译区分已用/可用,避免重名 */
* 优先智能"按词对应"翻译(精准且唯一),找不到时回退到通俗组合。 */
function sensorLabelAvail(s: AvailableSensor): string {
if (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
}
if (osdConfig.value.labelLanguage === 'en') return s.sensorName
// 优先:智能精准翻译(typed 字典 + 字典 + 模式匹配)
const smart = smartSensorLabelZh(s.sensorName, s.type)
if (smart) return smart
// 容量类(data/smalldata):用字典翻译 + 硬件名区分(已用内存/可用内存/已用空间/可用空间)
if (s.type === 'data' || s.type === 'smalldata') {
const dictName = SENSOR_NAME_ZH[s.sensorName]
if (dictName) {
const hw = shortHardwareLabelAvail(s)
// 存储类用硬盘型号
const prefix = s.groupId === 'storage' ? truncateHwName(s.hardwareName) : hw
return prefix ? `${prefix}${dictName}` : dictName
}
return SENSOR_NAME_ZH[s.sensorName] ?? s.sensorName
}
// 中文:用 通俗类型后缀 生成更明显的名字(如 CPU温度、CPU使用率、GPU功耗
// 回退:通俗类型组合(短前缀 + 类型后缀
const type = colloquialTypeLabel({ type: s.type } as OsdItem)
const hw = shortHardwareLabelAvail(s)
// 存储类用硬件名(硬盘型号)+ 类型
if (s.groupId === 'storage') {
return `${truncateHwName(s.hardwareName)}${type}`
}
if (hw && type) return `${hw}${type}`
// 回退到字典翻译
return SENSOR_NAME_ZH[s.sensorName] ?? s.sensorName
}
@@ -851,6 +884,76 @@ function hwLabelAvail(s: AvailableSensor): string {
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 模式匹配,挑选日常监控最关注的指标。
@@ -864,14 +967,14 @@ function isCommon(s: { sensorName: string; hardwareName: string; type: string; s
const hw = s.hardwareName.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 (name === 'CPU Package' && type === 'temperature') return true
if (hw.includes('cpu') || nameLc.startsWith('cpu') || hw.includes('amd ryzen')) {
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 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 Core Average' && type === 'temperature') return true
return false
}
@@ -951,6 +1054,8 @@ const availableSensors = computed<AvailableSensor[]>(() => {
special: 'net-down',
})
for (const g of store.snapshot?.groups ?? []) {
// 悬浮窗不显示存储分组(硬盘容量/温度等已在主界面监控,OSD 场景无需)
if (g.id === 'storage') continue
const groupName = groupMeta[g.id]?.name ?? g.name
for (const s of g.sensors) {
const key = `${g.id}/${s.hardwareName}/${s.name}/${s.type}`.replace(/\s+/g, '_').toLowerCase()
@@ -1019,7 +1124,11 @@ const osdPickSelected = ref<Record<string, boolean>>({})
function openOsdPickDialog() {
const items = osdConfig.value.overlayItems
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
osdPickDialogOpen.value = true
}
@@ -1028,8 +1137,9 @@ function confirmOsdPick() {
// 收集所有勾选项(保留原有项对象,新增项从 availableSensors 构造)
const oldItems = osdConfig.value.overlayItems
const newItems: OsdItem[] = []
// 保留原有项
// 保留原有项(存储项不再支持,直接丢弃)
for (const old of oldItems) {
if (old.groupId === 'storage') continue
if (osdPickSelected.value[old.key]) {
newItems.push(old)
}
@@ -1048,13 +1158,12 @@ function confirmOsdPick() {
})
}
}
// 按默认顺序排序:CPU → GPU → 内存 → 网络 → 存储 → 其余
// 按默认顺序排序:CPU → GPU → 内存 → 网络 → 其余
const groupOrder = (id: string): number => {
if (id === 'cpu') return 0
if (id.startsWith('gpu')) return 1
if (id === 'memory') return 2
if (id === 'network') return 3
if (id === 'storage') return 4
return 9
}
newItems.sort((a, b) => groupOrder(a.groupId) - groupOrder(b.groupId))
@@ -1138,53 +1247,6 @@ function onBgAlphaInput(alpha: number) {
updateOsdConfig('bgColor', `rgba(${r}, ${g}, ${b}, ${a})`)
}
/** 根据 OSD item 查找当前快照中的传感器值(支持特殊项) */
function getOsdItemValue(item: OsdItem): number | null {
// 特殊项:网速
if (item.special === 'net-up') return store.networkSpeed?.uploadBps ?? null
if (item.special === 'net-down') return store.networkSpeed?.downloadBps ?? null
const g = store.groupById[item.groupId]
if (!g) return null
const s = g.sensors.find(s =>
s.hardwareName === item.hardwareName && s.name === item.sensorName
)
return s?.value ?? null
}
/** 格式化 OSD 显示值(简洁模式,不含单位后缀;网速已含单位字符串) */
function fmtOsdValue(v: number | null, type: string, _unit: string, special?: string): string {
if (v == null || !isFinite(v)) return '--'
// 网速特殊项:自适应 KB/s 或 MB/s(值已含单位字符串)
if (special === 'net-up' || special === 'net-down') {
if (v >= 1_048_576) return (v / 1_048_576).toFixed(2) + ' MB/s'
if (v >= 1024) return (v / 1024).toFixed(1) + ' KB/s'
return v.toFixed(0) + ' B/s'
}
const digits = (type === 'voltage' || type === 'power') ? 2
: (type === 'temperature' || type === 'load' || type === 'level') ? 0
: 1
return v.toFixed(digits)
}
/** 获取单位后缀(简洁模式,网速已含单位返回空) */
function unitSuffix(item: OsdItem): string {
if (item.special === 'net-up' || item.special === 'net-down') return ''
if (!osdConfig.value.showUnit) return ''
switch (item.type) {
case 'temperature': return '°C'
case 'load': return '%'
case 'power': return 'W'
case 'voltage': return 'V'
case 'fan': return 'RPM'
case 'clock':
case 'frequency': return 'MHz'
case 'data':
case 'smalldata': return 'GB'
case 'level': return '%'
default: return item.unit || ''
}
}
/** 将 hex 颜色 + 不透明度(0-100) 转为 rgba 字符串 */
function withOpacity(hex: string, opacityPct: number): string {
const a = Math.max(0, Math.min(100, opacityPct)) / 100
@@ -1210,42 +1272,6 @@ function osdItemColor(item: OsdItem): string {
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 窗口并推送数据) =====
const OSD_OVERLAY_LABEL = 'osd-overlay'
/** 抑制百分比 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">
<!-- CPU温度 + 功耗 + 曲线图未读数据以 -- 占位 -->
<!-- CPU温度 + 功耗 + 频率未读数据以 -- 占位 -->
<Card class="py-0 gap-0">
<CardHeader class="pb-1.5 px-3.5 pt-2.5">
<CardTitle class="flex items-center justify-between text-sm">
@@ -1741,7 +1767,7 @@ watch([
</CardTitle>
</CardHeader>
<CardContent class="px-3.5 pb-2.5 space-y-1.5">
<!-- 温度 + 功耗 + 曲线图 -->
<!-- 温度 + 功耗 -->
<div class="flex items-end justify-between gap-2">
<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-base font-medium tabular-nums">{{ fmt(cpuPower, 1) }}<span class="text-xs text-muted-foreground ml-0.5">W</span></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>
@@ -1768,7 +1791,7 @@ watch([
</CardContent>
</Card>
<!-- GPU结构与 CPU 一致温度 + 功耗 + 曲线图未读数据以 -- 占位 -->
<!-- GPU结构与 CPU 一致温度 + 功耗未读数据以 -- 占位 -->
<Card class="py-0 gap-0">
<CardHeader class="pb-1.5 px-3.5 pt-2.5">
<CardTitle class="flex items-center justify-between text-sm">
@@ -1777,7 +1800,7 @@ watch([
</CardTitle>
</CardHeader>
<CardContent class="px-3.5 pb-2.5 space-y-1.5">
<!-- 温度 + 功耗 + 曲线图 CPU 卡片结构一致 -->
<!-- 温度 + 功耗 CPU 卡片结构一致 -->
<div class="flex items-end justify-between gap-2">
<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-base font-medium tabular-nums">{{ fmt(gpuPower, 1) }}<span class="text-xs text-muted-foreground ml-0.5">W</span></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>
@@ -1835,7 +1855,7 @@ watch([
</CardContent>
</Card>
<!-- 网络跨3列下载/上传 + 曲线图独立于 Kernel Tauri 后台推送 -->
<!-- 网络跨3列下载/上传速率独立于 Kernel Tauri 后台推送 -->
<Card class="md:col-span-3 py-0 gap-0">
<CardHeader class="pb-1.5 px-3.5 pt-2.5">
<CardTitle class="flex items-center gap-1.5 text-sm">
@@ -1847,26 +1867,16 @@ watch([
<!-- 下载 -->
<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 shrink-0">
<div class="text-2xl font-bold tabular-nums leading-tight text-sky-500">
{{ downSpeed.value }}<span class="text-sm font-normal text-muted-foreground ml-0.5">{{ downSpeed.unit }}</span>
</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 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 shrink-0">
<div class="text-2xl font-bold tabular-nums leading-tight text-violet-500">
{{ upSpeed.value }}<span class="text-sm font-normal text-muted-foreground ml-0.5">{{ upSpeed.unit }}</span>
</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>
</CardContent>
@@ -2136,9 +2146,6 @@ watch([
<span class="truncate">{{ sensorLabel(item) }}</span>
<span class="text-xs text-muted-foreground truncate">/ {{ hwLabel(item) }}</span>
</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)">
<span class="text-lg leading-none">×</span>
</Button>
@@ -2651,78 +2658,7 @@ watch([
</CardContent>
</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>
</ScrollArea>
</TabsContent>
+726
View File
@@ -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>
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -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)."}