From c7578a2e6bb2db7fc218a7054e166957b0088f78 Mon Sep 17 00:00:00 2001 From: zhongluofeng Date: Tue, 4 Aug 2026 16:00:57 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BF=AB=E9=80=9F=E9=9D=A2=E6=9D=BF=E6=A8=A1?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/lib.rs | 11 +- src-tauri/src/quickpanel/commands.rs | 72 +-- src-tauri/src/quickpanel/mod.rs | 10 +- src-tauri/src/quickpanel/special_locations.rs | 232 +++++++ src/modules/quickpanel/QuickPanel.vue | 140 +++- src/modules/quickpanel/QuickPanelModule.vue | 8 + src/modules/quickpanel/providers.ts | 598 ++++++++++++++++-- 7 files changed, 954 insertions(+), 117 deletions(-) create mode 100644 src-tauri/src/quickpanel/special_locations.rs diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 983523c..fdf8c96 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -65,10 +65,11 @@ use clipboard::{ use quickpanel::{ quickpanel_build_file_index, quickpanel_clear_app_icon_cache, quickpanel_delete_file, quickpanel_file_index_stats, quickpanel_get_app_icon, quickpanel_get_settings, - quickpanel_hide_popup, quickpanel_init_file_index, quickpanel_lock_screen, - quickpanel_open_file, quickpanel_register_shortcut, quickpanel_reveal_in_explorer, - quickpanel_run_custom_command, quickpanel_run_system_command, quickpanel_save_settings, - quickpanel_scan_apps, quickpanel_search_files, quickpanel_show_popup, quickpanel_show_window, + quickpanel_get_special_locations, quickpanel_hide_popup, quickpanel_init_file_index, + quickpanel_lock_screen, quickpanel_open_file, quickpanel_open_special, + quickpanel_register_shortcut, quickpanel_reveal_in_explorer, quickpanel_run_custom_command, + quickpanel_run_system_command, quickpanel_save_settings, quickpanel_scan_apps, + quickpanel_search_files, quickpanel_show_popup, quickpanel_show_window, quickpanel_unregister_shortcut, }; use tray_menu::{tray_menu_action, tray_menu_hide, tray_menu_ready}; @@ -225,6 +226,8 @@ pub fn run() { quickpanel_delete_file, quickpanel_run_custom_command, quickpanel_run_system_command, + quickpanel_get_special_locations, + quickpanel_open_special, tray_menu_action, tray_menu_hide, tray_menu_ready, diff --git a/src-tauri/src/quickpanel/commands.rs b/src-tauri/src/quickpanel/commands.rs index 40fae84..03426da 100644 --- a/src-tauri/src/quickpanel/commands.rs +++ b/src-tauri/src/quickpanel/commands.rs @@ -188,62 +188,32 @@ pub fn quickpanel_reveal_in_explorer(path: String) -> Result<(), String> { Ok(()) } -/// 用系统默认程序打开文件(ShellExecuteW)。 -/// 无关联应用时,自动 fallback 到「打开方式」对话框(verb: openas)。 +/// 用系统默认程序打开文件/文件夹。 +/// - 目录:explorer.exe 直接打开(修复索引目录点击后未打开的问题) +/// - 文件:ShellExecuteW open,无关联应用时自动 fallback 到「打开方式」对话框(verb: openas) #[tauri::command] pub fn quickpanel_open_file(path: String) -> Result<(), String> { - #[cfg(windows)] - { - use std::ffi::OsStr; - use std::os::windows::ffi::OsStrExt; - use windows_sys::Win32::Foundation::HWND; - use windows_sys::Win32::UI::Shell::ShellExecuteW; - use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL; + super::special_locations::open_path(&path) +} - let normalized = path.replace('/', "\\"); - let wide_path: Vec = OsStr::new(&normalized) - .encode_wide() - .chain(std::iter::once(0)) - .collect(); - let wide_open: Vec = OsStr::new("open") - .encode_wide() - .chain(std::iter::once(0)) - .collect(); - let wide_openas: Vec = OsStr::new("openas") - .encode_wide() - .chain(std::iter::once(0)) - .collect(); +/// 获取 Windows 常用快捷位置(hosts、回收站、此电脑、用户目录、系统管理工具等) +#[tauri::command] +pub fn quickpanel_get_special_locations() -> Vec { + super::special_locations::get_special_locations() +} - unsafe { - let mut hinst = ShellExecuteW( - 0 as HWND, - wide_open.as_ptr(), - wide_path.as_ptr(), - std::ptr::null(), - std::ptr::null(), - SW_SHOWNORMAL, - ); - if hinst <= 32 { - hinst = ShellExecuteW( - 0 as HWND, - wide_openas.as_ptr(), - wide_path.as_ptr(), - std::ptr::null(), - std::ptr::null(), - SW_SHOWNORMAL, - ); - if hinst <= 32 { - return Err(format!("打开文件失败 (code: {})", hinst as i32)); - } - } - } +/// 打开快捷位置(kind: file | shell | cmd) +#[tauri::command] +pub fn quickpanel_open_special( + kind: String, + target: String, + args: Vec, +) -> Result<(), String> { + match kind.as_str() { + "shell" => super::special_locations::open_shell(&target), + "cmd" => super::special_locations::open_cmd(&target, &args), + _ => super::special_locations::open_path(&target), } - #[cfg(not(windows))] - { - let _ = path; - return Err(String::from("当前平台不支持")); - } - Ok(()) } /// 删除文件(移到回收站) diff --git a/src-tauri/src/quickpanel/mod.rs b/src-tauri/src/quickpanel/mod.rs index 6164ed4..1372654 100644 --- a/src-tauri/src/quickpanel/mod.rs +++ b/src-tauri/src/quickpanel/mod.rs @@ -9,14 +9,16 @@ pub mod commands; pub mod file_index; pub mod icon_extractor; pub mod popup; +pub mod special_locations; pub use commands::{ quickpanel_build_file_index, quickpanel_clear_app_icon_cache, quickpanel_delete_file, quickpanel_file_index_stats, quickpanel_get_app_icon, quickpanel_get_settings, - quickpanel_hide_popup, quickpanel_init_file_index, quickpanel_lock_screen, - quickpanel_open_file, quickpanel_register_shortcut, quickpanel_reveal_in_explorer, - quickpanel_run_custom_command, quickpanel_run_system_command, quickpanel_save_settings, - quickpanel_scan_apps, quickpanel_search_files, quickpanel_show_popup, quickpanel_show_window, + quickpanel_get_special_locations, quickpanel_hide_popup, quickpanel_init_file_index, + quickpanel_lock_screen, quickpanel_open_file, quickpanel_open_special, + quickpanel_register_shortcut, quickpanel_reveal_in_explorer, quickpanel_run_custom_command, + quickpanel_run_system_command, quickpanel_save_settings, quickpanel_scan_apps, + quickpanel_search_files, quickpanel_show_popup, quickpanel_show_window, quickpanel_unregister_shortcut, }; pub use popup::{ensure_window, load_settings, register_shortcut}; diff --git a/src-tauri/src/quickpanel/special_locations.rs b/src-tauri/src/quickpanel/special_locations.rs new file mode 100644 index 0000000..81492fc --- /dev/null +++ b/src-tauri/src/quickpanel/special_locations.rs @@ -0,0 +1,232 @@ +//! 快捷位置:Windows 常用系统位置/工具。 +//! +//! 提供两类能力: +//! - `get_special_locations`:返回预定义列表(hosts、回收站、此电脑、用户目录、系统管理工具), +//! 路径在 Rust 侧解析(环境变量 / dirs crate),前端只负责展示与搜索。 +//! - 打开逻辑:目录走 explorer.exe(比 ShellExecute "open" 目录更可靠), +//! 文件走 ShellExecuteW(open + openas fallback),shell 位置与系统工具走 ShellExecuteW。 + +use std::path::PathBuf; +use serde::Serialize; + +/// 快捷位置条目 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SpecialLocation { + pub id: String, + pub title: String, + pub subtitle: String, + pub keywords: Vec, + /// file: 真实文件/文件夹路径;shell: explorer 打开的 shell 路径;cmd: 可执行命令 + pub kind: String, + pub target: String, + pub args: Vec, +} + +fn sl( + id: &str, + title: &str, + subtitle: &str, + keywords: &[&str], + kind: &str, + target: &str, + args: &[&str], +) -> SpecialLocation { + SpecialLocation { + id: id.to_string(), + title: title.to_string(), + subtitle: subtitle.to_string(), + keywords: keywords.iter().map(|s| s.to_string()).collect(), + kind: kind.to_string(), + target: target.to_string(), + args: args.iter().map(|s| s.to_string()).collect(), + } +} + +/// Windows 系统目录(%SystemRoot%) +fn system_root() -> String { + std::env::var("SystemRoot") + .or_else(|_| std::env::var("windir")) + .unwrap_or_else(|_| "C:\\Windows".to_string()) +} + +fn dir_str(d: Option) -> Option { + d.map(|p| p.to_string_lossy().to_string()) +} + +/// 返回全部快捷位置(路径已在 Rust 侧解析,避免前端硬编码) +pub fn get_special_locations() -> Vec { + let mut list = Vec::new(); + + // ===== shell 位置(explorer 打开,CLSID 格式) ===== + list.push(sl( + "sp-recycle-bin", + "回收站", + "Windows 回收站", + &["recycle", "bin", "trash", "回收站", "垃圾桶", "垃圾箱"], + "shell", + "::{645FF040-5081-101B-9F08-00AA002F954E}", + &[], + )); + list.push(sl( + "sp-this-pc", + "此电脑", + "我的电脑", + &["此电脑", "我的电脑", "computer", "pc", "thispc"], + "shell", + "::{20D04FE0-3AEA-1069-A2D8-08002B30309D}", + &[], + )); + list.push(sl( + "sp-network", + "网络", + "网络位置", + &["网络", "network", "网上邻居"], + "shell", + "::{208D2C60-3AEA-1069-A2D7-08002B30309D}", + &[], + )); + + // ===== 常用文件 ===== + let hosts = format!("{}\\System32\\drivers\\etc\\hosts", system_root()); + list.push(sl( + "sp-hosts", + "hosts 文件", + &hosts, + &["hosts", "host", "主机文件", "域名映射"], + "file", + &hosts, + &[], + )); + + // ===== 用户目录(真实路径) ===== + let user_dirs: &[(&str, &str, &[&str], Option)] = &[ + ("sp-documents", "我的文档", &["文档", "我的文档", "documents", "docs"], dirs::document_dir()), + ("sp-desktop", "桌面", &["桌面", "desktop"], dirs::desktop_dir()), + ("sp-downloads", "下载", &["下载", "download", "downloads"], dirs::download_dir()), + ("sp-pictures", "图片", &["图片", "照片", "pictures", "photos"], dirs::picture_dir()), + ("sp-music", "音乐", &["音乐", "music"], dirs::audio_dir()), + ("sp-videos", "视频", &["视频", "videos"], dirs::video_dir()), + ]; + for (id, title, keywords, path) in user_dirs { + if let Some(p) = dir_str(path.clone()) { + list.push(sl(id, title, &p, keywords, "file", &p, &[])); + } + } + + // ===== 系统管理工具(ShellExecuteW 直接启动 .msc/.cpl/.exe) ===== + let tools: &[(&str, &str, &[&str], &str)] = &[ + ("sp-devmgmt", "设备管理器", &["设备管理器", "设备", "devmgmt", "device"], "devmgmt.msc"), + ("sp-diskmgmt", "磁盘管理", &["磁盘管理", "磁盘", "diskmgmt", "disk"], "diskmgmt.msc"), + ("sp-compmgmt", "计算机管理", &["计算机管理", "compmgmt"], "compmgmt.msc"), + ("sp-services", "服务", &["服务", "services", "service"], "services.msc"), + ("sp-perfmon", "性能监视器", &["性能监视器", "性能", "perfmon"], "perfmon.msc"), + ("sp-msinfo", "系统信息", &["系统信息", "msinfo", "systeminfo"], "msinfo32.exe"), + ("sp-ncpa", "网络连接", &["网络连接", "ncpa", "网卡"], "ncpa.cpl"), + ("sp-appwiz", "程序和功能", &["程序和功能", "卸载", "appwiz", "uninstall"], "appwiz.cpl"), + ("sp-powercfg", "电源选项", &["电源选项", "电源", "powercfg", "power"], "powercfg.cpl"), + ("sp-osk", "屏幕键盘", &["屏幕键盘", "键盘", "osk", "virtual keyboard"], "osk.exe"), + ("sp-magnify", "放大镜", &["放大镜", "magnify", "magnifier"], "magnify.exe"), + ]; + for (id, title, keywords, exe) in tools { + list.push(sl(id, title, exe, keywords, "cmd", exe, &[])); + } + list.push(sl( + "sp-envvar", + "环境变量", + "rundll32 sysdm.cpl,EditEnvironmentVariables", + &["环境变量", "环境", "env", "environment"], + "cmd", + "rundll32.exe", + &["sysdm.cpl,EditEnvironmentVariables"], + )); + + list +} + +// ===== 打开逻辑 ===== + +/// ShellExecuteW 调用(verb 可指定,如 "open" / "openas") +#[cfg(windows)] +fn shell_execute_verb(exe: &str, params: &str, verb: &str) -> Result<(), String> { + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Foundation::HWND; + use windows_sys::Win32::UI::Shell::ShellExecuteW; + use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL; + + let wide_exe: Vec = OsStr::new(exe) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let wide_params: Vec = OsStr::new(params) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let wide_verb: Vec = OsStr::new(verb) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + unsafe { + let hinst = ShellExecuteW( + 0 as HWND, + wide_verb.as_ptr(), + wide_exe.as_ptr(), + wide_params.as_ptr(), + std::ptr::null(), + SW_SHOWNORMAL, + ); + // ShellExecuteW 返回值 <= 32 表示错误 + if hinst <= 32 { + return Err(format!("打开失败 (code: {})", hinst as i32)); + } + } + Ok(()) +} + +#[cfg(not(windows))] +fn shell_execute_verb(_exe: &str, _params: &str, _verb: &str) -> Result<(), String> { + Err("当前平台不支持".into()) +} + +/// 打开文件/文件夹路径: +/// - 目录:explorer.exe 直接打开(比 ShellExecute "open" 目录更可靠,修复索引目录点击不生效问题) +/// - 文件:ShellExecuteW open,无关联程序时 fallback 到「打开方式」对话框 +pub fn open_path(path: &str) -> Result<(), String> { + let p = std::path::Path::new(path); + #[cfg(windows)] + { + if p.is_dir() { + use crate::process_manager::setup_creation_flags; + let mut cmd = std::process::Command::new("explorer.exe"); + cmd.arg(path); + setup_creation_flags(&mut cmd); + return cmd + .spawn() + .map(|_| ()) + .map_err(|e| format!("打开文件夹失败: {}", e)); + } + let params = String::new(); + if shell_execute_verb(path, ¶ms, "open").is_err() { + return shell_execute_verb(path, ¶ms, "openas"); + } + Ok(()) + } + #[cfg(not(windows))] + { + let _ = p; + Err("当前平台不支持".into()) + } +} + +/// 打开 shell 位置(explorer shell: 或 ::{CLSID}) +pub fn open_shell(target: &str) -> Result<(), String> { + shell_execute_verb("explorer.exe", target, "open") +} + +/// 打开系统工具(.msc / .cpl / .exe,参数空格连接) +pub fn open_cmd(exe: &str, args: &[String]) -> Result<(), String> { + let params = args.join(" "); + shell_execute_verb(exe, ¶ms, "open") +} diff --git a/src/modules/quickpanel/QuickPanel.vue b/src/modules/quickpanel/QuickPanel.vue index 26aa207..7cf04f3 100644 --- a/src/modules/quickpanel/QuickPanel.vue +++ b/src/modules/quickpanel/QuickPanel.vue @@ -3,7 +3,7 @@ import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue' import { invoke } from '@tauri-apps/api/core' import { listen, type UnlistenFn } from '@tauri-apps/api/event' import { getCurrentWindow, Effect, EffectState } from '@tauri-apps/api/window' -import { Search, Command, Loader2, CornerDownLeft, Calculator, Globe, Lock, ChevronRight, Terminal, History } from '@lucide/vue' +import { Search, Command, Loader2, CornerDownLeft, Calculator, Globe, Lock, ChevronRight, Terminal, History, FolderOpen, Ruler, Trash2 } from '@lucide/vue' import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion' import { aggregateSearch, loadAppIconsForResults, recordHistoryItem, getMoreHistoryItems, getMoreHistoryCount, setFileIndexReady, type QPItem, type QPSubAction } from './providers' @@ -16,6 +16,8 @@ const loading = ref(false) // 子动作展开:展开的 item 索引,null 表示未展开 const subActionExpanded = ref(null) const subActionIndex = ref(0) +// 待删除确认项(文件/文件夹删除前弹窗确认) +const pendingDelete = ref<{ path: string; isDir: boolean; name: string } | null>(null) let unlistenFns: UnlistenFn[] = [] let searchTimer: ReturnType | null = null @@ -119,7 +121,12 @@ async function executeItem(item: QPItem) { await hideWindow() } -async function executeSubAction(sub: QPSubAction) { +async function executeSubAction(sub: QPSubAction, item?: QPItem) { + // 删除类子动作:先弹窗确认,确认后由 confirmDelete 执行并隐藏窗口 + if (sub.id === 'delete' && item?.deleteInfo) { + pendingDelete.value = { ...item.deleteInfo, name: item.title } + return + } try { await sub.action() } catch (e) { @@ -128,6 +135,23 @@ async function executeSubAction(sub: QPSubAction) { await hideWindow() } +// ===== 删除确认弹窗 ===== +function cancelDelete() { + pendingDelete.value = null +} + +async function confirmDelete() { + const pd = pendingDelete.value + if (!pd) return + pendingDelete.value = null + try { + await invoke('quickpanel_delete_file', { path: pd.path }) + } catch (e) { + console.error('[quickpanel] 删除失败:', e) + } + await hideWindow() +} + // 子动作展开/收起 function toggleSubActions(idx: number) { const item = results.value[idx] @@ -152,8 +176,21 @@ function currentSubActions(): QPSubAction[] { // ===== 键盘导航 ===== function onKeydown(e: KeyboardEvent) { + // 删除确认弹窗打开时:Esc 取消,Enter 确认 + if (pendingDelete.value) { + if (e.key === 'Escape') { + e.preventDefault() + cancelDelete() + } else if (e.key === 'Enter') { + e.preventDefault() + confirmDelete() + } + return + } + const expanded = subActionExpanded.value !== null const subs = currentSubActions() + const expandedItem = expanded ? results.value[subActionExpanded.value!] : undefined if (expanded) { // 子动作导航模式 @@ -168,7 +205,7 @@ function onKeydown(e: KeyboardEvent) { } else if (e.key === 'Enter') { e.preventDefault() const sub = subs[subActionIndex.value] - if (sub) executeSubAction(sub) + if (sub) executeSubAction(sub, expandedItem) } else if (e.key === 'Escape') { e.preventDefault() collapseSubActions() @@ -180,7 +217,7 @@ function onKeydown(e: KeyboardEvent) { e.preventDefault() const idx = parseInt(e.key, 10) - 1 const sub = subs[idx] - if (sub) executeSubAction(sub) + if (sub) executeSubAction(sub, expandedItem) } return } @@ -247,6 +284,8 @@ const groupIcon = (group: string) => { if (group === '系统') return Lock if (group === '自定义') return Terminal if (group === '历史') return History + if (group === '快捷') return FolderOpen + if (group === '换算') return Ruler return Search } @@ -531,7 +570,7 @@ onUnmounted(() => { :key="sub.id" class="qp-sub-item" :class="{ 'qp-sub-selected': sIdx === subActionIndex }" - @click="executeSubAction(sub)" + @click="executeSubAction(sub, item)" @mouseenter="onSubActionHover(sIdx)" > {{ sIdx + 1 }} @@ -550,6 +589,22 @@ onUnmounted(() => { Enter 执行 Esc {{ subActionExpanded !== null ? '收起' : '关闭' }} + + +
+
+
+ +

确定删除?

+
+

{{ pendingDelete.name }}

+

将移动到回收站{{ pendingDelete.isDir ? '(含所有子内容)' : '' }},删除后可在回收站中还原。

+
+ + +
+
+
@@ -757,4 +812,79 @@ onUnmounted(() => { .qp-results::-webkit-scrollbar-track { background: transparent; } + +/* ===== 删除确认弹窗 ===== */ +.qp-confirm-mask { + position: absolute; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.45); + border-radius: 10px; + backdrop-filter: blur(2px); +} + +.qp-confirm-box { + width: 300px; + max-width: 86%; + padding: 16px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--card); + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35); +} + +.qp-confirm-head { + display: flex; + align-items: center; + gap: 8px; +} + +.qp-confirm-title { + font-size: 14px; + font-weight: 600; + color: var(--foreground); +} + +.qp-confirm-name { + margin-top: 10px; + font-size: 13px; + color: var(--foreground); +} + +.qp-confirm-tip { + margin-top: 4px; + font-size: 12px; + line-height: 1.5; + color: var(--muted-foreground); +} + +.qp-confirm-btn { + padding: 5px 14px; + font-size: 12px; + font-weight: 500; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--muted); + color: var(--foreground); + cursor: pointer; + transition: background 0.15s ease; +} + +.qp-confirm-btn:hover { + background: var(--accent); +} + +.qp-confirm-danger { + background: var(--destructive); + border-color: transparent; + color: white; +} + +.qp-confirm-danger:hover { + background: var(--destructive); + opacity: 0.9; +} diff --git a/src/modules/quickpanel/QuickPanelModule.vue b/src/modules/quickpanel/QuickPanelModule.vue index 216e79f..a40407d 100644 --- a/src/modules/quickpanel/QuickPanelModule.vue +++ b/src/modules/quickpanel/QuickPanelModule.vue @@ -534,6 +534,14 @@ async function changeEngine(v: string) { 计算 输入算式即算,Enter 复制结果 +
+ 换算 + 单位/货币/时间/温度换算(如 1m、1Mbps、1$) +
+
+ 快捷 + hosts、回收站、系统工具等常用位置 +
系统 锁屏、退出应用 diff --git a/src/modules/quickpanel/providers.ts b/src/modules/quickpanel/providers.ts index 81f66ba..113ce46 100644 --- a/src/modules/quickpanel/providers.ts +++ b/src/modules/quickpanel/providers.ts @@ -38,8 +38,12 @@ export interface QPItem { action: () => void | Promise /** 子动作菜单(可选)。执行子动作后同样隐藏窗口 */ subActions?: QPSubAction[] + /** 删除确认信息(仅可删除项设置,如文件/文件夹,用于弹窗确认后执行删除) */ + deleteInfo?: { path: string; isDir: boolean } /** 用于历史记录的查询文本(仅历史项设置,点击历史时用此重新搜索恢复 action) */ historyQuery?: string + /** 应用可靠性排序(仅 group='应用' 项设置,越小越可靠:开始菜单 0 / 桌面 1 / 其他 2) */ + appRank?: number } export interface QPProvider { @@ -387,6 +391,71 @@ class SystemProvider implements QPProvider { } } +// ===== 应用通用:构建启动动作与子动作(开始菜单 / 文件索引 .lnk 共用) ===== + +/** 启动一个应用(.lnk / .exe 等),通过 Rust spawn 子进程 */ +function makeAppLaunch(path: string) { + return async () => { + try { + // .lnk 文件不能用 openUrl 打开,需直接 spawn + await invoke('quickpanel_run_custom_command', { command: path, args: [] }) + } catch (e) { + console.error('[quickpanel] 启动应用失败:', e) + } + } +} + +/** 应用项的标准子动作:启动 / 在资源管理器中显示 / 复制路径(+ 可选删除) */ +function makeAppSubActions(path: string, includeDelete = false): QPSubAction[] { + const launch = makeAppLaunch(path) + const subs: QPSubAction[] = [ + { id: 'launch', label: '启动', action: launch }, + { + id: 'reveal', + label: '在资源管理器中显示', + action: async () => { + try { + await invoke('quickpanel_reveal_in_explorer', { path }) + } catch (e) { + console.error('[quickpanel] 资源管理器显示失败:', e) + } + }, + }, + { + id: 'copy-path', + label: '复制路径', + action: async () => { + try { + await navigator.clipboard.writeText(path) + } catch { + /* 忽略 */ + } + }, + }, + ] + if (includeDelete) { + subs.push({ + id: 'delete', + label: '删除', + action: async () => { + try { + await invoke('quickpanel_delete_file', { path }) + } catch (e) { + console.error('[quickpanel] 删除失败:', e) + } + }, + }) + } + return subs +} + +/** 根据路径推断应用可靠性排序:桌面 1 / 其他位置 2(开始菜单由调用方直接给 0) */ +function appRankFromPath(path: string): number { + const p = path.toLowerCase() + if (p.includes('\\desktop\\') || p.includes('/desktop/')) return 1 + return 2 +} + // ===== app Provider:扫描开始菜单应用 ===== interface AppRecord { @@ -430,17 +499,6 @@ class AppProvider implements QPProvider { const forms = buildItemForms(app.name) const score = bestScore(query, forms) if (score >= 0) { - const launch = async () => { - try { - // .lnk 文件不能用 openUrl 打开,需直接 spawn - await invoke('quickpanel_run_custom_command', { - command: app.path, - args: [], - }) - } catch (e) { - console.error('[quickpanel] 启动应用失败:', e) - } - } results.push({ item: { id: `app-${idx}`, @@ -448,32 +506,9 @@ class AppProvider implements QPProvider { subtitle: app.path, group: '应用', iconPath: app.path, - action: launch, - subActions: [ - { id: 'launch', label: '启动', action: launch }, - { - id: 'reveal', - label: '在资源管理器中显示', - action: async () => { - try { - await invoke('quickpanel_reveal_in_explorer', { path: app.path }) - } catch (e) { - console.error('[quickpanel] 资源管理器显示失败:', e) - } - }, - }, - { - id: 'copy-path', - label: '复制路径', - action: async () => { - try { - await navigator.clipboard.writeText(app.path) - } catch { - /* 忽略 */ - } - }, - }, - ], + action: makeAppLaunch(app.path), + subActions: makeAppSubActions(app.path), + appRank: 0, // 开始菜单:最可靠来源 }, score, }) @@ -554,9 +589,26 @@ class FileProvider implements QPProvider { limit: 20, }) return files.map((f, idx) => { + // .lnk 快捷方式按应用处理:带图标、用启动命令,并与开始菜单应用统一去重 + // 注意:Rust 返回的 ext 不带点(如 "lnk"),这里直接按文件名判断最稳妥 + const isLnk = !f.isDir && f.name.toLowerCase().endsWith('.lnk') + if (isLnk) { + return { + id: `file-app-${idx}`, + title: f.name, + subtitle: f.path, + group: '应用', + score: 0.55, // 略低于开始菜单应用(0.6+),去重时让位于开始菜单 + iconPath: f.path, + action: makeAppLaunch(f.path), + subActions: makeAppSubActions(f.path, true), + deleteInfo: { path: f.path, isDir: false }, + appRank: appRankFromPath(f.path), + } + } const openFile = async () => { try { - // 用系统默认程序打开;无关联应用时 Rust 端会 fallback 到「打开方式」对话框 + // 目录:Rust 端用 explorer.exe 打开;文件:默认程序打开(无关联时 fallback 打开方式) await invoke('quickpanel_open_file', { path: f.path }) } catch (e) { console.error('[quickpanel] 打开文件失败:', e) @@ -569,23 +621,26 @@ class FileProvider implements QPProvider { group: '文件', score: 0.6, action: openFile, + // 目录:打开即导航到该目录,无需再提供「在资源管理器中显示」,避免重复 subActions: [ { id: 'open', label: f.isDir ? '打开文件夹' : '打开', action: openFile, }, - { - id: 'reveal', - label: '在资源管理器中显示', - action: async () => { - try { - await invoke('quickpanel_reveal_in_explorer', { path: f.path }) - } catch (e) { - console.error('[quickpanel] 资源管理器显示失败:', e) - } - }, - }, + ...(f.isDir + ? [] + : [{ + id: 'reveal', + label: '在资源管理器中显示', + action: async () => { + try { + await invoke('quickpanel_reveal_in_explorer', { path: f.path }) + } catch (e) { + console.error('[quickpanel] 资源管理器显示失败:', e) + } + }, + }]), { id: 'copy-path', label: '复制路径', @@ -602,8 +657,7 @@ class FileProvider implements QPProvider { label: '删除', action: async () => { try { - // 移到回收站:explorer.exe 不直接支持,用 PowerShell 或直接删除 - // 这里用 Rust 命令删除(简化实现,实际移到回收站需 SHFileOperation) + // 移到回收站(PowerShell + Microsoft.VisualBasic) await invoke('quickpanel_delete_file', { path: f.path }) } catch (e) { console.error('[quickpanel] 删除失败:', e) @@ -611,6 +665,7 @@ class FileProvider implements QPProvider { }, }, ], + deleteInfo: { path: f.path, isDir: f.isDir }, } }) } catch (e) { @@ -846,6 +901,399 @@ class HistoryProvider implements QPProvider { } } +// ===== special Provider:Windows 常用快捷位置 ===== + +interface SpecialLocation { + id: string + title: string + subtitle: string + keywords: string[] + /** file: 真实路径;shell: explorer 打开的 shell 路径;cmd: 可执行命令 */ + kind: 'file' | 'shell' | 'cmd' + target: string + args: string[] +} + +let specialCache: SpecialLocation[] | null = null +let specialCacheTime = 0 +const SPECIAL_CACHE_TTL = 60_000 // 1 分钟缓存 + +async function loadSpecials(): Promise { + if (specialCache && Date.now() - specialCacheTime < SPECIAL_CACHE_TTL) { + return specialCache + } + try { + const list = await invoke('quickpanel_get_special_locations') + specialCache = list + specialCacheTime = Date.now() + return list + } catch (e) { + console.error('[quickpanel] 获取快捷位置失败:', e) + return [] + } +} + +class SpecialProvider implements QPProvider { + id = 'special' + label = '快捷' + priority = 60 + + async search(query: string): Promise { + const list = await loadSpecials() + if (!list.length) return [] + if (!query.trim()) return [] // 空查询不占用列表,由用户主动搜索 + + const open = async (s: SpecialLocation) => { + try { + await invoke('quickpanel_open_special', { + kind: s.kind, + target: s.target, + args: s.args, + }) + } catch (e) { + console.error('[quickpanel] 打开快捷位置失败:', e) + } + } + + const items: QPItem[] = list.map(s => ({ + id: `sp-${s.id}`, + title: s.title, + subtitle: s.subtitle, + group: '快捷', + action: () => open(s), + subActions: + s.kind === 'file' + ? [ + { id: 'open', label: '打开', action: () => open(s) }, + { + id: 'reveal', + label: '在资源管理器中显示', + action: async () => { + try { + await invoke('quickpanel_reveal_in_explorer', { path: s.target }) + } catch (e) { + console.error('[quickpanel] 资源管理器显示失败:', e) + } + }, + }, + { + id: 'copy-path', + label: '复制路径', + action: async () => { + try { + await navigator.clipboard.writeText(s.target) + } catch { + /* 忽略 */ + } + }, + }, + ] + : undefined, + })) + + const scored: Array<{ item: QPItem; score: number }> = [] + items.forEach((item, idx) => { + const forms = buildItemForms(item.title, list[idx].keywords) + const score = bestScore(query, forms) + if (score >= 0) scored.push({ item, score }) + }) + scored.sort((a, b) => b.score - a.score) + return scored.slice(0, 8).map(s => ({ ...s.item, score: s.score })) + } +} + +// ===== unit Provider:单位 / 货币 / 时间 / 温度换算 ===== + +interface UnitDef { + /** 可匹配的符号(含中文),小写优先;带 exactCase 的单位只做精确大小写匹配 */ + symbols: string[] + label: string + /** 与基准单位的换算系数(基准单位 = 1) */ + factor: number + /** 仅精确大小写匹配(如小写 m = 米,避免与 MB 混淆) */ + exactCase?: boolean +} + +interface UnitCategory { + id: string + name: string + units: UnitDef[] +} + +const UNIT_CATEGORIES: UnitCategory[] = [ + { + id: 'length', + name: '长度', + units: [ + { symbols: ['m', 'meter', 'meters', '米', '公尺'], label: '米', factor: 1, exactCase: true }, + { symbols: ['km', 'kilometer', 'kilometers', '千米', '公里'], label: '千米', factor: 1000 }, + { symbols: ['cm', 'centimeter', 'centimeters', '厘米'], label: '厘米', factor: 0.01 }, + { symbols: ['mm', 'millimeter', 'millimeters', '毫米'], label: '毫米', factor: 0.001 }, + { symbols: ['in', 'inch', 'inches', '英寸'], label: '英寸', factor: 0.0254 }, + { symbols: ['ft', 'foot', 'feet', '英尺'], label: '英尺', factor: 0.3048 }, + { symbols: ['yd', 'yard', 'yards', '码'], label: '码', factor: 0.9144 }, + { symbols: ['mi', 'mile', 'miles', '英里'], label: '英里', factor: 1609.344 }, + { symbols: ['里', 'li'], label: '里', factor: 500 }, + ], + }, + { + id: 'data', + name: '数据', + units: [ + { symbols: ['b', 'byte', 'bytes', '字节'], label: '字节', factor: 1 }, + { symbols: ['kb', 'kib', 'kilobyte', 'kilobytes', '千字节'], label: 'KB', factor: 1024 }, + { symbols: ['mb', 'mib', 'megabyte', 'megabytes', '兆字节'], label: 'MB', factor: 1024 ** 2 }, + { symbols: ['gb', 'gib', 'gigabyte', 'gigabytes', '吉字节'], label: 'GB', factor: 1024 ** 3 }, + { symbols: ['tb', 'tib', 'terabyte', 'terabytes', '太字节'], label: 'TB', factor: 1024 ** 4 }, + { symbols: ['bit', 'bits', '比特'], label: 'bit', factor: 1 / 8 }, + ], + }, + { + id: 'speed', + name: '网速', + units: [ + { symbols: ['bps', '比特/秒'], label: 'bps', factor: 1 }, + { symbols: ['kbps', '千比特/秒'], label: 'Kbps', factor: 1024 }, + { symbols: ['mbps', '兆比特/秒'], label: 'Mbps', factor: 1024 ** 2 }, + { symbols: ['gbps', '吉比特/秒'], label: 'Gbps', factor: 1024 ** 3 }, + { symbols: ['b/s'], label: 'B/s', factor: 8 }, + { symbols: ['kb/s'], label: 'KB/s', factor: 8 * 1024 }, + { symbols: ['mb/s'], label: 'MB/s', factor: 8 * 1024 ** 2 }, + { symbols: ['gb/s'], label: 'GB/s', factor: 8 * 1024 ** 3 }, + ], + }, + { + id: 'time', + name: '时间', + units: [ + { symbols: ['s', 'sec', 'secs', 'second', 'seconds', '秒'], label: '秒', factor: 1 }, + { symbols: ['min', 'mins', 'minute', 'minutes', '分钟', '分'], label: '分钟', factor: 60 }, + { symbols: ['h', 'hr', 'hrs', 'hour', 'hours', '小时', '时'], label: '小时', factor: 3600 }, + { symbols: ['day', 'days', '天', '日'], label: '天', factor: 86400 }, + { symbols: ['week', 'weeks', '周', '星期'], label: '周', factor: 604800 }, + { symbols: ['year', 'years', '年'], label: '年', factor: 31536000 }, + ], + }, + { + id: 'weight', + name: '重量', + units: [ + { symbols: ['kg', '千克', '公斤'], label: '千克', factor: 1 }, + { symbols: ['g', 'gram', 'grams', '克'], label: '克', factor: 0.001 }, + { symbols: ['mg', 'milligram', '毫克'], label: '毫克', factor: 1e-6 }, + { symbols: ['t', 'ton', 'tons', '吨'], label: '吨', factor: 1000 }, + { symbols: ['lb', 'lbs', 'pound', 'pounds', '磅'], label: '磅', factor: 0.45359237 }, + { symbols: ['oz', 'ounce', 'ounces', '盎司'], label: '盎司', factor: 0.028349523125 }, + { symbols: ['斤', 'jin'], label: '斤', factor: 0.5 }, + { symbols: ['两', 'liang'], label: '两', factor: 0.05 }, + ], + }, +] + +// ===== 货币换算(汇率动态获取,带本地缓存与兜底值) ===== + +const DEFAULT_CURRENCY_RATES: Record = { + usd: 1, + cny: 7.2, + eur: 0.92, + gbp: 0.78, + jpy: 156, + hkd: 7.8, +} +const CURRENCY_CACHE_KEY = 'thing_quickpanel_currency_rates' + +function getCurrencyRates(): Record { + try { + const raw = localStorage.getItem(CURRENCY_CACHE_KEY) + if (raw) { + const p = JSON.parse(raw) + if (p?.rates && Date.now() - p.ts < 24 * 3600 * 1000) return p.rates + } + } catch { + /* 忽略损坏缓存 */ + } + return DEFAULT_CURRENCY_RATES +} + +let currencyRefreshing = false +/** 后台刷新汇率(失败静默,继续用缓存/兜底值),结果写入 localStorage 供下次使用 */ +async function refreshCurrencyRates() { + if (currencyRefreshing) return + currencyRefreshing = true + try { + const res = await fetch('https://open.er-api.com/v6/latest/USD') + const data = await res.json() + if (data?.result === 'success' && data.rates) { + const r = data.rates as Record + const rates: Record = { + usd: 1, + cny: r.CNY ?? DEFAULT_CURRENCY_RATES.cny, + eur: r.EUR ?? DEFAULT_CURRENCY_RATES.eur, + gbp: r.GBP ?? DEFAULT_CURRENCY_RATES.gbp, + jpy: r.JPY ?? DEFAULT_CURRENCY_RATES.jpy, + hkd: r.HKD ?? DEFAULT_CURRENCY_RATES.hkd, + } + localStorage.setItem(CURRENCY_CACHE_KEY, JSON.stringify({ ts: Date.now(), rates })) + } + } catch { + /* 网络失败,继续使用默认/缓存汇率 */ + } finally { + currencyRefreshing = false + } +} + +/** 动态构建货币类别(基准 = 美元;factor 为「1 单位该货币 = ? 美元」) */ +function getCurrencyCategory(): UnitCategory { + const r = getCurrencyRates() + const perUsd = (v: number) => (v > 0 ? 1 / v : 0) + return { + id: 'currency', + name: '货币', + units: [ + { symbols: ['$', 'usd', '美元', '美金', '美刀'], label: '美元', factor: 1 }, + { symbols: ['¥', '¥', 'rmb', 'cny', '元', '人民币'], label: '人民币', factor: perUsd(r.cny) }, + { symbols: ['€', 'eur', '欧元'], label: '欧元', factor: perUsd(r.eur) }, + { symbols: ['£', 'gbp', '英镑'], label: '英镑', factor: perUsd(r.gbp) }, + { symbols: ['jpy', '日元', '日圆'], label: '日元', factor: perUsd(r.jpy) }, + { symbols: ['hkd', '港币', '港元'], label: '港元', factor: perUsd(r.hkd) }, + ], + } +} + +/** 温度匹配(仿射换算,单独处理) */ +function matchTemperature(token: string): 'C' | 'F' | 'K' | null { + const t = token.toLowerCase().replace(/°/g, '') + if (['c', 'celsius', '摄氏度', '摄氏'].includes(t)) return 'C' + if (['f', 'fahrenheit', '华氏度', '华氏'].includes(t)) return 'F' + if (['kelvin', '开尔文'].includes(t)) return 'K' + return null +} + +/** 在(普通 + 货币)类别中匹配单位 token */ +function matchUnit( + token: string, + categories: UnitCategory[], +): { cat: UnitCategory; unit: UnitDef } | null { + // 第一轮:精确大小写匹配 + for (const cat of categories) { + for (const unit of cat.units) { + if (unit.symbols.some(s => s === token)) return { cat, unit } + } + } + // 第二轮:大小写不敏感;exactCase 单位(如 m=米)跳过,避免 "1M" 误判为 1 米 + const lower = token.toLowerCase() + for (const cat of categories) { + for (const unit of cat.units) { + if (unit.exactCase) continue + if (unit.symbols.some(s => s.toLowerCase() === lower)) return { cat, unit } + } + } + return null +} + +/** 数值格式化(去掉多余的浮点尾巴) */ +function formatUnitValue(v: number): string { + if (!isFinite(v)) return '' + if (v === 0) return '0' + const abs = Math.abs(v) + if (abs >= 1e12) return v.toExponential(2) + if (abs >= 1e6) return Number(v.toFixed(0)).toLocaleString('en-US') + if (abs >= 1000) return Number(v.toFixed(1)).toLocaleString('en-US') + if (abs >= 100) return Number(v.toFixed(1)).toString() + if (abs >= 1) return Number(v.toFixed(2)).toString() + if (abs >= 1e-4) return Number(v.toFixed(4)).toString() + return v.toExponential(2) +} + +/** 结果展示优先级:整数 > 常见量级(1~1000) > 其他 */ +function unitNiceRank(v: number): number { + if (Number.isInteger(v)) return 0 + const abs = Math.abs(v) + if (abs >= 1 && abs < 1000) return 1 + return 2 +} + +function buildUnitResultItem( + value: number, + fromLabel: string, + catName: string, + toLabel: string, + toValue: number, + idx: number, +): QPItem { + const text = `${formatUnitValue(toValue)} ${toLabel}` + return { + id: `unit-${catName}-${idx}`, + title: text, + subtitle: `${value} ${fromLabel}(${catName}换算)`, + group: '换算', + score: 0.85, + action: async () => { + try { + await navigator.clipboard.writeText(text) + } catch { + /* 忽略 */ + } + }, + } +} + +class UnitProvider implements QPProvider { + id = 'unit' + label = '换算' + priority = 80 + + async search(query: string): Promise { + const trimmed = query.trim() + if (!trimmed) return [] + const m = trimmed.match(/^(\d+(?:\.\d+)?)\s*(.+)$/) + if (!m) return [] + const value = parseFloat(m[1]) + if (!isFinite(value) || value <= 0) return [] + const token = m[2].trim() + if (!token) return [] + + // 温度(仿射换算) + const tFrom = matchTemperature(token) + if (tFrom) { + const celsius = + tFrom === 'C' ? value : tFrom === 'F' ? ((value - 32) * 5) / 9 : value - 273.15 + const convs: Array<{ label: string; v: number }> = [ + { label: '摄氏度', v: celsius }, + { label: '华氏度', v: (celsius * 9) / 5 + 32 }, + { label: '开尔文', v: celsius + 273.15 }, + ] + return convs + .filter(c => !(tFrom === 'C' && c.label === '摄氏度') && !(tFrom === 'F' && c.label === '华氏度') && !(tFrom === 'K' && c.label === '开尔文')) + .map((c, i) => buildUnitResultItem(value, `${tFrom}°`, '温度', c.label, c.v, i)) + } + + // 普通单位 / 货币 + const currencyCat = getCurrencyCategory() + const categories = [...UNIT_CATEGORIES, currencyCat] + const matched = matchUnit(token, categories) + if (!matched) return [] + const { cat, unit } = matched + if (cat.id === 'currency') { + // 命中货币:后台刷新一次汇率,不阻塞本次结果 + void refreshCurrencyRates() + } + + const base = value * unit.factor + const results: Array<{ item: QPItem; rank: number }> = [] + for (const u of cat.units) { + if (u === unit) continue + const v = base / u.factor + results.push({ + item: buildUnitResultItem(value, unit.label, cat.name, u.label, v, results.length), + rank: unitNiceRank(v), + }) + } + results.sort((a, b) => a.rank - b.rank) + return results.slice(0, 8).map(r => r.item) + } +} + // ===== Provider 注册 ===== let providers: QPProvider[] | null = null @@ -860,6 +1308,8 @@ export function getProviders(): QPProvider[] { new FileProvider(), new ClipboardProvider(), new CalcProvider(), + new UnitProvider(), + new SpecialProvider(), new SystemProvider(), new WebProvider(), ] @@ -884,6 +1334,48 @@ export async function aggregateSearch(query: string): Promise { merged.push(item) }) }) - merged.sort((a, b) => (b.score ?? 0) - (a.score ?? 0)) - return merged + + // 去重:所有来源的「应用」(含文件索引中的 .lnk)按名称归并,保留可靠性最高的来源 + // 可靠性:开始菜单(appRank 0) > 桌面(1) > 其他位置(2);同可靠性时保留分数更高的 + // (如 "TRAE Work CN" 在开始菜单 + 桌面 + 某索引目录都有 .lnk,只留开始菜单那条) + const appKey = (title: string): string => { + let t = title.trim().toLowerCase() + if (t.endsWith('.lnk')) t = t.slice(0, -4).trim() + return t + } + // 应用候选:应用分组,以及文件分组中的 .lnk 快捷方式 + const isAppLike = (item: QPItem): boolean => { + if (item.group === '应用') return true + if (item.group === '文件' && item.title && item.title.toLowerCase().endsWith('.lnk')) return true + return false + } + const bestAppByKey = new Map() + for (const item of merged) { + if (!isAppLike(item) || !item.title) continue + const key = appKey(item.title) + const prev = bestAppByKey.get(key) + if (!prev) { + bestAppByKey.set(key, item) + continue + } + // 比较可靠性:appRank 越小越可靠;文件分组 .lnk 无 appRank 时按路径推断 + const rankOf = (i: QPItem): number => { + if (i.appRank !== undefined) return i.appRank + if (i.group === '文件') return appRankFromPath(i.subtitle ?? '') + return 2 + } + const rankA = rankOf(item) + const rankB = rankOf(prev) + if (rankA < rankB || (rankA === rankB && (item.score ?? 0) > (prev.score ?? 0))) { + bestAppByKey.set(key, item) + } + } + const keptAppIds = new Set(Array.from(bestAppByKey.values()).map(i => i.id)) + const deduped = merged.filter(item => { + if (!isAppLike(item)) return true + return keptAppIds.has(item.id) + }) + + deduped.sort((a, b) => (b.score ?? 0) - (a.score ?? 0)) + return deduped }