From c913379e2b5bacf8c5273e5df6d3b84ab567486a Mon Sep 17 00:00:00 2001 From: zhongluofeng Date: Mon, 3 Aug 2026 17:36:07 +0800 Subject: [PATCH] =?UTF-8?q?=E6=88=AA=E5=9B=BE=E6=A8=A1=E5=9D=97=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/clipboard/commands.rs | 4 + src-tauri/src/clipboard/popup.rs | 104 +- src-tauri/src/lib.rs | 15 +- src-tauri/src/screenshot/capture.rs | 180 ++- src-tauri/src/screenshot/commands.rs | 33 + src-tauri/src/tray_menu.rs | 5 - src/App.vue | 8 - src/components/layout/TitleBar.vue | 28 +- src/components/ui/slider/Slider.vue | 43 + src/components/ui/slider/index.ts | 1 + src/modules/clipboard/ClipboardModule.vue | 182 ++- src/modules/downloader/DownloaderModule.vue | 13 +- src/modules/proxy/ProxyModule.vue | 23 +- src/modules/screenshot/ScreenshotModule.vue | 27 +- src/modules/screenshot/ScreenshotOverlay.vue | 1300 ++++++++++++++++-- src/modules/screenshot/index.ts | 2 +- src/modules/tray/TrayMenu.vue | 6 +- src/stores/moduleTabsStore.ts | 35 +- src/style.css | 35 + 19 files changed, 1744 insertions(+), 300 deletions(-) create mode 100644 src/components/ui/slider/Slider.vue create mode 100644 src/components/ui/slider/index.ts diff --git a/src-tauri/src/clipboard/commands.rs b/src-tauri/src/clipboard/commands.rs index c183998..942387d 100644 --- a/src-tauri/src/clipboard/commands.rs +++ b/src-tauri/src/clipboard/commands.rs @@ -134,6 +134,10 @@ pub async fn clipboard_save_settings( // 快捷键变化时重新注册 if settings.shortcut != prev_shortcut { super::popup::register_shortcut(&app, &settings.shortcut)?; + // 新快捷键非空时确保弹窗窗口已预创建 + if !settings.shortcut.trim().is_empty() { + super::popup::ensure_popup_window(&app); + } } Ok(()) } diff --git a/src-tauri/src/clipboard/popup.rs b/src-tauri/src/clipboard/popup.rs index 1976936..529c35e 100644 --- a/src-tauri/src/clipboard/popup.rs +++ b/src-tauri/src/clipboard/popup.rs @@ -9,6 +9,7 @@ //! 6. 窗口失焦自动隐藏 use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder, Emitter}; use tauri::window::{Effect, EffectsBuilder}; use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; @@ -19,6 +20,10 @@ pub const POPUP_LABEL: &str = "clipboard-popup"; /// 当前注册的快捷键(用于注销旧快捷键) static CURRENT_SHORTCUT: Mutex> = Mutex::new(None); +/// 标志:show_popup 兜底创建路径设为 true,前端 onMounted 回调 show_window 时据此判断是否显示。 +/// 预创建路径不设置,避免应用启动时弹窗自动弹出。 +static POPUP_PENDING_SHOW: AtomicBool = AtomicBool::new(false); + /// 解析快捷键字符串为 Shortcut(格式如 "Alt+V"、"Ctrl+Shift+V") /// 失败返回 None。 pub fn parse_shortcut(s: &str) -> Option { @@ -69,6 +74,58 @@ pub fn unregister_shortcut(app: &AppHandle) { } } +/// 创建弹窗窗口(隐藏状态)并注册失焦监听。 +/// 位置默认在屏幕外,show_popup 时会重新定位。 +/// 预创建后首次按快捷键走"窗口已存在"分支直接 show,避免首次创建的时序问题。 +fn create_popup_window(app: &AppHandle) { + let win = match WebviewWindowBuilder::new( + app, + POPUP_LABEL, + WebviewUrl::App("index.html#clipboard-popup".into()), + ) + .title("剪贴板") + .inner_size(380.0, 460.0) + .position(-10000.0, -10000.0) // 屏幕外,避免隐藏时一闪 + .decorations(false) + .transparent(true) + .shadow(true) + .always_on_top(true) + .skip_taskbar(true) + .resizable(false) + .visible(false) + .focused(false) // 不抢占焦点,避免创建即触发 Focused(false) + .effects(EffectsBuilder::new().effects(vec![Effect::Mica]).build()) + .build() + { + Ok(w) => w, + Err(e) => { + eprintln!("[clipboard] 创建弹窗失败: {}", e); + return; + } + }; + + // 监听窗口失焦:自动隐藏 + let app_handle = app.clone(); + let win_handle = win.clone(); + win.on_window_event(move |event| { + if let tauri::WindowEvent::Focused(false) = event { + let _ = win_handle.hide(); + let _ = app_handle.emit("clipboard-popup-hide", ()); + } + }); + + eprintln!("[clipboard] 弹窗窗口已预创建(隐藏状态)"); +} + +/// 应用启动时预创建弹窗窗口(隐藏)。 +/// 这样首次按快捷键时窗口已存在,直接 show + 定位,避免首次创建时序问题。 +pub fn ensure_popup_window(app: &AppHandle) { + if app.get_webview_window(POPUP_LABEL).is_some() { + return; + } + create_popup_window(app); +} + /// 在鼠标当前位置显示弹窗。 /// 窗口不存在则创建(隐藏状态,等前端挂载后调用 show_window 显示)。 /// 窗口已存在则移动到鼠标位置并显示。 @@ -115,49 +172,18 @@ pub fn show_popup(app: &AppHandle) { return; } - // 首次创建窗口:先隐藏,等前端挂载完成后再显示 - // 这样确保主题效果(dark class、mica)在窗口可见前已应用 - let win = match WebviewWindowBuilder::new( - app, - POPUP_LABEL, - WebviewUrl::App("index.html#clipboard-popup".into()), - ) - .title("剪贴板") - .inner_size(w, h) - .position(x, y) - .decorations(false) - .transparent(true) - .shadow(true) - .always_on_top(true) - .skip_taskbar(true) - .resizable(false) - .visible(false) // 关键:先隐藏,前端 onMounted 后调用 show_window - .focused(true) - .effects(EffectsBuilder::new().effects(vec![Effect::Mica]).build()) - .build() - { - Ok(w) => w, - Err(e) => { - eprintln!("[clipboard] 创建弹窗失败: {}", e); - return; - } - }; - - // 监听窗口失焦:自动隐藏 - let app_handle = app.clone(); - let win_handle = win.clone(); - win.on_window_event(move |event| { - if let tauri::WindowEvent::Focused(false) = event { - let _ = win_handle.hide(); - let _ = app_handle.emit("clipboard-popup-hide", ()); - } - }); - - eprintln!("[clipboard] 弹窗窗口已创建(隐藏状态)"); + // 兜底:窗口被销毁时重新创建(隐藏),等前端 onMounted 回调 show_window + POPUP_PENDING_SHOW.store(true, Ordering::SeqCst); + create_popup_window(app); } /// 显示已创建的弹窗窗口(由前端 onMounted 后调用)。 +/// 预创建路径下前端 onMounted 也会调用此函数,但 POPUP_PENDING_SHOW 为 false 时直接跳过, +/// 避免应用启动时弹窗自动弹出。仅 show_popup 兜底创建路径才真正显示。 pub fn show_window(app: &AppHandle) { + if !POPUP_PENDING_SHOW.swap(false, Ordering::SeqCst) { + return; + } if let Some(win) = app.get_webview_window(POPUP_LABEL) { let _ = win.show(); let _ = win.set_focus(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 36a14cb..ff426aa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -47,11 +47,11 @@ use process_manager::{ }; use screenshot::commands::{ screenshot_capture_fullscreen, screenshot_capture_window, screenshot_clear_fullscreen, - screenshot_copy_image, screenshot_crop_copy_stored, screenshot_crop_stored, - screenshot_cursor_pos, screenshot_disable_transitions, screenshot_enum_windows, - screenshot_fullscreen_png, screenshot_get_editor_image, screenshot_get_fullscreen_bmp, - screenshot_register_shortcut, screenshot_save_png, screenshot_set_editor_image, - screenshot_unregister_shortcut, screenshot_window_from_point, + screenshot_compose_copy, screenshot_copy_image, screenshot_crop_copy_stored, + screenshot_crop_stored, screenshot_cursor_pos, screenshot_disable_transitions, + screenshot_enum_windows, screenshot_fullscreen_png, screenshot_get_editor_image, + screenshot_get_fullscreen_bmp, screenshot_register_shortcut, screenshot_save_png, + screenshot_set_editor_image, screenshot_unregister_shortcut, screenshot_window_from_point, }; use clipboard::{ ClipboardManager, @@ -215,7 +215,8 @@ pub fn run() { screenshot_save_png, screenshot_register_shortcut, screenshot_unregister_shortcut, - screenshot_disable_transitions + screenshot_disable_transitions, + screenshot_compose_copy ]) .setup(|app| { // 初始化日志系统,日志目录: {app_data_dir}/logs/ @@ -280,6 +281,8 @@ pub fn run() { if let Err(e) = clipboard::popup::register_shortcut(&app_handle, &shortcut) { eprintln!("[clipboard] 快捷键注册失败: {}", e); } + // 预创建弹窗窗口(隐藏),首次按快捷键时直接 show,避免首次创建时序问题 + clipboard::popup::ensure_popup_window(&app_handle); } app.manage(clipboard); diff --git a/src-tauri/src/screenshot/capture.rs b/src-tauri/src/screenshot/capture.rs index 558d087..0ce2755 100644 --- a/src-tauri/src/screenshot/capture.rs +++ b/src-tauri/src/screenshot/capture.rs @@ -78,12 +78,6 @@ fn bgra_to_bmp(bgra: &[u8], width: i32, height: i32) -> Result, String> let data_size = w * h * 4; let file_size = 14 + 40 + data_size; - // BitBlt 取出的 alpha 通道未定义(常为 0),强制不透明 - let mut pixels = bgra.to_vec(); - for a in pixels.iter_mut().skip(3).step_by(4) { - *a = 255; - } - let mut bmp = Vec::with_capacity(file_size); // BITMAPFILEHEADER bmp.extend_from_slice(b"BM"); @@ -103,7 +97,12 @@ fn bgra_to_bmp(bgra: &[u8], width: i32, height: i32) -> Result, String> bmp.extend_from_slice(&0u32.to_le_bytes()); // y ppm bmp.extend_from_slice(&0u32.to_le_bytes()); // colors used bmp.extend_from_slice(&0u32.to_le_bytes()); // important colors - bmp.extend_from_slice(&pixels); + // 像素数据:memcpy 一次性拷贝,再原地修正 alpha(避免 to_vec 中间分配) + let pixel_start = bmp.len(); + bmp.extend_from_slice(bgra); + for i in (pixel_start + 3..bmp.len()).step_by(4) { + bmp[i] = 255; + } Ok(bmp) } @@ -117,16 +116,16 @@ fn bgra_to_png(bgra: &[u8], width: i32, height: i32) -> Result, String> 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 pixel_count = (width as usize) * (height as usize); + let mut rgba = vec![0u8; pixel_count * 4]; + // chunks_exact 迭代器写法:4 字节对齐连续读取,编译器更容易自动向量化 + for (src, dst) in bgra.chunks_exact(4).zip(rgba.chunks_exact_mut(4)) { + dst[0] = src[2]; // R + dst[1] = src[1]; // G + dst[2] = src[0]; // B + dst[3] = 255; // A 强制不透明 } - let mut buf = Vec::with_capacity(w * h * 4 / 3); + let mut buf = Vec::with_capacity(pixel_count * 4 / 3); PngEncoder::new_with_quality( &mut buf, CompressionType::Fast, @@ -493,7 +492,9 @@ fn bgra_to_dib(bgra: &[u8], width: i32, height: i32) -> Vec { let header_size = std::mem::size_of::() as u32; let w = width as usize; let h = height as usize; - let mut dib = Vec::with_capacity(header_size as usize + w * h * 4); + let row_size = w * 4; + let pixel_size = row_size * h; + let mut dib = Vec::with_capacity(header_size as usize + pixel_size); let header = BITMAPINFOHEADER { biSize: header_size, biWidth: width, @@ -501,7 +502,7 @@ fn bgra_to_dib(bgra: &[u8], width: i32, height: i32) -> Vec { biPlanes: 1, biBitCount: 32, biCompression: BI_RGB, - biSizeImage: (w * h * 4) as u32, + biSizeImage: (pixel_size) as u32, biXPelsPerMeter: 0, biYPelsPerMeter: 0, biClrUsed: 0, @@ -514,15 +515,17 @@ fn bgra_to_dib(bgra: &[u8], width: i32, height: i32) -> Vec { ) }; dib.extend_from_slice(raw); - // 从 top-down 行序倒序拷贝为 bottom-up,alpha 强制 255 - for y in (0..h).rev() { - let row = &bgra[y * w * 4..(y + 1) * w * 4]; - for x in 0..w { - dib.push(row[x * 4]); // B - dib.push(row[x * 4 + 1]); // G - dib.push(row[x * 4 + 2]); // R - dib.push(255); // A - } + // 预分配像素区,按行 memcpy 拷贝(top-down → bottom-up 行序翻转),再原地修正 alpha + let pixel_start = dib.len(); + dib.resize(dib.len() + pixel_size, 0); + for y in 0..h { + let src = y * row_size; + let dst = (h - 1 - y) * row_size; + dib[pixel_start + dst..pixel_start + dst + row_size] + .copy_from_slice(&bgra[src..src + row_size]); + } + for i in (pixel_start + 3..pixel_start + pixel_size).step_by(4) { + dib[i] = 255; } dib } @@ -592,11 +595,17 @@ pub fn copy_png_to_clipboard(png_base64: &str) -> Result<(), String> { 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 { +/// raw RGBA 像素 → CF_DIB(直接从连续像素切片构造,无需 image crate 解码) +/// +/// 用于有标注导出路径:前端 canvas.getImageData() 直传 raw RGBA, +/// 省去 toDataURL(PNG 编码+base64) → Rust base64 解码 → PNG 解码 的三次往返。 +fn rgba_raw_to_dib(rgba: &[u8], width: i32, height: i32) -> Vec { let header_size = std::mem::size_of::() 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 w = width as usize; + let h = height as usize; + let row_size = w * 4; + let pixel_size = row_size * h; + let mut dib = Vec::with_capacity(header_size as usize + pixel_size); let header = BITMAPINFOHEADER { biSize: header_size, biWidth: width, @@ -604,7 +613,7 @@ fn rgba_to_dib(rgba: &image::RgbaImage, width: i32, height: i32) -> Vec { biPlanes: 1, biBitCount: 32, biCompression: BI_RGB, - biSizeImage: (row_size * height as usize) as u32, + biSizeImage: (pixel_size) as u32, biXPelsPerMeter: 0, biYPelsPerMeter: 0, biClrUsed: 0, @@ -617,14 +626,105 @@ fn rgba_to_dib(rgba: &image::RgbaImage, width: i32, height: i32) -> Vec { ) }; 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 + let pixel_start = dib.len(); + dib.resize(dib.len() + pixel_size, 0); + // RGBA top-down → BGRA bottom-up,alpha 强制 255 + for y in 0..h { + let src_off = (h - 1 - y) * row_size; + let dst_off = y * row_size; + for x in 0..w { + let si = src_off + x * 4; + let di = pixel_start + dst_off + x * 4; + dib[di] = rgba[si + 2]; // B + dib[di + 1] = rgba[si + 1]; // G + dib[di + 2] = rgba[si]; // R + dib[di + 3] = 255; // A + } + } + dib +} + +/// raw RGBA → PNG(直接从连续像素切片编码,跳过 image crate 的 RgbaImage 包装) +fn rgba_raw_to_png(rgba: &[u8], width: i32, height: i32) -> Result, String> { + use image::codecs::png::{CompressionType, FilterType, PngEncoder}; + use image::ImageEncoder; + if width <= 0 || height <= 0 { + return Err("无效尺寸".into()); + } + let mut buf = Vec::with_capacity(rgba.len() / 3); + PngEncoder::new_with_quality( + &mut buf, + CompressionType::Fast, + FilterType::NoFilter, + ) + .write_image(rgba, width as u32, height as u32, image::ExtendedColorType::Rgba8) + .map_err(|e| format!("PNG 编码失败: {}", e))?; + Ok(buf) +} + +/// 有标注导出:raw RGBA → 剪贴板 DIB + PNG base64(一次调用完成复制+编码) +/// +/// 替代原 composeBase64 + screenshot_copy_image 两步: +/// 省去前端 toDataURL(PNG 编码+base64) + Rust base64 解码 + PNG 解码, +/// 直接 raw RGBA 传输 → DIB 写剪贴板 + PNG 编码返回。 +pub fn compose_copy_rgba(rgba: &[u8], width: i32, height: i32) -> Result { + let expected = (width as usize) * (height as usize) * 4; + if rgba.len() < expected { + return Err(format!("像素数据不足: {} < {}", rgba.len(), expected)); + } + let dib = rgba_raw_to_dib(rgba, width, height); + write_dib_to_clipboard(&dib)?; + let png = rgba_raw_to_png(rgba, width, height)?; + Ok(CaptureData { + png_base64: base64_encode(&png), + width, + height, + }) +} + +/// RGBA → CF_DIB(BITMAPINFOHEADER + BGRA bottom-up,alpha 强制 255) +fn rgba_to_dib(rgba: &image::RgbaImage, width: i32, height: i32) -> Vec { + let header_size = std::mem::size_of::() as u32; + let w = width as usize; + let h = height as usize; + let row_size = w * 4; + let pixel_size = row_size * h; + let mut dib = Vec::with_capacity(header_size as usize + pixel_size); + let header = BITMAPINFOHEADER { + biSize: header_size, + biWidth: width, + biHeight: height, // 正值 = bottom-up + biPlanes: 1, + biBitCount: 32, + biCompression: BI_RGB, + biSizeImage: (pixel_size) 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::(), + ) + }; + dib.extend_from_slice(raw); + // 预分配像素区,用 as_raw() 连续切片替代逐像素 get_pixel + let pixel_start = dib.len(); + dib.resize(dib.len() + pixel_size, 0); + let src = rgba.as_raw(); // &[u8] RGBA top-down + for y in 0..h { + // bottom-up: DIB 第一行 = 图像最后一行 + let src_off = (h - 1 - y) * row_size; + let dst_off = y * row_size; + for x in 0..w { + let si = src_off + x * 4; + let di = pixel_start + dst_off + x * 4; + dib[di] = src[si + 2]; // B + dib[di + 1] = src[si + 1]; // G + dib[di + 2] = src[si]; // R + dib[di + 3] = 255; // A } } dib diff --git a/src-tauri/src/screenshot/commands.rs b/src-tauri/src/screenshot/commands.rs index 70025cb..17e89ff 100644 --- a/src-tauri/src/screenshot/commands.rs +++ b/src-tauri/src/screenshot/commands.rs @@ -279,6 +279,39 @@ pub async fn screenshot_copy_image(png_base64: String) -> Result<(), String> { } } +/// 有标注导出:接收 raw RGBA(前端 canvas.getImageData 直传),一次完成 剪贴板+PNG base64。 +/// +/// 省去前端 toDataURL(PNG 编码+base64) → Rust base64 解码 → PNG 解码 三次往返。 +/// body 格式:前 8 字节 = width(i32 LE) + height(i32 LE),之后为 raw RGBA 像素。 +#[tauri::command] +pub async fn screenshot_compose_copy( + request: tauri::ipc::Request<'_>, +) -> Result { + #[cfg(windows)] + { + let body = match request.body() { + tauri::ipc::InvokeBody::Raw(data) => data.clone(), + _ => return Err("需要 raw body(ArrayBuffer)".into()), + }; + if body.len() < 8 { + return Err("数据不足:缺少尺寸头".into()); + } + let width = i32::from_le_bytes([body[0], body[1], body[2], body[3]]); + let height = i32::from_le_bytes([body[4], body[5], body[6], body[7]]); + let rgba = body[8..].to_vec(); + tauri::async_runtime::spawn_blocking(move || { + super::capture::compose_copy_rgba(&rgba, width, height) + }) + .await + .map_err(|e| format!("合成复制任务失败: {}", e))? + } + #[cfg(not(windows))] + { + let _ = request; + Err("截图仅支持 Windows".into()) + } +} + /// 将 PNG base64 写入文件 #[tauri::command] pub async fn screenshot_save_png(png_base64: String, path: String) -> Result<(), String> { diff --git a/src-tauri/src/tray_menu.rs b/src-tauri/src/tray_menu.rs index 2625f6c..88e2b43 100644 --- a/src-tauri/src/tray_menu.rs +++ b/src-tauri/src/tray_menu.rs @@ -394,11 +394,6 @@ 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(); diff --git a/src/App.vue b/src/App.vue index 4ef36bf..c8d66bb 100644 --- a/src/App.vue +++ b/src/App.vue @@ -159,14 +159,6 @@ onMounted(async () => { handleModuleChange('settings') }) ) - // 托盘截图:直接触发截图流程(不切换模块,覆盖层独立窗口,进入后点选窗口或长按拖选区) - trayUnlisteners.push( - await listen('tray:start-screenshot', () => { - screenshotStore.startCapture().catch(err => - console.error('[screenshot] 托盘触发截图失败:', err) - ) - }) - ) // 截图导出监听(应用级常驻,确保任何来源的截图都记录到历史) screenshotStore.initExportListener().catch(e => console.error('[screenshot] 导出监听初始化失败:', e)) }) diff --git a/src/components/layout/TitleBar.vue b/src/components/layout/TitleBar.vue index bcc2547..7c3819e 100644 --- a/src/components/layout/TitleBar.vue +++ b/src/components/layout/TitleBar.vue @@ -1,6 +1,6 @@ + + diff --git a/src/components/ui/slider/index.ts b/src/components/ui/slider/index.ts new file mode 100644 index 0000000..f7a7b09 --- /dev/null +++ b/src/components/ui/slider/index.ts @@ -0,0 +1 @@ +export { default as Slider } from "./Slider.vue" diff --git a/src/modules/clipboard/ClipboardModule.vue b/src/modules/clipboard/ClipboardModule.vue index 7be12a3..7bd432e 100644 --- a/src/modules/clipboard/ClipboardModule.vue +++ b/src/modules/clipboard/ClipboardModule.vue @@ -1,12 +1,13 @@ @@ -245,33 +331,39 @@ onUnmounted(() => { - -
- - - - - + +
+ +
+ + 快捷弹窗:{{ displayShortcut(form.shortcut) }}未设置 +
@@ -356,11 +448,10 @@ onUnmounted(() => {
-
+

基本设置

-
@@ -434,12 +525,29 @@ onUnmounted(() => {
- -

格式:修饰键+主键,如 Ctrl+C、Alt+V、Shift+F1。修改后自动生效。

+
+
+ + + +
+ +
+

点击方框录入快捷键,需至少一个修饰键 + 字母/数字/功能键。默认 Alt+V

diff --git a/src/modules/downloader/DownloaderModule.vue b/src/modules/downloader/DownloaderModule.vue index cd1f245..4c52815 100644 --- a/src/modules/downloader/DownloaderModule.vue +++ b/src/modules/downloader/DownloaderModule.vue @@ -13,6 +13,7 @@ import { invoke } from '@tauri-apps/api/core' import { open as openDialog } from '@tauri-apps/plugin-dialog' import { useDownloaderStore, type DownloadTask, type TaskStatus, type CheckUrlResult } from '@/stores/downloaderStore' import { useModuleTabs } from '@/lib/useModuleTabs' +import { useModuleTabsStore } from '@/stores/moduleTabsStore' import { createLogger } from '@/lib/logger' import { pendingNewDownload } from '@/lib/trayEvents' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' @@ -42,6 +43,7 @@ const logger = createLogger('downloader') // ===== 主 Tab 状态 ===== const activeTab = ref('tasks') +const tabsStore = useModuleTabsStore() const tabsListRef = useModuleTabs(activeTab, [ { value: 'tasks', label: '下载任务' }, { value: 'settings', label: '设置' }, @@ -483,6 +485,9 @@ const handleSaveSettings = async (): Promise => { } } +// 注册保存处理函数到标签栏 store(TitleBar 保存按钮调用) +tabsStore.registerSave(handleSaveSettings) + // Dialog 保存:成功后关闭弹窗 const handleDialogSave = async () => { const ok = await handleSaveSettings() @@ -963,14 +968,6 @@ const toggleSortOrder = () => {
- -
- -
- diff --git a/src/modules/proxy/ProxyModule.vue b/src/modules/proxy/ProxyModule.vue index 29ea566..1e1d683 100644 --- a/src/modules/proxy/ProxyModule.vue +++ b/src/modules/proxy/ProxyModule.vue @@ -11,6 +11,7 @@ import { appDataDir } from '@tauri-apps/api/path' import { revealItemInDir } from '@tauri-apps/plugin-opener' import { useProxyStore, type ProxyNode } from '@/stores/proxyStore' import { useModuleTabs } from '@/lib/useModuleTabs' +import { useModuleTabsStore } from '@/stores/moduleTabsStore' import { createLogger } from '@/lib/logger' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' @@ -71,6 +72,7 @@ const onConfirmOpenChange = (open: boolean) => { const activeTab = ref('overview') // 浮动标签切换器:注册到 TitleBar,滚动遮挡时自动显示 +const tabsStore = useModuleTabsStore() const tabsListRef = useModuleTabs(activeTab, [ { value: 'overview', label: '概览' }, { value: 'proxies', label: '节点' }, @@ -856,10 +858,8 @@ const syncLocalSettings = () => { watch(() => store.settings, syncLocalSettings, { immediate: true }) -const savingSettings = ref(false) const saveSettingsForm = async () => { if (!store.settings) return - savingSettings.value = true try { await store.saveSettings({ ...store.settings, @@ -868,10 +868,11 @@ const saveSettingsForm = async () => { toast.success('设置已保存') } catch (e) { toast.error('保存失败', { description: String(e) }) - } finally { - savingSettings.value = false } } + +// 注册保存处理函数到标签栏 store(TitleBar 保存按钮调用) +tabsStore.registerSave(saveSettingsForm)