截图模块初始化

This commit is contained in:
zhongluofeng
2026-07-31 18:30:55 +08:00
parent 9d8f963cc6
commit 66575c6166
16 changed files with 1011 additions and 481 deletions
+26 -16
View File
@@ -8,13 +8,14 @@
"name": "thing", "name": "thing",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@lucide/vue": "^1.25.0", "@lucide/vue": "^1.28.0",
"@tailwindcss/vite": "^4.3.2", "@tailwindcss/vite": "^4.3.2",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-autostart": "~2", "@tauri-apps/plugin-autostart": "~2",
"@tauri-apps/plugin-dialog": "^2", "@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-global-shortcut": "^2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@vueuse/core": "^14.3.0", "@vueuse/core": "^14.4.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"pinia": "^3.0.4", "pinia": "^3.0.4",
@@ -603,9 +604,9 @@
} }
}, },
"node_modules/@lucide/vue": { "node_modules/@lucide/vue": {
"version": "1.25.0", "version": "1.28.0",
"resolved": "https://registry.npmmirror.com/@lucide/vue/-/vue-1.25.0.tgz", "resolved": "https://registry.npmmirror.com/@lucide/vue/-/vue-1.28.0.tgz",
"integrity": "sha512-hkEetV+v48ScIn3uwqwWQ66sI8foeP2q6OMI09GzLFH4SfvBlfe3JHYlMBdBCqFC7WRlhFsndyDn/awRKRc2OQ==", "integrity": "sha512-cO89UyNM/0i2Srdi04nFWeEOQkqzDZlM2ehtWyBhcMpRTJDKpMZPCGAVlmVBgBgr5HaeL5L6FsU8RHsOdwXtKw==",
"license": "ISC", "license": "ISC",
"peerDependencies": { "peerDependencies": {
"vue": ">=3.0.1" "vue": ">=3.0.1"
@@ -1455,6 +1456,15 @@
"@tauri-apps/api": "^2.11.0" "@tauri-apps/api": "^2.11.0"
} }
}, },
"node_modules/@tauri-apps/plugin-global-shortcut": {
"version": "2.3.2",
"resolved": "https://registry.npmmirror.com/@tauri-apps/plugin-global-shortcut/-/plugin-global-shortcut-2.3.2.tgz",
"integrity": "sha512-UReHNXrLvpEjylE4jb4oCYiy96uRykPUthoCQCmRXYrd5hs5X9DrW+qOn7GLW57EJN4tdK8bgK5twBTz2NOxzA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-opener": { "node_modules/@tauri-apps/plugin-opener": {
"version": "2.5.4", "version": "2.5.4",
"license": "MIT OR Apache-2.0", "license": "MIT OR Apache-2.0",
@@ -1671,14 +1681,14 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vueuse/core": { "node_modules/@vueuse/core": {
"version": "14.3.0", "version": "14.4.0",
"resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.3.0.tgz", "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.4.0.tgz",
"integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==", "integrity": "sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/web-bluetooth": "^0.0.21", "@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "14.3.0", "@vueuse/metadata": "14.4.0",
"@vueuse/shared": "14.3.0" "@vueuse/shared": "14.4.0"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
@@ -1688,18 +1698,18 @@
} }
}, },
"node_modules/@vueuse/metadata": { "node_modules/@vueuse/metadata": {
"version": "14.3.0", "version": "14.4.0",
"resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.3.0.tgz", "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.4.0.tgz",
"integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==", "integrity": "sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
} }
}, },
"node_modules/@vueuse/shared": { "node_modules/@vueuse/shared": {
"version": "14.3.0", "version": "14.4.0",
"resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.3.0.tgz", "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.4.0.tgz",
"integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==", "integrity": "sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
+2 -2
View File
@@ -10,14 +10,14 @@
"tauri": "tauri" "tauri": "tauri"
}, },
"dependencies": { "dependencies": {
"@lucide/vue": "^1.25.0", "@lucide/vue": "^1.28.0",
"@tailwindcss/vite": "^4.3.2", "@tailwindcss/vite": "^4.3.2",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-autostart": "~2", "@tauri-apps/plugin-autostart": "~2",
"@tauri-apps/plugin-dialog": "^2", "@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-global-shortcut": "^2", "@tauri-apps/plugin-global-shortcut": "^2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@vueuse/core": "^14.3.0", "@vueuse/core": "^14.4.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"pinia": "^3.0.4", "pinia": "^3.0.4",
+1
View File
@@ -56,6 +56,7 @@ windows-sys = { version = "0.52", features = [
"Win32_UI_HiDpi", "Win32_UI_HiDpi",
"Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Input_KeyboardAndMouse",
"Win32_Graphics_Gdi", "Win32_Graphics_Gdi",
"Win32_Storage_Xps",
] } ] }
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
+60 -22
View File
@@ -83,30 +83,28 @@ 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 // 获取光标所在显示器的工作区(物理像素,与 get_cursor_pos 同一坐标系
let (screen_w, screen_h) = get_work_area().unwrap_or((1920.0, 1080.0)); let (wa_left, wa_top, wa_right, wa_bottom) = get_work_area_at_point(mx, my)
.unwrap_or((0, 0, 1920, 1040));
// 获取 scale factor,将窗口逻辑尺寸换算为物理像素用于边界裁剪 // 获取光标所在显示器的 DPI,将物理坐标转为逻辑坐标(DIP)
let scale = app let dpi = get_dpi_for_point(mx, my).unwrap_or(96);
.get_webview_window(POPUP_LABEL) let scale = dpi as f64 / 96.0;
.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 mx_l = mx as f64 / scale;
let h_phys = (h * scale) as i32; let my_l = my as f64 / scale;
let wa_left_l = wa_left as f64 / scale;
let wa_top_l = wa_top as f64 / scale;
let wa_right_l = wa_right as f64 / scale;
let wa_bottom_l = wa_bottom as f64 / scale;
// 物理坐标 clamping // 逻辑坐标 clamping(窗口尺寸 w/h 也是逻辑像素)
let x = mx.max(0).min(screen_w as i32 - w_phys); let x = mx_l.max(wa_left_l).min(wa_right_l - w);
let y = my.max(0).min(screen_h as i32 - h_phys); let y = my_l.max(wa_top_l).min(wa_bottom_l - h);
// 窗口已存在:移动 + 显示 + 请求焦点 // 窗口已存在:移动 + 显示 + 请求焦点
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::Physical(tauri::PhysicalPosition { let _ = win.set_position(tauri::Position::Logical(tauri::LogicalPosition {
x, x,
y, y,
})); }));
@@ -126,7 +124,7 @@ pub fn show_popup(app: &AppHandle) {
) )
.title("剪贴板") .title("剪贴板")
.inner_size(w, h) .inner_size(w, h)
.position(x as f64 / scale, y as f64 / scale) .position(x, y)
.decorations(false) .decorations(false)
.transparent(true) .transparent(true)
.shadow(true) .shadow(true)
@@ -233,7 +231,7 @@ mod win_api {
use windows_sys::Win32::Foundation::POINT; use windows_sys::Win32::Foundation::POINT;
use windows_sys::Win32::UI::WindowsAndMessaging::{GetCursorPos, SystemParametersInfoW, SPI_GETWORKAREA}; use windows_sys::Win32::UI::WindowsAndMessaging::{GetCursorPos, SystemParametersInfoW, SPI_GETWORKAREA};
/// 获取鼠标位置(屏幕坐标,逻辑像素) /// 获取鼠标位置(屏幕坐标,物理像素)
pub fn get_cursor_pos() -> Option<(i32, i32)> { pub fn get_cursor_pos() -> Option<(i32, i32)> {
let mut pt = POINT { x: 0, y: 0 }; let mut pt = POINT { x: 0, y: 0 };
unsafe { unsafe {
@@ -245,7 +243,7 @@ mod win_api {
} }
} }
/// 获取屏工作区(排除任务栏,逻辑像素) /// 获取屏工作区尺寸(排除任务栏,物理像素)
pub fn get_work_area() -> Option<(f64, f64)> { pub fn get_work_area() -> Option<(f64, f64)> {
use windows_sys::Win32::Foundation::RECT; use windows_sys::Win32::Foundation::RECT;
let mut rect = RECT { left: 0, top: 0, right: 0, bottom: 0 }; let mut rect = RECT { left: 0, top: 0, right: 0, bottom: 0 };
@@ -257,12 +255,52 @@ mod win_api {
} }
} }
} }
/// 获取指定点所在显示器的工作区(排除任务栏),返回 (left, top, right, bottom) 物理像素。
/// 使用 MonitorFromPoint 支持多显示器环境。
pub fn get_work_area_at_point(x: i32, y: i32) -> Option<(i32, i32, i32, i32)> {
use windows_sys::Win32::Graphics::Gdi::{
GetMonitorInfoW, MonitorFromPoint, MONITORINFO, MONITOR_DEFAULTTONEAREST,
};
let pt = POINT { x, y };
let hmon = unsafe { MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST) };
let mut mi: MONITORINFO = unsafe { std::mem::zeroed() };
mi.cbSize = std::mem::size_of::<MONITORINFO>() as u32;
unsafe {
if GetMonitorInfoW(hmon, &mut mi) != 0 {
let rc = mi.rcWork;
Some((rc.left, rc.top, rc.right, rc.bottom))
} else {
None
}
}
}
/// 获取指定点所在显示器的有效 DPI。
/// scale factor = dpi / 96。
pub fn get_dpi_for_point(x: i32, y: i32) -> Option<u32> {
use windows_sys::Win32::Graphics::Gdi::{MonitorFromPoint, MONITOR_DEFAULTTONEAREST};
use windows_sys::Win32::UI::HiDpi::{GetDpiForMonitor, MDT_EFFECTIVE_DPI};
let pt = POINT { x, y };
let hmon = unsafe { MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST) };
let mut dpi_x: u32 = 0;
let mut dpi_y: u32 = 0;
unsafe {
if GetDpiForMonitor(hmon, MDT_EFFECTIVE_DPI, &mut dpi_x, &mut dpi_y) == 0 {
Some(dpi_x)
} else {
None
}
}
}
} }
#[cfg(not(windows))] #[cfg(not(windows))]
mod win_api { mod win_api {
pub fn get_cursor_pos() -> Option<(i32, i32)> { None } pub fn get_cursor_pos() -> Option<(i32, i32)> { None }
pub fn get_work_area() -> Option<(f64, f64)> { None } pub fn get_work_area() -> Option<(f64, f64)> { None }
pub fn get_work_area_at_point(_x: i32, _y: i32) -> Option<(i32, i32, i32, i32)> { None }
pub fn get_dpi_for_point(_x: i32, _y: i32) -> Option<u32> { None }
} }
pub use win_api::{get_cursor_pos, get_work_area}; pub use win_api::{get_cursor_pos, get_work_area, get_work_area_at_point, get_dpi_for_point};
+20 -3
View File
@@ -232,10 +232,19 @@ pub fn dib_info(dib: &[u8]) -> Option<(u32, u32)> {
Some((width as u32, height.abs() as u32)) Some((width as u32, height.abs() as u32))
} }
/// 将 CF_DIB 字节转换为 PNG 字节(支持 24/32bpp BI_RGB)。 /// 将 CF_DIB 字节转换为 PNG 字节(支持 24/32bpp BI_RGB / BI_BITFIELDS)。
pub fn dib_to_png(dib: &[u8]) -> Option<Vec<u8>> { pub fn dib_to_png(dib: &[u8]) -> Option<Vec<u8>> {
use image::codecs::png::PngEncoder; use image::codecs::png::PngEncoder;
use image::ImageEncoder; use image::ImageEncoder;
// 兜底:数据本身就是 PNG / JPEG(极少数来源直接存放压缩数据)
if dib.len() >= 4 && &dib[0..4] == b"\x89PNG" {
return Some(dib.to_vec());
}
if dib.len() >= 3 && dib[0] == 0xFF && dib[1] == 0xD8 && dib[2] == 0xFF {
return Some(dib.to_vec());
}
if dib.len() < 40 { if dib.len() < 40 {
return None; return None;
} }
@@ -244,7 +253,14 @@ pub fn dib_to_png(dib: &[u8]) -> Option<Vec<u8>> {
let height_raw = i32::from_le_bytes([dib[8], dib[9], dib[10], dib[11]]); let height_raw = i32::from_le_bytes([dib[8], dib[9], dib[10], dib[11]]);
let bpp = u16::from_le_bytes([dib[14], dib[15]]); let bpp = u16::from_le_bytes([dib[14], dib[15]]);
let compression = u32::from_le_bytes([dib[16], dib[17], dib[18], dib[19]]); let compression = u32::from_le_bytes([dib[16], dib[17], dib[18], dib[19]]);
if width <= 0 || compression != 0 { if width <= 0 {
return None;
}
// 接受 BI_RGB(0) 和 BI_BITFIELDS(3)。
// Windows 截图工具(Win+Shift+S / 截图工具)常用 BI_BITFIELDS 标记 32bpp BGRA
// 像素数据本身未压缩,与 BI_RGB 解码方式一致。
// 拒绝 BI_RLE4/8(1/2) 和 BI_JPEG/PNG(4/5) 等真正压缩格式。
if compression != 0 && compression != 3 {
return None; return None;
} }
if bpp != 24 && bpp != 32 { if bpp != 24 && bpp != 32 {
@@ -269,7 +285,8 @@ pub fn dib_to_png(dib: &[u8]) -> Option<Vec<u8>> {
rgba[dp] = dib[sp + 2]; // R rgba[dp] = dib[sp + 2]; // R
rgba[dp + 1] = dib[sp + 1]; // G rgba[dp + 1] = dib[sp + 1]; // G
rgba[dp + 2] = dib[sp]; // B rgba[dp + 2] = dib[sp]; // B
rgba[dp + 3] = 255; // A // 32bpp 保留 alpha 通道(截图工具常用);24bpp 不透明
rgba[dp + 3] = if bpp == 32 { dib[sp + 3] } else { 255 };
} }
} }
let mut buf = Vec::new(); let mut buf = Vec::new();
+20 -3
View File
@@ -9,6 +9,7 @@ mod monitor_kernel;
mod network_monitor; mod network_monitor;
mod osd_window; mod osd_window;
mod process_manager; mod process_manager;
mod screenshot;
mod snap_fix; mod snap_fix;
mod tray_menu; mod tray_menu;
@@ -43,6 +44,12 @@ use process_manager::{
get_all_process_status, get_process_status, start_monitoring_thread, start_process, get_all_process_status, get_process_status, start_monitoring_thread, start_process,
stop_all_processes, stop_process, ProcessManager, stop_all_processes, stop_process, ProcessManager,
}; };
use screenshot::commands::{
screenshot_capture_fullscreen, screenshot_capture_window, screenshot_copy_image,
screenshot_crop_stored, screenshot_enum_windows, screenshot_get_editor_image,
screenshot_save_png, screenshot_set_editor_image, screenshot_take_fullscreen,
screenshot_window_from_point,
};
use clipboard::{ use clipboard::{
ClipboardManager, ClipboardManager,
clipboard_clear, clipboard_copy_back, clipboard_count, clipboard_delete, clipboard_get_history, clipboard_clear, clipboard_copy_back, clipboard_count, clipboard_delete, clipboard_get_history,
@@ -51,7 +58,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}; use tray_menu::{tray_menu_action, tray_menu_hide, tray_menu_ready};
#[tauri::command] #[tauri::command]
fn greet(name: &str) -> String { fn greet(name: &str) -> String {
@@ -185,8 +192,18 @@ pub fn run() {
clipboard_paste_to_target, clipboard_paste_to_target,
tray_menu_action, tray_menu_action,
tray_menu_hide, tray_menu_hide,
tray_menu_show_window, tray_menu_ready,
snap_fix::fix_snap_background snap_fix::fix_snap_background,
screenshot_capture_fullscreen,
screenshot_take_fullscreen,
screenshot_crop_stored,
screenshot_window_from_point,
screenshot_enum_windows,
screenshot_capture_window,
screenshot_set_editor_image,
screenshot_get_editor_image,
screenshot_copy_image,
screenshot_save_png
]) ])
.setup(|app| { .setup(|app| {
// 初始化日志系统,日志目录: {app_data_dir}/logs/ // 初始化日志系统,日志目录: {app_data_dir}/logs/
+2 -2
View File
@@ -1101,7 +1101,7 @@ fn clear_system_proxy_windows() -> Result<(), String> {
} }
#[cfg(windows)] #[cfg(windows)]
fn get_system_proxy_windows() -> bool { pub(crate) fn get_system_proxy_windows() -> bool {
use winreg::enums::*; use winreg::enums::*;
use winreg::RegKey; use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER); let hkcu = RegKey::predef(HKEY_CURRENT_USER);
@@ -1130,7 +1130,7 @@ fn clear_system_proxy_windows() -> Result<(), String> {
Err("系统代理仅支持 Windows".into()) Err("系统代理仅支持 Windows".into())
} }
#[cfg(not(windows))] #[cfg(not(windows))]
fn get_system_proxy_windows() -> bool { pub(crate) fn get_system_proxy_windows() -> bool {
false false
} }
+86 -52
View File
@@ -20,7 +20,11 @@ use tauri::{
/// 记录窗口最后显示时间,用于失焦防抖(避免显示瞬间因焦点未稳定而被立即隐藏) /// 记录窗口最后显示时间,用于失焦防抖(避免显示瞬间因焦点未稳定而被立即隐藏)
static LAST_SHOW_TIME: Mutex<Option<Instant>> = Mutex::new(None); static LAST_SHOW_TIME: Mutex<Option<Instant>> = Mutex::new(None);
use crate::clipboard::popup::{get_cursor_pos, get_work_area}; /// 保存最近一次右键时计算出的定位参数(逻辑坐标),供 `tray_menu_ready` 使用
/// (x, tray_top_l, wa_top_l, wa_bottom_l, scale)
static LAST_MENU_LAYOUT: Mutex<Option<(f64, f64, f64, f64, f64)>> = Mutex::new(None);
use crate::clipboard::popup::{get_work_area, get_work_area_at_point, get_dpi_for_point};
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;
@@ -211,7 +215,8 @@ pub async fn get_tray_menu_state(app: &AppHandle) -> TrayMenuState {
// ===== 显示/隐藏托盘菜单窗口 ===== // ===== 显示/隐藏托盘菜单窗口 =====
/// 托盘菜单窗口尺寸(逻辑像素) /// 托盘菜单窗口尺寸(逻辑像素)
const MENU_W: f64 = 260.0; /// 宽度需与前端 TrayMenu.vue 的 MENU_WIDTH 保持一致,避免 resize 时右对齐错位
const MENU_W: f64 = 220.0;
const MENU_H: f64 = 420.0; const MENU_H: f64 = 420.0;
/// 预创建隐藏的托盘菜单窗口(在应用启动时调用)。 /// 预创建隐藏的托盘菜单窗口(在应用启动时调用)。
@@ -272,26 +277,37 @@ pub fn precreate_tray_menu_window(app: &AppHandle) {
eprintln!("[tray-menu] 菜单窗口已预创建(隐藏渲染)"); eprintln!("[tray-menu] 菜单窗口已预创建(隐藏渲染)");
} }
/// 在鼠标当前位置显示托盘菜单窗口。 /// 右键托盘时调用:计算定位参数、发送状态给前端,但不立即显示窗口。
/// 窗口不存在则创建并显示,已存在则移动到鼠标位置并显示 /// 窗口等待前端测量内容高度后调用 `tray_menu_ready` 才显示,确保底部精确对齐托盘图标
pub fn show_tray_menu(app: &AppHandle) { ///
let (mx, my) = match get_cursor_pos() { /// `cursor_pos`: 事件报告的鼠标物理坐标;`tray_rect`: 托盘图标区域(物理像素)。
Some(p) => p, pub fn show_tray_menu(app: &AppHandle, cursor_pos: (f64, f64), tray_rect: (f64, f64, f64, f64)) {
None => return, let (mx, my) = (cursor_pos.0, cursor_pos.1);
}; let tray_top = tray_rect.1;
// GetCursorPos 在 Per-Monitor DPI V2 下返回物理像素,直接用作 PhysicalPosition // 获取光标所在显示器的工作区(物理像素)
let win = app.get_webview_window(TRAY_MENU_LABEL); let (wa_left, wa_top, wa_right, wa_bottom) = get_work_area_at_point(mx as i32, my as i32)
let scale = win.as_ref() .unwrap_or((0, 0, 1920, 1040));
.and_then(|w| w.scale_factor().ok())
.unwrap_or(1.0);
let w_phys = (MENU_W * scale) as i32; // 获取光标所在显示器的 DPI,将物理坐标转为逻辑坐标(DIP)
let h_phys = (MENU_H * scale) as i32; let dpi = get_dpi_for_point(mx as i32, my as i32).unwrap_or(96);
let scale = dpi as f64 / 96.0;
// 托盘位于屏幕右下角,菜单始终出现在鼠标左上方 let mx_l = mx / scale;
let x = (mx - w_phys).max(0); let tray_top_l = tray_top / scale;
let y = (my - h_phys - 8).max(0); let wa_left_l = wa_left as f64 / scale;
let wa_right_l = wa_right as f64 / scale;
let wa_top_l = wa_top as f64 / scale;
let wa_bottom_l = wa_bottom as f64 / scale;
// 水平:菜单左边缘对齐鼠标 X(向右延伸),超出右边界则左移
let x = mx_l.max(wa_left_l).min(wa_right_l - MENU_W);
// 保存布局参数,供 tray_menu_ready 使用
{
let mut layout = LAST_MENU_LAYOUT.lock().unwrap();
*layout = Some((x, tray_top_l, wa_top_l, wa_bottom_l, scale));
}
// 记录显示时间,用于失焦防抖 // 记录显示时间,用于失焦防抖
{ {
@@ -299,43 +315,18 @@ pub fn show_tray_menu(app: &AppHandle) {
*t = Some(Instant::now()); *t = Some(Instant::now());
} }
// 窗口存在(预创建或之前显示过):移动 + 显示 + 发送状态 // 确保窗口存在(兖底)
if let Some(win) = win { if app.get_webview_window(TRAY_MENU_LABEL).is_none() {
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); 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);
});
}
} }
/// 预创建窗口完成后由前端调用(仅触发状态推送,不显示窗口——预创建模式下窗口保持隐藏)。 // 发送状态给前端(前端测量内容高度后调用 tray_menu_ready 显示窗口)
pub fn show_window(app: &AppHandle) {
if app.get_webview_window(TRAY_MENU_LABEL).is_some() {
let app_clone = app.clone(); 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 state = get_tray_menu_state(&app_clone).await;
let _ = app_clone.emit("tray-menu-show", state); let _ = app_clone.emit("tray-menu-show", state);
}); });
} }
}
/// 隐藏托盘菜单窗口(不销毁,保留复用) /// 隐藏托盘菜单窗口(不销毁,保留复用)
pub fn hide_tray_menu(app: &AppHandle) { pub fn hide_tray_menu(app: &AppHandle) {
@@ -403,6 +394,11 @@ pub async fn tray_menu_action(
let _ = app.emit("tray:new-download", ()); let _ = app.emit("tray:new-download", ());
hide_tray_menu(&app); hide_tray_menu(&app);
} }
"screenshot_region" => {
hide_tray_menu(&app);
// 主窗口(隐藏运行中)接收事件后调用 screenshotStore.startCapture('region')
let _ = app.emit("tray:start-screenshot", "region");
}
"settings" => { "settings" => {
if let Some(window) = app.get_webview_window("main") { if let Some(window) = app.get_webview_window("main") {
window.show().ok(); window.show().ok();
@@ -433,10 +429,35 @@ pub async fn tray_menu_hide(app: AppHandle) -> Result<(), String> {
Ok(()) Ok(())
} }
/// 显示已创建的菜单窗口(前端 onMounted 后调用) /// 前端测量内容高度后调用:调整窗口大小、精确定位、然后显示。
/// 这样窗口从一开始就是正确尺寸和位置,不会出现间隙。
#[tauri::command] #[tauri::command]
pub async fn tray_menu_show_window(app: AppHandle) -> Result<(), String> { pub async fn tray_menu_ready(content_height: f64, app: AppHandle) -> Result<(), String> {
show_window(&app); let win = app.get_webview_window(TRAY_MENU_LABEL)
.ok_or("tray-menu window not found")?;
let (x, tray_top_l, wa_top_l, wa_bottom_l, _scale) = {
let layout = LAST_MENU_LAYOUT.lock().unwrap();
layout.unwrap_or((0.0, 1040.0, 0.0, 1040.0, 1.0))
};
// 将内容高度限制在合理范围内
let h = content_height.max(100.0).min(520.0);
// 调整窗口尺寸
let _ = win.set_size(tauri::Size::Logical(tauri::LogicalSize {
width: MENU_W,
height: h,
}));
// 垂直:菜单下边缘紧贴托盘图标顶部(向上弹出)
let y = (tray_top_l - h).max(wa_top_l).min(wa_bottom_l - h);
let pos = tauri::Position::Logical(tauri::LogicalPosition { x, y });
let _ = win.set_position(pos);
let _ = win.show();
let _ = win.set_focus();
Ok(()) Ok(())
} }
@@ -695,10 +716,23 @@ pub fn create_tray_menu(app: &AppHandle) -> Result<(), tauri::Error> {
} }
TrayIconEvent::Click { TrayIconEvent::Click {
button: MouseButton::Right, button: MouseButton::Right,
position,
rect,
.. ..
} => { } => {
// 右键:显示自定义菜单窗口 // 右键:显示自定义菜单窗口(使用事件中的精确坐标)
show_tray_menu(&app); let cursor = (position.x, position.y);
// 从 Rect 的 Position/Size 枚举中提取物理像素值
let (rx, ry) = match rect.position {
tauri::Position::Physical(p) => (p.x as f64, p.y as f64),
tauri::Position::Logical(p) => (p.x, p.y),
};
let (_rw, rh) = match rect.size {
tauri::Size::Physical(s) => (s.width as f64, s.height as f64),
tauri::Size::Logical(s) => (s.width, s.height),
};
let tray_r = (rx, ry, _rw, rh);
show_tray_menu(&app, cursor, tray_r);
} }
_ => {} _ => {}
} }
+13
View File
@@ -6,12 +6,14 @@ import Sidebar from '@/components/layout/Sidebar.vue'
import ModuleContainer from '@/components/layout/ModuleContainer.vue' import ModuleContainer from '@/components/layout/ModuleContainer.vue'
import { Toaster } from '@/components/ui/sonner' import { Toaster } from '@/components/ui/sonner'
import { useAppStore } from '@/stores/appStore' import { useAppStore } from '@/stores/appStore'
import { useScreenshotStore, type CaptureMode } from '@/stores/screenshotStore'
import { TooltipProvider } from '@/components/ui/tooltip' import { TooltipProvider } from '@/components/ui/tooltip'
import { moduleRegistry } from '@/modules/registry' import { moduleRegistry } from '@/modules/registry'
import type { ModuleMeta } from '@/types/module' import type { ModuleMeta } from '@/types/module'
import { pendingNewDownload, pendingOpenSettings } from '@/lib/trayEvents' import { pendingNewDownload, pendingOpenSettings } from '@/lib/trayEvents'
const appStore = useAppStore() const appStore = useAppStore()
const screenshotStore = useScreenshotStore()
/** 侧边栏 / 标题栏需要的模块信息(id + name + icon */ /** 侧边栏 / 标题栏需要的模块信息(id + name + icon */
interface NavModule { interface NavModule {
@@ -157,12 +159,23 @@ onMounted(async () => {
handleModuleChange('settings') handleModuleChange('settings')
}) })
) )
// 托盘截图:直接触发截图流程(不切换模块,覆盖层/编辑器独立窗口)
trayUnlisteners.push(
await listen<CaptureMode>('tray:start-screenshot', (e) => {
screenshotStore.startCapture(e.payload || 'region').catch(err =>
console.error('[screenshot] 托盘触发截图失败:', err)
)
})
)
// 截图导出监听(应用级常驻,确保任何来源的截图都记录到历史)
screenshotStore.initExportListener().catch(e => console.error('[screenshot] 导出监听初始化失败:', e))
}) })
const trayUnlisteners: UnlistenFn[] = [] const trayUnlisteners: UnlistenFn[] = []
onUnmounted(() => { onUnmounted(() => {
trayUnlisteners.forEach(fn => fn()) trayUnlisteners.forEach(fn => fn())
screenshotStore.destroyExportListener()
}) })
</script> </script>
+1 -1
View File
@@ -13,7 +13,7 @@ defineProps<{
class="flex-1" class="flex-1"
:style="{ backgroundColor: 'var(--effect-bg)', backdropFilter: 'var(--effect-blur)' }" :style="{ backgroundColor: 'var(--effect-bg)', backdropFilter: 'var(--effect-blur)' }"
> >
<ScrollArea class="h-full w-full"> <ScrollArea data-main-scroll class="h-full w-full">
<Transition name="fade-slide" mode="out-in"> <Transition name="fade-slide" mode="out-in">
<div <div
v-if="activeComponent" v-if="activeComponent"
+64 -4
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { Search, Settings, ChevronRight } from '@lucide/vue' import { Search, Settings, ChevronRight, ArrowUp } from '@lucide/vue'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window' import { getCurrentWindow } from '@tauri-apps/api/window'
@@ -137,6 +137,48 @@ if (tauriWindow) {
// 初始时窗口已显示,恢复 hover // 初始时窗口已显示,恢复 hover
restoreHover(300) restoreHover(300)
// ===== 回到顶部按钮 =====
const showScrollTop = ref(false)
let scrollViewport: HTMLElement | null = null
let rafId: number | null = null
const handleMainScroll = () => {
if (!scrollViewport) return
showScrollTop.value = scrollViewport.scrollTop > 200
}
const scrollToTop = () => {
if (!scrollViewport) return
const start = scrollViewport.scrollTop
if (start === 0) return
// 物理变速滚动:cubic ease-out,先快后慢
const duration = 500
const startTime = performance.now()
const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3)
if (rafId !== null) cancelAnimationFrame(rafId)
const animate = (now: number) => {
const elapsed = now - startTime
const progress = Math.min(elapsed / duration, 1)
scrollViewport!.scrollTop = start * (1 - easeOutCubic(progress))
if (progress < 1) {
rafId = requestAnimationFrame(animate)
} else {
rafId = null
}
}
rafId = requestAnimationFrame(animate)
}
const initScrollListener = () => {
scrollViewport = document.querySelector<HTMLElement>(
'[data-main-scroll] [data-reka-scroll-area-viewport]'
)
if (scrollViewport) {
scrollViewport.addEventListener('scroll', handleMainScroll, { passive: true })
handleMainScroll()
}
}
onMounted(async () => { onMounted(async () => {
if (tauriWindow) { if (tauriWindow) {
try { try {
@@ -152,12 +194,16 @@ onMounted(async () => {
invoke('fix_snap_background').catch(() => {}) invoke('fix_snap_background').catch(() => {})
}, 1000) }, 1000)
} }
// 等待模块容器渲染后绑定滚动监听
nextTick(initScrollListener)
}) })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('mousemove', handleFirstMouseMove) window.removeEventListener('mousemove', handleFirstMouseMove)
if (restoreHoverTimer) clearTimeout(restoreHoverTimer) if (restoreHoverTimer) clearTimeout(restoreHoverTimer)
if (unlistenMaximize) unlistenMaximize() if (unlistenMaximize) unlistenMaximize()
if (scrollViewport) scrollViewport.removeEventListener('scroll', handleMainScroll)
if (rafId !== null) cancelAnimationFrame(rafId)
}) })
const handleBlur = () => { const handleBlur = () => {
@@ -178,11 +224,12 @@ const handleBlur = () => {
> >
<span class="font-semibold text-sm">Thing</span> <span class="font-semibold text-sm">Thing</span>
<!-- 浮动标签切换器模块内 TabsList 滚出可视区时显示 --> <!-- 浮动标签切换器 + 回到顶部按钮 -->
<div class="flex items-center gap-1 ml-2 pointer-events-auto">
<Transition name="floating-tabs"> <Transition name="floating-tabs">
<div <div
v-if="tabsStore.floatingVisible && tabsStore.tabs.length > 0" v-if="tabsStore.floatingVisible && tabsStore.tabs.length > 0"
class="flex items-center gap-0.5 ml-2 pointer-events-auto" class="flex items-center gap-0.5"
> >
<button <button
v-for="tab in tabsStore.tabs" v-for="tab in tabsStore.tabs"
@@ -200,6 +247,18 @@ const handleBlur = () => {
</button> </button>
</div> </div>
</Transition> </Transition>
<Transition name="floating-tabs">
<button
v-if="showScrollTop"
class="flex items-center justify-center h-6 w-6 rounded-md text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
title="回到顶部"
@click="scrollToTop"
@mousedown.stop
>
<ArrowUp class="h-3.5 w-3.5" />
</button>
</Transition>
</div>
<div class="flex-1"></div> <div class="flex-1"></div>
<div class="relative max-w-xs mr-3 pointer-events-auto"> <div class="relative max-w-xs mr-3 pointer-events-auto">
@@ -310,6 +369,7 @@ const handleBlur = () => {
.hover-suppressed button:hover, .hover-suppressed button:hover,
.hover-suppressed button.is-hovered { .hover-suppressed button.is-hovered {
background-color: transparent !important; background-color: transparent !important;
color: var(--foreground) !important;
} }
/* 最大化按钮:snap-layout 插件的原生子窗口遮挡了指针事件, /* 最大化按钮:snap-layout 插件的原生子窗口遮挡了指针事件,
+14 -1
View File
@@ -16,8 +16,9 @@ window.addEventListener('unhandledrejection', (event) => {
}) })
// ===== OSD 窗口模式检测 ===== // ===== OSD 窗口模式检测 =====
// 通过 URL hash 识别:#osd-overlay(悬浮窗)/ #clipboard-popup(剪贴板快捷弹窗)/ #tray-menu(托盘菜单) // 通过 URL hash 识别独立窗口#osd-overlay / #clipboard-popup / #tray-menu / #screenshot-overlay / #screenshot-editor
// 这些窗口是精简的独立 Vue 应用,不加载主应用的 store 和模块 // 这些窗口是精简的独立 Vue 应用,不加载主应用的 store 和模块
// 注意:screenshot-overlay 带 query?mode=region),用 startsWith 匹配
const winHash = window.location.hash const winHash = window.location.hash
if (winHash === '#osd-overlay') { if (winHash === '#osd-overlay') {
logger.info(`OSD 窗口启动: ${winHash}`) logger.info(`OSD 窗口启动: ${winHash}`)
@@ -37,6 +38,18 @@ if (winHash === '#osd-overlay') {
const app = createApp(TrayMenu) const app = createApp(TrayMenu)
app.mount('#app') app.mount('#app')
}) })
} else if (winHash.startsWith('#screenshot-overlay')) {
logger.info(`截图覆盖层窗口启动: ${winHash}`)
void import('./modules/screenshot/ScreenshotOverlay.vue').then(({ default: ScreenshotOverlay }) => {
const app = createApp(ScreenshotOverlay)
app.mount('#app')
})
} else if (winHash === '#screenshot-editor') {
logger.info(`截图编辑器窗口启动: ${winHash}`)
void import('./modules/screenshot/ScreenshotEditor.vue').then(({ default: ScreenshotEditor }) => {
const app = createApp(ScreenshotEditor)
app.mount('#app')
})
} else { } else {
// ===== 主应用模式 ===== // ===== 主应用模式 =====
void import('./App.vue').then(async ({ default: App }) => { void import('./App.vue').then(async ({ default: App }) => {
+53 -19
View File
@@ -3,12 +3,12 @@ import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { import {
ClipboardList, Copy, Pin, PinOff, Trash2, Search, Image as ImageIcon, ClipboardList, Copy, Pin, PinOff, Trash2, Search, Image as ImageIcon,
FileText, Files, Settings as SettingsIcon, Loader2, Eraser, Check, Keyboard, FileText, Files, Settings as SettingsIcon, Loader2, Eraser, Check, Keyboard,
ChevronLeft, ChevronRight,
} from '@lucide/vue' } from '@lucide/vue'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { useClipboardStore, type ClipboardItem, type ClipboardKind } from '@/stores/clipboardStore' import { useClipboardStore, type ClipboardItem, type ClipboardKind, type ClipboardItemDetail } from '@/stores/clipboardStore'
import { useModuleTabs } from '@/lib/useModuleTabs' import { useModuleTabs } from '@/lib/useModuleTabs'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -22,6 +22,9 @@ import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription,
AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import {
Pagination, PaginationContent, PaginationItem, PaginationEllipsis,
} from '@/components/ui/pagination'
const store = useClipboardStore() const store = useClipboardStore()
@@ -66,7 +69,7 @@ const gotoPage = async (p: number) => {
// 详情弹窗 // 详情弹窗
const detailOpen = ref(false) const detailOpen = ref(false)
const detailLoading = ref(false) const detailLoading = ref(false)
const detail = ref<ReturnType<typeof Object> & { content?: string | null; imageBase64?: string | null; kind?: string } | null>(null) const detail = ref<ClipboardItemDetail | null>(null)
const openDetail = async (item: ClipboardItem) => { const openDetail = async (item: ClipboardItem) => {
detailOpen.value = true detailOpen.value = true
detailLoading.value = true detailLoading.value = true
@@ -76,6 +79,15 @@ const openDetail = async (item: ClipboardItem) => {
detailLoading.value = false detailLoading.value = false
} }
/** 根据 base64 前缀判断图片 MIME 类型(dib_to_png 可能直接透传 PNG/JPEG */
const imageSrc = computed(() => {
const b64 = detail.value?.imageBase64
if (!b64) return ''
// PNG base64 以 iVBORw0KGgo 开头,JPEG 以 /9j/ 开头
const mime = b64.startsWith('/9j/') ? 'image/jpeg' : 'image/png'
return `data:${mime};base64,${b64}`
})
// 清空确认 // 清空确认
const clearOpen = ref(false) const clearOpen = ref(false)
@@ -226,7 +238,6 @@ onUnmounted(() => {
</div> </div>
<div class="flex-1" /> <div class="flex-1" />
<Badge variant="secondary"> <Badge variant="secondary">
<Loader2 v-if="store.status.running" class="h-3 w-3 mr-1 animate-spin" />
{{ store.historyTotal }} {{ store.historyTotal }}
</Badge> </Badge>
<Button variant="outline" size="sm" @click="clearOpen = true" :disabled="!historyList.length"> <Button variant="outline" size="sm" @click="clearOpen = true" :disabled="!historyList.length">
@@ -234,10 +245,40 @@ onUnmounted(() => {
</Button> </Button>
</div> </div>
<div class="flex-1 min-h-0 overflow-y-auto space-y-1.5 pr-1"> <!-- 分页置于列表上方靠左显示 -->
<div v-if="totalPages > 1" class="flex items-center justify-start pb-2 shrink-0">
<Pagination
v-slot="{ page }"
:page="currentPage"
:total="store.historyTotal"
:items-per-page="PAGE_SIZE"
:sibling-count="1"
show-edges
@update:page="gotoPage"
class="justify-start"
>
<PaginationContent v-slot="{ items }" class="gap-1">
<template v-for="(item, index) in items" :key="index">
<PaginationItem
v-if="item.type === 'page'"
:value="item.value"
:is-active="item.value === page"
size="icon"
class="size-7 text-xs"
>
{{ item.value }}
</PaginationItem>
<PaginationEllipsis v-else class="size-7" />
</template>
</PaginationContent>
</Pagination>
</div>
<ScrollArea class="flex-1 min-h-0">
<div class="space-y-1.5 pr-2">
<div <div
v-if="!historyList.length" v-if="!historyList.length"
class="flex flex-col items-center justify-center h-full text-muted-foreground" class="flex flex-col items-center justify-center text-muted-foreground py-12"
> >
<ClipboardList class="h-12 w-12 mb-3 opacity-40" /> <ClipboardList class="h-12 w-12 mb-3 opacity-40" />
<p class="text-sm">暂无历史记录复制内容后将自动收录</p> <p class="text-sm">暂无历史记录复制内容后将自动收录</p>
@@ -271,17 +312,7 @@ onUnmounted(() => {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</ScrollArea>
<!-- 分页 -->
<div v-if="totalPages > 1" class="flex items-center justify-center gap-2 pt-3 shrink-0">
<Button variant="outline" size="icon" class="h-7 w-7" :disabled="currentPage <= 1" @click="gotoPage(currentPage - 1)">
<ChevronLeft class="h-4 w-4" />
</Button>
<span class="text-xs text-muted-foreground tabular-nums">{{ currentPage }} / {{ totalPages }}</span>
<Button variant="outline" size="icon" class="h-7 w-7" :disabled="currentPage >= totalPages" @click="gotoPage(currentPage + 1)">
<ChevronRight class="h-4 w-4" />
</Button>
</div>
</TabsContent> </TabsContent>
<!-- 固定 --> <!-- 固定 -->
@@ -428,11 +459,14 @@ onUnmounted(() => {
</div> </div>
<template v-else-if="detail"> <template v-else-if="detail">
<img <img
v-if="detail.kind === 'image' && detail.imageBase64" v-if="detail.kind === 'image' && imageSrc"
:src="`data:image/png;base64,${detail.imageBase64}`" :src="imageSrc"
class="max-w-full max-h-[60vh] mx-auto rounded" class="max-w-full max-h-[60vh] mx-auto rounded"
alt="剪贴板图片" alt="剪贴板图片"
/> />
<p v-else-if="detail.kind === 'image'" class="text-sm text-muted-foreground text-center py-8">
图片预览不可用
</p>
<pre v-else-if="detail.kind === 'text'" class="text-sm whitespace-pre-wrap break-all font-mono bg-muted/50 p-3 rounded">{{ detail.content }}</pre> <pre v-else-if="detail.kind === 'text'" class="text-sm whitespace-pre-wrap break-all font-mono bg-muted/50 p-3 rounded">{{ detail.content }}</pre>
<ul v-else-if="detail.kind === 'files'" class="space-y-1 text-sm"> <ul v-else-if="detail.kind === 'files'" class="space-y-1 text-sm">
<li <li
+172 -166
View File
@@ -6,9 +6,13 @@ import { getCurrentWindow } from '@tauri-apps/api/window'
import { Effect, EffectState } from '@tauri-apps/api/window' import { Effect, EffectState } from '@tauri-apps/api/window'
import { import {
ClipboardList, Pin, PinOff, Trash2, Search, Image as ImageIcon, ClipboardList, Pin, PinOff, Trash2, Search, Image as ImageIcon,
FileText, Files, Loader2, ChevronLeft, ChevronRight, FileText, Files, Loader2,
} from '@lucide/vue' } from '@lucide/vue'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Input } from '@/components/ui/input'
import {
Pagination, PaginationContent, PaginationItem, PaginationEllipsis,
} from '@/components/ui/pagination'
// ===== 与 Rust 端对应的数据结构(camelCase ===== // ===== 与 Rust 端对应的数据结构(camelCase =====
type ClipboardKind = 'text' | 'image' | 'files' type ClipboardKind = 'text' | 'image' | 'files'
@@ -123,10 +127,14 @@ function onKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
e.preventDefault() e.preventDefault()
selectedIndex.value = Math.min(selectedIndex.value + 1, items.value.length - 1) selectedIndex.value = Math.min(selectedIndex.value + 1, items.value.length - 1)
cancelHoverTimer()
previewVisible.value = false
scrollSelectedIntoView() scrollSelectedIntoView()
} else if (e.key === 'ArrowUp') { } else if (e.key === 'ArrowUp') {
e.preventDefault() e.preventDefault()
selectedIndex.value = Math.max(selectedIndex.value - 1, 0) selectedIndex.value = Math.max(selectedIndex.value - 1, 0)
cancelHoverTimer()
previewVisible.value = false
scrollSelectedIntoView() scrollSelectedIntoView()
} else if (e.key === 'Enter') { } else if (e.key === 'Enter') {
e.preventDefault() e.preventDefault()
@@ -169,6 +177,62 @@ const formatTime = (ms: number) => {
const hasItems = computed(() => items.value.length > 0) const hasItems = computed(() => items.value.length > 0)
// ===== 图片悬停预览(悬停 100ms 后显示缩略图) =====
const previewSrc = ref('')
const previewVisible = ref(false)
let hoverTimer: ReturnType<typeof setTimeout> | null = null
// 缓存已加载的图片 id → dataUrl,避免重复请求
const imageCache = new Map<number, string>()
/** 根据 base64 前缀判断 MIME 类型 */
function buildImageDataUrl(b64: string): string {
const mime = b64.startsWith('/9j/') ? 'image/jpeg' : 'image/png'
return `data:${mime};base64,${b64}`
}
async function onItemHover(idx: number, item: ClipboardItem) {
selectedIndex.value = idx
// 仅图片类型触发预览
if (item.kind !== 'image') {
cancelHoverTimer()
previewVisible.value = false
return
}
// 先取消之前的定时器和预览
cancelHoverTimer()
// 100ms 后加载并显示(快速响应悬停意图)
hoverTimer = setTimeout(async () => {
try {
let src = imageCache.get(item.id)
if (!src) {
const detail = await invoke<{ imageBase64: string | null } | null>('clipboard_get_item', { id: item.id })
if (detail?.imageBase64) {
src = buildImageDataUrl(detail.imageBase64)
imageCache.set(item.id, src)
}
}
if (src) {
previewSrc.value = src
previewVisible.value = true
}
} catch {
/* 忽略加载失败 */
}
}, 100)
}
function cancelHoverTimer() {
if (hoverTimer) {
clearTimeout(hoverTimer)
hoverTimer = null
}
}
function onItemLeave() {
cancelHoverTimer()
previewVisible.value = false
}
// ===== 主题应用(与主应用同步) ===== // ===== 主题应用(与主应用同步) =====
/** 从 localStorage 读取主应用的主题设置 */ /** 从 localStorage 读取主应用的主题设置 */
function readMainTheme(): { theme: string; effect: string } { function readMainTheme(): { theme: string; effect: string } {
@@ -271,6 +335,10 @@ onMounted(async () => {
await applyTheme() await applyTheme()
searchQuery.value = '' searchQuery.value = ''
currentPage.value = 1 currentPage.value = 1
// 重置预览状态,清空缓存避免历史图片占用内存
cancelHoverTimer()
previewVisible.value = false
imageCache.clear()
await loadData() await loadData()
await nextTick() await nextTick()
searchInputRef.value?.focus() searchInputRef.value?.focus()
@@ -290,74 +358,102 @@ onMounted(async () => {
}) })
onUnmounted(() => { onUnmounted(() => {
cancelHoverTimer()
unlistenFns.forEach((fn) => fn()) unlistenFns.forEach((fn) => fn())
}) })
</script> </script>
<template> <template>
<div class="popup-root flex flex-col h-screen w-screen" @keydown="onKeydown"> <div class="popup-root flex flex-col h-screen w-screen" @keydown="onKeydown">
<!-- 搜索栏 --> <!-- 搜索栏与剪切板主页统一样式 -->
<div class="popup-header shrink-0"> <div class="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
<div class="search-wrap"> <div class="relative flex-1 max-w-sm">
<Search class="h-4 w-4 text-muted-foreground" /> <Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input <Input
ref="searchInputRef" ref="searchInputRef"
v-model="searchQuery" v-model="searchQuery"
placeholder="搜索剪贴板历史..." placeholder="搜索剪贴板历史..."
class="search-input" class="pl-8 text-sm"
spellcheck="false" spellcheck="false"
/> />
</div> </div>
<span class="popup-count">{{ total }} </span> <span class="text-xs text-muted-foreground whitespace-nowrap">{{ total }} </span>
</div> </div>
<!-- 图片悬停预览浮层 -->
<Transition name="popup-preview">
<div v-if="previewVisible && previewSrc" class="popup-preview">
<img :src="previewSrc" alt="预览" />
</div>
</Transition>
<!-- 列表 --> <!-- 列表 -->
<ScrollArea class="popup-list flex-1 min-h-0"> <ScrollArea class="popup-list flex-1 min-h-0">
<div v-if="loading && !hasItems" class="popup-empty"> <div class="space-y-1.5 p-2">
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" /> <div v-if="loading && !hasItems" class="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Loader2 class="h-6 w-6 animate-spin" />
</div> </div>
<div v-else-if="!hasItems" class="popup-empty"> <div v-else-if="!hasItems" class="flex flex-col items-center justify-center py-12 text-muted-foreground">
<ClipboardList class="h-10 w-10 mb-2 opacity-40" /> <ClipboardList class="h-10 w-10 mb-2 opacity-40" />
<p class="text-sm text-muted-foreground"> <p class="text-sm">
{{ searchQuery ? '无匹配结果' : '暂无历史记录' }} {{ searchQuery ? '无匹配结果' : '暂无历史记录' }}
</p> </p>
</div> </div>
<div <div
v-for="(item, idx) in items" v-for="(item, idx) in items"
:key="item.id" :key="item.id"
class="popup-item" class="popup-item group"
:class="{ 'popup-item-selected': idx === selectedIndex }" :class="{ 'popup-item-selected': idx === selectedIndex }"
@click="selectAndPaste(item)" @click="selectAndPaste(item)"
@mouseenter="selectedIndex = idx" @mouseenter="onItemHover(idx, item)"
@mouseleave="onItemLeave"
> >
<component :is="kindIcon(item.kind)" class="popup-item-icon" /> <component :is="kindIcon(item.kind)" class="h-4 w-4 text-muted-foreground shrink-0 mt-0.5" />
<div class="popup-item-body"> <div class="flex-1 min-w-0">
<p class="popup-item-preview">{{ item.preview }}</p> <p class="text-sm break-all line-clamp-1" :title="item.preview">{{ item.preview }}</p>
<div class="popup-item-meta"> <div class="flex items-center gap-2 mt-0.5 text-xs text-muted-foreground flex-wrap">
<span class="popup-item-kind" :class="kindBadgeClass(item.kind)">{{ kindLabel(item.kind) }}</span> <span class="popup-item-kind px-1.5 py-0 text-[10px] border rounded-sm" :class="kindBadgeClass(item.kind)">{{ kindLabel(item.kind) }}</span>
<span>{{ formatTime(item.createdAt) }}</span> <span>{{ formatTime(item.createdAt) }}</span>
</div> </div>
</div> </div>
<div class="popup-item-actions"> <div class="popup-item-actions shrink-0">
<button class="popup-action-btn" title="固定" @click="togglePin(item, $event)"> <button class="popup-action-btn h-7 w-7" title="固定" @click="togglePin(item, $event)">
<component :is="item.pinned ? PinOff : Pin" class="h-3.5 w-3.5" /> <component :is="item.pinned ? PinOff : Pin" class="h-3.5 w-3.5" />
</button> </button>
<button class="popup-action-btn hover:text-destructive" title="删除" @click="deleteItem(item, $event)"> <button class="popup-action-btn h-7 w-7 hover:text-destructive" title="删除" @click="deleteItem(item, $event)">
<Trash2 class="h-3.5 w-3.5" /> <Trash2 class="h-3.5 w-3.5" />
</button> </button>
</div> </div>
</div> </div>
</div>
</ScrollArea> </ScrollArea>
<!-- 分页 --> <!-- 分页与剪切板历史统一样式 -->
<div v-if="totalPages > 1" class="popup-pagination"> <div v-if="totalPages > 1" class="flex items-center justify-center gap-1 px-2 py-1 border-t border-border">
<button class="popup-page-btn" :disabled="currentPage <= 1" @click="gotoPage(currentPage - 1)"> <Pagination
<ChevronLeft class="h-3.5 w-3.5" /> v-slot="{ page }"
</button> :page="currentPage"
<span class="popup-page-info">{{ currentPage }} / {{ totalPages }}</span> :total="total"
<button class="popup-page-btn" :disabled="currentPage >= totalPages" @click="gotoPage(currentPage + 1)"> :items-per-page="PAGE_SIZE"
<ChevronRight class="h-3.5 w-3.5" /> :sibling-count="1"
</button> show-edges
@update:page="gotoPage"
>
<PaginationContent v-slot="{ items: pageItems }" class="gap-1">
<template v-for="(item, index) in pageItems" :key="index">
<PaginationItem
v-if="item.type === 'page'"
:value="item.value"
:is-active="item.value === page"
size="icon"
class="size-7 text-xs"
>
{{ item.value }}
</PaginationItem>
<PaginationEllipsis v-else class="size-7" />
</template>
</PaginationContent>
</Pagination>
</div> </div>
<!-- 底部提示 --> <!-- 底部提示 -->
@@ -377,49 +473,12 @@ onUnmounted(() => {
color: var(--foreground); color: var(--foreground);
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
} position: relative;
/* 搜索栏 */
.popup-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
}
.search-wrap {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
background: var(--input);
border-radius: 6px;
padding: 6px 10px;
}
.search-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--foreground);
font-size: 13px;
}
.search-input::placeholder {
color: var(--muted-foreground);
}
.popup-count {
font-size: 11px;
color: var(--muted-foreground);
white-space: nowrap;
} }
/* 列表 */ /* 列表 */
.popup-list { .popup-list {
padding: 4px; padding: 0;
} }
/* reka-ui ScrollAreaViewport 内部会出现一个 div,需保证高度撑满 */ /* reka-ui ScrollAreaViewport 内部会出现一个 div,需保证高度撑满 */
@@ -427,66 +486,26 @@ onUnmounted(() => {
min-height: 100%; min-height: 100%;
} }
/* 条目卡片样式(与主界面 Card 统一) */
.popup-item { .popup-item {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 10px; gap: 12px;
padding: 8px 10px; padding: 8px 12px;
border-radius: 6px; border-radius: var(--radius);
cursor: pointer; cursor: pointer;
/* 预留边框空间,避免 hover/选中时布局抖动 */ border: 1px solid var(--border);
border: 1px solid transparent; background: var(--card);
/* 统一 hover 和 selected 为同一高亮效果,避免错乱 */ transition: box-shadow 0.2s, background-color 0.1s;
transition: background-color 0.1s, border-color 0.1s; }
/* hover 使用 shadow(同主界面 hover:shadow-md),键盘选中保留轻微高亮 */
.popup-item:hover {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
} }
/* 鼠标悬停和键盘选中统一使用 accent 高亮 + 反色边框增强提醒 */
.popup-item:hover,
.popup-item-selected { .popup-item-selected {
background: var(--accent); background: var(--accent);
border-color: var(--foreground);
}
.popup-item-icon {
width: 18px;
height: 18px;
margin-top: 1px;
color: var(--muted-foreground);
flex-shrink: 0;
}
.popup-item-body {
flex: 1;
min-width: 0;
}
.popup-item-preview {
font-size: 13px;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
word-break: break-all;
}
.popup-item-meta {
display: flex;
align-items: center;
gap: 8px;
margin-top: 3px;
font-size: 11px;
color: var(--muted-foreground);
}
.popup-item-kind {
background: var(--secondary);
color: var(--secondary-foreground);
padding: 0 5px;
border-radius: 3px;
font-size: 10px;
border: 1px solid transparent;
} }
/* 类型 badge 配色(与主界面一致) */ /* 类型 badge 配色(与主界面一致) */
@@ -522,7 +541,6 @@ onUnmounted(() => {
gap: 2px; gap: 2px;
opacity: 0; opacity: 0;
transition: opacity 0.1s; transition: opacity 0.1s;
flex-shrink: 0;
} }
/* 选中项和悬停项都显示操作按钮 */ /* 选中项和悬停项都显示操作按钮 */
@@ -535,27 +553,20 @@ onUnmounted(() => {
background: transparent; background: transparent;
border: none; border: none;
cursor: pointer; cursor: pointer;
padding: 4px;
border-radius: 4px;
color: var(--muted-foreground);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
color: var(--muted-foreground);
transition: background-color 0.1s, color 0.1s;
} }
.popup-action-btn:hover { .popup-action-btn:hover {
background: var(--muted); background: var(--muted);
color: var(--foreground);
} }
/* 空状态 */ /* 空状态(使用 Tailwind 类,无需额外 CSS */
.popup-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 200px;
color: var(--muted-foreground);
}
/* 底部 */ /* 底部 */
.popup-footer { .popup-footer {
@@ -568,42 +579,37 @@ onUnmounted(() => {
color: var(--muted-foreground); color: var(--muted-foreground);
} }
/* 分页 */ /* 图片悬停预览浮层:固定在弹窗右上角,不遮挡列表操作 */
.popup-pagination { .popup-preview {
display: flex; position: absolute;
align-items: center; top: 50px;
justify-content: center; right: 10px;
gap: 8px; z-index: 100;
padding: 4px 12px; max-width: 180px;
border-top: 1px solid var(--border); max-height: 180px;
} border-radius: 6px;
overflow: hidden;
.popup-page-btn {
background: transparent;
border: 1px solid var(--border); border: 1px solid var(--border);
cursor: pointer; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
padding: 3px; background: var(--popover, var(--background));
border-radius: 4px; pointer-events: none;
color: var(--foreground);
display: flex;
align-items: center;
} }
.popup-page-btn:hover:not(:disabled) { .popup-preview img {
background: var(--accent); display: block;
max-width: 100%;
max-height: 180px;
object-fit: contain;
} }
.popup-page-btn:disabled { /* 预览浮层淡入淡出 */
opacity: 0.4; .popup-preview-enter-active,
cursor: not-allowed; .popup-preview-leave-active {
transition: opacity 0.15s ease;
} }
.popup-preview-enter-from,
.popup-page-info { .popup-preview-leave-to {
font-size: 11px; opacity: 0;
color: var(--muted-foreground);
font-variant-numeric: tabular-nums;
min-width: 50px;
text-align: center;
} }
.popup-footer kbd { .popup-footer kbd {
+195 -14
View File
@@ -1,24 +1,205 @@
<script setup lang="ts"> <script setup lang="ts">
import { Camera } from '@lucide/vue' import { ref, onMounted, onUnmounted } from 'vue'
import {
Camera, Square, AppWindow, Maximize, Copy, Save, Trash2, Image as ImageIcon, Loader2,
} from '@lucide/vue'
import { toast } from 'vue-sonner'
import { useScreenshotStore, type CaptureMode, type RecentCapture } from '@/stores/screenshotStore'
import { useModuleTabs } from '@/lib/useModuleTabs'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
const store = useScreenshotStore()
const activeTab = ref('capture')
const tabsListRef = useModuleTabs(activeTab, [
{ value: 'capture', label: '截图' },
{ value: 'history', label: '历史' },
])
const captureActions: { mode: CaptureMode; icon: typeof Square; label: string; desc: string }[] = [
{ mode: 'region', icon: Square, label: '区域截图', desc: '拖动选择屏幕任意区域' },
{ mode: 'window', icon: AppWindow, label: '窗口截图', desc: '点击捕获指定窗口' },
{ mode: 'fullscreen', icon: Maximize, label: '全屏截图', desc: '直接捕获整个虚拟屏' },
]
function formatTime(t: number): string {
const d = new Date(t)
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
const ss = String(d.getSeconds()).padStart(2, '0')
return `${hh}:${mm}:${ss}`
}
function thumbSrc(item: RecentCapture): string {
return `data:image/png;base64,${item.pngBase64}`
}
async function handleCapture(mode: CaptureMode) {
await store.startCapture(mode)
}
async function handleCopy(item: RecentCapture) {
await store.copyImage(item.pngBase64)
}
async function handleSave(item: RecentCapture) {
await store.saveImage(item.pngBase64)
}
function handleDelete(item: RecentCapture) {
const idx = store.recent.findIndex(r => r.id === item.id)
if (idx >= 0) store.recent.splice(idx, 1)
toast.success('已从历史移除')
}
onMounted(() => {
store.initExportListener().catch(e => console.error('[screenshot] 导出监听初始化失败:', e))
})
onUnmounted(() => {
store.destroyExportListener()
})
</script> </script>
<template> <template>
<div class="h-full p-6 overflow-y-auto"> <div class="h-full overflow-hidden">
<Card> <Tabs v-model="activeTab" class="h-full flex flex-col">
<CardHeader> <div ref="tabsListRef" class="px-4 pt-3 pb-2 shrink-0">
<CardTitle class="flex items-center gap-2"> <TabsList>
<Camera class="h-5 w-5 text-primary" /> <TabsTrigger value="capture">截图</TabsTrigger>
截图模块 <TabsTrigger value="history">
</CardTitle> 历史
</CardHeader> <span v-if="store.recent.length" class="ml-1 text-xs text-muted-foreground">
<CardContent> ({{ store.recent.length }})
<div class="flex flex-col items-center justify-center h-64 text-muted-foreground"> </span>
<Camera class="h-16 w-16 mb-4 opacity-50" /> </TabsTrigger>
<p>截图功能开发中...</p> </TabsList>
<p class="text-sm mt-2">支持区域截图窗口截图全屏截图滚动截图等功能</p>
</div> </div>
<!-- 截图触发 -->
<TabsContent value="capture" class="flex-1 min-h-0 mt-0">
<ScrollArea class="h-full">
<div class="p-4 pt-0 space-y-4">
<!-- 截图模式卡片 -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<Card
v-for="action in captureActions"
:key="action.mode"
class="cursor-pointer transition-colors hover:border-primary/50 hover:bg-accent/50"
:class="{ 'pointer-events-none opacity-60': store.capturing }"
@click="handleCapture(action.mode)"
>
<CardContent class="flex flex-col items-center gap-2 py-6 text-center">
<div class="flex items-center justify-center h-12 w-12 rounded-full bg-primary/10 text-primary">
<component :is="action.icon" class="h-6 w-6" />
</div>
<div class="font-medium">{{ action.label }}</div>
<div class="text-xs text-muted-foreground">{{ action.desc }}</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
<!-- 截图中提示 -->
<Card v-if="store.capturing">
<CardContent class="flex items-center justify-center gap-2 py-8 text-muted-foreground">
<Loader2 class="h-5 w-5 animate-spin text-primary" />
<span>正在捕获屏幕</span>
</CardContent>
</Card>
<!-- 说明 -->
<Card>
<CardHeader>
<CardTitle class="text-sm flex items-center gap-2">
<Camera class="h-4 w-4 text-primary" />
使用说明
</CardTitle>
</CardHeader>
<CardContent class="text-sm text-muted-foreground space-y-1.5">
<p>· <span class="text-foreground">区域截图</span>进入覆盖层后拖动鼠标选择区域松开后可编辑复制或保存</p>
<p>· <span class="text-foreground">窗口截图</span>移动鼠标高亮目标窗口点击即可捕获该窗口</p>
<p>· <span class="text-foreground">全屏截图</span>直接捕获所有显示器拼接画面并进入编辑器</p>
<p>· 选区/编辑器中按 <kbd class="px-1 py-0.5 text-xs rounded bg-muted border">Esc</kbd> 取消</p>
</CardContent>
</Card>
</div>
</ScrollArea>
</TabsContent>
<!-- 历史记录 -->
<TabsContent value="history" class="flex-1 min-h-0 mt-0">
<ScrollArea class="h-full">
<div class="p-4 pt-0">
<!-- 空状态 -->
<div
v-if="store.recent.length === 0"
class="flex flex-col items-center justify-center h-64 text-muted-foreground gap-3"
>
<ImageIcon class="h-12 w-12 opacity-40" />
<p>暂无截图历史</p>
<p class="text-xs">截图并导出后会显示在这里</p>
</div>
<!-- 历史网格 -->
<div v-else class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
<div
v-for="item in store.recent"
:key="item.id"
class="group relative rounded-lg border bg-muted/30 overflow-hidden"
>
<!-- 缩略图 -->
<div class="aspect-video flex items-center justify-center bg-zinc-900">
<img
:src="thumbSrc(item)"
class="max-w-full max-h-full object-contain"
alt="截图"
/>
</div>
<!-- 信息 -->
<div class="px-2 py-1.5 flex items-center justify-between text-xs text-muted-foreground">
<span>{{ formatTime(item.time) }}</span>
<span>{{ item.width }}×{{ item.height }}</span>
</div>
<!-- 悬浮操作 -->
<div
class="absolute inset-0 flex items-center justify-center gap-2 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Button
variant="secondary"
size="icon-sm"
title="复制到剪贴板"
@click.stop="handleCopy(item)"
>
<Copy class="h-4 w-4" />
</Button>
<Button
variant="secondary"
size="icon-sm"
title="保存到文件"
@click.stop="handleSave(item)"
>
<Save class="h-4 w-4" />
</Button>
<Button
variant="secondary"
size="icon-sm"
class="text-destructive hover:text-destructive"
title="从历史移除"
@click.stop="handleDelete(item)"
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
</ScrollArea>
</TabsContent>
</Tabs>
</div>
</template> </template>
+228 -122
View File
@@ -2,17 +2,16 @@
import { ref, reactive, computed, onMounted, onUnmounted, nextTick } from 'vue' import { ref, reactive, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event' import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { getCurrentWindow, LogicalSize, PhysicalPosition } from '@tauri-apps/api/window' import { getCurrentWindow } from '@tauri-apps/api/window'
import { Effect, EffectState } from '@tauri-apps/api/window' import { Effect, EffectState } from '@tauri-apps/api/window'
import { import {
Globe, Power, PowerOff, RefreshCw, Check, Monitor, Download, Globe, Power, PowerOff, RefreshCw, Check, Monitor, Download,
Settings, LogOut, Settings, LogOut, Camera,
} from '@lucide/vue' } from '@lucide/vue'
import { import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
// ===== 类型定义(与 Rust 端对应) =====
interface ProxyNodeInfo { interface ProxyNodeInfo {
name: string name: string
delay: number | null delay: number | null
@@ -20,38 +19,52 @@ interface ProxyNodeInfo {
} }
interface TrayMenuState { interface TrayMenuState {
proxyRunning: boolean proxyRunning: boolean
systemProxy: boolean
monitorRunning: boolean monitorRunning: boolean
proxyGroup: string | null proxyGroup: string | null
proxyNodes: ProxyNodeInfo[] proxyNodes: ProxyNodeInfo[]
proxyCurrent: string | null proxyCurrent: string | null
} }
// ===== 状态 =====
const state = reactive<TrayMenuState>({ const state = reactive<TrayMenuState>({
proxyRunning: false, proxyRunning: false,
systemProxy: false,
monitorRunning: false, monitorRunning: false,
proxyGroup: null, proxyGroup: null,
proxyNodes: [], proxyNodes: [],
proxyCurrent: null, proxyCurrent: null,
}) })
const loading = ref(false) const loadingAction = ref<string | null>(null)
const refreshing = ref(false) const refreshing = ref(false)
const osdVisible = ref(false)
let unlistenFns: UnlistenFn[] = [] let unlistenFns: UnlistenFn[] = []
// ===== 节点名缩写(badge 显示) ===== function readOsdVisible(): boolean {
// 地区映射 try {
const raw = localStorage.getItem('thing_monitor_osd_config')
if (raw) {
const parsed = JSON.parse(raw)
return parsed.config?.overlayEnabled ?? false
}
} catch { /* 忽略 */ }
return false
}
const proxyLoading = computed(() =>
loadingAction.value !== null && loadingAction.value.startsWith('proxy')
)
const REGION_MAP: Record<string, string> = { const REGION_MAP: Record<string, string> = {
'日本': 'JP', '香港': 'HK', '美国': 'US', '新加坡': 'SG', '日本': 'JP', '香港': 'HK', '美国': 'US', '新加坡': 'SG',
'台湾': 'TW', '韩国': 'KR', '印度': 'IN', '英国': 'UK', '台湾': 'TW', '韩国': 'KR', '印度': 'IN', '英国': 'UK',
'德国': 'DE', '法国': 'FR', '荷兰': 'NL', '加拿大': 'CA', '德国': 'DE', '法国': 'FR', '荷兰': 'NL', '加拿大': 'CA',
'澳大利亚': 'AU', '巴西': 'BR', '俄罗斯': 'RU', '泰国': 'TH', '澳大利亚': 'AU', '巴西': 'BR', '俄罗斯': 'RU', '泰国': 'TH',
'越南': 'VN', '马来西亚': 'MY', '菲律宾': 'PH', '印尼': 'ID', '越南': 'VN', '马来西亚': 'MY', '菲律宾': 'PH', '印尼': 'ID',
'日本东京': 'JP', '东京': 'JP', '大阪': 'JP', 'Japan': 'JP',
'Japan': 'JP', 'Tokyo': 'JP', 'Osaka': 'JP',
'Hong Kong': 'HK', 'Kong': 'HK', 'Hong Kong': 'HK', 'Kong': 'HK',
'USA': 'US', 'United States': 'US', 'America': 'US', 'USA': 'US', 'United States': 'US', 'America': 'US',
'Singapore': 'SG', 'Taiwan': 'TW', 'Korea': 'KR', 'India': 'IN', 'Singapore': 'SG', 'Taiwan': 'TW', 'Korea': 'KR', 'India': 'IN',
'UK': 'UK', 'London': 'UK', 'UK': 'UK',
'Germany': 'DE', 'France': 'FR', 'Netherlands': 'NL', 'Germany': 'DE', 'France': 'FR', 'Netherlands': 'NL',
'Canada': 'CA', 'Australia': 'AU', 'Brazil': 'BR', 'Canada': 'CA', 'Australia': 'AU', 'Brazil': 'BR',
'Russia': 'RU', 'Thailand': 'TH', 'Vietnam': 'VN', 'Russia': 'RU', 'Thailand': 'TH', 'Vietnam': 'VN',
@@ -60,7 +73,6 @@ const REGION_MAP: Record<string, string> = {
'Poland': 'PL', 'Turkey': 'TR', 'Finland': 'FI', 'Norway': 'NO', 'Poland': 'PL', 'Turkey': 'TR', 'Finland': 'FI', 'Norway': 'NO',
} }
// 订阅/服务商模式
const PROVIDER_PATTERNS: { regex: RegExp; label: string }[] = [ const PROVIDER_PATTERNS: { regex: RegExp; label: string }[] = [
{ regex: /\bAWS\b/i, label: 'AWS' }, { regex: /\bAWS\b/i, label: 'AWS' },
{ regex: /\bGCP\b/i, label: 'GCP' }, { regex: /\bGCP\b/i, label: 'GCP' },
@@ -92,60 +104,147 @@ const PROVIDER_PATTERNS: { regex: RegExp; label: string }[] = [
{ regex: /\bBGP\b/i, label: 'BGP' }, { regex: /\bBGP\b/i, label: 'BGP' },
] ]
function abbreviateNodeName(name: string): string { /** 城市映射表:节点名包含关键词 → badge 中显示的城市标识。
// 检测服务商 * 城市级别的地名(如圣何塞、东京)从 REGION_MAP 移出至此,
let provider = '' * 避免与国家/地区标识重复,并在缩写中加入以区分同名节点。 */
const CITY_MAP: Record<string, string> = {
// 美国
'圣何塞': '圣何塞', '洛杉矶': '洛杉矶', '纽约': '纽约', '硅谷': '硅谷',
'西雅图': '西雅图', '芝加哥': '芝加哥', '达拉斯': '达拉斯', '迈阿密': '迈阿密',
'旧金山': '旧金山', '凤凰城': '凤凰城', '华盛顿': '华盛顿', '波士顿': '波士顿',
'亚特兰大': '亚特兰大', '休斯顿': '休斯顿', '丹佛': '丹佛', '奥斯汀': '奥斯汀',
'费城': '费城', '拉斯维加斯': '拉斯维加斯', '波特兰': '波特兰',
// 加拿大
'多伦多': '多伦多', '温哥华': '温哥华', '蒙特利尔': '蒙特利尔',
// 日本
'东京': '东京', '大阪': '大阪',
// 韩国
'首尔': '首尔',
// 中国台湾
'台北': '台北', '台中': '台中', '高雄': '高雄',
// 中国香港
'九龙': '九龙',
// 英国
'伦敦': '伦敦', '曼城': '曼城', '爱丁堡': '爱丁堡',
// 德国
'法兰克福': '法兰克福', '柏林': '柏林', '慕尼黑': '慕尼黑',
// 法国
'巴黎': '巴黎',
// 荷兰
'阿姆斯特丹': '阿姆斯特丹',
// 澳大利亚
'悉尼': '悉尼', '墨尔本': '墨尔本',
// 印度
'孟买': '孟买', '德里': '德里', '班加罗尔': '班加罗尔',
// 俄罗斯
'莫斯科': '莫斯科', '圣彼得堡': '圣彼得堡',
// 东南亚
'曼谷': '曼谷', '河内': '河内', '胡志明': '胡志明',
'吉隆坡': '吉隆坡', '马尼拉': '马尼拉', '雅加达': '雅加达',
// 中东
'迪拜': '迪拜',
// 其他欧洲
'伊斯坦布尔': '伊斯坦布尔', '斯德哥尔摩': '斯德哥尔摩',
'苏黎世': '苏黎世', '维也纳': '维也纳', '华沙': '华沙',
'马德里': '马德里', '罗马': '罗马', '米兰': '米兰',
// 英文城市名
'San Jose': 'SJC', 'SanJose': 'SJC',
'Los Angeles': 'LAX',
'New York': 'NYC',
'Silicon': '硅谷',
'Seattle': 'SEA', 'Chicago': 'CHI', 'Dallas': 'DAL', 'Miami': 'MIA',
'San Francisco': 'SFO', 'Phoenix': 'PHX', 'Washington': 'WAS',
'Boston': 'BOS', 'Atlanta': 'ATL', 'Houston': 'HOU', 'Denver': 'DEN',
'Austin': 'AUS', 'Toronto': 'YTO', 'Vancouver': 'YVR', 'Montreal': 'YUL',
'Tokyo': '东京', 'Osaka': '大阪', 'Seoul': '首尔', 'Taipei': '台北',
'London': '伦敦', 'Manchester': '曼城',
'Frankfurt': '法兰克福', 'Berlin': '柏林', 'Munich': '慕尼黑',
'Paris': '巴黎', 'Amsterdam': 'AMS',
'Sydney': 'SYD', 'Melbourne': 'MEL',
'Mumbai': 'BOM', 'Delhi': 'DEL', 'Moscow': 'MOW',
'Bangkok': 'BKK', 'Dubai': 'DXB',
}
interface NodeParts {
provider: string
region: string
city: string
num: string
ratio: string
}
/** 解析节点名的各组成部分:provider、region、city、编号、倍率 */
function parseNodeName(name: string): NodeParts {
const parts: NodeParts = { provider: '', region: '', city: '', num: '', ratio: '' }
for (const p of PROVIDER_PATTERNS) { for (const p of PROVIDER_PATTERNS) {
if (p.regex.test(name)) { if (p.regex.test(name)) {
provider = p.label parts.provider = p.label
break break
} }
} }
// 检测地区代码
let region = ''
for (const [key, code] of Object.entries(REGION_MAP)) { for (const [key, code] of Object.entries(REGION_MAP)) {
if (name.includes(key)) { if (name.includes(key)) {
region = code parts.region = code
break break
} }
} }
// 提取数字部分(节点编号) for (const [key, label] of Object.entries(CITY_MAP)) {
const numMatch = name.match(/(\d{2,3})/) if (name.includes(key)) {
const num = numMatch ? numMatch[1] : '' parts.city = label
break
}
}
// 组合缩写 // 倍率:先提取,避免与编号数字冲突(如 "01-0.1倍"
const parts: string[] = [] const ratioMatch = name.match(/(\d+(?:\.\d+)?)\s*倍/)
if (provider) parts.push(provider) if (ratioMatch) {
if (region) parts.push(region) parts.ratio = ratioMatch[1]
if (num) { }
// 有服务商时加点分隔,如 JP-AWS-01
parts.push(num) // 编号:从移除倍率后的字符串中提取 2-3 位数字
} else if (parts.length === 0) { const nameWithoutRatio = name.replace(/\d+(?:\.\d+)?\s*倍/g, '')
// 没有匹配到任何模式,兜底 const numMatch = nameWithoutRatio.match(/(\d{2,3})/)
if (numMatch) {
parts.num = numMatch[1]
}
return parts
}
function abbreviateNodeName(name: string): string {
const parts = parseNodeName(name)
const result: string[] = []
if (parts.provider) result.push(parts.provider)
if (parts.region) result.push(parts.region)
if (parts.city) result.push(parts.city)
if (parts.num) result.push(parts.num)
// 倍率为 1 时不显示(标准倍率无需标注)
if (parts.ratio && parts.ratio !== '1') result.push(parts.ratio)
if (result.length === 0) {
const cnMatch = name.match(/^([\u4e00-\u9fa5]{2,4})\s*(\d{2,3})/) const cnMatch = name.match(/^([\u4e00-\u9fa5]{2,4})\s*(\d{2,3})/)
if (cnMatch) return `${cnMatch[1]}${cnMatch[2]}` if (cnMatch) return `${cnMatch[1]}${cnMatch[2]}`
if (/^[A-Za-z]/.test(name)) return name.slice(0, 6) if (/^[A-Za-z]/.test(name)) return name.slice(0, 6)
return name.slice(0, 4) return name.slice(0, 4)
} }
return parts.join('-') return result.join('-')
} }
// 当前选中节点的缩写
const currentNodeAbbr = computed(() => { const currentNodeAbbr = computed(() => {
const node = state.proxyNodes.find(n => n.isCurrent) const node = state.proxyNodes.find(n => n.isCurrent)
return node ? abbreviateNodeName(node.name) : '' return node ? abbreviateNodeName(node.name) : ''
}) })
// 当前选中节点的延迟
const currentNodeDelay = computed(() => { const currentNodeDelay = computed(() => {
const node = state.proxyNodes.find(n => n.isCurrent) const node = state.proxyNodes.find(n => n.isCurrent)
return node?.delay ?? null return node?.delay ?? null
}) })
// 按延迟排序的节点列表(有延迟的按升序在前,无延迟的在后)
const sortedNodes = computed(() => { const sortedNodes = computed(() => {
const arr = [...state.proxyNodes] const arr = [...state.proxyNodes]
arr.sort((a, b) => { arr.sort((a, b) => {
@@ -159,23 +258,19 @@ const sortedNodes = computed(() => {
return arr return arr
}) })
// ===== 菜单动作(所有按钮点击后立即关闭菜单,任务在后台执行) =====
async function handleAction(action: string, payload?: Record<string, unknown>) { async function handleAction(action: string, payload?: Record<string, unknown>) {
// 立即关闭菜单窗口
try { await invoke('tray_menu_hide') } catch { /* 忽略 */ } try { await invoke('tray_menu_hide') } catch { /* 忽略 */ }
// 后台执行任务(不 await
if (action === 'proxy_refresh') refreshing.value = true if (action === 'proxy_refresh') refreshing.value = true
loading.value = true loadingAction.value = action
invoke('tray_menu_action', { action, payload: payload ?? null }) invoke('tray_menu_action', { action, payload: payload ?? null })
.catch((e) => console.error('[tray-menu] 动作失败:', e)) .catch((e) => console.error('[tray-menu] 动作失败:', e))
.finally(() => { .finally(() => {
loading.value = false loadingAction.value = null
refreshing.value = false refreshing.value = false
}) })
} }
// 代理开关按钮文案
const proxyButtonLabel = computed(() => state.proxyRunning ? '关闭代理' : '开启代理') const proxyButtonLabel = computed(() => state.proxyRunning ? '关闭代理' : '开启代理')
const proxyButtonIcon = computed(() => state.proxyRunning ? PowerOff : Power) const proxyButtonIcon = computed(() => state.proxyRunning ? PowerOff : Power)
@@ -183,14 +278,16 @@ function handleProxyToggle() {
handleAction(state.proxyRunning ? 'proxy_disable' : 'proxy_enable') handleAction(state.proxyRunning ? 'proxy_disable' : 'proxy_enable')
} }
// 节点选择 function handleSystemProxyToggle() {
handleAction('system_proxy_toggle')
}
function handleSelectNode(name: unknown) { function handleSelectNode(name: unknown) {
if (typeof name === 'string') { if (typeof name === 'string') {
handleAction('proxy_select_node', { name }) handleAction('proxy_select_node', { name })
} }
} }
// ===== 延迟显示辅助 =====
function delayText(delay: number | null): string { function delayText(delay: number | null): string {
if (delay === null) return '未测试' if (delay === null) return '未测试'
if (delay === 0) return '超时' if (delay === 0) return '超时'
@@ -204,7 +301,6 @@ function delayClass(delay: number | null): string {
return 'delay-slow' return 'delay-slow'
} }
// ===== 键盘 =====
function onKeydown(e: KeyboardEvent) { function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault() e.preventDefault()
@@ -212,37 +308,33 @@ function onKeydown(e: KeyboardEvent) {
} }
} }
// ===== 窗口高度自适应(底部锚定,防止内容变化导致位置偏移) =====
const MENU_WIDTH = 260
const MENU_MIN_HEIGHT = 200 const MENU_MIN_HEIGHT = 200
const MENU_MAX_HEIGHT = 520 const MENU_MAX_HEIGHT = 520
let resizeObserver: ResizeObserver | null = null
async function adjustWindowHeight() { /** 测量内容高度(逻辑像素) */
try { function measureContentHeight(): number {
const root = document.querySelector('.tray-menu-root') as HTMLElement | null const root = document.querySelector('.tray-menu-root') as HTMLElement | null
if (!root) return if (!root) return MENU_MIN_HEIGHT
const contentHeight = root.scrollHeight // 临时取消 max-height 限制以获取真实内容高度
const height = Math.min(Math.max(contentHeight, MENU_MIN_HEIGHT), MENU_MAX_HEIGHT) const scroll = root.querySelector('.tray-menu-scroll') as HTMLElement | null
const tauriWin = getCurrentWindow() const prevMax = scroll?.style.maxHeight ?? ''
if (scroll) scroll.style.maxHeight = 'none'
// 记录当前窗口底部在屏幕上的物理位置(用于锚定底部,避免高度变化后偏移) const height = root.scrollHeight
const oldPos = await tauriWin.outerPosition() if (scroll) scroll.style.maxHeight = prevMax
const oldSize = await tauriWin.outerSize() return Math.min(Math.max(height, MENU_MIN_HEIGHT), MENU_MAX_HEIGHT)
const oldBottom = oldPos.y + oldSize.height }
await tauriWin.setSize(new LogicalSize(MENU_WIDTH, height)) /** 测量内容高度并通知 Rust 调整窗口大小、定位、显示 */
async function measureAndShow() {
// 根据新的物理高度,调整 y 坐标使得窗口底部保持在原位 await nextTick()
const newSize = await tauriWin.outerSize() const height = measureContentHeight()
const newY = Math.max(0, oldBottom - newSize.height) try {
if (Math.abs(newY - oldPos.y) > 2) { await invoke('tray_menu_ready', { contentHeight: height })
await tauriWin.setPosition(new PhysicalPosition(oldPos.x, newY)) } catch (e) {
} console.error('[tray-menu] tray_menu_ready 失败:', e)
} catch { /* 忽略 */ } }
} }
// ===== 主题应用(与主应用同步,复用 ClipboardPopup 逻辑) =====
function readMainTheme(): { theme: string; effect: string } { function readMainTheme(): { theme: string; effect: string } {
try { try {
const raw = localStorage.getItem('thing_app_settings') const raw = localStorage.getItem('thing_app_settings')
@@ -308,51 +400,36 @@ async function applyTheme() {
onMounted(async () => { onMounted(async () => {
await applyTheme() await applyTheme()
// 监听系统主题变化
const mq = window.matchMedia('(prefers-color-scheme: dark)') const mq = window.matchMedia('(prefers-color-scheme: dark)')
const onThemeChange = () => applyTheme() const onThemeChange = () => applyTheme()
mq.addEventListener('change', onThemeChange) mq.addEventListener('change', onThemeChange)
unlistenFns.push(() => mq.removeEventListener('change', onThemeChange)) unlistenFns.push(() => mq.removeEventListener('change', onThemeChange))
// 监听菜单显示事件:每次显示时重新同步主题 + 刷新状态 + 调整高度
unlistenFns.push(await listen<TrayMenuState>('tray-menu-show', async (event) => { unlistenFns.push(await listen<TrayMenuState>('tray-menu-show', async (event) => {
await applyTheme() await applyTheme()
Object.assign(state, event.payload) Object.assign(state, event.payload)
await nextTick() osdVisible.value = readOsdVisible()
await adjustWindowHeight() await measureAndShow()
})) }))
// 监听状态更新事件(动作执行后刷新 + 调整高度) // 仅更新状态数据,不重新显示窗口。
unlistenFns.push(await listen<TrayMenuState>('tray-menu-state-updated', async (event) => { // 动作完成后的状态更新不应让已隐藏的菜单重新弹出(measureAndShow 会触发 win.show)。
// 菜单显示统一由右键托盘触发的 tray-menu-show 事件负责。
unlistenFns.push(await listen<TrayMenuState>('tray-menu-state-updated', (event) => {
Object.assign(state, event.payload) Object.assign(state, event.payload)
await nextTick()
await adjustWindowHeight()
})) }))
// 使用 ResizeObserver 监听内容尺寸变化,自动调整窗口高度 // 预创建模式下不再调用 tray_menu_show_window,窗口显示统一由 tray_menu_ready 触发
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(() => { onUnmounted(() => {
unlistenFns.forEach((fn) => fn()) unlistenFns.forEach((fn) => fn())
resizeObserver?.disconnect()
}) })
</script> </script>
<template> <template>
<div class="tray-menu-root flex flex-col" @keydown="onKeydown" tabindex="0"> <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"> <div class="tray-menu-scroll flex-1 min-h-0 overflow-y-auto">
<!-- ===== 代理 section ===== -->
<div class="tray-section"> <div class="tray-section">
<div class="tray-section-header"> <div class="tray-section-header">
<Globe class="h-4 w-4 text-primary" /> <Globe class="h-4 w-4 text-primary" />
@@ -362,31 +439,33 @@ onUnmounted(() => {
</div> </div>
<div class="tray-section-body"> <div class="tray-section-body">
<!-- 开启/关闭代理单按钮切换 --> <button class="tray-item" :disabled="proxyLoading" @click="handleProxyToggle">
<button class="tray-item" :disabled="loading" @click="handleProxyToggle">
<component :is="proxyButtonIcon" class="h-4 w-4" /> <component :is="proxyButtonIcon" class="h-4 w-4" />
<span>{{ proxyButtonLabel }}</span> <span>{{ proxyButtonLabel }}</span>
</button> </button>
<!-- 刷新节点列表 --> <button v-if="state.proxyRunning" class="tray-item" :disabled="proxyLoading" @click="handleSystemProxyToggle">
<Power class="h-4 w-4" />
<span>系统代理</span>
<span class="tray-toggle-indicator" :class="{ active: state.systemProxy }"></span>
</button>
<button <button
v-if="state.proxyRunning" v-if="state.proxyRunning"
class="tray-item" class="tray-item"
:disabled="loading" :disabled="proxyLoading"
@click="handleAction('proxy_refresh')" @click="handleAction('proxy_refresh')"
> >
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': refreshing }" /> <RefreshCw class="h-4 w-4" :class="{ 'animate-spin': refreshing }" />
<span>刷新节点列表</span> <span>刷新节点列表</span>
</button> </button>
<!-- 节点选择 Select -->
<div v-if="state.proxyRunning && state.proxyNodes.length > 0" class="tray-node-select"> <div v-if="state.proxyRunning && state.proxyNodes.length > 0" class="tray-node-select">
<label class="tray-node-label">节点</label> <label class="tray-node-label">节点</label>
<Select <Select
:model-value="state.proxyCurrent ?? ''" :model-value="state.proxyCurrent ?? ''"
:disabled="loading" :disabled="proxyLoading"
@update:model-value="handleSelectNode" @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"> <SelectTrigger class="tray-select-trigger" size="sm">
<span v-if="currentNodeAbbr" class="tray-select-badge">{{ currentNodeAbbr }}</span> <span v-if="currentNodeAbbr" class="tray-select-badge">{{ currentNodeAbbr }}</span>
@@ -416,36 +495,49 @@ onUnmounted(() => {
<div class="tray-separator"></div> <div class="tray-separator"></div>
<!-- ===== OSD + Kernel ===== --> <div class="tray-section">
<button class="tray-item" :disabled="loading" @click="handleAction('osd_toggle')"> <div class="tray-section-header">
<Monitor class="h-4 w-4 text-primary" />
<span class="tray-section-title">监控</span>
<span v-if="state.monitorRunning" 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" @click="handleAction('osd_toggle')">
<Monitor class="h-4 w-4" /> <Monitor class="h-4 w-4" />
<span>切换 OSD</span> <span>OSD 显示</span>
<span class="tray-toggle-indicator" :class="{ active: osdVisible }"></span>
</button> </button>
<button <button
class="tray-item" class="tray-item"
:disabled="!state.monitorRunning || loading" :disabled="!state.monitorRunning"
@click="handleAction('kernel_restart')" @click="handleAction('kernel_restart')"
> >
<RefreshCw class="h-4 w-4" /> <RefreshCw class="h-4 w-4" />
<span>重启 Kernel</span> <span>重启 Kernel</span>
</button> </button>
</div>
</div>
<div class="tray-separator"></div> <div class="tray-separator"></div>
<!-- ===== 新建下载 ===== --> <button class="tray-item" @click="handleAction('download_new')">
<button class="tray-item" :disabled="loading" @click="handleAction('download_new')">
<Download class="h-4 w-4" /> <Download class="h-4 w-4" />
<span>新建下载</span> <span>新建下载</span>
</button> </button>
<button class="tray-item" @click="handleAction('screenshot_region')">
<Camera class="h-4 w-4" />
<span>区域截图</span>
</button>
<div class="tray-separator"></div> <div class="tray-separator"></div>
<!-- ===== 设置 + 退出 ===== --> <button class="tray-item" @click="handleAction('settings')">
<button class="tray-item" :disabled="loading" @click="handleAction('settings')">
<Settings class="h-4 w-4" /> <Settings class="h-4 w-4" />
<span>常规设置</span> <span>常规设置</span>
</button> </button>
<button class="tray-item tray-quit" :disabled="loading" @click="handleAction('quit')"> <button class="tray-item tray-quit" @click="handleAction('quit')">
<LogOut class="h-4 w-4" /> <LogOut class="h-4 w-4" />
<span>退出</span> <span>退出</span>
</button> </button>
@@ -464,14 +556,12 @@ onUnmounted(() => {
outline: none; outline: none;
} }
/* 滚动区域(高度由内容决定,不用 flex-1 撑满) */
.tray-menu-scroll { .tray-menu-scroll {
padding: 6px; padding: 6px;
overflow-y: auto; overflow-y: auto;
max-height: 520px; max-height: 520px;
} }
/* 滚动条 */
.tray-menu-scroll::-webkit-scrollbar { .tray-menu-scroll::-webkit-scrollbar {
width: 5px; width: 5px;
} }
@@ -484,7 +574,6 @@ onUnmounted(() => {
background: transparent; background: transparent;
} }
/* ===== Section ===== */
.tray-section { .tray-section {
margin-bottom: 2px; margin-bottom: 2px;
} }
@@ -513,7 +602,6 @@ onUnmounted(() => {
padding-top: 2px; padding-top: 2px;
} }
/* 状态指示点 */
.tray-status-dot { .tray-status-dot {
width: 6px; width: 6px;
height: 6px; height: 6px;
@@ -529,7 +617,6 @@ onUnmounted(() => {
opacity: 0.5; opacity: 0.5;
} }
/* ===== 菜单项 ===== */
.tray-item { .tray-item {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -565,7 +652,36 @@ onUnmounted(() => {
color: var(--muted-foreground); color: var(--muted-foreground);
} }
/* 退出项特殊样式 */ .tray-toggle-indicator {
margin-left: auto;
width: 28px;
height: 16px;
border-radius: 8px;
background: var(--muted-foreground);
opacity: 0.3;
position: relative;
transition: all 0.2s;
flex-shrink: 0;
}
.tray-toggle-indicator::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 12px;
height: 12px;
border-radius: 50%;
background: white;
transition: transform 0.2s;
}
.tray-toggle-indicator.active {
background: var(--primary);
opacity: 1;
}
.tray-toggle-indicator.active::after {
transform: translateX(12px);
}
.tray-quit { .tray-quit {
color: var(--destructive); color: var(--destructive);
} }
@@ -576,14 +692,12 @@ onUnmounted(() => {
background: color-mix(in oklch, var(--destructive) 12%, transparent); background: color-mix(in oklch, var(--destructive) 12%, transparent);
} }
/* ===== 分隔线 ===== */
.tray-separator { .tray-separator {
height: 1px; height: 1px;
background: var(--border); background: var(--border);
margin: 4px 10px; margin: 4px 10px;
} }
/* ===== 节点选择 ===== */
.tray-node-select { .tray-node-select {
padding: 4px 10px; padding: 4px 10px;
} }
@@ -598,7 +712,6 @@ onUnmounted(() => {
opacity: 0.7; opacity: 0.7;
} }
/* Select trigger 样式覆盖 */
.tray-select-trigger { .tray-select-trigger {
width: 100%; width: 100%;
height: 30px; height: 30px;
@@ -611,7 +724,6 @@ onUnmounted(() => {
background: var(--secondary); background: var(--secondary);
} }
/* 选中节点的 badge 缩写 */
.tray-select-badge { .tray-select-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -626,7 +738,6 @@ onUnmounted(() => {
flex-shrink: 0; flex-shrink: 0;
} }
/* SelectTrigger 内的延迟 badge */
.tray-select-delay-inline { .tray-select-delay-inline {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -647,7 +758,6 @@ onUnmounted(() => {
white-space: nowrap; white-space: nowrap;
} }
/* 下拉选项中每个节点的 badge */
.tray-select-badge-option { .tray-select-badge-option {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -671,7 +781,6 @@ onUnmounted(() => {
font-weight: 500; font-weight: 500;
} }
/* 延迟配色 */
.delay-fast { .delay-fast {
background: rgba(34, 197, 94, 0.12); background: rgba(34, 197, 94, 0.12);
color: rgb(22, 163, 74); color: rgb(22, 163, 74);
@@ -693,7 +802,6 @@ onUnmounted(() => {
color: var(--muted-foreground); color: var(--muted-foreground);
} }
/* 深色主题下调整延迟配色 */
.dark .delay-fast { .dark .delay-fast {
color: rgb(74, 222, 128); color: rgb(74, 222, 128);
} }
@@ -708,10 +816,8 @@ onUnmounted(() => {
} }
</style> </style>
<!-- 全局样式SelectContent 通过 Portal 渲染到 body 外部scoped 样式不生效 -->
<style> <style>
.tray-select-content[data-slot="select-content"] { .tray-select-content[data-slot="select-content"] {
/* 固定宽度等于 trigger 宽度,防止长节点名撑宽下拉框 */
width: var(--reka-select-trigger-width); width: var(--reka-select-trigger-width);
min-width: var(--reka-select-trigger-width); min-width: var(--reka-select-trigger-width);
max-width: var(--reka-select-trigger-width); max-width: var(--reka-select-trigger-width);