截图模块初始化

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",
"version": "0.1.0",
"dependencies": {
"@lucide/vue": "^1.25.0",
"@lucide/vue": "^1.28.0",
"@tailwindcss/vite": "^4.3.2",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-autostart": "~2",
"@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-global-shortcut": "^2",
"@tauri-apps/plugin-opener": "^2",
"@vueuse/core": "^14.3.0",
"@vueuse/core": "^14.4.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"pinia": "^3.0.4",
@@ -603,9 +604,9 @@
}
},
"node_modules/@lucide/vue": {
"version": "1.25.0",
"resolved": "https://registry.npmmirror.com/@lucide/vue/-/vue-1.25.0.tgz",
"integrity": "sha512-hkEetV+v48ScIn3uwqwWQ66sI8foeP2q6OMI09GzLFH4SfvBlfe3JHYlMBdBCqFC7WRlhFsndyDn/awRKRc2OQ==",
"version": "1.28.0",
"resolved": "https://registry.npmmirror.com/@lucide/vue/-/vue-1.28.0.tgz",
"integrity": "sha512-cO89UyNM/0i2Srdi04nFWeEOQkqzDZlM2ehtWyBhcMpRTJDKpMZPCGAVlmVBgBgr5HaeL5L6FsU8RHsOdwXtKw==",
"license": "ISC",
"peerDependencies": {
"vue": ">=3.0.1"
@@ -1455,6 +1456,15 @@
"@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": {
"version": "2.5.4",
"license": "MIT OR Apache-2.0",
@@ -1671,14 +1681,14 @@
"license": "MIT"
},
"node_modules/@vueuse/core": {
"version": "14.3.0",
"resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.3.0.tgz",
"integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==",
"version": "14.4.0",
"resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.4.0.tgz",
"integrity": "sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "14.3.0",
"@vueuse/shared": "14.3.0"
"@vueuse/metadata": "14.4.0",
"@vueuse/shared": "14.4.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
@@ -1688,18 +1698,18 @@
}
},
"node_modules/@vueuse/metadata": {
"version": "14.3.0",
"resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.3.0.tgz",
"integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==",
"version": "14.4.0",
"resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.4.0.tgz",
"integrity": "sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "14.3.0",
"resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.3.0.tgz",
"integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==",
"version": "14.4.0",
"resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.4.0.tgz",
"integrity": "sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
+2 -2
View File
@@ -10,14 +10,14 @@
"tauri": "tauri"
},
"dependencies": {
"@lucide/vue": "^1.25.0",
"@lucide/vue": "^1.28.0",
"@tailwindcss/vite": "^4.3.2",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-autostart": "~2",
"@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-global-shortcut": "^2",
"@tauri-apps/plugin-opener": "^2",
"@vueuse/core": "^14.3.0",
"@vueuse/core": "^14.4.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"pinia": "^3.0.4",
+1
View File
@@ -56,6 +56,7 @@ windows-sys = { version = "0.52", features = [
"Win32_UI_HiDpi",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_Graphics_Gdi",
"Win32_Storage_Xps",
] }
[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 h = 460.0_f64;
// 获取屏幕工作区(物理像素,Per-Monitor DPI V2
let (screen_w, screen_h) = get_work_area().unwrap_or((1920.0, 1080.0));
// 获取光标所在显示器的工作区(物理像素,与 get_cursor_pos 同一坐标系
let (wa_left, wa_top, wa_right, wa_bottom) = get_work_area_at_point(mx, my)
.unwrap_or((0, 0, 1920, 1040));
// 获取 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);
// 获取光标所在显示器的 DPI,将物理坐标转为逻辑坐标(DIP)
let dpi = get_dpi_for_point(mx, my).unwrap_or(96);
let scale = dpi as f64 / 96.0;
let w_phys = (w * scale) as i32;
let h_phys = (h * scale) as i32;
let mx_l = mx as f64 / scale;
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
let x = mx.max(0).min(screen_w as i32 - w_phys);
let y = my.max(0).min(screen_h as i32 - h_phys);
// 逻辑坐标 clamping(窗口尺寸 w/h 也是逻辑像素)
let x = mx_l.max(wa_left_l).min(wa_right_l - w);
let y = my_l.max(wa_top_l).min(wa_bottom_l - h);
// 窗口已存在:移动 + 显示 + 请求焦点
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,
y,
}));
@@ -126,7 +124,7 @@ pub fn show_popup(app: &AppHandle) {
)
.title("剪贴板")
.inner_size(w, h)
.position(x as f64 / scale, y as f64 / scale)
.position(x, y)
.decorations(false)
.transparent(true)
.shadow(true)
@@ -233,7 +231,7 @@ mod win_api {
use windows_sys::Win32::Foundation::POINT;
use windows_sys::Win32::UI::WindowsAndMessaging::{GetCursorPos, SystemParametersInfoW, SPI_GETWORKAREA};
/// 获取鼠标位置(屏幕坐标,逻辑像素)
/// 获取鼠标位置(屏幕坐标,物理像素)
pub fn get_cursor_pos() -> Option<(i32, i32)> {
let mut pt = POINT { x: 0, y: 0 };
unsafe {
@@ -245,7 +243,7 @@ mod win_api {
}
}
/// 获取屏工作区(排除任务栏,逻辑像素)
/// 获取屏工作区尺寸(排除任务栏,物理像素)
pub fn get_work_area() -> Option<(f64, f64)> {
use windows_sys::Win32::Foundation::RECT;
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))]
mod win_api {
pub fn get_cursor_pos() -> Option<(i32, i32)> { 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))
}
/// 将 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>> {
use image::codecs::png::PngEncoder;
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 {
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 bpp = u16::from_le_bytes([dib[14], dib[15]]);
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;
}
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 + 1] = dib[sp + 1]; // G
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();
+20 -3
View File
@@ -9,6 +9,7 @@ mod monitor_kernel;
mod network_monitor;
mod osd_window;
mod process_manager;
mod screenshot;
mod snap_fix;
mod tray_menu;
@@ -43,6 +44,12 @@ use process_manager::{
get_all_process_status, get_process_status, start_monitoring_thread, start_process,
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::{
ClipboardManager,
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_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]
fn greet(name: &str) -> String {
@@ -185,8 +192,18 @@ pub fn run() {
clipboard_paste_to_target,
tray_menu_action,
tray_menu_hide,
tray_menu_show_window,
snap_fix::fix_snap_background
tray_menu_ready,
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| {
// 初始化日志系统,日志目录: {app_data_dir}/logs/
+2 -2
View File
@@ -1101,7 +1101,7 @@ fn clear_system_proxy_windows() -> Result<(), String> {
}
#[cfg(windows)]
fn get_system_proxy_windows() -> bool {
pub(crate) fn get_system_proxy_windows() -> bool {
use winreg::enums::*;
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
@@ -1130,7 +1130,7 @@ fn clear_system_proxy_windows() -> Result<(), String> {
Err("系统代理仅支持 Windows".into())
}
#[cfg(not(windows))]
fn get_system_proxy_windows() -> bool {
pub(crate) fn get_system_proxy_windows() -> bool {
false
}
+86 -52
View File
@@ -20,7 +20,11 @@ use tauri::{
/// 记录窗口最后显示时间,用于失焦防抖(避免显示瞬间因焦点未稳定而被立即隐藏)
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::download_engine::DownloadEngine;
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;
/// 预创建隐藏的托盘菜单窗口(在应用启动时调用)。
@@ -272,26 +277,37 @@ pub fn precreate_tray_menu_window(app: &AppHandle) {
eprintln!("[tray-menu] 菜单窗口已预创建(隐藏渲染)");
}
/// 在鼠标当前位置显示托盘菜单窗口。
/// 窗口不存在则创建并显示,已存在则移动到鼠标位置并显示
pub fn show_tray_menu(app: &AppHandle) {
let (mx, my) = match get_cursor_pos() {
Some(p) => p,
None => return,
};
/// 右键托盘时调用:计算定位参数、发送状态给前端,但不立即显示窗口。
/// 窗口等待前端测量内容高度后调用 `tray_menu_ready` 才显示,确保底部精确对齐托盘图标
///
/// `cursor_pos`: 事件报告的鼠标物理坐标;`tray_rect`: 托盘图标区域(物理像素)。
pub fn show_tray_menu(app: &AppHandle, cursor_pos: (f64, f64), tray_rect: (f64, f64, f64, f64)) {
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 scale = win.as_ref()
.and_then(|w| w.scale_factor().ok())
.unwrap_or(1.0);
// 获取光标所在显示器的工作区(物理像素)
let (wa_left, wa_top, wa_right, wa_bottom) = get_work_area_at_point(mx as i32, my as i32)
.unwrap_or((0, 0, 1920, 1040));
let w_phys = (MENU_W * scale) as i32;
let h_phys = (MENU_H * scale) as i32;
// 获取光标所在显示器的 DPI,将物理坐标转为逻辑坐标(DIP)
let dpi = get_dpi_for_point(mx as i32, my as i32).unwrap_or(96);
let scale = dpi as f64 / 96.0;
// 托盘位于屏幕右下角,菜单始终出现在鼠标左上方
let x = (mx - w_phys).max(0);
let y = (my - h_phys - 8).max(0);
let mx_l = mx / scale;
let tray_top_l = tray_top / scale;
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());
}
// 窗口存在(预创建或之前显示过):移动 + 显示 + 发送状态
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 已预创建)
// 确保窗口存在(兖底)
if app.get_webview_window(TRAY_MENU_LABEL).is_none() {
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() {
// 发送状态给前端(前端测量内容高度后调用 tray_menu_ready 显示窗口)
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) {
@@ -403,6 +394,11 @@ pub async fn tray_menu_action(
let _ = app.emit("tray:new-download", ());
hide_tray_menu(&app);
}
"screenshot_region" => {
hide_tray_menu(&app);
// 主窗口(隐藏运行中)接收事件后调用 screenshotStore.startCapture('region')
let _ = app.emit("tray:start-screenshot", "region");
}
"settings" => {
if let Some(window) = app.get_webview_window("main") {
window.show().ok();
@@ -433,10 +429,35 @@ pub async fn tray_menu_hide(app: AppHandle) -> Result<(), String> {
Ok(())
}
/// 显示已创建的菜单窗口(前端 onMounted 后调用)
/// 前端测量内容高度后调用:调整窗口大小、精确定位、然后显示。
/// 这样窗口从一开始就是正确尺寸和位置,不会出现间隙。
#[tauri::command]
pub async fn tray_menu_show_window(app: AppHandle) -> Result<(), String> {
show_window(&app);
pub async fn tray_menu_ready(content_height: f64, app: AppHandle) -> Result<(), String> {
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(())
}
@@ -695,10 +716,23 @@ pub fn create_tray_menu(app: &AppHandle) -> Result<(), tauri::Error> {
}
TrayIconEvent::Click {
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 { Toaster } from '@/components/ui/sonner'
import { useAppStore } from '@/stores/appStore'
import { useScreenshotStore, type CaptureMode } from '@/stores/screenshotStore'
import { TooltipProvider } from '@/components/ui/tooltip'
import { moduleRegistry } from '@/modules/registry'
import type { ModuleMeta } from '@/types/module'
import { pendingNewDownload, pendingOpenSettings } from '@/lib/trayEvents'
const appStore = useAppStore()
const screenshotStore = useScreenshotStore()
/** 侧边栏 / 标题栏需要的模块信息(id + name + icon */
interface NavModule {
@@ -157,12 +159,23 @@ onMounted(async () => {
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[] = []
onUnmounted(() => {
trayUnlisteners.forEach(fn => fn())
screenshotStore.destroyExportListener()
})
</script>
+1 -1
View File
@@ -13,7 +13,7 @@ defineProps<{
class="flex-1"
: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">
<div
v-if="activeComponent"
+64 -4
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { Search, Settings, ChevronRight } from '@lucide/vue'
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { Search, Settings, ChevronRight, ArrowUp } from '@lucide/vue'
import { Input } from '@/components/ui/input'
import { invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
@@ -137,6 +137,48 @@ if (tauriWindow) {
// 初始时窗口已显示,恢复 hover
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 () => {
if (tauriWindow) {
try {
@@ -152,12 +194,16 @@ onMounted(async () => {
invoke('fix_snap_background').catch(() => {})
}, 1000)
}
// 等待模块容器渲染后绑定滚动监听
nextTick(initScrollListener)
})
onUnmounted(() => {
window.removeEventListener('mousemove', handleFirstMouseMove)
if (restoreHoverTimer) clearTimeout(restoreHoverTimer)
if (unlistenMaximize) unlistenMaximize()
if (scrollViewport) scrollViewport.removeEventListener('scroll', handleMainScroll)
if (rafId !== null) cancelAnimationFrame(rafId)
})
const handleBlur = () => {
@@ -178,11 +224,12 @@ const handleBlur = () => {
>
<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">
<div
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
v-for="tab in tabsStore.tabs"
@@ -200,6 +247,18 @@ const handleBlur = () => {
</button>
</div>
</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="relative max-w-xs mr-3 pointer-events-auto">
@@ -310,6 +369,7 @@ const handleBlur = () => {
.hover-suppressed button:hover,
.hover-suppressed button.is-hovered {
background-color: transparent !important;
color: var(--foreground) !important;
}
/* 最大化按钮:snap-layout 插件的原生子窗口遮挡了指针事件,
+14 -1
View File
@@ -16,8 +16,9 @@ window.addEventListener('unhandledrejection', (event) => {
})
// ===== OSD 窗口模式检测 =====
// 通过 URL hash 识别:#osd-overlay(悬浮窗)/ #clipboard-popup(剪贴板快捷弹窗)/ #tray-menu(托盘菜单)
// 通过 URL hash 识别独立窗口#osd-overlay / #clipboard-popup / #tray-menu / #screenshot-overlay / #screenshot-editor
// 这些窗口是精简的独立 Vue 应用,不加载主应用的 store 和模块
// 注意:screenshot-overlay 带 query?mode=region),用 startsWith 匹配
const winHash = window.location.hash
if (winHash === '#osd-overlay') {
logger.info(`OSD 窗口启动: ${winHash}`)
@@ -37,6 +38,18 @@ if (winHash === '#osd-overlay') {
const app = createApp(TrayMenu)
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 {
// ===== 主应用模式 =====
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 {
ClipboardList, Copy, Pin, PinOff, Trash2, Search, Image as ImageIcon,
FileText, Files, Settings as SettingsIcon, Loader2, Eraser, Check, Keyboard,
ChevronLeft, ChevronRight,
} from '@lucide/vue'
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 { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -22,6 +22,9 @@ import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription,
AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
Pagination, PaginationContent, PaginationItem, PaginationEllipsis,
} from '@/components/ui/pagination'
const store = useClipboardStore()
@@ -66,7 +69,7 @@ const gotoPage = async (p: number) => {
// 详情弹窗
const detailOpen = 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) => {
detailOpen.value = true
detailLoading.value = true
@@ -76,6 +79,15 @@ const openDetail = async (item: ClipboardItem) => {
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)
@@ -226,7 +238,6 @@ onUnmounted(() => {
</div>
<div class="flex-1" />
<Badge variant="secondary">
<Loader2 v-if="store.status.running" class="h-3 w-3 mr-1 animate-spin" />
{{ store.historyTotal }}
</Badge>
<Button variant="outline" size="sm" @click="clearOpen = true" :disabled="!historyList.length">
@@ -234,10 +245,40 @@ onUnmounted(() => {
</Button>
</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
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" />
<p class="text-sm">暂无历史记录复制内容后将自动收录</p>
@@ -271,17 +312,7 @@ onUnmounted(() => {
</CardContent>
</Card>
</div>
<!-- 分页 -->
<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>
</ScrollArea>
</TabsContent>
<!-- 固定 -->
@@ -428,11 +459,14 @@ onUnmounted(() => {
</div>
<template v-else-if="detail">
<img
v-if="detail.kind === 'image' && detail.imageBase64"
:src="`data:image/png;base64,${detail.imageBase64}`"
v-if="detail.kind === 'image' && imageSrc"
:src="imageSrc"
class="max-w-full max-h-[60vh] mx-auto rounded"
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>
<ul v-else-if="detail.kind === 'files'" class="space-y-1 text-sm">
<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 {
ClipboardList, Pin, PinOff, Trash2, Search, Image as ImageIcon,
FileText, Files, Loader2, ChevronLeft, ChevronRight,
FileText, Files, Loader2,
} from '@lucide/vue'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Input } from '@/components/ui/input'
import {
Pagination, PaginationContent, PaginationItem, PaginationEllipsis,
} from '@/components/ui/pagination'
// ===== 与 Rust 端对应的数据结构(camelCase =====
type ClipboardKind = 'text' | 'image' | 'files'
@@ -123,10 +127,14 @@ function onKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault()
selectedIndex.value = Math.min(selectedIndex.value + 1, items.value.length - 1)
cancelHoverTimer()
previewVisible.value = false
scrollSelectedIntoView()
} else if (e.key === 'ArrowUp') {
e.preventDefault()
selectedIndex.value = Math.max(selectedIndex.value - 1, 0)
cancelHoverTimer()
previewVisible.value = false
scrollSelectedIntoView()
} else if (e.key === 'Enter') {
e.preventDefault()
@@ -169,6 +177,62 @@ const formatTime = (ms: number) => {
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 读取主应用的主题设置 */
function readMainTheme(): { theme: string; effect: string } {
@@ -271,6 +335,10 @@ onMounted(async () => {
await applyTheme()
searchQuery.value = ''
currentPage.value = 1
// 重置预览状态,清空缓存避免历史图片占用内存
cancelHoverTimer()
previewVisible.value = false
imageCache.clear()
await loadData()
await nextTick()
searchInputRef.value?.focus()
@@ -290,74 +358,102 @@ onMounted(async () => {
})
onUnmounted(() => {
cancelHoverTimer()
unlistenFns.forEach((fn) => fn())
})
</script>
<template>
<div class="popup-root flex flex-col h-screen w-screen" @keydown="onKeydown">
<!-- 搜索栏 -->
<div class="popup-header shrink-0">
<div class="search-wrap">
<Search class="h-4 w-4 text-muted-foreground" />
<input
<!-- 搜索栏与剪切板主页统一样式 -->
<div class="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
<div class="relative flex-1 max-w-sm">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
ref="searchInputRef"
v-model="searchQuery"
placeholder="搜索剪贴板历史..."
class="search-input"
class="pl-8 text-sm"
spellcheck="false"
/>
</div>
<span class="popup-count">{{ total }} </span>
<span class="text-xs text-muted-foreground whitespace-nowrap">{{ total }} </span>
</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">
<div v-if="loading && !hasItems" class="popup-empty">
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
<div class="space-y-1.5 p-2">
<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 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" />
<p class="text-sm text-muted-foreground">
<p class="text-sm">
{{ searchQuery ? '无匹配结果' : '暂无历史记录' }}
</p>
</div>
<div
v-for="(item, idx) in items"
:key="item.id"
class="popup-item"
class="popup-item group"
:class="{ 'popup-item-selected': idx === selectedIndex }"
@click="selectAndPaste(item)"
@mouseenter="selectedIndex = idx"
@mouseenter="onItemHover(idx, item)"
@mouseleave="onItemLeave"
>
<component :is="kindIcon(item.kind)" class="popup-item-icon" />
<div class="popup-item-body">
<p class="popup-item-preview">{{ item.preview }}</p>
<div class="popup-item-meta">
<span class="popup-item-kind" :class="kindBadgeClass(item.kind)">{{ kindLabel(item.kind) }}</span>
<component :is="kindIcon(item.kind)" class="h-4 w-4 text-muted-foreground shrink-0 mt-0.5" />
<div class="flex-1 min-w-0">
<p class="text-sm break-all line-clamp-1" :title="item.preview">{{ item.preview }}</p>
<div class="flex items-center gap-2 mt-0.5 text-xs text-muted-foreground flex-wrap">
<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>
</div>
</div>
<div class="popup-item-actions">
<button class="popup-action-btn" title="固定" @click="togglePin(item, $event)">
<div class="popup-item-actions shrink-0">
<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" />
</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" />
</button>
</div>
</div>
</div>
</ScrollArea>
<!-- 分页 -->
<div v-if="totalPages > 1" class="popup-pagination">
<button class="popup-page-btn" :disabled="currentPage <= 1" @click="gotoPage(currentPage - 1)">
<ChevronLeft class="h-3.5 w-3.5" />
</button>
<span class="popup-page-info">{{ currentPage }} / {{ totalPages }}</span>
<button class="popup-page-btn" :disabled="currentPage >= totalPages" @click="gotoPage(currentPage + 1)">
<ChevronRight class="h-3.5 w-3.5" />
</button>
<!-- 分页与剪切板历史统一样式 -->
<div v-if="totalPages > 1" class="flex items-center justify-center gap-1 px-2 py-1 border-t border-border">
<Pagination
v-slot="{ page }"
:page="currentPage"
:total="total"
:items-per-page="PAGE_SIZE"
:sibling-count="1"
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>
<!-- 底部提示 -->
@@ -377,49 +473,12 @@ onUnmounted(() => {
color: var(--foreground);
border-radius: 8px;
overflow: hidden;
}
/* 搜索栏 */
.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;
position: relative;
}
/* 列表 */
.popup-list {
padding: 4px;
padding: 0;
}
/* reka-ui ScrollAreaViewport 内部会出现一个 div,需保证高度撑满 */
@@ -427,66 +486,26 @@ onUnmounted(() => {
min-height: 100%;
}
/* 条目卡片样式(与主界面 Card 统一) */
.popup-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 10px;
border-radius: 6px;
gap: 12px;
padding: 8px 12px;
border-radius: var(--radius);
cursor: pointer;
/* 预留边框空间,避免 hover/选中时布局抖动 */
border: 1px solid transparent;
/* 统一 hover 和 selected 为同一高亮效果,避免错乱 */
transition: background-color 0.1s, border-color 0.1s;
border: 1px solid var(--border);
background: var(--card);
transition: box-shadow 0.2s, background-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 {
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 配色(与主界面一致) */
@@ -522,7 +541,6 @@ onUnmounted(() => {
gap: 2px;
opacity: 0;
transition: opacity 0.1s;
flex-shrink: 0;
}
/* 选中项和悬停项都显示操作按钮 */
@@ -535,27 +553,20 @@ onUnmounted(() => {
background: transparent;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 4px;
color: var(--muted-foreground);
display: flex;
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 {
background: var(--muted);
color: var(--foreground);
}
/* 空状态 */
.popup-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 200px;
color: var(--muted-foreground);
}
/* 空状态(使用 Tailwind 类,无需额外 CSS */
/* 底部 */
.popup-footer {
@@ -568,42 +579,37 @@ onUnmounted(() => {
color: var(--muted-foreground);
}
/* 分页 */
.popup-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 4px 12px;
border-top: 1px solid var(--border);
}
.popup-page-btn {
background: transparent;
/* 图片悬停预览浮层:固定在弹窗右上角,不遮挡列表操作 */
.popup-preview {
position: absolute;
top: 50px;
right: 10px;
z-index: 100;
max-width: 180px;
max-height: 180px;
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--border);
cursor: pointer;
padding: 3px;
border-radius: 4px;
color: var(--foreground);
display: flex;
align-items: center;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
background: var(--popover, var(--background));
pointer-events: none;
}
.popup-page-btn:hover:not(:disabled) {
background: var(--accent);
.popup-preview img {
display: block;
max-width: 100%;
max-height: 180px;
object-fit: contain;
}
.popup-page-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
/* 预览浮层淡入淡出 */
.popup-preview-enter-active,
.popup-preview-leave-active {
transition: opacity 0.15s ease;
}
.popup-page-info {
font-size: 11px;
color: var(--muted-foreground);
font-variant-numeric: tabular-nums;
min-width: 50px;
text-align: center;
.popup-preview-enter-from,
.popup-preview-leave-to {
opacity: 0;
}
.popup-footer kbd {
+195 -14
View File
@@ -1,24 +1,205 @@
<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 { 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>
<template>
<div class="h-full p-6 overflow-y-auto">
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Camera class="h-5 w-5 text-primary" />
截图模块
</CardTitle>
</CardHeader>
<CardContent>
<div class="flex flex-col items-center justify-center h-64 text-muted-foreground">
<Camera class="h-16 w-16 mb-4 opacity-50" />
<p>截图功能开发中...</p>
<p class="text-sm mt-2">支持区域截图窗口截图全屏截图滚动截图等功能</p>
<div class="h-full overflow-hidden">
<Tabs v-model="activeTab" class="h-full flex flex-col">
<div ref="tabsListRef" class="px-4 pt-3 pb-2 shrink-0">
<TabsList>
<TabsTrigger value="capture">截图</TabsTrigger>
<TabsTrigger value="history">
历史
<span v-if="store.recent.length" class="ml-1 text-xs text-muted-foreground">
({{ store.recent.length }})
</span>
</TabsTrigger>
</TabsList>
</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>
</Card>
</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>
+228 -122
View File
@@ -2,17 +2,16 @@
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 { getCurrentWindow } from '@tauri-apps/api/window'
import { Effect, EffectState } from '@tauri-apps/api/window'
import {
Globe, Power, PowerOff, RefreshCw, Check, Monitor, Download,
Settings, LogOut,
Settings, LogOut, Camera,
} from '@lucide/vue'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
// ===== 类型定义(与 Rust 端对应) =====
interface ProxyNodeInfo {
name: string
delay: number | null
@@ -20,38 +19,52 @@ interface ProxyNodeInfo {
}
interface TrayMenuState {
proxyRunning: boolean
systemProxy: boolean
monitorRunning: boolean
proxyGroup: string | null
proxyNodes: ProxyNodeInfo[]
proxyCurrent: string | null
}
// ===== 状态 =====
const state = reactive<TrayMenuState>({
proxyRunning: false,
systemProxy: false,
monitorRunning: false,
proxyGroup: null,
proxyNodes: [],
proxyCurrent: null,
})
const loading = ref(false)
const loadingAction = ref<string | null>(null)
const refreshing = ref(false)
const osdVisible = ref(false)
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> = {
'日本': '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',
'Japan': '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',
'UK': 'UK',
'Germany': 'DE', 'France': 'FR', 'Netherlands': 'NL',
'Canada': 'CA', 'Australia': 'AU', 'Brazil': 'BR',
'Russia': 'RU', 'Thailand': 'TH', 'Vietnam': 'VN',
@@ -60,7 +73,6 @@ const REGION_MAP: Record<string, string> = {
'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' },
@@ -92,60 +104,147 @@ const PROVIDER_PATTERNS: { regex: RegExp; label: string }[] = [
{ regex: /\bBGP\b/i, label: 'BGP' },
]
function abbreviateNodeName(name: string): string {
// 检测服务商
let provider = ''
/** 城市映射表:节点名包含关键词 → badge 中显示的城市标识。
* 城市级别的地名(如圣何塞、东京)从 REGION_MAP 移出至此,
* 避免与国家/地区标识重复,并在缩写中加入以区分同名节点。 */
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) {
if (p.regex.test(name)) {
provider = p.label
parts.provider = p.label
break
}
}
// 检测地区代码
let region = ''
for (const [key, code] of Object.entries(REGION_MAP)) {
if (name.includes(key)) {
region = code
parts.region = code
break
}
}
// 提取数字部分(节点编号)
const numMatch = name.match(/(\d{2,3})/)
const num = numMatch ? numMatch[1] : ''
for (const [key, label] of Object.entries(CITY_MAP)) {
if (name.includes(key)) {
parts.city = label
break
}
}
// 组合缩写
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) {
// 没有匹配到任何模式,兜底
// 倍率:先提取,避免与编号数字冲突(如 "01-0.1倍"
const ratioMatch = name.match(/(\d+(?:\.\d+)?)\s*倍/)
if (ratioMatch) {
parts.ratio = ratioMatch[1]
}
// 编号:从移除倍率后的字符串中提取 2-3 位数字
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})/)
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('-')
return result.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) => {
@@ -159,23 +258,19 @@ const sortedNodes = computed(() => {
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
loadingAction.value = action
invoke('tray_menu_action', { action, payload: payload ?? null })
.catch((e) => console.error('[tray-menu] 动作失败:', e))
.finally(() => {
loading.value = false
loadingAction.value = null
refreshing.value = false
})
}
// 代理开关按钮文案
const proxyButtonLabel = computed(() => state.proxyRunning ? '关闭代理' : '开启代理')
const proxyButtonIcon = computed(() => state.proxyRunning ? PowerOff : Power)
@@ -183,14 +278,16 @@ function handleProxyToggle() {
handleAction(state.proxyRunning ? 'proxy_disable' : 'proxy_enable')
}
// 节点选择
function handleSystemProxyToggle() {
handleAction('system_proxy_toggle')
}
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 '超时'
@@ -204,7 +301,6 @@ function delayClass(delay: number | null): string {
return 'delay-slow'
}
// ===== 键盘 =====
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault()
@@ -212,37 +308,33 @@ function onKeydown(e: KeyboardEvent) {
}
}
// ===== 窗口高度自适应(底部锚定,防止内容变化导致位置偏移) =====
const MENU_WIDTH = 260
const MENU_MIN_HEIGHT = 200
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
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 { /* 忽略 */ }
if (!root) return MENU_MIN_HEIGHT
// 临时取消 max-height 限制以获取真实内容高度
const scroll = root.querySelector('.tray-menu-scroll') as HTMLElement | null
const prevMax = scroll?.style.maxHeight ?? ''
if (scroll) scroll.style.maxHeight = 'none'
const height = root.scrollHeight
if (scroll) scroll.style.maxHeight = prevMax
return Math.min(Math.max(height, MENU_MIN_HEIGHT), MENU_MAX_HEIGHT)
}
/** 测量内容高度并通知 Rust 调整窗口大小、定位、显示 */
async function measureAndShow() {
await nextTick()
const height = measureContentHeight()
try {
await invoke('tray_menu_ready', { contentHeight: height })
} catch (e) {
console.error('[tray-menu] tray_menu_ready 失败:', e)
}
}
// ===== 主题应用(与主应用同步,复用 ClipboardPopup 逻辑) =====
function readMainTheme(): { theme: string; effect: string } {
try {
const raw = localStorage.getItem('thing_app_settings')
@@ -308,51 +400,36 @@ async function applyTheme() {
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()
osdVisible.value = readOsdVisible()
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)
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 { /* 忽略 */ }
// 预创建模式下不再调用 tray_menu_show_window,窗口显示统一由 tray_menu_ready 触发
})
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" />
@@ -362,31 +439,33 @@ onUnmounted(() => {
</div>
<div class="tray-section-body">
<!-- 开启/关闭代理单按钮切换 -->
<button class="tray-item" :disabled="loading" @click="handleProxyToggle">
<button class="tray-item" :disabled="proxyLoading" @click="handleProxyToggle">
<component :is="proxyButtonIcon" class="h-4 w-4" />
<span>{{ proxyButtonLabel }}</span>
</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
v-if="state.proxyRunning"
class="tray-item"
:disabled="loading"
:disabled="proxyLoading"
@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"
:disabled="proxyLoading"
@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>
@@ -416,36 +495,49 @@ onUnmounted(() => {
<div class="tray-separator"></div>
<!-- ===== OSD + Kernel ===== -->
<button class="tray-item" :disabled="loading" @click="handleAction('osd_toggle')">
<div class="tray-section">
<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" />
<span>切换 OSD</span>
<span>OSD 显示</span>
<span class="tray-toggle-indicator" :class="{ active: osdVisible }"></span>
</button>
<button
class="tray-item"
:disabled="!state.monitorRunning || loading"
:disabled="!state.monitorRunning"
@click="handleAction('kernel_restart')"
>
<RefreshCw class="h-4 w-4" />
<span>重启 Kernel</span>
</button>
</div>
</div>
<div class="tray-separator"></div>
<!-- ===== 新建下载 ===== -->
<button class="tray-item" :disabled="loading" @click="handleAction('download_new')">
<button class="tray-item" @click="handleAction('download_new')">
<Download class="h-4 w-4" />
<span>新建下载</span>
</button>
<button class="tray-item" @click="handleAction('screenshot_region')">
<Camera class="h-4 w-4" />
<span>区域截图</span>
</button>
<div class="tray-separator"></div>
<!-- ===== 设置 + 退出 ===== -->
<button class="tray-item" :disabled="loading" @click="handleAction('settings')">
<button class="tray-item" @click="handleAction('settings')">
<Settings class="h-4 w-4" />
<span>常规设置</span>
</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" />
<span>退出</span>
</button>
@@ -464,14 +556,12 @@ onUnmounted(() => {
outline: none;
}
/* 滚动区域(高度由内容决定,不用 flex-1 撑满) */
.tray-menu-scroll {
padding: 6px;
overflow-y: auto;
max-height: 520px;
}
/* 滚动条 */
.tray-menu-scroll::-webkit-scrollbar {
width: 5px;
}
@@ -484,7 +574,6 @@ onUnmounted(() => {
background: transparent;
}
/* ===== Section ===== */
.tray-section {
margin-bottom: 2px;
}
@@ -513,7 +602,6 @@ onUnmounted(() => {
padding-top: 2px;
}
/* 状态指示点 */
.tray-status-dot {
width: 6px;
height: 6px;
@@ -529,7 +617,6 @@ onUnmounted(() => {
opacity: 0.5;
}
/* ===== 菜单项 ===== */
.tray-item {
display: flex;
align-items: center;
@@ -565,7 +652,36 @@ onUnmounted(() => {
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 {
color: var(--destructive);
}
@@ -576,14 +692,12 @@ onUnmounted(() => {
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;
}
@@ -598,7 +712,6 @@ onUnmounted(() => {
opacity: 0.7;
}
/* Select trigger 样式覆盖 */
.tray-select-trigger {
width: 100%;
height: 30px;
@@ -611,7 +724,6 @@ onUnmounted(() => {
background: var(--secondary);
}
/* 选中节点的 badge 缩写 */
.tray-select-badge {
display: inline-flex;
align-items: center;
@@ -626,7 +738,6 @@ onUnmounted(() => {
flex-shrink: 0;
}
/* SelectTrigger 内的延迟 badge */
.tray-select-delay-inline {
display: inline-flex;
align-items: center;
@@ -647,7 +758,6 @@ onUnmounted(() => {
white-space: nowrap;
}
/* 下拉选项中每个节点的 badge */
.tray-select-badge-option {
display: inline-flex;
align-items: center;
@@ -671,7 +781,6 @@ onUnmounted(() => {
font-weight: 500;
}
/* 延迟配色 */
.delay-fast {
background: rgba(34, 197, 94, 0.12);
color: rgb(22, 163, 74);
@@ -693,7 +802,6 @@ onUnmounted(() => {
color: var(--muted-foreground);
}
/* 深色主题下调整延迟配色 */
.dark .delay-fast {
color: rgb(74, 222, 128);
}
@@ -708,10 +816,8 @@ onUnmounted(() => {
}
</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);