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