2 Commits
Author SHA1 Message Date
zhongluofeng 89e5b7bed5 截图模块初始化 2026-07-31 18:31:13 +08:00
zhongluofeng 66575c6166 截图模块初始化 2026-07-31 18:30:55 +08:00
23 changed files with 3093 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]
+23
View File
@@ -0,0 +1,23 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "screenshot",
"description": "Capability for screenshot overlay and editor windows",
"windows": ["screenshot-overlay", "screenshot-editor"],
"permissions": [
"core:default",
"core:window:allow-hide",
"core:window:allow-show",
"core:window:allow-set-focus",
"core:window:allow-start-dragging",
"core:window:allow-set-position",
"core:window:allow-set-size",
"core:window:allow-set-always-on-top",
"core:window:allow-set-skip-taskbar",
"core:window:allow-set-decorations",
"core:window:allow-close",
"core:event:allow-emit",
"core:event:allow-listen",
"dialog:default",
"snap-layout:default"
]
}
+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
} }
+475
View File
@@ -0,0 +1,475 @@
//! Windows 屏幕捕获核心(仅 Windows 编译)
//!
//! 实现:
//! - 全屏(虚拟屏)捕获:BitBlt 从屏幕 DC 拷贝到兼容位图,GetDIBits 取像素
//! - 窗口捕获:PrintWindow(PW_RENDERFULLCONTENT) 捕获 DWM 内容(覆盖硬件加速窗口)
//! - 窗口拾取:WindowFromPoint + GetAncestor(GA_ROOT) 取顶层窗口
//! - 顶层窗口枚举:EnumWindows
//! - 像素 → PNG / CF_DIB 转换
//!
//! 设计说明:
//! - 不使用 Windows Graphics Capture:避免引入 windows crateWinRT)增大体积,
//! 且 WGC 默认绘制黄色捕获边框,截图工具不可接受。
//! - 一次性捕获使用 BitBlt/PrintWindow,延迟低、无用户提示。
//! - 32bpp 捕获后强制 alpha=255BitBlt 取出的 alpha 通道未定义(常为 0),
//! 直接编码 PNG 会得到全透明图,故强制不透明。
use std::sync::Mutex;
use windows_sys::Win32::Foundation::{HWND, POINT, RECT};
use windows_sys::Win32::Graphics::Gdi::{
BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject, GetDC, GetDIBits,
PatBlt, ReleaseDC, SelectObject, BITMAPINFO, BITMAPINFOHEADER, BLACKNESS, DIB_RGB_COLORS,
RGBQUAD, SRCCOPY,
};
use windows_sys::Win32::Storage::Xps::PrintWindow;
use windows_sys::Win32::System::DataExchange::{
CloseClipboard, EmptyClipboard, OpenClipboard, SetClipboardData,
};
use windows_sys::Win32::System::Memory::{GlobalAlloc, GlobalLock, GlobalUnlock, GMEM_MOVEABLE};
use windows_sys::Win32::System::Ole::CF_DIB;
use windows_sys::Win32::UI::WindowsAndMessaging::{
EnumWindows, GetAncestor, GetSystemMetrics, GetWindowLongPtrW, GetWindowRect,
GetWindowTextLengthW, GetWindowTextW, GWL_EXSTYLE, GA_ROOT, WS_EX_TOOLWINDOW, WindowFromPoint,
};
use super::{CaptureData, ScreenRect, WindowInfo};
/// 捕获结果(PNG 字节 + 原始 BGRA 像素,像素用于剪贴板 DIB 构造,避免重复解码)
pub struct CapturedImage {
pub width: i32,
pub height: i32,
pub png: Vec<u8>,
/// 32bpp BGRAtop-down(与 GetDIBits negative height 一致)
pub bgra: Vec<u8>,
}
impl From<RECT> for ScreenRect {
fn from(r: RECT) -> Self {
Self {
x: r.left,
y: r.top,
width: r.right - r.left,
height: r.bottom - r.top,
}
}
}
/// 全屏捕获静态存储(供覆盖层选区后裁剪取用)
static FULLSCREEN_CAPTURE: Mutex<Option<CapturedImage>> = Mutex::new(None);
const SM_XVIRTUALSCREEN: i32 = 76;
const SM_YVIRTUALSCREEN: i32 = 77;
const SM_CXVIRTUALSCREEN: i32 = 78;
const SM_CYVIRTUALSCREEN: i32 = 79;
const PW_RENDERFULLCONTENT: u32 = 0x00000002;
const BI_RGB: u32 = 0;
/// 将 BGRA top-down 像素编码为 PNG(强制 alpha=255
fn bgra_to_png(bgra: &[u8], width: i32, height: i32) -> Result<Vec<u8>, String> {
use image::codecs::png::PngEncoder;
use image::ImageEncoder;
if width <= 0 || height <= 0 {
return Err("无效尺寸".into());
}
let w = width as usize;
let h = height as usize;
let mut rgba = vec![0u8; w * h * 4];
for i in 0..(w * h) {
rgba[i * 4] = bgra[i * 4 + 2]; // R
rgba[i * 4 + 1] = bgra[i * 4 + 1]; // G
rgba[i * 4 + 2] = bgra[i * 4]; // B
rgba[i * 4 + 3] = 255; // 强制不透明
}
let mut buf = Vec::with_capacity(w * h * 4 / 3);
PngEncoder::new(&mut buf)
.write_image(&rgba, width as u32, height as u32, image::ExtendedColorType::Rgba8)
.map_err(|e| format!("PNG 编码失败: {}", e))?;
Ok(buf)
}
/// 从 HBITMAP 提取 32bpp BGRA top-down 像素
unsafe fn extract_pixels(
hdc_mem: isize,
hbm: isize,
width: i32,
height: i32,
) -> Result<Vec<u8>, String> {
let bi = BITMAPINFO {
bmiHeader: BITMAPINFOHEADER {
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
biWidth: width,
biHeight: -height, // 负值 = top-down
biPlanes: 1,
biBitCount: 32,
biCompression: BI_RGB,
biSizeImage: 0,
biXPelsPerMeter: 0,
biYPelsPerMeter: 0,
biClrUsed: 0,
biClrImportant: 0,
},
bmiColors: [RGBQUAD {
rgbBlue: 0,
rgbGreen: 0,
rgbRed: 0,
rgbReserved: 0,
}],
};
let mut pixels = vec![0u8; (width as usize) * (height as usize) * 4];
let got = GetDIBits(
hdc_mem,
hbm,
0,
height as u32,
pixels.as_mut_ptr() as *mut _,
&bi as *const _ as *mut _,
DIB_RGB_COLORS,
);
if got == 0 {
return Err("GetDIBits 失败".into());
}
Ok(pixels)
}
/// 捕获整个虚拟屏(所有显示器拼接为一张图)
pub fn capture_virtual_screen() -> Result<CapturedImage, String> {
unsafe {
let x = GetSystemMetrics(SM_XVIRTUALSCREEN);
let y = GetSystemMetrics(SM_YVIRTUALSCREEN);
let w = GetSystemMetrics(SM_CXVIRTUALSCREEN);
let h = GetSystemMetrics(SM_CYVIRTUALSCREEN);
if w <= 0 || h <= 0 {
return Err("无法获取虚拟屏尺寸".into());
}
let hdc_screen = GetDC(0);
if hdc_screen == 0 {
return Err("GetDC 失败".into());
}
let hdc_mem = CreateCompatibleDC(hdc_screen);
if hdc_mem == 0 {
ReleaseDC(0, hdc_screen);
return Err("CreateCompatibleDC 失败".into());
}
let hbm = CreateCompatibleBitmap(hdc_screen, w, h);
if hbm == 0 {
DeleteDC(hdc_mem);
ReleaseDC(0, hdc_screen);
return Err("CreateCompatibleBitmap 失败".into());
}
let old = SelectObject(hdc_mem, hbm);
let ok = BitBlt(hdc_mem, 0, 0, w, h, hdc_screen, x, y, SRCCOPY);
let result = if ok == 0 {
Err("BitBlt 失败".into())
} else {
extract_pixels(hdc_mem, hbm, w, h).and_then(|bgra| {
bgra_to_png(&bgra, w, h).map(|png| CapturedImage {
width: w,
height: h,
png,
bgra,
})
})
};
SelectObject(hdc_mem, old);
DeleteObject(hbm);
DeleteDC(hdc_mem);
ReleaseDC(0, hdc_screen);
result
}
}
/// 捕获指定窗口(PrintWindow + PW_RENDERFULLCONTENT,覆盖硬件加速窗口)
pub fn capture_window(hwnd: isize) -> Result<CapturedImage, String> {
unsafe {
let mut rect: RECT = std::mem::zeroed();
if GetWindowRect(hwnd, &mut rect) == 0 {
return Err("GetWindowRect 失败".into());
}
let w = rect.right - rect.left;
let h = rect.bottom - rect.top;
if w <= 0 || h <= 0 {
return Err("窗口尺寸无效".into());
}
let hdc_screen = GetDC(0);
if hdc_screen == 0 {
return Err("GetDC 失败".into());
}
let hdc_mem = CreateCompatibleDC(hdc_screen);
let hbm = CreateCompatibleBitmap(hdc_screen, w, h);
let old = SelectObject(hdc_mem, hbm);
// 先用黑色填充(PrintWindow 对部分窗口不绘制透明区域)
PatBlt(hdc_mem, 0, 0, w, h, BLACKNESS);
let ok = PrintWindow(hwnd, hdc_mem, PW_RENDERFULLCONTENT);
let result = if ok == 0 {
Err("PrintWindow 失败(可能窗口无响应或权限不足)".into())
} else {
extract_pixels(hdc_mem, hbm, w, h).and_then(|bgra| {
bgra_to_png(&bgra, w, h).map(|png| CapturedImage {
width: w,
height: h,
png,
bgra,
})
})
};
SelectObject(hdc_mem, old);
DeleteObject(hbm);
DeleteDC(hdc_mem);
ReleaseDC(0, hdc_screen);
result
}
}
/// 获取指定屏幕坐标下的顶层窗口(窗口拾取)
///
/// 入参 x/y 为物理屏幕坐标(前端需按显示器 scaleFactor 从逻辑坐标换算)。
pub fn window_from_point(x: i32, y: i32) -> Option<WindowInfo> {
unsafe {
let pt = POINT { x, y };
let mut hwnd = WindowFromPoint(pt);
if hwnd == 0 {
return None;
}
// 取顶层父窗口(WindowFromPoint 可能返回子窗口)
let root = GetAncestor(hwnd, GA_ROOT);
if root != 0 {
hwnd = root;
}
if windows_sys::Win32::UI::WindowsAndMessaging::IsWindowVisible(hwnd) == 0 {
return None;
}
let mut rect: RECT = std::mem::zeroed();
if GetWindowRect(hwnd, &mut rect) == 0 {
return None;
}
let srect = ScreenRect::from(rect);
if srect.width <= 0 || srect.height <= 0 {
return None;
}
Some(WindowInfo {
hwnd,
title: get_window_title(hwnd),
rect: srect,
})
}
}
/// 枚举所有可见、有标题的顶层窗口(供窗口列表选择)
pub fn enum_visible_windows() -> Vec<WindowInfo> {
extern "system" fn enum_proc(hwnd: HWND, lparam: isize) -> i32 {
unsafe {
let vec = &mut *(lparam as *mut Vec<WindowInfo>);
if windows_sys::Win32::UI::WindowsAndMessaging::IsWindowVisible(hwnd) == 0 {
return 1;
}
let mut rect: RECT = std::mem::zeroed();
if GetWindowRect(hwnd, &mut rect) == 0 {
return 1;
}
let srect = ScreenRect::from(rect);
if srect.width < 20 || srect.height < 20 {
return 1;
}
// 跳过工具窗口(本应用 OSD / 托盘菜单 / 截图覆盖层)
let ex = GetWindowLongPtrW(hwnd, GWL_EXSTYLE);
if ex & (WS_EX_TOOLWINDOW as isize) != 0 {
return 1;
}
let title = get_window_title(hwnd);
if title.is_empty() {
return 1;
}
vec.push(WindowInfo {
hwnd,
title,
rect: srect,
});
}
1
}
let mut out: Vec<WindowInfo> = Vec::new();
unsafe {
EnumWindows(Some(enum_proc), &mut out as *mut _ as isize);
}
out
}
unsafe fn get_window_title(hwnd: HWND) -> String {
let len = GetWindowTextLengthW(hwnd);
if len <= 0 {
return String::new();
}
let mut buf = vec![0u16; (len as usize) + 1];
let got = GetWindowTextW(hwnd, buf.as_mut_ptr(), buf.len() as i32);
if got <= 0 {
return String::new();
}
String::from_utf16_lossy(&buf[..got as usize])
}
/// 裁剪已存储的全屏捕获(按物理像素坐标)
pub fn crop_stored(x: i32, y: i32, w: i32, h: i32) -> Result<CaptureData, String> {
let guard = FULLSCREEN_CAPTURE.lock().map_err(|e| e.to_string())?;
let img = guard.as_ref().ok_or("无已存储的全屏捕获")?;
if x < 0 || y < 0 || w <= 0 || h <= 0 || x + w > img.width || y + h > img.height {
return Err("裁剪区域越界".into());
}
let sw = img.width as usize;
let sub = crop_bgra(&img.bgra, sw, x, y, w, h)?;
let png = bgra_to_png(&sub, w, h)?;
Ok(CaptureData {
png_base64: base64_encode(&png),
width: w,
height: h,
})
}
fn crop_bgra(
src: &[u8],
src_width: usize,
x: i32,
y: i32,
w: i32,
h: i32,
) -> Result<Vec<u8>, String> {
let mut out = vec![0u8; (w as usize) * (h as usize) * 4];
for row in 0..(h as usize) {
let src_off = ((y as usize) + row) * src_width * 4 + (x as usize) * 4;
let dst_off = row * (w as usize) * 4;
out[dst_off..dst_off + (w as usize) * 4]
.copy_from_slice(&src[src_off..src_off + (w as usize) * 4]);
}
Ok(out)
}
/// 存储全屏捕获(覆盖层选区裁剪时取用)
pub fn store_fullscreen(img: CapturedImage) -> CaptureData {
let data = CaptureData {
png_base64: base64_encode(&img.png),
width: img.width,
height: img.height,
};
if let Ok(mut g) = FULLSCREEN_CAPTURE.lock() {
*g = Some(img);
}
data
}
/// 取出并清除全屏捕获
pub fn take_fullscreen() -> Option<CaptureData> {
let mut g = FULLSCREEN_CAPTURE.lock().ok()?;
let img = g.take()?;
Some(CaptureData {
png_base64: base64_encode(&img.png),
width: img.width,
height: img.height,
})
}
// ===== 输出:剪贴板 / 文件 =====
/// 将 PNG base64 解码为像素,构造 CF_DIB 并写入剪贴板
pub fn copy_png_to_clipboard(png_base64: &str) -> Result<(), String> {
let png = base64_decode(png_base64).ok_or("base64 解码失败")?;
let img = image::load_from_memory(&png)
.map_err(|e| format!("PNG 解码失败: {}", e))?
.to_rgba8();
let (w, h) = (img.width() as i32, img.height() as i32);
let dib = rgba_to_dib(&img, w, h);
write_dib_to_clipboard(&dib)
}
/// RGBA → CF_DIBBITMAPINFOHEADER + BGRA bottom-upalpha 强制 255
fn rgba_to_dib(rgba: &image::RgbaImage, width: i32, height: i32) -> Vec<u8> {
let header_size = std::mem::size_of::<BITMAPINFOHEADER>() as u32;
let row_size = (width as usize) * 4;
let mut dib = Vec::with_capacity(header_size as usize + row_size * height as usize);
let header = BITMAPINFOHEADER {
biSize: header_size,
biWidth: width,
biHeight: height, // 正值 = bottom-up
biPlanes: 1,
biBitCount: 32,
biCompression: BI_RGB,
biSizeImage: (row_size * height as usize) as u32,
biXPelsPerMeter: 0,
biYPelsPerMeter: 0,
biClrUsed: 0,
biClrImportant: 0,
};
let raw = unsafe {
std::slice::from_raw_parts(
&header as *const _ as *const u8,
std::mem::size_of::<BITMAPINFOHEADER>(),
)
};
dib.extend_from_slice(raw);
// bottom-up 行序,BGRA 像素,alpha 强制 255
for y in (0..height as usize).rev() {
for x in 0..width as usize {
let p = rgba.get_pixel(x as u32, y as u32).0;
dib.push(p[2]); // B
dib.push(p[1]); // G
dib.push(p[0]); // R
dib.push(255); // A
}
}
dib
}
fn write_dib_to_clipboard(dib: &[u8]) -> Result<(), String> {
unsafe {
if OpenClipboard(0) == 0 {
return Err("OpenClipboard 失败".into());
}
let r = (|| {
if EmptyClipboard() == 0 {
return Err("EmptyClipboard 失败".to_string());
}
let hglob = GlobalAlloc(GMEM_MOVEABLE, dib.len());
if hglob.is_null() {
return Err("GlobalAlloc 失败".to_string());
}
let ptr = GlobalLock(hglob) as *mut u8;
if ptr.is_null() {
return Err("GlobalLock 失败".to_string());
}
std::ptr::copy_nonoverlapping(dib.as_ptr(), ptr, dib.len());
GlobalUnlock(hglob);
if SetClipboardData(CF_DIB as u32, hglob as isize) == 0 {
return Err("SetClipboardData 失败".to_string());
}
Ok(())
})();
CloseClipboard();
r
}
}
/// 将 PNG base64 写入文件
pub fn save_png_to_file(png_base64: &str, path: &str) -> Result<(), String> {
let png = base64_decode(png_base64).ok_or("base64 解码失败")?;
std::fs::write(path, &png).map_err(|e| format!("写入文件失败: {}", e))
}
pub fn base64_encode(b: &[u8]) -> String {
use base64::engine::general_purpose::STANDARD;
use base64::Engine as _;
STANDARD.encode(b)
}
fn base64_decode(s: &str) -> Option<Vec<u8>> {
use base64::engine::general_purpose::STANDARD;
use base64::Engine as _;
STANDARD.decode(s).ok()
}
+177
View File
@@ -0,0 +1,177 @@
//! Tauri 命令:截图模块
//!
//! 命令清单:
//! - screenshot_capture_fullscreen:捕获虚拟屏并存入静态,返回元数据 + base64
//! - screenshot_take_fullscreen:取出并清除静态存储的全屏捕获
//! - screenshot_crop_stored:按物理像素裁剪已存储的全屏捕获
//! - screenshot_window_from_point:拾取指定屏幕坐标下的顶层窗口
//! - screenshot_enum_windows:枚举可见顶层窗口
//! - screenshot_capture_window:按 hwnd 捕获指定窗口
//! - screenshot_set_editor_image / screenshot_get_editor_image:编辑器图片传递
//! - screenshot_copy_image:写入剪贴板(CF_DIB
//! - screenshot_save_png:写入文件
use super::{CaptureData, WindowInfo};
/// 捕获整个虚拟屏(多显示器拼接),存入静态供后续裁剪,并返回 base64
#[tauri::command]
pub async fn screenshot_capture_fullscreen() -> Result<CaptureData, String> {
#[cfg(windows)]
{
// 屏幕捕获涉及 GDI 调用,放线程池避免阻塞 async 调度
let img = tauri::async_runtime::spawn_blocking(|| {
super::capture::capture_virtual_screen()
})
.await
.map_err(|e| format!("捕获任务失败: {}", e))??;
Ok(super::capture::store_fullscreen(img))
}
#[cfg(not(windows))]
{
Err("截图仅支持 Windows".into())
}
}
/// 取出并清除静态存储的全屏捕获(覆盖层取消时清理用)
#[tauri::command]
pub async fn screenshot_take_fullscreen() -> Result<Option<CaptureData>, String> {
#[cfg(windows)]
{
Ok(super::capture::take_fullscreen())
}
#[cfg(not(windows))]
{
Ok(None)
}
}
/// 按物理像素坐标裁剪已存储的全屏捕获
#[tauri::command]
pub async fn screenshot_crop_stored(
x: i32,
y: i32,
w: i32,
h: i32,
) -> Result<CaptureData, String> {
#[cfg(windows)]
{
tauri::async_runtime::spawn_blocking(move || {
super::capture::crop_stored(x, y, w, h)
})
.await
.map_err(|e| format!("裁剪任务失败: {}", e))?
}
#[cfg(not(windows))]
{
let _ = (x, y, w, h);
Err("截图仅支持 Windows".into())
}
}
/// 拾取指定物理屏幕坐标下的顶层窗口
#[tauri::command]
pub async fn screenshot_window_from_point(
x: i32,
y: i32,
) -> Result<Option<WindowInfo>, String> {
#[cfg(windows)]
{
tauri::async_runtime::spawn_blocking(move || {
Ok(super::capture::window_from_point(x, y))
})
.await
.map_err(|e| format!("查询失败: {}", e))?
}
#[cfg(not(windows))]
{
let _ = (x, y);
Ok(None)
}
}
/// 枚举所有可见顶层窗口
#[tauri::command]
pub async fn screenshot_enum_windows() -> Result<Vec<WindowInfo>, String> {
#[cfg(windows)]
{
tauri::async_runtime::spawn_blocking(|| super::capture::enum_visible_windows())
.await
.map_err(|e| format!("枚举失败: {}", e))
}
#[cfg(not(windows))]
{
Ok(vec![])
}
}
/// 按 hwnd 捕获指定窗口
#[tauri::command]
pub async fn screenshot_capture_window(hwnd: isize) -> Result<CaptureData, String> {
#[cfg(windows)]
{
tauri::async_runtime::spawn_blocking(move || {
let img = super::capture::capture_window(hwnd)?;
Ok(super::CaptureData {
png_base64: super::capture::base64_encode(&img.png),
width: img.width,
height: img.height,
})
})
.await
.map_err(|e| format!("捕获任务失败: {}", e))?
}
#[cfg(not(windows))]
{
let _ = hwnd;
Err("截图仅支持 Windows".into())
}
}
/// 存入编辑器图片(base64 PNG
#[tauri::command]
pub async fn screenshot_set_editor_image(png_base64: String) -> Result<(), String> {
super::set_editor_image(png_base64);
Ok(())
}
/// 取出编辑器图片(编辑器窗口加载时调用,取出即清除)
#[tauri::command]
pub async fn screenshot_get_editor_image() -> Result<Option<String>, String> {
Ok(super::take_editor_image())
}
/// 将 PNG base64 写入系统剪贴板(转 CF_DIB)
#[tauri::command]
pub async fn screenshot_copy_image(png_base64: String) -> Result<(), String> {
#[cfg(windows)]
{
tauri::async_runtime::spawn_blocking(move || {
super::capture::copy_png_to_clipboard(&png_base64)
})
.await
.map_err(|e| format!("剪贴板任务失败: {}", e))?
}
#[cfg(not(windows))]
{
let _ = png_base64;
Err("剪贴板仅支持 Windows".into())
}
}
/// 将 PNG base64 写入文件
#[tauri::command]
pub async fn screenshot_save_png(png_base64: String, path: String) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
#[cfg(windows)]
{
super::capture::save_png_to_file(&png_base64, &path)
}
#[cfg(not(windows))]
{
let _ = (png_base64, path);
Err("文件写入仅支持 Windows".to_string())
}
})
.await
.map_err(|e| format!("保存任务失败: {}", e))?
}
+52
View File
@@ -0,0 +1,52 @@
//! 截图模块
//!
//! 跨平台数据结构定义 + Windows 捕获实现(capture.rs)。
//! 捕获引擎选型见 capture.rs 顶部说明:BitBlt + PrintWindow,不使用 WGC。
use std::sync::Mutex;
#[cfg(windows)]
pub mod capture;
pub mod commands;
/// 前端可见的捕获数据
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CaptureData {
pub png_base64: String,
pub width: i32,
pub height: i32,
}
/// 窗口信息(窗口拾取 / 枚举)
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WindowInfo {
pub hwnd: isize,
pub title: String,
pub rect: ScreenRect,
}
#[derive(serde::Serialize, Clone, Copy)]
#[serde(rename_all = "camelCase")]
pub struct ScreenRect {
pub x: i32,
pub y: i32,
pub width: i32,
pub height: i32,
}
/// 编辑器图片静态存储(覆盖层裁剪后存入 → 编辑器窗口加载取出)
static EDITOR_IMAGE: Mutex<Option<String>> = Mutex::new(None);
/// 存储编辑器图片(base64 PNG
pub fn set_editor_image(png_base64: String) {
if let Ok(mut g) = EDITOR_IMAGE.lock() {
*g = Some(png_base64);
}
}
/// 取出并清除编辑器图片
pub fn take_editor_image() -> Option<String> {
EDITOR_IMAGE.lock().ok()?.take()
}
+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,42 +315,17 @@ 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);
}); });
}
} }
/// 隐藏托盘菜单窗口(不销毁,保留复用) /// 隐藏托盘菜单窗口(不销毁,保留复用)
@@ -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 {
+653
View File
@@ -0,0 +1,653 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import type { Component } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { emit } from '@tauri-apps/api/event'
import { save } from '@tauri-apps/plugin-dialog'
import { toast } from 'vue-sonner'
import {
Square, MoveUpRight, Pencil, Type, Grid3x3, Highlighter,
Eraser, Undo2, Redo2, Trash2, Copy, Save, X, Image as ImageIcon,
} from '@lucide/vue'
import { Button } from '@/components/ui/button'
// ===== 标注数据结构 =====
type ToolType = 'rect' | 'arrow' | 'pen' | 'text' | 'mosaic' | 'highlight'
interface Point { x: number; y: number }
interface RectAnno { type: 'rect'; x1: number; y1: number; x2: number; y2: number; color: string; lineWidth: number }
interface ArrowAnno { type: 'arrow'; x1: number; y1: number; x2: number; y2: number; color: string; lineWidth: number }
interface PenAnno { type: 'pen'; points: Point[]; color: string; lineWidth: number }
interface TextAnno { type: 'text'; x: number; y: number; text: string; color: string; fontSize: number }
interface MosaicAnno { type: 'mosaic'; x1: number; y1: number; x2: number; y2: number; blockSize: number }
interface HighlightAnno { type: 'highlight'; x1: number; y1: number; x2: number; y2: number; color: string; alpha: number }
type Annotation = RectAnno | ArrowAnno | PenAnno | TextAnno | MosaicAnno | HighlightAnno
/** 可拖拽绘制的标注(不含文字,文字通过独立输入框提交) */
type DrawableAnnotation = RectAnno | ArrowAnno | PenAnno | MosaicAnno | HighlightAnno
// ===== 工具与选项 =====
const TOOLS: { value: ToolType; icon: Component; label: string }[] = [
{ value: 'rect', icon: Square, label: '矩形' },
{ value: 'arrow', icon: MoveUpRight, label: '箭头' },
{ value: 'pen', icon: Pencil, label: '画笔' },
{ value: 'text', icon: Type, label: '文字' },
{ value: 'mosaic', icon: Grid3x3, label: '马赛克' },
{ value: 'highlight', icon: Highlighter, label: '高亮' },
]
const COLORS = ['#ef4444', '#facc15', '#22c55e', '#3b82f6', '#000000', '#ffffff'] as const
const WIDTHS = [2, 4, 6] as const
const BLOCK_SIZES = [8, 10, 14] as const
const ALPHAS = [0.2, 0.4, 0.6] as const
// ===== 状态 =====
const canvasRef = ref<HTMLCanvasElement | null>(null)
const baseImage = ref<HTMLImageElement | null>(null)
const annotations = ref<Annotation[]>([])
const redoStack = ref<Annotation[]>([])
const draft = ref<DrawableAnnotation | null>(null)
const isDrawing = ref(false)
const currentTool = ref<ToolType>('rect')
const currentColor = ref<string>('#ef4444')
const currentLineWidth = ref<number>(4)
const blockSize = ref<number>(10)
const highlightAlpha = ref<number>(0.4)
const loaded = ref(false)
const loadError = ref(false)
// 文字输入浮层
const textInputPos = ref<Point | null>(null)
const textInputValue = ref('')
const textInputEl = ref<HTMLInputElement | null>(null)
const fontSizePx = computed(() => currentLineWidth.value * 3 + 14)
const canUndo = computed(() => annotations.value.length > 0)
const canRedo = computed(() => redoStack.value.length > 0)
// ===== 画布重绘 =====
function redraw() {
const canvas = canvasRef.value
const img = baseImage.value
if (!canvas || !img) return
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.clearRect(0, 0, canvas.width, canvas.height)
// 白色背景填充透明区
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, canvas.width, canvas.height)
// 底图
ctx.drawImage(img, 0, 0)
// 已提交标注
for (const anno of annotations.value) {
drawAnnotation(ctx, anno)
}
// 进行中的草稿
if (draft.value) {
if (draft.value.type === 'mosaic') {
drawMosaicDraft(ctx, draft.value)
} else {
drawAnnotation(ctx, draft.value)
}
}
}
function drawAnnotation(ctx: CanvasRenderingContext2D, anno: Annotation) {
switch (anno.type) {
case 'rect': drawRect(ctx, anno); break
case 'arrow': drawArrow(ctx, anno); break
case 'pen': drawPen(ctx, anno); break
case 'text': drawText(ctx, anno); break
case 'highlight': drawHighlight(ctx, anno); break
case 'mosaic': applyMosaic(ctx, anno); break
}
}
function drawRect(ctx: CanvasRenderingContext2D, a: RectAnno) {
ctx.strokeStyle = a.color
ctx.lineWidth = a.lineWidth
ctx.lineJoin = 'round'
ctx.lineCap = 'round'
ctx.strokeRect(Math.min(a.x1, a.x2), Math.min(a.y1, a.y2), Math.abs(a.x2 - a.x1), Math.abs(a.y2 - a.y1))
}
function drawArrow(ctx: CanvasRenderingContext2D, a: ArrowAnno) {
const { x1, y1, x2, y2, color, lineWidth } = a
ctx.strokeStyle = color
ctx.fillStyle = color
ctx.lineWidth = lineWidth
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.beginPath()
ctx.moveTo(x1, y1)
ctx.lineTo(x2, y2)
ctx.stroke()
// 箭头头部
const headLen = Math.max(10, lineWidth * 3.5)
const angle = Math.atan2(y2 - y1, x2 - x1)
ctx.beginPath()
ctx.moveTo(x2, y2)
ctx.lineTo(x2 - headLen * Math.cos(angle - Math.PI / 6), y2 - headLen * Math.sin(angle - Math.PI / 6))
ctx.lineTo(x2 - headLen * Math.cos(angle + Math.PI / 6), y2 - headLen * Math.sin(angle + Math.PI / 6))
ctx.closePath()
ctx.fill()
}
function drawPen(ctx: CanvasRenderingContext2D, a: PenAnno) {
if (a.points.length === 0) return
ctx.strokeStyle = a.color
ctx.lineWidth = a.lineWidth
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.beginPath()
ctx.moveTo(a.points[0].x, a.points[0].y)
for (let i = 1; i < a.points.length; i++) {
ctx.lineTo(a.points[i].x, a.points[i].y)
}
ctx.stroke()
}
function drawText(ctx: CanvasRenderingContext2D, a: TextAnno) {
ctx.font = `${a.fontSize}px sans-serif`
ctx.fillStyle = a.color
ctx.textBaseline = 'top'
ctx.fillText(a.text, a.x, a.y)
}
function drawHighlight(ctx: CanvasRenderingContext2D, a: HighlightAnno) {
ctx.globalAlpha = a.alpha
ctx.fillStyle = a.color
ctx.fillRect(Math.min(a.x1, a.x2), Math.min(a.y1, a.y2), Math.abs(a.x2 - a.x1), Math.abs(a.y2 - a.y1))
ctx.globalAlpha = 1
}
/** 对区域做像素化(马赛克):取块平均色填回 */
function applyMosaic(ctx: CanvasRenderingContext2D, a: MosaicAnno) {
const canvas = ctx.canvas
const block = Math.max(1, a.blockSize)
const sx = Math.max(0, Math.floor(Math.min(a.x1, a.x2)))
const sy = Math.max(0, Math.floor(Math.min(a.y1, a.y2)))
const sw = Math.min(canvas.width - sx, Math.floor(Math.abs(a.x2 - a.x1)))
const sh = Math.min(canvas.height - sy, Math.floor(Math.abs(a.y2 - a.y1)))
if (sw <= 0 || sh <= 0) return
const imageData = ctx.getImageData(sx, sy, sw, sh)
const data = imageData.data
for (let by = 0; by < sh; by += block) {
for (let bx = 0; bx < sw; bx += block) {
let r = 0, g = 0, b = 0, alpha = 0, count = 0
const maxJ = Math.min(by + block, sh)
const maxI = Math.min(bx + block, sw)
for (let j = by; j < maxJ; j++) {
for (let i = bx; i < maxI; i++) {
const idx = (j * sw + i) * 4
r += data[idx]
g += data[idx + 1]
b += data[idx + 2]
alpha += data[idx + 3]
count++
}
}
if (count === 0) continue
r = Math.round(r / count)
g = Math.round(g / count)
b = Math.round(b / count)
alpha = Math.round(alpha / count)
for (let j = by; j < maxJ; j++) {
for (let i = bx; i < maxI; i++) {
const idx = (j * sw + i) * 4
data[idx] = r
data[idx + 1] = g
data[idx + 2] = b
data[idx + 3] = alpha
}
}
}
}
ctx.putImageData(imageData, sx, sy)
}
/** 马赛克拖拽中的虚线框预览(避免每帧像素化开销) */
function drawMosaicDraft(ctx: CanvasRenderingContext2D, a: MosaicAnno) {
ctx.strokeStyle = 'rgba(255,255,255,0.8)'
ctx.lineWidth = 1
ctx.setLineDash([4, 4])
ctx.strokeRect(Math.min(a.x1, a.x2), Math.min(a.y1, a.y2), Math.abs(a.x2 - a.x1), Math.abs(a.y2 - a.y1))
ctx.setLineDash([])
}
// ===== 鼠标交互 =====
function getPoint(e: MouseEvent): Point {
const canvas = canvasRef.value!
const rect = canvas.getBoundingClientRect()
const scaleX = canvas.width / rect.width
const scaleY = canvas.height / rect.height
return { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY }
}
function onMouseDown(e: MouseEvent) {
if (!baseImage.value || !loaded.value) return
const p = getPoint(e)
if (currentTool.value === 'text') {
startTextInput(p)
return
}
isDrawing.value = true
switch (currentTool.value) {
case 'rect':
draft.value = { type: 'rect', x1: p.x, y1: p.y, x2: p.x, y2: p.y, color: currentColor.value, lineWidth: currentLineWidth.value }
break
case 'arrow':
draft.value = { type: 'arrow', x1: p.x, y1: p.y, x2: p.x, y2: p.y, color: currentColor.value, lineWidth: currentLineWidth.value }
break
case 'pen':
draft.value = { type: 'pen', points: [p], color: currentColor.value, lineWidth: currentLineWidth.value }
break
case 'mosaic':
draft.value = { type: 'mosaic', x1: p.x, y1: p.y, x2: p.x, y2: p.y, blockSize: blockSize.value }
break
case 'highlight':
draft.value = { type: 'highlight', x1: p.x, y1: p.y, x2: p.x, y2: p.y, color: currentColor.value, alpha: highlightAlpha.value }
break
}
redraw()
}
function onMouseMove(e: MouseEvent) {
if (!isDrawing.value || !draft.value) return
const p = getPoint(e)
const d = draft.value
if (d.type === 'pen') {
d.points.push(p)
} else {
d.x2 = p.x
d.y2 = p.y
}
redraw()
}
function onMouseUp() {
if (!isDrawing.value || !draft.value) return
const d = draft.value
// 过滤无效(空)标注
let valid: boolean
if (d.type === 'pen') {
valid = d.points.length > 1
} else {
valid = Math.abs(d.x2 - d.x1) > 1 || Math.abs(d.y2 - d.y1) > 1
}
if (valid) {
annotations.value.push(d)
redoStack.value = []
}
draft.value = null
isDrawing.value = false
redraw()
}
// ===== 文字输入 =====
function startTextInput(p: Point) {
textInputPos.value = { x: p.x, y: p.y }
textInputValue.value = ''
nextTick(() => textInputEl.value?.focus())
}
function commitText() {
const pos = textInputPos.value
if (!pos) return
textInputPos.value = null
const value = textInputValue.value.trim()
textInputValue.value = ''
if (value) {
annotations.value.push({
type: 'text',
x: pos.x,
y: pos.y,
text: value,
color: currentColor.value,
fontSize: fontSizePx.value,
})
redoStack.value = []
redraw()
}
}
function cancelText() {
textInputPos.value = null
textInputValue.value = ''
}
// ===== 撤销 / 重做 / 清空 =====
function undo() {
if (annotations.value.length === 0) return
const last = annotations.value.pop()!
redoStack.value.push(last)
redraw()
}
function redo() {
if (redoStack.value.length === 0) return
const a = redoStack.value.pop()!
annotations.value.push(a)
redraw()
}
function clearAll() {
annotations.value = []
redoStack.value = []
redraw()
}
// ===== 导出 =====
function getPngBase64(): string | null {
const canvas = canvasRef.value
if (!canvas) return null
const dataUrl = canvas.toDataURL('image/png')
return dataUrl.substring('data:image/png;base64,'.length)
}
async function copyToClipboard() {
const canvas = canvasRef.value
const pngBase64 = getPngBase64()
if (!canvas || !pngBase64) return
try {
await invoke('screenshot_copy_image', { pngBase64 })
toast.success('已复制到剪贴板')
await emit('screenshot-exported', { pngBase64, width: canvas.width, height: canvas.height })
} catch (e) {
toast.error('复制失败')
console.error('[screenshot-editor] 复制失败:', e)
}
}
async function saveToFile() {
const canvas = canvasRef.value
const pngBase64 = getPngBase64()
if (!canvas || !pngBase64) return
try {
const path = await save({
defaultPath: `screenshot_${Date.now()}.png`,
filters: [{ name: 'PNG', extensions: ['png'] }],
})
if (!path) return
await invoke('screenshot_save_png', { pngBase64, path })
toast.success('已保存')
await emit('screenshot-exported', { pngBase64, width: canvas.width, height: canvas.height })
} catch (e) {
toast.error('保存失败')
console.error('[screenshot-editor] 保存失败:', e)
}
}
// ===== 窗口控制 =====
async function closeWindow() {
try {
await getCurrentWindow().close()
} catch (e) {
console.error('[screenshot-editor] 关闭失败:', e)
}
}
// ===== 生命周期 =====
onMounted(() => {
// 绑定到 window 以便鼠标移出 canvas 仍能继续绘制 / 释放
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
void (async () => {
try {
const b64 = await invoke<string | null>('screenshot_get_editor_image')
if (!b64) {
loadError.value = true
return
}
const img = new Image()
img.onload = () => {
baseImage.value = img
loaded.value = true
nextTick(() => {
const canvas = canvasRef.value
if (canvas) {
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
}
redraw()
})
}
img.onerror = () => {
loadError.value = true
}
img.src = `data:image/png;base64,${b64}`
} catch (e) {
console.error('[screenshot-editor] 加载图片失败:', e)
loadError.value = true
}
})()
})
onUnmounted(() => {
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
})
</script>
<template>
<div class="h-screen w-screen flex flex-col bg-zinc-950 text-zinc-100 overflow-hidden">
<!-- 标题栏 -->
<div
class="h-9 flex items-center justify-between px-3 bg-zinc-900 border-b border-zinc-800 shrink-0 select-none"
data-tauri-drag-region
>
<span class="text-sm font-medium">截图编辑器</span>
<Button
variant="ghost"
size="icon-sm"
class="text-zinc-400 hover:text-white hover:bg-zinc-800"
title="关闭"
@click="closeWindow"
@mousedown.stop
>
<X class="h-4 w-4" />
</Button>
</div>
<!-- 工具栏 -->
<div class="flex items-center gap-3 px-3 py-2 bg-zinc-900 border-b border-zinc-800 shrink-0 flex-wrap">
<!-- 工具组 -->
<div class="flex items-center gap-1">
<Button
v-for="tool in TOOLS"
:key="tool.value"
variant="ghost"
size="icon-sm"
:class="currentTool === tool.value
? 'bg-zinc-700 text-white hover:bg-zinc-700 hover:text-white'
: 'text-zinc-400 hover:bg-zinc-800 hover:text-white'"
:title="tool.label"
@click="currentTool = tool.value"
>
<component :is="tool.icon" class="h-4 w-4" />
</Button>
</div>
<div class="h-6 w-px bg-zinc-700" />
<!-- 颜色 -->
<div class="flex items-center gap-1.5">
<span class="text-xs text-zinc-500">颜色</span>
<button
v-for="c in COLORS"
:key="c"
class="size-6 rounded-full border border-zinc-500 transition-transform hover:scale-110"
:class="currentColor === c ? 'ring-2 ring-blue-400 ring-offset-1 ring-offset-zinc-900 scale-110' : ''"
:style="{ backgroundColor: c }"
:title="c"
@click="currentColor = c"
/>
</div>
<div class="h-6 w-px bg-zinc-700" />
<!-- 线宽 -->
<div class="flex items-center gap-1">
<span class="text-xs text-zinc-500">线宽</span>
<Button
v-for="w in WIDTHS"
:key="w"
variant="ghost"
size="sm"
class="h-8 px-2"
:class="currentLineWidth === w
? 'bg-zinc-700 text-white hover:bg-zinc-700 hover:text-white'
: 'text-zinc-400 hover:bg-zinc-800 hover:text-white'"
@click="currentLineWidth = w"
>{{ w }}</Button>
</div>
<!-- 马赛克块大小 -->
<div v-if="currentTool === 'mosaic'" class="flex items-center gap-1">
<span class="text-xs text-zinc-500">块大小</span>
<Button
v-for="b in BLOCK_SIZES"
:key="b"
variant="ghost"
size="sm"
class="h-8 px-2"
:class="blockSize === b
? 'bg-zinc-700 text-white hover:bg-zinc-700 hover:text-white'
: 'text-zinc-400 hover:bg-zinc-800 hover:text-white'"
@click="blockSize = b"
>{{ b }}</Button>
</div>
<!-- 高亮透明度 -->
<div v-if="currentTool === 'highlight'" class="flex items-center gap-1">
<span class="text-xs text-zinc-500">透明度</span>
<Button
v-for="a in ALPHAS"
:key="a"
variant="ghost"
size="sm"
class="h-8 px-2"
:class="highlightAlpha === a
? 'bg-zinc-700 text-white hover:bg-zinc-700 hover:text-white'
: 'text-zinc-400 hover:bg-zinc-800 hover:text-white'"
@click="highlightAlpha = a"
>{{ Math.round(a * 100) }}%</Button>
</div>
<div class="h-6 w-px bg-zinc-700" />
<!-- 历史 / 清空 -->
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
class="text-zinc-400 hover:bg-zinc-800 hover:text-white"
:disabled="!canUndo"
title="橡皮(撤销最近标注)"
@click="undo"
>
<Eraser class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-zinc-400 hover:bg-zinc-800 hover:text-white"
:disabled="!canUndo"
title="撤销"
@click="undo"
>
<Undo2 class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-zinc-400 hover:bg-zinc-800 hover:text-white"
:disabled="!canRedo"
title="重做"
@click="redo"
>
<Redo2 class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-zinc-400 hover:bg-red-900 hover:text-white"
:disabled="!canUndo"
title="清空"
@click="clearAll"
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
</div>
<!-- 画布区 -->
<div class="flex-1 min-h-0 overflow-auto bg-zinc-950 p-4">
<!-- 错误提示 -->
<div
v-if="loadError"
class="flex flex-col items-center justify-center h-full gap-3 text-zinc-400"
>
<ImageIcon class="h-12 w-12 opacity-40" />
<p>未找到待编辑的截图</p>
<Button variant="ghost" class="text-zinc-200 hover:bg-zinc-800 hover:text-white" @click="closeWindow">
关闭
</Button>
</div>
<!-- 加载中 -->
<div v-else-if="!loaded" class="flex items-center justify-center h-full text-zinc-500">
<p>加载中...</p>
</div>
<!-- 画布 -->
<div v-else class="canvas-wrap relative inline-block shadow-2xl">
<canvas
ref="canvasRef"
class="block max-w-none select-none"
:style="{ cursor: currentTool === 'text' ? 'text' : 'crosshair' }"
@mousedown="onMouseDown"
/>
<input
v-if="textInputPos"
ref="textInputEl"
v-model="textInputValue"
class="absolute z-10 bg-transparent outline-none"
:style="{
left: textInputPos.x + 'px',
top: textInputPos.y + 'px',
color: currentColor,
fontSize: fontSizePx + 'px',
fontFamily: 'sans-serif',
lineHeight: '1',
padding: '0 2px',
border: '1px dashed ' + currentColor,
}"
placeholder="输入文字"
@keydown.enter.prevent="commitText"
@keydown.esc.prevent="cancelText"
@blur="commitText"
/>
</div>
</div>
<!-- 底部操作栏 -->
<div class="flex items-center justify-end gap-2 px-3 py-2 bg-zinc-900 border-t border-zinc-800 shrink-0">
<Button variant="ghost" class="text-zinc-300 hover:bg-zinc-800 hover:text-white" @click="closeWindow">
取消
</Button>
<Button variant="ghost" class="text-zinc-200 hover:bg-zinc-800 hover:text-white" @click="saveToFile">
<Save class="h-4 w-4" />
保存到文件
</Button>
<Button variant="ghost" class="bg-blue-600 text-white hover:bg-blue-500" @click="copyToClipboard">
<Copy class="h-4 w-4" />
复制到剪贴板
</Button>
</div>
</div>
</template>
+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>
@@ -0,0 +1,501 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
interface CaptureData {
pngBase64: string
width: number
height: number
}
interface WindowInfo {
hwnd: number
title: string
rect: { x: number; y: number; width: number; height: number }
}
const win = getCurrentWindow()
const hash = window.location.hash
const query = hash.split('?')[1] || ''
const params = new URLSearchParams(query)
const mode = (params.get('mode') || 'region') as 'region' | 'window'
const imgEl = ref<HTMLImageElement | null>(null)
const imgSrc = ref('')
const imgWidth = ref(0) // 自然(物理)像素
const imgHeight = ref(0)
const loading = ref(true)
const errorMsg = ref('')
// 选区状态(CSS 逻辑像素,相对窗口左上角)
const dragging = ref(false)
const startX = ref(0)
const startY = ref(0)
const curX = ref(0)
const curY = ref(0)
const hasSelection = ref(false)
// 窗口拾取高亮(CSS 逻辑像素)
const winHighlight = ref<{
x: number
y: number
w: number
h: number
title: string
} | null>(null)
// 物理坐标换算所需
let winOuterX = 0
let winOuterY = 0
let dpr = 1
let currentHwnd = 0
let pickRaf = 0
let lastPickX = -1
let lastPickY = -1
const sel = computed(() => {
const x = Math.min(startX.value, curX.value)
const y = Math.min(startY.value, curY.value)
const w = Math.abs(curX.value - startX.value)
const h = Math.abs(curY.value - startY.value)
return { x, y, w, h }
})
const selSize = computed(() => {
if (!imgWidth.value || !imgHeight.value) return null
const sx = imgWidth.value / window.innerWidth
const sy = imgHeight.value / window.innerHeight
return { w: Math.round(sel.value.w * sx), h: Math.round(sel.value.h * sy) }
})
// 工具栏位置(选区右下方,溢出时翻到上方/左方)
const toolbarPos = computed(() => {
if (!hasSelection.value) return null
const s = sel.value
let left = s.x + s.w + 8
let top = s.y + s.h + 8
if (left + 280 > window.innerWidth) left = s.x + s.w - 280
if (top + 40 > window.innerHeight) top = s.y - 48
return { left: Math.max(8, left), top: Math.max(8, top) }
})
onMounted(async () => {
try {
const pos = await win.outerPosition()
winOuterX = pos.x
winOuterY = pos.y
dpr = window.devicePixelRatio || 1
const data = await invoke<CaptureData | null>('screenshot_take_fullscreen')
if (!data) {
errorMsg.value = '未找到屏幕捕获数据'
loading.value = false
return
}
imgSrc.value = 'data:image/png;base64,' + data.pngBase64
imgWidth.value = data.width
imgHeight.value = data.height
loading.value = false
await win.show()
await win.setFocus()
} catch (e) {
errorMsg.value = (e as Error).message
loading.value = false
}
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
window.addEventListener('keydown', onKeyDown)
})
onUnmounted(() => {
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
window.removeEventListener('keydown', onKeyDown)
})
function onMouseDown(e: MouseEvent) {
if (e.button !== 0) return
if (mode === 'window') {
if (currentHwnd) finishWindowCapture(currentHwnd)
return
}
// 区域模式:开始拖拽,清除上次选区
dragging.value = true
hasSelection.value = false
startX.value = e.clientX
startY.value = e.clientY
curX.value = e.clientX
curY.value = e.clientY
}
function onMouseMove(e: MouseEvent) {
if (mode === 'window') {
scheduleWindowPick(e.clientX, e.clientY)
return
}
if (dragging.value) {
curX.value = e.clientX
curY.value = e.clientY
}
}
function onMouseUp() {
if (mode !== 'region' || !dragging.value) return
dragging.value = false
if (sel.value.w < 4 || sel.value.h < 4) {
hasSelection.value = false
return
}
hasSelection.value = true
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
cancel()
} else if (e.key === 'Enter' && mode === 'region' && hasSelection.value) {
void doEdit()
}
}
function scheduleWindowPick(cssX: number, cssY: number) {
const physX = Math.round(winOuterX + cssX * dpr)
const physY = Math.round(winOuterY + cssY * dpr)
if (physX === lastPickX && physY === lastPickY) return
lastPickX = physX
lastPickY = physY
if (pickRaf) return
pickRaf = requestAnimationFrame(async () => {
pickRaf = 0
try {
const info = await invoke<WindowInfo | null>('screenshot_window_from_point', {
x: physX,
y: physY,
})
if (info) {
winHighlight.value = {
x: (info.rect.x - winOuterX) / dpr,
y: (info.rect.y - winOuterY) / dpr,
w: info.rect.width / dpr,
h: info.rect.height / dpr,
title: info.title,
}
currentHwnd = info.hwnd
} else {
winHighlight.value = null
currentHwnd = 0
}
} catch {
// 忽略拾取错误
}
})
}
/** 区域裁剪:从底图自然像素裁剪,返回 base64(无 data: 前缀) */
function cropSelection(): string | null {
const img = imgEl.value
if (!img || !imgWidth.value) return null
const scaleX = imgWidth.value / window.innerWidth
const scaleY = imgHeight.value / window.innerHeight
const sx = Math.round(sel.value.x * scaleX)
const sy = Math.round(sel.value.y * scaleY)
const sw = Math.round(sel.value.w * scaleX)
const sh = Math.round(sel.value.h * scaleY)
if (sw < 2 || sh < 2) return null
const canvas = document.createElement('canvas')
canvas.width = sw
canvas.height = sh
const ctx = canvas.getContext('2d')
if (!ctx) return null
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, sw, sh)
const url = canvas.toDataURL('image/png')
return url.slice(url.indexOf(',') + 1)
}
async function doEdit() {
const base64 = cropSelection()
if (!base64) return
await invoke('screenshot_set_editor_image', { pngBase64: base64 })
await openEditorAndClose()
}
async function doCopy() {
const base64 = cropSelection()
if (!base64) return
try {
await invoke('screenshot_copy_image', { pngBase64: base64 })
} catch (e) {
console.error(e)
}
await win.close()
}
async function doSave() {
const base64 = cropSelection()
if (!base64) return
try {
const { save } = await import('@tauri-apps/plugin-dialog')
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
const path = await save({
defaultPath: `screenshot_${ts}.png`,
filters: [{ name: 'PNG', extensions: ['png'] }],
})
if (path) await invoke('screenshot_save_png', { pngBase64: base64, path })
} catch (e) {
console.error(e)
}
await win.close()
}
async function finishWindowCapture(hwnd: number) {
if (!hwnd) return
try {
const data = await invoke<CaptureData>('screenshot_capture_window', { hwnd })
await invoke('screenshot_set_editor_image', { pngBase64: data.pngBase64 })
await openEditorAndClose()
} catch (e) {
console.error('[screenshot] 窗口捕获失败', e)
// 失败则留在覆盖层,用户可重试或取消
}
}
async function openEditorAndClose() {
const label = 'screenshot-editor'
try {
const existing = await WebviewWindow.getByLabel(label)
if (!existing) {
new WebviewWindow(label, {
url: 'index.html#screenshot-editor',
title: '截图编辑器',
width: 960,
height: 720,
minWidth: 640,
minHeight: 480,
decorations: false,
transparent: true,
resizable: true,
shadow: true,
focus: true,
visible: true,
})
} else {
await existing.show()
await existing.setFocus()
}
} catch (e) {
console.error(e)
}
await win.close()
}
function cancel() {
void win.close()
}
</script>
<template>
<div
class="overlay-root"
:class="mode === 'window' ? 'cursor-crosshair' : 'cursor-crosshair'"
@mousedown="onMouseDown"
>
<!-- 冻结的屏幕底图 -->
<img
v-if="imgSrc"
ref="imgEl"
:src="imgSrc"
class="bg-img"
draggable="false"
@load="() => {}"
/>
<!-- 加载中 -->
<div v-if="loading" class="hint">正在准备截图</div>
<div v-else-if="errorMsg" class="hint error">
{{ errorMsg }}
<button class="btn" @click="cancel">关闭</button>
</div>
<!-- 区域模式选区遮罩 + 选框 -->
<template v-if="mode === 'region' && !loading && !errorMsg">
<div
v-if="dragging || hasSelection"
class="selection"
:style="{
left: sel.x + 'px',
top: sel.y + 'px',
width: sel.w + 'px',
height: sel.h + 'px',
}"
>
<span v-if="selSize && (dragging || hasSelection)" class="size-tag">
{{ selSize.w }} × {{ selSize.h }}
</span>
</div>
<!-- 选区工具栏 -->
<div
v-if="hasSelection && toolbarPos"
class="toolbar"
:style="{ left: toolbarPos.left + 'px', top: toolbarPos.top + 'px' }"
@mousedown.stop
>
<button class="tb-btn primary" title="编辑" @click="doEdit">编辑</button>
<button class="tb-btn" title="复制到剪贴板" @click="doCopy">复制</button>
<button class="tb-btn" title="保存到文件" @click="doSave">保存</button>
<button class="tb-btn icon" title="取消 (Esc)" @click="cancel"></button>
</div>
</template>
<!-- 窗口模式高亮框 -->
<template v-if="mode === 'window' && !loading && !errorMsg && winHighlight">
<div
class="win-highlight"
:style="{
left: winHighlight.x + 'px',
top: winHighlight.y + 'px',
width: winHighlight.w + 'px',
height: winHighlight.h + 'px',
}"
>
<span class="win-title">{{ winHighlight.title }}</span>
</div>
</template>
<!-- 底部提示 -->
<div v-if="!loading && !errorMsg" class="bottom-hint">
<template v-if="mode === 'region'">
拖动选择区域 · Enter 编辑 · Esc 取消
</template>
<template v-else>
点击窗口捕获 · Esc 取消
</template>
</div>
</div>
</template>
<style scoped>
.overlay-root {
position: fixed;
inset: 0;
overflow: hidden;
user-select: none;
}
.bg-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: fill;
pointer-events: none;
}
.cursor-crosshair {
cursor: crosshair;
}
.hint {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
color: #fff;
font-size: 14px;
background: rgba(0, 0, 0, 0.45);
}
.hint.error {
flex-direction: column;
}
/* 选区:透明内部 + 巨大 box-shadow 形成外部遮罩 */
.selection {
position: absolute;
border: 1px solid #3b82f6;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45);
pointer-events: none;
}
.size-tag {
position: absolute;
top: -24px;
left: 0;
padding: 2px 6px;
background: #3b82f6;
color: #fff;
font-size: 12px;
border-radius: 3px;
white-space: nowrap;
}
.toolbar {
position: absolute;
display: flex;
gap: 4px;
padding: 4px;
background: rgba(24, 24, 27, 0.95);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
z-index: 10;
}
.tb-btn {
padding: 4px 10px;
background: transparent;
border: none;
color: #e4e4e7;
font-size: 13px;
border-radius: 4px;
cursor: pointer;
}
.tb-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.tb-btn.primary {
background: #3b82f6;
color: #fff;
}
.tb-btn.primary:hover {
background: #2563eb;
}
.tb-btn.icon {
padding: 4px 8px;
}
.win-highlight {
position: absolute;
border: 2px solid #3b82f6;
background: rgba(59, 130, 246, 0.12);
pointer-events: none;
}
.win-title {
position: absolute;
top: -22px;
left: -2px;
max-width: 240px;
padding: 1px 6px;
background: #3b82f6;
color: #fff;
font-size: 12px;
border-radius: 3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bottom-hint {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
padding: 4px 12px;
background: rgba(24, 24, 27, 0.8);
color: #d4d4d8;
font-size: 12px;
border-radius: 4px;
pointer-events: none;
}
.btn {
padding: 4px 12px;
background: #3b82f6;
border: none;
color: #fff;
border-radius: 4px;
cursor: pointer;
}
</style>
+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);
+201
View File
@@ -0,0 +1,201 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
import { availableMonitors } from '@tauri-apps/api/window'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { toast } from 'vue-sonner'
interface CaptureData {
pngBase64: string
width: number
height: number
}
export interface RecentCapture {
id: string
pngBase64: string
width: number
height: number
time: number
mode: string
}
export type CaptureMode = 'region' | 'window' | 'fullscreen'
const OVERLAY_LABEL = 'screenshot-overlay'
const EDITOR_LABEL = 'screenshot-editor'
export const useScreenshotStore = defineStore('screenshot', () => {
const capturing = ref(false)
const recent = ref<RecentCapture[]>([])
let exportUnlisten: UnlistenFn | null = null
/** 计算所有显示器的逻辑像素联合矩形(用于覆盖层窗口定位/尺寸) */
async function computeVirtualLogicalRect() {
const monitors = await availableMonitors()
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const m of monitors) {
const s = m.scaleFactor || 1
const lx = m.position.x / s
const ly = m.position.y / s
const lw = m.size.width / s
const lh = m.size.height / s
if (lx < minX) minX = lx
if (ly < minY) minY = ly
if (lx + lw > maxX) maxX = lx + lw
if (ly + lh > maxY) maxY = ly + lh
}
if (!Number.isFinite(minX)) {
minX = 0
minY = 0
maxX = 800
maxY = 600
}
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }
}
/** 启动截图:region=区域选择,window=窗口拾取,fullscreen=直接进编辑器 */
async function startCapture(mode: CaptureMode) {
if (capturing.value) return
capturing.value = true
try {
// 1. 捕获虚拟屏(覆盖层尚未创建 → 不会出现在截图中)
const data = await invoke<CaptureData>('screenshot_capture_fullscreen')
if (mode === 'fullscreen') {
// 全屏截图直接送入编辑器
await invoke('screenshot_set_editor_image', { pngBase64: data.pngBase64 })
// 清掉静态全屏缓存(编辑器用 cropped/全图,不再需要原始 BGRA
await invoke('screenshot_take_fullscreen').catch(() => {})
await openEditor()
return
}
// 2. 区域/窗口模式:创建覆盖层选区窗口
await openOverlay(mode)
} catch (e) {
console.error('[screenshot] 捕获失败', e)
toast.error('截图启动失败:' + (e as Error).message)
// 失败时清理静态缓存
await invoke('screenshot_take_fullscreen').catch(() => {})
} finally {
capturing.value = false
}
}
async function openOverlay(mode: CaptureMode) {
const existing = await WebviewWindow.getByLabel(OVERLAY_LABEL)
if (existing) await existing.close()
const rect = await computeVirtualLogicalRect()
const url = `index.html#screenshot-overlay?mode=${mode}`
new WebviewWindow(OVERLAY_LABEL, {
url,
title: '截图',
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
decorations: false,
transparent: true,
alwaysOnTop: true,
skipTaskbar: true,
resizable: false,
shadow: false,
focus: true,
visible: true,
})
}
async function openEditor() {
const existing = await WebviewWindow.getByLabel(EDITOR_LABEL)
if (existing) {
await existing.show()
await existing.setFocus()
return
}
new WebviewWindow(EDITOR_LABEL, {
url: 'index.html#screenshot-editor',
title: '截图编辑器',
width: 960,
height: 720,
minWidth: 640,
minHeight: 480,
decorations: false,
transparent: true,
alwaysOnTop: false,
skipTaskbar: false,
resizable: true,
shadow: true,
focus: true,
visible: true,
})
}
function addRecent(pngBase64: string, width: number, height: number, mode: string) {
recent.value.unshift({
id: crypto.randomUUID(),
pngBase64,
width,
height,
time: Date.now(),
mode,
})
if (recent.value.length > 12) recent.value.pop()
}
async function copyImage(pngBase64: string) {
await invoke('screenshot_copy_image', { pngBase64 })
toast.success('已复制到剪贴板')
}
async function saveImage(pngBase64: string) {
const { save } = await import('@tauri-apps/plugin-dialog')
const ts = new Date()
.toISOString()
.replace(/[:.]/g, '-')
.slice(0, 19)
const path = await save({
defaultPath: `screenshot_${ts}.png`,
filters: [{ name: 'PNG', extensions: ['png'] }],
})
if (!path) return
await invoke('screenshot_save_png', { pngBase64, path })
toast.success('已保存到文件')
}
/** 监听编辑器导出事件(主窗口记录历史 + 提示) */
async function initExportListener() {
if (exportUnlisten) return
exportUnlisten = await listen<{
pngBase64: string
width: number
height: number
}>('screenshot-exported', (e) => {
addRecent(e.payload.pngBase64, e.payload.width, e.payload.height, 'edited')
})
}
function destroyExportListener() {
if (exportUnlisten) {
exportUnlisten()
exportUnlisten = null
}
}
return {
capturing,
recent,
startCapture,
openEditor,
addRecent,
copyImage,
saveImage,
initExportListener,
destroyExportListener,
}
})