快速面板模块
This commit is contained in:
Generated
+7
@@ -19,6 +19,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
|
"pinyin-pro": "^3.28.2",
|
||||||
"reka-ui": "^2.10.1",
|
"reka-ui": "^2.10.1",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss": "^4.3.2",
|
"tailwindcss": "^4.3.2",
|
||||||
@@ -2290,6 +2291,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pinyin-pro": {
|
||||||
|
"version": "3.28.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/pinyin-pro/-/pinyin-pro-3.28.2.tgz",
|
||||||
|
"integrity": "sha512-jV38yxXHLfidirMC4hrXasLDozLCSq/4DfX88GnHcSEJ2+GpSedG6I9VOiEXJu6iQ5dbJC/RjmzyMuS5h/wH5A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.19",
|
"version": "8.5.19",
|
||||||
"funding": [
|
"funding": [
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
|
"pinyin-pro": "^3.28.2",
|
||||||
"reka-ui": "^2.10.1",
|
"reka-ui": "^2.10.1",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss": "^4.3.2",
|
"tailwindcss": "^4.3.2",
|
||||||
|
|||||||
Generated
+93
-1
@@ -1227,6 +1227,16 @@ dependencies = [
|
|||||||
"rustc_version",
|
"rustc_version",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "filetime"
|
||||||
|
version = "0.2.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.9"
|
version = "0.1.9"
|
||||||
@@ -1306,6 +1316,15 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fsevent-sys"
|
||||||
|
version = "4.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -2114,6 +2133,26 @@ dependencies = [
|
|||||||
"cfb",
|
"cfb",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inotify"
|
||||||
|
version = "0.9.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"inotify-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inotify-sys"
|
||||||
|
version = "0.1.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "inout"
|
name = "inout"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -2275,6 +2314,26 @@ dependencies = [
|
|||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "kqueue"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5"
|
||||||
|
dependencies = [
|
||||||
|
"kqueue-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "kqueue-sys"
|
||||||
|
version = "1.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.0",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libappindicator"
|
name = "libappindicator"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -2454,6 +2513,18 @@ dependencies = [
|
|||||||
"simd-adler32",
|
"simd-adler32",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mio"
|
||||||
|
version = "0.8.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"wasi",
|
||||||
|
"windows-sys 0.48.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
@@ -2543,6 +2614,25 @@ version = "1.0.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "notify"
|
||||||
|
version = "6.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.0",
|
||||||
|
"crossbeam-channel",
|
||||||
|
"filetime",
|
||||||
|
"fsevent-sys",
|
||||||
|
"inotify",
|
||||||
|
"kqueue",
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"mio 0.8.11",
|
||||||
|
"walkdir",
|
||||||
|
"windows-sys 0.48.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "notify-rust"
|
name = "notify-rust"
|
||||||
version = "4.18.0"
|
version = "4.18.0"
|
||||||
@@ -4579,6 +4669,7 @@ dependencies = [
|
|||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"image",
|
"image",
|
||||||
|
"notify",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
@@ -4596,6 +4687,7 @@ dependencies = [
|
|||||||
"tauri-plugin-snap-layout",
|
"tauri-plugin-snap-layout",
|
||||||
"tokio",
|
"tokio",
|
||||||
"url",
|
"url",
|
||||||
|
"walkdir",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
"winreg 0.52.0",
|
"winreg 0.52.0",
|
||||||
"zip",
|
"zip",
|
||||||
@@ -4704,7 +4796,7 @@ checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"libc",
|
"libc",
|
||||||
"mio",
|
"mio 1.2.2",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tokio-macros",
|
"tokio-macros",
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ sysinfo = "0.32"
|
|||||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||||
|
walkdir = "2"
|
||||||
|
notify = { version = "6", features = [] }
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
winreg = "0.52"
|
winreg = "0.52"
|
||||||
@@ -58,6 +60,7 @@ windows-sys = { version = "0.52", features = [
|
|||||||
"Win32_Graphics_Gdi",
|
"Win32_Graphics_Gdi",
|
||||||
"Win32_Graphics_Dwm",
|
"Win32_Graphics_Dwm",
|
||||||
"Win32_Storage_Xps",
|
"Win32_Storage_Xps",
|
||||||
|
"Win32_Storage_FileSystem",
|
||||||
] }
|
] }
|
||||||
|
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "quick-panel",
|
||||||
|
"description": "Capability for quick panel popup window",
|
||||||
|
"windows": ["quick-panel"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"core:window:allow-hide",
|
||||||
|
"core:window:allow-show",
|
||||||
|
"core:window:allow-set-focus",
|
||||||
|
"core:window:allow-close",
|
||||||
|
"core:window:allow-set-theme",
|
||||||
|
"core:window:allow-set-effects",
|
||||||
|
"core:window:allow-set-background-color",
|
||||||
|
"core:event:allow-emit",
|
||||||
|
"core:event:allow-listen",
|
||||||
|
"opener:allow-open-url",
|
||||||
|
"snap-layout:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ mod monitor_kernel;
|
|||||||
mod network_monitor;
|
mod network_monitor;
|
||||||
mod osd_window;
|
mod osd_window;
|
||||||
mod process_manager;
|
mod process_manager;
|
||||||
|
mod quickpanel;
|
||||||
mod screenshot;
|
mod screenshot;
|
||||||
mod snap_fix;
|
mod snap_fix;
|
||||||
mod tray_menu;
|
mod tray_menu;
|
||||||
@@ -61,6 +62,15 @@ use clipboard::{
|
|||||||
clipboard_set_pinned, clipboard_show_popup, clipboard_show_window, clipboard_start,
|
clipboard_set_pinned, clipboard_show_popup, clipboard_show_window, clipboard_start,
|
||||||
clipboard_status, clipboard_stop, clipboard_unregister_shortcut,
|
clipboard_status, clipboard_stop, clipboard_unregister_shortcut,
|
||||||
};
|
};
|
||||||
|
use 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_unregister_shortcut,
|
||||||
|
};
|
||||||
use tray_menu::{tray_menu_action, tray_menu_hide, tray_menu_ready};
|
use tray_menu::{tray_menu_action, tray_menu_hide, tray_menu_ready};
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -195,6 +205,26 @@ pub fn run() {
|
|||||||
clipboard_show_window,
|
clipboard_show_window,
|
||||||
clipboard_hide_popup,
|
clipboard_hide_popup,
|
||||||
clipboard_paste_to_target,
|
clipboard_paste_to_target,
|
||||||
|
quickpanel_get_settings,
|
||||||
|
quickpanel_save_settings,
|
||||||
|
quickpanel_register_shortcut,
|
||||||
|
quickpanel_unregister_shortcut,
|
||||||
|
quickpanel_show_popup,
|
||||||
|
quickpanel_show_window,
|
||||||
|
quickpanel_hide_popup,
|
||||||
|
quickpanel_lock_screen,
|
||||||
|
quickpanel_init_file_index,
|
||||||
|
quickpanel_build_file_index,
|
||||||
|
quickpanel_search_files,
|
||||||
|
quickpanel_file_index_stats,
|
||||||
|
quickpanel_scan_apps,
|
||||||
|
quickpanel_get_app_icon,
|
||||||
|
quickpanel_clear_app_icon_cache,
|
||||||
|
quickpanel_reveal_in_explorer,
|
||||||
|
quickpanel_open_file,
|
||||||
|
quickpanel_delete_file,
|
||||||
|
quickpanel_run_custom_command,
|
||||||
|
quickpanel_run_system_command,
|
||||||
tray_menu_action,
|
tray_menu_action,
|
||||||
tray_menu_hide,
|
tray_menu_hide,
|
||||||
tray_menu_ready,
|
tray_menu_ready,
|
||||||
@@ -286,6 +316,20 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
app.manage(clipboard);
|
app.manage(clipboard);
|
||||||
|
|
||||||
|
// 快速面板:应用启动时注册全局快捷键 + 预创建隐藏窗口。
|
||||||
|
// defaultEnabled:true 假设启用;用户在设置页禁用模块时由前端 onDisable 钩子注销快捷键。
|
||||||
|
let qp_settings = quickpanel::load_settings(&app.handle());
|
||||||
|
if !qp_settings.shortcut.trim().is_empty() {
|
||||||
|
let app_handle = app.handle().clone();
|
||||||
|
if let Err(e) = quickpanel::register_shortcut(&app_handle, &qp_settings.shortcut) {
|
||||||
|
eprintln!("[quickpanel] 快捷键注册失败: {}", e);
|
||||||
|
}
|
||||||
|
// 预创建弹窗窗口(隐藏),首次按快捷键时直接 show,避免首次创建时序问题
|
||||||
|
quickpanel::ensure_window(&app_handle);
|
||||||
|
}
|
||||||
|
// 初始化文件索引数据库(不立即构建,由前端设置页或首次唤起时触发)
|
||||||
|
quickpanel::file_index::init(&app.handle());
|
||||||
|
|
||||||
// 自定义托盘菜单(代理/OSD/Kernel/下载/设置/退出)
|
// 自定义托盘菜单(代理/OSD/Kernel/下载/设置/退出)
|
||||||
tray_menu::create_tray_menu(app.handle())?;
|
tray_menu::create_tray_menu(app.handle())?;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
//! 应用扫描:Windows 开始菜单 .lnk + PATH 中的可执行文件。
|
||||||
|
//!
|
||||||
|
//! 简化实现:扫描开始菜单目录(系统 + 用户)下的 .lnk 快捷方式,
|
||||||
|
//! 名称取文件名(去 .lnk 后缀)。PATH 可执行文件扫描可选(避免噪音过多)。
|
||||||
|
//! 结果不持久化,每次唤起时按需刷新(数据量小,几十毫秒内完成)。
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use serde::Serialize;
|
||||||
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AppRecord {
|
||||||
|
pub name: String,
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 扫描开始菜单(系统 + 用户)。返回去重后的应用列表。
|
||||||
|
pub fn scan_apps() -> Vec<AppRecord> {
|
||||||
|
let mut apps = Vec::new();
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
|
||||||
|
// 开始菜单目录
|
||||||
|
let mut dirs = Vec::new();
|
||||||
|
|
||||||
|
// 系统开始菜单:C:\ProgramData\Microsoft\Windows\Start Menu\Programs
|
||||||
|
if let Ok(prog_data) = std::env::var("ProgramData") {
|
||||||
|
dirs.push(
|
||||||
|
PathBuf::from(prog_data)
|
||||||
|
.join("Microsoft")
|
||||||
|
.join("Windows")
|
||||||
|
.join("Start Menu")
|
||||||
|
.join("Programs"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 用户开始菜单:%APPDATA%\Microsoft\Windows\Start Menu\Programs
|
||||||
|
if let Ok(appdata) = std::env::var("APPDATA") {
|
||||||
|
dirs.push(
|
||||||
|
PathBuf::from(appdata)
|
||||||
|
.join("Microsoft")
|
||||||
|
.join("Windows")
|
||||||
|
.join("Start Menu")
|
||||||
|
.join("Programs"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for dir in dirs {
|
||||||
|
if !dir.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for entry in WalkDir::new(&dir)
|
||||||
|
.max_depth(5)
|
||||||
|
.follow_links(false)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
{
|
||||||
|
let p = entry.path();
|
||||||
|
if !p.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let ext = p.extension().map(|e| e.to_string_lossy().to_lowercase()).unwrap_or_default();
|
||||||
|
if ext != "lnk" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(name_os) = p.file_stem() else { continue };
|
||||||
|
let name = name_os.to_string_lossy().to_string();
|
||||||
|
let path_str = p.to_string_lossy().to_string();
|
||||||
|
// 去重:同名应用保留第一个
|
||||||
|
if seen.insert(name.to_lowercase()) {
|
||||||
|
apps.push(AppRecord { name, path: path_str });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按名称排序
|
||||||
|
apps.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||||
|
apps
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
//! Tauri 命令:快速面板模块
|
||||||
|
|
||||||
|
use tauri::AppHandle;
|
||||||
|
|
||||||
|
use super::popup::{self, QuickPanelSettings};
|
||||||
|
use super::{file_index, app_scanner, icon_extractor};
|
||||||
|
|
||||||
|
/// 读取快速面板设置(快捷键等)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn quickpanel_get_settings(app: AppHandle) -> Result<QuickPanelSettings, String> {
|
||||||
|
Ok(popup::load_settings(&app))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存快速面板设置;快捷键变化时自动重新注册 + 预创建窗口
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn quickpanel_save_settings(
|
||||||
|
settings: QuickPanelSettings,
|
||||||
|
app: AppHandle,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let prev_shortcut = popup::load_settings(&app).shortcut;
|
||||||
|
popup::save_settings(&app, &settings)?;
|
||||||
|
// 快捷键变化时重新注册
|
||||||
|
if settings.shortcut != prev_shortcut {
|
||||||
|
popup::register_shortcut(&app, &settings.shortcut)?;
|
||||||
|
// 新快捷键非空时确保弹窗窗口已预创建
|
||||||
|
if !settings.shortcut.trim().is_empty() {
|
||||||
|
popup::ensure_window(&app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 注册(或切换)快速面板全局快捷键
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn quickpanel_register_shortcut(
|
||||||
|
shortcut: String,
|
||||||
|
app: AppHandle,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
popup::register_shortcut(&app, &shortcut)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 注销快速面板全局快捷键
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn quickpanel_unregister_shortcut(app: AppHandle) -> Result<(), String> {
|
||||||
|
popup::unregister_shortcut(&app);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 手动触发显示快速面板(供 UI 按钮调用)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn quickpanel_show_popup(app: AppHandle) -> Result<(), String> {
|
||||||
|
popup::show_popup(&app);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 隐藏快速面板
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn quickpanel_hide_popup(app: AppHandle) -> Result<(), String> {
|
||||||
|
popup::hide_popup(&app);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 显示已创建的弹窗窗口(前端 onMounted 后调用)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn quickpanel_show_window(app: AppHandle) -> Result<(), String> {
|
||||||
|
popup::show_window(&app);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 锁定屏幕(Windows: rundll32 user32.dll,LockWorkStation,CREATE_NO_WINDOW 避免黑窗)
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn quickpanel_lock_screen() -> Result<(), String> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
use crate::process_manager::setup_creation_flags;
|
||||||
|
let mut cmd = std::process::Command::new("rundll32.exe");
|
||||||
|
cmd.arg("user32.dll,LockWorkStation");
|
||||||
|
setup_creation_flags(&mut cmd);
|
||||||
|
cmd.spawn().map_err(|e| format!("锁屏失败: {}", e))?;
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
return Err("当前平台不支持锁屏".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 初始化文件索引数据库(应用启动时调用)
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn quickpanel_init_file_index(app: AppHandle) -> Result<(), String> {
|
||||||
|
file_index::init(&app);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 构建文件索引(全量重建,阻塞操作建议在 spawn_blocking 调用)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn quickpanel_build_file_index(app: AppHandle) -> Result<i64, String> {
|
||||||
|
let settings = popup::load_settings(&app);
|
||||||
|
let dirs = if settings.index_dirs.is_empty() {
|
||||||
|
popup::QuickPanelSettings::default().index_dirs
|
||||||
|
} else {
|
||||||
|
settings.index_dirs
|
||||||
|
};
|
||||||
|
// 阻塞操作放到 spawn_blocking
|
||||||
|
tauri::async_runtime::spawn_blocking(move || file_index::build_index(&dirs))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("索引任务失败: {}", e))?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 搜索文件索引
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn quickpanel_search_files(query: String, limit: Option<i64>) -> Vec<file_index::FileRecord> {
|
||||||
|
file_index::search(&query, limit.unwrap_or(50))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取索引状态
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn quickpanel_file_index_stats() -> file_index::IndexStats {
|
||||||
|
file_index::stats()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 扫描已安装应用
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn quickpanel_scan_apps() -> Vec<app_scanner::AppRecord> {
|
||||||
|
app_scanner::scan_apps()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取应用图标(data URL)。命中内存/磁盘缓存时零 Windows API 调用。
|
||||||
|
/// 前端按需为可见项调用,避免一次性加载全部图标。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn quickpanel_get_app_icon(app: AppHandle, path: String) -> Option<String> {
|
||||||
|
icon_extractor::get_icon_data_url(&app, &path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清理图标缓存(磁盘 + 内存)
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn quickpanel_clear_app_icon_cache(app: AppHandle) -> Result<(), String> {
|
||||||
|
icon_extractor::clear_cache(&app);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在资源管理器中显示文件(选中)
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn quickpanel_reveal_in_explorer(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;
|
||||||
|
|
||||||
|
// 规范化路径分隔符
|
||||||
|
let normalized = path.replace('/', "\\");
|
||||||
|
// explorer.exe /select,"path" — 用 ShellExecuteW 直接传参,
|
||||||
|
// 避免 std::process::Command 的 arg 转义破坏 /select 语法。
|
||||||
|
// 对 .lnk 文件也能正确选中(explorer 直接选中 .lnk 文件本身)。
|
||||||
|
let params = format!("/select,\"{}\"", normalized);
|
||||||
|
let wide_exe: Vec<u16> = OsStr::new("explorer.exe")
|
||||||
|
.encode_wide()
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect();
|
||||||
|
let wide_params: Vec<u16> = OsStr::new(¶ms)
|
||||||
|
.encode_wide()
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let hinst = ShellExecuteW(
|
||||||
|
0 as HWND,
|
||||||
|
std::ptr::null(),
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
let _ = path;
|
||||||
|
return Err("当前平台不支持".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用系统默认程序打开文件(ShellExecuteW)。
|
||||||
|
/// 无关联应用时,自动 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;
|
||||||
|
|
||||||
|
let normalized = path.replace('/', "\\");
|
||||||
|
let wide_path: Vec<u16> = OsStr::new(&normalized)
|
||||||
|
.encode_wide()
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect();
|
||||||
|
let wide_open: Vec<u16> = OsStr::new("open")
|
||||||
|
.encode_wide()
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect();
|
||||||
|
let wide_openas: Vec<u16> = OsStr::new("openas")
|
||||||
|
.encode_wide()
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
let _ = path;
|
||||||
|
return Err(String::from("当前平台不支持"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除文件(移到回收站)
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn quickpanel_delete_file(path: String) -> Result<(), String> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
use crate::process_manager::setup_creation_flags;
|
||||||
|
let p = std::path::Path::new(&path);
|
||||||
|
let is_dir = p.is_dir();
|
||||||
|
// 用 PowerShell + Microsoft.VisualBasic 移到回收站
|
||||||
|
let script = if is_dir {
|
||||||
|
format!(
|
||||||
|
"Add-Type -AssemblyName Microsoft.VisualBasic; [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteDirectory('{}','OnlyErrorDialogs','SendToRecycleBin')",
|
||||||
|
path.replace('\'', "''")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"Add-Type -AssemblyName Microsoft.VisualBasic; [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile('{}','OnlyErrorDialogs','SendToRecycleBin')",
|
||||||
|
path.replace('\'', "''")
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let mut cmd = std::process::Command::new("powershell.exe");
|
||||||
|
cmd.args(["-NoProfile", "-NonInteractive", "-Command", &script]);
|
||||||
|
setup_creation_flags(&mut cmd);
|
||||||
|
let output = cmd.output().map_err(|e| format!("删除失败: {}", e))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(format!(
|
||||||
|
"删除失败: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
let _ = path;
|
||||||
|
return Err("当前平台不支持".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 运行自定义命令(执行可执行文件 + 参数)
|
||||||
|
/// .lnk 快捷方式不能直接 spawn(os error 193),需通过 cmd /C 启动
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn quickpanel_run_custom_command(command: String, args: Vec<String>) -> Result<(), String> {
|
||||||
|
use crate::process_manager::setup_creation_flags;
|
||||||
|
let is_lnk = command
|
||||||
|
.to_lowercase()
|
||||||
|
.ends_with(".lnk");
|
||||||
|
let mut cmd = if is_lnk {
|
||||||
|
// cmd /C start "" "path.lnk" arg1 arg2
|
||||||
|
let mut c = std::process::Command::new("cmd");
|
||||||
|
c.args(["/C", "start", "", &command]);
|
||||||
|
c.args(&args);
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
let mut c = std::process::Command::new(&command);
|
||||||
|
c.args(&args);
|
||||||
|
c
|
||||||
|
};
|
||||||
|
setup_creation_flags(&mut cmd);
|
||||||
|
cmd.spawn().map_err(|e| format!("运行命令失败: {}", e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 运行系统命令(不设置 CREATE_NO_WINDOW,使 cmd/powershell/regedit 等显示自身窗口)
|
||||||
|
/// 适用于内置系统工具:regedit、shutdown、cmd、powershell、taskmgr 等。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn quickpanel_run_system_command(command: String, args: Vec<String>) -> Result<(), String> {
|
||||||
|
let mut cmd = std::process::Command::new(&command);
|
||||||
|
cmd.args(&args);
|
||||||
|
cmd.spawn().map_err(|e| format!("运行系统命令失败: {}", e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
//! 文件索引:walkdir 遍历 + rusqlite 存储/搜索。
|
||||||
|
//!
|
||||||
|
//! schema:
|
||||||
|
//! files(path TEXT PK, name TEXT, ext TEXT, size INT, mtime INT, is_dir INT)
|
||||||
|
//! 索引:name LIKE 搜索(name_lower 已预存为小写,避免 lower() 全表扫描)。
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::UNIX_EPOCH;
|
||||||
|
|
||||||
|
use rusqlite::{params, Connection};
|
||||||
|
use serde::Serialize;
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
|
/// 单个文件记录(返回给前端)
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FileRecord {
|
||||||
|
pub path: String,
|
||||||
|
pub name: String,
|
||||||
|
pub ext: String,
|
||||||
|
pub size: i64,
|
||||||
|
pub is_dir: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 索引状态(返回给前端)
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct IndexStats {
|
||||||
|
pub total: i64,
|
||||||
|
pub last_built_at: i64,
|
||||||
|
pub last_built_dirs: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Inner {
|
||||||
|
conn: Mutex<Connection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
static INDEX: std::sync::OnceLock<Mutex<Option<Inner>>> = std::sync::OnceLock::new();
|
||||||
|
|
||||||
|
fn index_slot() -> &'static Mutex<Option<Inner>> {
|
||||||
|
INDEX.get_or_init(|| Mutex::new(None))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 数据库路径:{app_data_dir}/quickpanel/files.db
|
||||||
|
fn db_path(app: &AppHandle) -> PathBuf {
|
||||||
|
app.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("."))
|
||||||
|
.join("quickpanel")
|
||||||
|
.join("files.db")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 初始化数据库连接(创建表 + 索引)。若已初始化则跳过。
|
||||||
|
pub fn init(app: &AppHandle) {
|
||||||
|
let path = db_path(app);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).ok();
|
||||||
|
}
|
||||||
|
let conn = match Connection::open(&path) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[quickpanel] 文件索引 DB 初始化失败: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
conn.busy_timeout(std::time::Duration::from_secs(3)).ok();
|
||||||
|
let _ = conn.execute_batch(
|
||||||
|
"CREATE TABLE IF NOT EXISTS files (
|
||||||
|
path TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
name_lower TEXT NOT NULL,
|
||||||
|
ext TEXT NOT NULL DEFAULT '',
|
||||||
|
size INTEGER NOT NULL DEFAULT 0,
|
||||||
|
mtime INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_dir INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_name_lower ON files(name_lower);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ext ON files(ext);
|
||||||
|
CREATE TABLE IF NOT EXISTS files_meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);",
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut guard = index_slot().lock().unwrap();
|
||||||
|
*guard = Some(Inner {
|
||||||
|
conn: Mutex::new(conn),
|
||||||
|
});
|
||||||
|
eprintln!("[quickpanel] 文件索引 DB 已就绪: {}", path.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断索引是否已初始化
|
||||||
|
fn with_conn<F, R>(f: F) -> Option<R>
|
||||||
|
where
|
||||||
|
F: FnOnce(&Connection) -> R,
|
||||||
|
{
|
||||||
|
let guard = index_slot().lock().unwrap();
|
||||||
|
if let Some(inner) = guard.as_ref() {
|
||||||
|
if let Ok(conn) = inner.conn.lock() {
|
||||||
|
return Some(f(&conn));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 遍历指定目录列表建立索引(全量重建)。
|
||||||
|
/// 返回索引条目数。在 spawn_blocking 中调用。
|
||||||
|
/// 重建完成后自动启动 notify 监听器做增量更新。
|
||||||
|
pub fn build_index(dirs: &[String]) -> Result<i64, String> {
|
||||||
|
// 清空旧数据
|
||||||
|
let cleared = with_conn(|conn| {
|
||||||
|
conn.execute("DELETE FROM files", []).ok()
|
||||||
|
}).unwrap_or(None);
|
||||||
|
|
||||||
|
if cleared.is_none() {
|
||||||
|
return Err("文件索引未初始化".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut count = 0i64;
|
||||||
|
for dir in dirs {
|
||||||
|
count += walk_and_index(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录构建元信息
|
||||||
|
let now = now_secs();
|
||||||
|
let dirs_json = serde_json::to_string(dirs).unwrap_or_default();
|
||||||
|
let _ = with_conn(|conn| {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO files_meta (key, value) VALUES ('last_built_at', ?1)",
|
||||||
|
params![now.to_string()],
|
||||||
|
).ok();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO files_meta (key, value) VALUES ('last_built_dirs', ?1)",
|
||||||
|
params![dirs_json],
|
||||||
|
).ok()
|
||||||
|
});
|
||||||
|
|
||||||
|
// 启动/刷新 notify 监听器
|
||||||
|
start_watcher(dirs);
|
||||||
|
|
||||||
|
eprintln!("[quickpanel] 文件索引完成,共 {} 条", count);
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 遍历单个目录并写入索引,返回新增条目数
|
||||||
|
fn walk_and_index(dir: &str) -> i64 {
|
||||||
|
let path = Path::new(dir);
|
||||||
|
if !path.exists() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let mut count = 0i64;
|
||||||
|
for entry in WalkDir::new(path)
|
||||||
|
.max_depth(10)
|
||||||
|
.follow_links(false)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
{
|
||||||
|
let p = entry.path();
|
||||||
|
if upsert_path(p) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将单个路径写入索引(创建/修改)。返回 true 表示已写入。
|
||||||
|
/// 跳过隐藏文件(. 开头)、不存在的路径。
|
||||||
|
fn upsert_path(p: &Path) -> bool {
|
||||||
|
let Some(name_os) = p.file_name() else { return false };
|
||||||
|
let name = name_os.to_string_lossy().to_string();
|
||||||
|
if name.starts_with('.') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let meta = match std::fs::metadata(p) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
let name_lower = name.to_lowercase();
|
||||||
|
let ext = p.extension()
|
||||||
|
.map(|e| e.to_string_lossy().to_lowercase())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let size = meta.len() as i64;
|
||||||
|
let mtime = meta.modified().ok()
|
||||||
|
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let is_dir = meta.is_dir() as i32;
|
||||||
|
let path_str = p.to_string_lossy().to_string();
|
||||||
|
|
||||||
|
let _ = with_conn(|conn| {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO files (path, name, name_lower, ext, size, mtime, is_dir)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||||
|
params![path_str, name, name_lower, ext, size, mtime, is_dir],
|
||||||
|
)
|
||||||
|
});
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从索引中删除指定路径
|
||||||
|
fn remove_path(p: &Path) {
|
||||||
|
let path_str = p.to_string_lossy().to_string();
|
||||||
|
let _ = with_conn(|conn| {
|
||||||
|
// 删除该路径及其子项(目录被删除时,子文件也失效)
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM files WHERE path = ?1 OR path LIKE ?2",
|
||||||
|
params![path_str, format!("{}%", path_str)],
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 搜索文件:name_lower LIKE %query%,按名字长度升序(短名优先)。
|
||||||
|
pub fn search(query: &str, limit: i64) -> Vec<FileRecord> {
|
||||||
|
let like = format!("%{}%", query.to_lowercase());
|
||||||
|
with_conn(|conn| {
|
||||||
|
let mut stmt = match conn.prepare(
|
||||||
|
"SELECT path, name, ext, size, is_dir FROM files
|
||||||
|
WHERE name_lower LIKE ?1
|
||||||
|
ORDER BY LENGTH(name) ASC, name ASC LIMIT ?2",
|
||||||
|
) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return vec![],
|
||||||
|
};
|
||||||
|
stmt.query_map(params![like, limit], |r| {
|
||||||
|
Ok(FileRecord {
|
||||||
|
path: r.get(0)?,
|
||||||
|
name: r.get(1)?,
|
||||||
|
ext: r.get(2)?,
|
||||||
|
size: r.get(3)?,
|
||||||
|
is_dir: r.get::<_, i32>(4)? != 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map(|r| r.filter_map(|i| i.ok()).collect())
|
||||||
|
.unwrap_or_default()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 索引状态
|
||||||
|
pub fn stats() -> IndexStats {
|
||||||
|
let total = with_conn(|conn| {
|
||||||
|
conn.query_row("SELECT COUNT(*) FROM files", [], |r| r.get::<_, i64>(0))
|
||||||
|
.unwrap_or(0)
|
||||||
|
})
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let last_built_at = with_conn(|conn| {
|
||||||
|
// query_row 返回 Result<String, Error>,统一处理失败
|
||||||
|
let res: rusqlite::Result<String> = conn.query_row(
|
||||||
|
"SELECT value FROM files_meta WHERE key = 'last_built_at'",
|
||||||
|
[],
|
||||||
|
|r| r.get::<_, String>(0),
|
||||||
|
);
|
||||||
|
res.ok().and_then(|s| s.parse().ok()).unwrap_or(0)
|
||||||
|
})
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let last_built_dirs = with_conn(|conn| {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT value FROM files_meta WHERE key = 'last_built_dirs'",
|
||||||
|
[],
|
||||||
|
|r| r.get::<_, String>(0),
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| serde_json::from_str::<Vec<String>>(&s).ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
IndexStats {
|
||||||
|
total,
|
||||||
|
last_built_at,
|
||||||
|
last_built_dirs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_secs() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== notify 增量监听 =====
|
||||||
|
|
||||||
|
use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||||
|
|
||||||
|
static WATCHER: std::sync::OnceLock<Mutex<Option<RecommendedWatcher>>> = std::sync::OnceLock::new();
|
||||||
|
|
||||||
|
fn watcher_slot() -> &'static Mutex<Option<RecommendedWatcher>> {
|
||||||
|
WATCHER.get_or_init(|| Mutex::new(None))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动/刷新 notify 监听器。重建索引或修改索引目录后调用。
|
||||||
|
/// 会先停止旧监听器,再为新目录列表添加递归监听。
|
||||||
|
pub fn start_watcher(dirs: &[String]) {
|
||||||
|
// 创建新 watcher(notify v6: recommended_watcher 只接受回调,Config 默认)
|
||||||
|
let mut watcher = match notify::recommended_watcher(
|
||||||
|
move |res: notify::Result<notify::Event>| {
|
||||||
|
if let Ok(event) = res {
|
||||||
|
handle_fs_event(&event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Ok(w) => w,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[quickpanel] notify watcher 创建失败: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 为每个目录添加递归监听
|
||||||
|
for dir in dirs {
|
||||||
|
let path = Path::new(dir);
|
||||||
|
if !path.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Err(e) = watcher.watch(path, RecursiveMode::Recursive) {
|
||||||
|
eprintln!("[quickpanel] watch {} 失败: {}", dir, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 替换旧 watcher(drop 时自动 unwatch)
|
||||||
|
let mut guard = watcher_slot().lock().unwrap();
|
||||||
|
*guard = Some(watcher);
|
||||||
|
eprintln!("[quickpanel] notify 监听已启动,监听 {} 个目录", dirs.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 处理文件系统事件:创建/修改 → upsert,删除 → remove,重命名 → remove + upsert
|
||||||
|
fn handle_fs_event(event: ¬ify::Event) {
|
||||||
|
match event.kind {
|
||||||
|
EventKind::Create(_) | EventKind::Modify(_) => {
|
||||||
|
for path in &event.paths {
|
||||||
|
if path.exists() {
|
||||||
|
upsert_path(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EventKind::Remove(_) => {
|
||||||
|
for path in &event.paths {
|
||||||
|
remove_path(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// 忽略访问/其他事件
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 停止 notify 监听器
|
||||||
|
pub fn stop_watcher() {
|
||||||
|
let mut guard = watcher_slot().lock().unwrap();
|
||||||
|
*guard = None;
|
||||||
|
}
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
//! 应用图标提取:Windows SHGetFileInfo → HICON → RGBA → PNG,带磁盘 + 内存缓存。
|
||||||
|
//!
|
||||||
|
//! 流程:
|
||||||
|
//! 1. 内存缓存命中 → 直接返回 data URL
|
||||||
|
//! 2. 磁盘缓存命中({app_data_dir}/quickpanel/icons/{hash}.png) → 读取并缓存
|
||||||
|
//! 3. 调用 SHGetFileInfoW 提取 HICON → GetDIBits 取 32bit BGRA → 转 RGBA → PNG
|
||||||
|
//! 4. 写入磁盘缓存 + 内存缓存,返回 data URL
|
||||||
|
//!
|
||||||
|
//! 设计取舍:
|
||||||
|
//! - 返回 base64 data URL 而非文件路径,避免独立弹窗窗口的 asset 协议配置问题
|
||||||
|
//! - 磁盘缓存避免重复 Windows API 调用(昂贵),内存缓存避免重复磁盘读取 + 编码
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use base64::Engine as _;
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
|
// ===== 内存缓存 =====
|
||||||
|
static MEM_CACHE: Mutex<Option<HashMap<String, String>>> = Mutex::new(None);
|
||||||
|
const MEM_CACHE_MAX: usize = 512;
|
||||||
|
|
||||||
|
fn mem_get(path: &str) -> Option<String> {
|
||||||
|
let cache = MEM_CACHE.lock().ok()?;
|
||||||
|
cache.as_ref()?.get(path).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mem_put(path: String, url: String) {
|
||||||
|
if let Ok(mut guard) = MEM_CACHE.lock() {
|
||||||
|
let map = guard.get_or_insert_with(HashMap::new);
|
||||||
|
if map.len() >= MEM_CACHE_MAX {
|
||||||
|
// 简单清理:丢弃一半(最早插入的,HashMap 无序,近似随机)
|
||||||
|
let keep = map.len() / 2;
|
||||||
|
let keys: Vec<String> = map.keys().cloned().collect();
|
||||||
|
for k in keys.iter().skip(keep) {
|
||||||
|
map.remove(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
map.insert(path, url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 磁盘缓存路径 =====
|
||||||
|
fn cache_dir(app: &AppHandle) -> PathBuf {
|
||||||
|
app.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("."))
|
||||||
|
.join("quickpanel")
|
||||||
|
.join("icons")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_hash(path: &str) -> String {
|
||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
// 统一小写 + 正斜杠,避免大小写/分隔符差异导致缓存未命中
|
||||||
|
let normalized = path.to_lowercase().replace('\\', "/");
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
normalized.hash(&mut hasher);
|
||||||
|
format!("{:016x}", hasher.finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 公共 API =====
|
||||||
|
|
||||||
|
/// 获取应用图标 data URL。命中缓存则零开销;未命中则提取 + 编码 + 落盘。
|
||||||
|
/// 返回 Ok(None) 表示提取失败或不支持的平台。
|
||||||
|
pub fn get_icon_data_url(app: &AppHandle, path: &str) -> Option<String> {
|
||||||
|
if path.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// 规范化路径分隔符:混合 / 和 \ 会导致 SHGetFileInfoW 失败
|
||||||
|
let normalized = path.replace('/', "\\");
|
||||||
|
let path = normalized.as_str();
|
||||||
|
|
||||||
|
// 1. 内存缓存
|
||||||
|
if let Some(url) = mem_get(path) {
|
||||||
|
return Some(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
let dir = cache_dir(app);
|
||||||
|
let hash = path_hash(path);
|
||||||
|
let cache_path = dir.join(format!("{}.png", hash));
|
||||||
|
|
||||||
|
// 2. 磁盘缓存
|
||||||
|
if cache_path.exists() {
|
||||||
|
if let Ok(bytes) = std::fs::read(&cache_path) {
|
||||||
|
let url = png_to_data_url(&bytes);
|
||||||
|
mem_put(path.to_string(), url.clone());
|
||||||
|
return Some(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 提取
|
||||||
|
let png = extract_icon_png(path)?;
|
||||||
|
|
||||||
|
// 4. 落盘(失败不影响返回)
|
||||||
|
std::fs::create_dir_all(&dir).ok();
|
||||||
|
std::fs::write(&cache_path, &png).ok();
|
||||||
|
|
||||||
|
// 5. 缓存 + 返回
|
||||||
|
let url = png_to_data_url(&png);
|
||||||
|
mem_put(path.to_string(), url.clone());
|
||||||
|
Some(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清理整个图标磁盘缓存(设置页可调用)
|
||||||
|
pub fn clear_cache(app: &AppHandle) {
|
||||||
|
let dir = cache_dir(app);
|
||||||
|
if dir.exists() {
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
std::fs::create_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
if let Ok(mut guard) = MEM_CACHE.lock() {
|
||||||
|
*guard = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn png_to_data_url(png: &[u8]) -> String {
|
||||||
|
let b64 = base64::engine::general_purpose::STANDARD.encode(png);
|
||||||
|
format!("data:image/png;base64,{}", b64)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Windows 图标提取 =====
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn extract_icon_png(path: &str) -> Option<Vec<u8>> {
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::os::windows::ffi::OsStrExt;
|
||||||
|
use windows_sys::Win32::UI::Shell::{
|
||||||
|
SHGetFileInfoW, SHFILEINFOW, SHGFI_ICON, SHGFI_LARGEICON,
|
||||||
|
};
|
||||||
|
use windows_sys::Win32::UI::WindowsAndMessaging::DestroyIcon;
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let wide: Vec<u16> = OsStr::new(path)
|
||||||
|
.encode_wide()
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut shfi: SHFILEINFOW = std::mem::zeroed();
|
||||||
|
let _ = SHGetFileInfoW(
|
||||||
|
wide.as_ptr(),
|
||||||
|
0,
|
||||||
|
&mut shfi,
|
||||||
|
std::mem::size_of::<SHFILEINFOW>() as u32,
|
||||||
|
SHGFI_ICON | SHGFI_LARGEICON,
|
||||||
|
);
|
||||||
|
|
||||||
|
// hIcon 为 0 表示无图标
|
||||||
|
if shfi.hIcon == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = hicon_to_png(shfi.hIcon);
|
||||||
|
let _ = DestroyIcon(shfi.hIcon);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn extract_icon_png(_path: &str) -> Option<Vec<u8>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HICON → PNG bytes
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn hicon_to_png(hicon: windows_sys::Win32::UI::WindowsAndMessaging::HICON) -> Option<Vec<u8>> {
|
||||||
|
use windows_sys::Win32::Graphics::Gdi::DeleteObject;
|
||||||
|
use windows_sys::Win32::UI::WindowsAndMessaging::{GetIconInfo, ICONINFO};
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let mut icon_info: ICONINFO = std::mem::zeroed();
|
||||||
|
if GetIconInfo(hicon, &mut icon_info) == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let has_color = icon_info.hbmColor != 0;
|
||||||
|
let has_mask = icon_info.hbmMask != 0;
|
||||||
|
|
||||||
|
let result: Option<(Vec<u8>, u32, u32)> = if has_color {
|
||||||
|
// 32-bit BGRA → RGBA
|
||||||
|
let (mut rgba, w, h) = bitmap_to_rgba32(icon_info.hbmColor)?;
|
||||||
|
|
||||||
|
// 检查 alpha 是否全 0(旧式无 alpha 通道图标)
|
||||||
|
let alpha_any = rgba.chunks_exact(4).any(|c| c[3] != 0);
|
||||||
|
if !alpha_any {
|
||||||
|
if has_mask {
|
||||||
|
// 用 mask 补 alpha(白=透明,黑=不透明)
|
||||||
|
let _ = apply_mask_alpha(&mut rgba, w, h, icon_info.hbmMask);
|
||||||
|
} else {
|
||||||
|
// 无 mask,设为全不透明
|
||||||
|
for c in rgba.chunks_exact_mut(4) {
|
||||||
|
c[3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some((rgba, w, h))
|
||||||
|
} else {
|
||||||
|
// 无颜色位图:monochrome 图标,罕见且无色,跳过
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清理 GDI 对象
|
||||||
|
if has_color {
|
||||||
|
let _ = DeleteObject(icon_info.hbmColor);
|
||||||
|
}
|
||||||
|
if has_mask {
|
||||||
|
let _ = DeleteObject(icon_info.hbmMask);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (rgba, w, h) = result?;
|
||||||
|
encode_png(&rgba, w, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取 32-bit 位图为 RGBA(top-down),BGRA→RGBA
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn bitmap_to_rgba32(
|
||||||
|
hbm: windows_sys::Win32::Graphics::Gdi::HBITMAP,
|
||||||
|
) -> Option<(Vec<u8>, u32, u32)> {
|
||||||
|
use windows_sys::Win32::Foundation::HWND;
|
||||||
|
use windows_sys::Win32::Graphics::Gdi::{
|
||||||
|
GetDC, GetDIBits, GetObjectW, BITMAP, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS,
|
||||||
|
ReleaseDC,
|
||||||
|
};
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
// 取尺寸
|
||||||
|
let mut bmp: BITMAP = std::mem::zeroed();
|
||||||
|
let got = GetObjectW(
|
||||||
|
hbm,
|
||||||
|
std::mem::size_of::<BITMAP>() as i32,
|
||||||
|
&mut bmp as *mut _ as *mut _,
|
||||||
|
);
|
||||||
|
if got == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let w = bmp.bmWidth as u32;
|
||||||
|
let h = bmp.bmHeight as u32;
|
||||||
|
if w == 0 || h == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 32-bit top-down DIB
|
||||||
|
let mut bi: BITMAPINFO = std::mem::zeroed();
|
||||||
|
bi.bmiHeader.biSize = std::mem::size_of::<BITMAPINFOHEADER>() as u32;
|
||||||
|
bi.bmiHeader.biWidth = w as i32;
|
||||||
|
bi.bmiHeader.biHeight = -(h as i32); // 负值 = top-down
|
||||||
|
bi.bmiHeader.biPlanes = 1;
|
||||||
|
bi.bmiHeader.biBitCount = 32;
|
||||||
|
bi.bmiHeader.biCompression = BI_RGB;
|
||||||
|
|
||||||
|
let mut pixels = vec![0u8; (w * h * 4) as usize];
|
||||||
|
let hdc = GetDC(0 as HWND);
|
||||||
|
if hdc == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let ret = GetDIBits(
|
||||||
|
hdc,
|
||||||
|
hbm,
|
||||||
|
0,
|
||||||
|
h,
|
||||||
|
pixels.as_mut_ptr() as *mut _,
|
||||||
|
&mut bi,
|
||||||
|
DIB_RGB_COLORS,
|
||||||
|
);
|
||||||
|
let _ = ReleaseDC(0 as HWND, hdc);
|
||||||
|
if ret == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BGRA → RGBA
|
||||||
|
for chunk in pixels.chunks_exact_mut(4) {
|
||||||
|
chunk.swap(0, 2);
|
||||||
|
}
|
||||||
|
Some((pixels, w, h))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用 1bpp mask 设置 alpha:mask 白(1)=透明,黑(0)=不透明
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn apply_mask_alpha(
|
||||||
|
rgba: &mut [u8],
|
||||||
|
w: u32,
|
||||||
|
h: u32,
|
||||||
|
hbm_mask: windows_sys::Win32::Graphics::Gdi::HBITMAP,
|
||||||
|
) -> Result<(), ()> {
|
||||||
|
use windows_sys::Win32::Foundation::HWND;
|
||||||
|
use windows_sys::Win32::Graphics::Gdi::{
|
||||||
|
GetDC, GetDIBits, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, ReleaseDC,
|
||||||
|
};
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let mut bi: BITMAPINFO = std::mem::zeroed();
|
||||||
|
bi.bmiHeader.biSize = std::mem::size_of::<BITMAPINFOHEADER>() as u32;
|
||||||
|
bi.bmiHeader.biWidth = w as i32;
|
||||||
|
bi.bmiHeader.biHeight = -(h as i32);
|
||||||
|
bi.bmiHeader.biPlanes = 1;
|
||||||
|
bi.bmiHeader.biBitCount = 1;
|
||||||
|
bi.bmiHeader.biCompression = BI_RGB;
|
||||||
|
|
||||||
|
// 1bpp,每行 4 字节对齐
|
||||||
|
let row_bytes = ((w + 31) / 32 * 4) as usize;
|
||||||
|
let mut mask = vec![0u8; row_bytes * h as usize];
|
||||||
|
|
||||||
|
let hdc = GetDC(0 as HWND);
|
||||||
|
if hdc == 0 {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
let ret = GetDIBits(
|
||||||
|
hdc,
|
||||||
|
hbm_mask,
|
||||||
|
0,
|
||||||
|
h,
|
||||||
|
mask.as_mut_ptr() as *mut _,
|
||||||
|
&mut bi,
|
||||||
|
DIB_RGB_COLORS,
|
||||||
|
);
|
||||||
|
let _ = ReleaseDC(0 as HWND, hdc);
|
||||||
|
if ret == 0 {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
|
||||||
|
for y in 0..h as usize {
|
||||||
|
for x in 0..w as usize {
|
||||||
|
let byte_idx = y * row_bytes + x / 8;
|
||||||
|
let bit = (mask[byte_idx] >> (7 - (x % 8))) & 1;
|
||||||
|
let alpha = if bit == 1 { 0 } else { 255 };
|
||||||
|
rgba[(y * w as usize + x) * 4 + 3] = alpha;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RGBA → PNG
|
||||||
|
fn encode_png(rgba: &[u8], w: u32, h: u32) -> Option<Vec<u8>> {
|
||||||
|
use image::{ImageBuffer, RgbaImage};
|
||||||
|
let img: RgbaImage = ImageBuffer::from_raw(w, h, rgba.to_vec())?;
|
||||||
|
let mut buf = std::io::Cursor::new(Vec::new());
|
||||||
|
image::DynamicImage::ImageRgba8(img)
|
||||||
|
.write_to(&mut buf, image::ImageFormat::Png)
|
||||||
|
.ok()?;
|
||||||
|
Some(buf.into_inner())
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
//! 快速面板模块:全局快捷键唤起的多源命令面板。
|
||||||
|
//!
|
||||||
|
//! Phase 1:窗口骨架(预创建隐藏窗口 + 快捷键 + 失焦隐藏)
|
||||||
|
//! Phase 2:fuzzy + 拼音引擎,command/calc/web/system Provider
|
||||||
|
//! Phase 3:文件索引(walkdir + rusqlite)、应用扫描、剪贴板历史复用
|
||||||
|
|
||||||
|
pub mod app_scanner;
|
||||||
|
pub mod commands;
|
||||||
|
pub mod file_index;
|
||||||
|
pub mod icon_extractor;
|
||||||
|
pub mod popup;
|
||||||
|
|
||||||
|
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_unregister_shortcut,
|
||||||
|
};
|
||||||
|
pub use popup::{ensure_window, load_settings, register_shortcut};
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
//! 快速面板弹窗:全局快捷键唤起的多源命令面板。
|
||||||
|
//!
|
||||||
|
//! 流程(与剪贴板弹窗同构):
|
||||||
|
//! 1. 应用启动 → `ensure_window` 预创建隐藏窗口(屏幕外)
|
||||||
|
//! 2. 全局快捷键按下 → `show_popup` 在鼠标所在显示器中央定位并显示
|
||||||
|
//! 3. 前端 Vue 挂载完成、主题应用后调用 `quickpanel_show_window` 显示窗口
|
||||||
|
//! 4. 前端监听 `quickpanel-show` 事件刷新数据/聚焦输入
|
||||||
|
//! 5. 窗口失焦自动隐藏(保留窗口复用,不销毁)
|
||||||
|
//!
|
||||||
|
//! Win32 API(鼠标/显示器/DPI)复用 clipboard::popup 已 pub use 的实现,
|
||||||
|
//! 避免重复封装。
|
||||||
|
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
|
||||||
|
use tauri::window::{Effect, EffectsBuilder};
|
||||||
|
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
|
||||||
|
|
||||||
|
use crate::clipboard::popup::{get_cursor_pos, get_work_area_at_point, get_dpi_for_point};
|
||||||
|
|
||||||
|
/// 弹窗窗口标签
|
||||||
|
pub const POPUP_LABEL: &str = "quick-panel";
|
||||||
|
|
||||||
|
/// 窗口尺寸(逻辑像素)
|
||||||
|
const WIN_W: f64 = 600.0;
|
||||||
|
const WIN_H: f64 = 420.0;
|
||||||
|
|
||||||
|
/// 当前注册的快捷键(用于注销旧快捷键)
|
||||||
|
static CURRENT_SHORTCUT: Mutex<Option<String>> = Mutex::new(None);
|
||||||
|
|
||||||
|
/// 标志:show_popup 兜底创建路径设为 true,前端 onMounted 回调 show_window 时据此判断是否显示。
|
||||||
|
/// 预创建路径不设置,避免应用启动时弹窗自动弹出。
|
||||||
|
static POPUP_PENDING_SHOW: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// 自定义命令
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CustomCommand {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub command: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub args: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 快速面板设置
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct QuickPanelSettings {
|
||||||
|
/// 全局快捷键(如 "Alt+Space"),空字符串表示不注册。
|
||||||
|
#[serde(default = "default_shortcut")]
|
||||||
|
pub shortcut: String,
|
||||||
|
/// 唤起位置:center(鼠标所在显示器中央)| cursor(鼠标位置)
|
||||||
|
#[serde(default = "default_popup_position")]
|
||||||
|
pub popup_position: String,
|
||||||
|
/// 默认搜索引擎:google | bing | baidu
|
||||||
|
#[serde(default = "default_search_engine")]
|
||||||
|
pub search_engine: String,
|
||||||
|
/// 文件索引目录列表(空列表表示使用默认:桌面/文档/下载)
|
||||||
|
#[serde(default = "default_index_dirs")]
|
||||||
|
pub index_dirs: Vec<String>,
|
||||||
|
/// 自定义命令列表
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_commands: Vec<CustomCommand>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_shortcut() -> String {
|
||||||
|
"Alt+Space".to_string()
|
||||||
|
}
|
||||||
|
fn default_popup_position() -> String {
|
||||||
|
"center".to_string()
|
||||||
|
}
|
||||||
|
fn default_search_engine() -> String {
|
||||||
|
"bing".to_string()
|
||||||
|
}
|
||||||
|
fn default_index_dirs() -> Vec<String> {
|
||||||
|
// 桌面/文档/下载目录(延迟到实际使用时解析,避免启动时失败)
|
||||||
|
let mut dirs = Vec::new();
|
||||||
|
if let Some(d) = dirs::desktop_dir() {
|
||||||
|
dirs.push(d.to_string_lossy().to_string());
|
||||||
|
}
|
||||||
|
if let Some(d) = dirs::document_dir() {
|
||||||
|
dirs.push(d.to_string_lossy().to_string());
|
||||||
|
}
|
||||||
|
if let Some(d) = dirs::download_dir() {
|
||||||
|
dirs.push(d.to_string_lossy().to_string());
|
||||||
|
}
|
||||||
|
dirs
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for QuickPanelSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
shortcut: default_shortcut(),
|
||||||
|
popup_position: default_popup_position(),
|
||||||
|
search_engine: default_search_engine(),
|
||||||
|
index_dirs: default_index_dirs(),
|
||||||
|
custom_commands: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置文件路径:{app_data_dir}/quickpanel/settings.json
|
||||||
|
fn settings_path(app: &AppHandle) -> PathBuf {
|
||||||
|
app.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("."))
|
||||||
|
.join("quickpanel")
|
||||||
|
.join("settings.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取设置,文件不存在或解析失败返回默认值
|
||||||
|
pub fn load_settings(app: &AppHandle) -> QuickPanelSettings {
|
||||||
|
let path = settings_path(app);
|
||||||
|
std::fs::read_to_string(&path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| serde_json::from_str(&s).ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存设置到磁盘
|
||||||
|
pub fn save_settings(app: &AppHandle, settings: &QuickPanelSettings) -> Result<(), String> {
|
||||||
|
let path = settings_path(app);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).map_err(|e| format!("创建设置目录失败: {}", e))?;
|
||||||
|
}
|
||||||
|
let json = serde_json::to_string_pretty(settings).map_err(|e| format!("序列化设置失败: {}", e))?;
|
||||||
|
std::fs::write(&path, json).map_err(|e| format!("写入设置文件失败: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析快捷键字符串为 Shortcut(格式如 "Alt+Space"、"Ctrl+Shift+P")
|
||||||
|
/// 失败返回 None。
|
||||||
|
pub fn parse_shortcut(s: &str) -> Option<Shortcut> {
|
||||||
|
s.trim().parse::<Shortcut>().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 注册全局快捷键。重复调用会先注销旧快捷键。
|
||||||
|
/// 传入空字符串则仅注销不注册。
|
||||||
|
pub fn register_shortcut(app: &AppHandle, shortcut_str: &str) -> Result<(), String> {
|
||||||
|
// 先注销旧快捷键
|
||||||
|
unregister_shortcut(app);
|
||||||
|
|
||||||
|
if shortcut_str.trim().is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let shortcut = parse_shortcut(shortcut_str)
|
||||||
|
.ok_or_else(|| format!("无效的快捷键: {}", shortcut_str))?;
|
||||||
|
|
||||||
|
let app_handle = app.clone();
|
||||||
|
app.global_shortcut()
|
||||||
|
.on_shortcut(shortcut, move |_app, _shortcut, event| {
|
||||||
|
// 仅在按下时触发(松开不触发)
|
||||||
|
if event.state == ShortcutState::Pressed {
|
||||||
|
show_popup(&app_handle);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("注册快捷键失败: {}", e))?;
|
||||||
|
|
||||||
|
if let Ok(mut cur) = CURRENT_SHORTCUT.lock() {
|
||||||
|
*cur = Some(shortcut_str.to_string());
|
||||||
|
}
|
||||||
|
eprintln!("[quickpanel] 已注册快捷键: {}", shortcut_str);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 注销当前快捷键
|
||||||
|
pub fn unregister_shortcut(app: &AppHandle) {
|
||||||
|
if let Ok(cur) = CURRENT_SHORTCUT.lock() {
|
||||||
|
if let Some(ref s) = *cur {
|
||||||
|
if let Some(shortcut) = parse_shortcut(s) {
|
||||||
|
let _ = app.global_shortcut().unregister(shortcut);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(mut cur) = CURRENT_SHORTCUT.lock() {
|
||||||
|
*cur = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建弹窗窗口(隐藏状态)并注册失焦监听。
|
||||||
|
/// 位置默认在屏幕外,show_popup 时会重新定位到鼠标所在显示器中央。
|
||||||
|
/// 预创建后首次按快捷键走"窗口已存在"分支直接 show,避免首次创建的时序问题。
|
||||||
|
fn create_popup_window(app: &AppHandle) {
|
||||||
|
let win = match WebviewWindowBuilder::new(
|
||||||
|
app,
|
||||||
|
POPUP_LABEL,
|
||||||
|
WebviewUrl::App("index.html#quick-panel".into()),
|
||||||
|
)
|
||||||
|
.title("快速面板")
|
||||||
|
.inner_size(WIN_W, WIN_H)
|
||||||
|
.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!("[quickpanel] 创建弹窗失败: {}", 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("quickpanel-hide", ());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
eprintln!("[quickpanel] 弹窗窗口已预创建(隐藏状态)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 应用启动时预创建弹窗窗口(隐藏)。
|
||||||
|
/// 这样首次按快捷键时窗口已存在,直接 show + 定位,避免首次创建时序问题。
|
||||||
|
pub fn ensure_window(app: &AppHandle) {
|
||||||
|
if app.get_webview_window(POPUP_LABEL).is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
create_popup_window(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在指定位置显示弹窗。
|
||||||
|
/// popup_position = "cursor" 时在鼠标位置附近显示,否则在鼠标所在显示器中央显示。
|
||||||
|
/// 窗口不存在则创建(隐藏状态,等前端挂载后调用 show_window 显示)。
|
||||||
|
pub fn show_popup(app: &AppHandle) {
|
||||||
|
let settings = load_settings(app);
|
||||||
|
let cursor_mode = settings.popup_position == "cursor";
|
||||||
|
|
||||||
|
// 获取鼠标位置(物理像素)
|
||||||
|
let (mx, my) = match get_cursor_pos() {
|
||||||
|
Some(p) => p,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取光标所在显示器的工作区(物理像素,与 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));
|
||||||
|
|
||||||
|
// 获取光标所在显示器的 DPI,将物理坐标转为逻辑坐标(DIP)
|
||||||
|
let dpi = get_dpi_for_point(mx, my).unwrap_or(96);
|
||||||
|
let scale = dpi as f64 / 96.0;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
let (x, y) = if cursor_mode {
|
||||||
|
// 鼠标位置模式:以鼠标为基准偏移,clamp 到工作区内
|
||||||
|
let mx_l = mx as f64 / scale;
|
||||||
|
let my_l = my as f64 / scale;
|
||||||
|
let x = (mx_l + 12.0).min(wa_right_l - WIN_W).max(wa_left_l);
|
||||||
|
let y = (my_l + 12.0).min(wa_bottom_l - WIN_H).max(wa_top_l);
|
||||||
|
(x, y)
|
||||||
|
} else {
|
||||||
|
// 中央模式:窗口居中于鼠标所在显示器工作区
|
||||||
|
let wa_w = wa_right_l - wa_left_l;
|
||||||
|
let wa_h = wa_bottom_l - wa_top_l;
|
||||||
|
(wa_left_l + (wa_w - WIN_W) / 2.0, wa_top_l + (wa_h - WIN_H) / 2.0)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 窗口已存在:移动 + 显示 + 请求焦点
|
||||||
|
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
|
||||||
|
let _ = win.set_position(tauri::Position::Logical(tauri::LogicalPosition { x, y }));
|
||||||
|
let _ = win.show();
|
||||||
|
let _ = win.set_focus();
|
||||||
|
// 通知前端刷新数据
|
||||||
|
let _ = app.emit("quickpanel-show", ());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底:窗口被销毁时重新创建(隐藏),等前端 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();
|
||||||
|
// 通知前端刷新数据
|
||||||
|
let _ = app.emit("quickpanel-show", ());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 隐藏弹窗(不销毁,保留复用)
|
||||||
|
pub fn hide_popup(app: &AppHandle) {
|
||||||
|
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
|
||||||
|
let _ = win.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
+60
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted, shallowRef, computed, watch, type Component } from 'vue'
|
import { ref, onMounted, onUnmounted, shallowRef, computed, watch, type Component } from 'vue'
|
||||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||||
import TitleBar from '@/components/layout/TitleBar.vue'
|
import TitleBar from '@/components/layout/TitleBar.vue'
|
||||||
import Sidebar from '@/components/layout/Sidebar.vue'
|
import Sidebar from '@/components/layout/Sidebar.vue'
|
||||||
import ModuleContainer from '@/components/layout/ModuleContainer.vue'
|
import ModuleContainer from '@/components/layout/ModuleContainer.vue'
|
||||||
@@ -105,8 +107,48 @@ watch(() => appStore.enabledModules.length, () => {
|
|||||||
activeModule.value = fallback
|
activeModule.value = fallback
|
||||||
loadModule(fallback)
|
loadModule(fallback)
|
||||||
}
|
}
|
||||||
|
// 模块启用/禁用变化时重新同步快速面板命令缓存
|
||||||
|
syncQuickPanelCommands()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 同步快速面板命令缓存到 localStorage(供独立窗口读取) */
|
||||||
|
function syncQuickPanelCommands() {
|
||||||
|
const enabledIds = appStore.enabledModules.map(m => m.id)
|
||||||
|
const all = moduleRegistry.getAllSearchItems()
|
||||||
|
const commands: Array<{
|
||||||
|
moduleId: string
|
||||||
|
moduleName: string
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
keywords: string[]
|
||||||
|
}> = []
|
||||||
|
for (const { moduleId, items } of all) {
|
||||||
|
const config = moduleRegistry.getConfig(moduleId)
|
||||||
|
// 内置模块或已启用模块的搜索项才收录
|
||||||
|
if (!config?.builtin && !enabledIds.includes(moduleId)) continue
|
||||||
|
for (const item of items) {
|
||||||
|
commands.push({
|
||||||
|
moduleId,
|
||||||
|
moduleName: config?.name ?? moduleId,
|
||||||
|
title: item.title,
|
||||||
|
description: item.description,
|
||||||
|
keywords: item.keywords,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
localStorage.setItem('thing_quickpanel_commands', JSON.stringify(commands))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同步快速面板设置到 localStorage(供独立窗口的 web provider 读取搜索引擎) */
|
||||||
|
async function syncQuickPanelSettings() {
|
||||||
|
try {
|
||||||
|
const s = await invoke<{ shortcut: string; popupPosition: string; searchEngine: string; indexDirs: string[]; customCommands: unknown[] }>('quickpanel_get_settings')
|
||||||
|
localStorage.setItem('thing_quickpanel_settings', JSON.stringify(s))
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[quickpanel] 同步设置失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 计算启动时应打开的默认模块:优先上次记忆,其次排序第一个 */
|
/** 计算启动时应打开的默认模块:优先上次记忆,其次排序第一个 */
|
||||||
const resolveDefaultModule = (): string => {
|
const resolveDefaultModule = (): string => {
|
||||||
const enabledIds = appStore.enabledModules.map(m => m.id)
|
const enabledIds = appStore.enabledModules.map(m => m.id)
|
||||||
@@ -140,6 +182,24 @@ onMounted(async () => {
|
|||||||
activeModule.value = defaultModule
|
activeModule.value = defaultModule
|
||||||
loadModule(defaultModule)
|
loadModule(defaultModule)
|
||||||
|
|
||||||
|
// 快速面板:同步命令缓存与设置到 localStorage,供独立窗口读取
|
||||||
|
syncQuickPanelCommands()
|
||||||
|
syncQuickPanelSettings()
|
||||||
|
// 监听快速面板执行命令事件:显示主窗口 + 切换模块
|
||||||
|
trayUnlisteners.push(
|
||||||
|
await listen<{ moduleId: string }>('quickpanel-execute-command', async (e) => {
|
||||||
|
const win = getCurrentWindow()
|
||||||
|
try {
|
||||||
|
await win.show()
|
||||||
|
await win.unminimize()
|
||||||
|
await win.setFocus()
|
||||||
|
} catch {
|
||||||
|
/* 忽略窗口操作失败 */
|
||||||
|
}
|
||||||
|
handleSearch(e.payload.moduleId)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
// 监听托盘菜单事件
|
// 监听托盘菜单事件
|
||||||
// tray:toggle-osd 由 MonitorModule 直接监听(预渲染实例始终挂载)
|
// tray:toggle-osd 由 MonitorModule 直接监听(预渲染实例始终挂载)
|
||||||
trayUnlisteners.push(
|
trayUnlisteners.push(
|
||||||
|
|||||||
+7
-1
@@ -16,7 +16,7 @@ window.addEventListener('unhandledrejection', (event) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// ===== OSD 窗口模式检测 =====
|
// ===== OSD 窗口模式检测 =====
|
||||||
// 通过 URL hash 识别独立窗口:#osd-overlay / #clipboard-popup / #tray-menu / #screenshot-overlay / #screenshot-editor
|
// 通过 URL hash 识别独立窗口:#osd-overlay / #clipboard-popup / #quick-panel / #tray-menu / #screenshot-overlay / #screenshot-editor
|
||||||
// 这些窗口是精简的独立 Vue 应用,不加载主应用的 store 和模块
|
// 这些窗口是精简的独立 Vue 应用,不加载主应用的 store 和模块
|
||||||
const winHash = window.location.hash
|
const winHash = window.location.hash
|
||||||
if (winHash === '#osd-overlay') {
|
if (winHash === '#osd-overlay') {
|
||||||
@@ -31,6 +31,12 @@ if (winHash === '#osd-overlay') {
|
|||||||
const app = createApp(ClipboardPopup)
|
const app = createApp(ClipboardPopup)
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
})
|
})
|
||||||
|
} else if (winHash === '#quick-panel') {
|
||||||
|
logger.info(`快速面板弹窗窗口启动: ${winHash}`)
|
||||||
|
void import('./modules/quickpanel/QuickPanel.vue').then(({ default: QuickPanel }) => {
|
||||||
|
const app = createApp(QuickPanel)
|
||||||
|
app.mount('#app')
|
||||||
|
})
|
||||||
} else if (winHash === '#tray-menu') {
|
} else if (winHash === '#tray-menu') {
|
||||||
logger.info(`托盘菜单窗口启动: ${winHash}`)
|
logger.info(`托盘菜单窗口启动: ${winHash}`)
|
||||||
void import('./modules/tray/TrayMenu.vue').then(({ default: TrayMenu }) => {
|
void import('./modules/tray/TrayMenu.vue').then(({ default: TrayMenu }) => {
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { Search } from '@lucide/vue'
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="h-full p-6 overflow-y-auto">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle class="flex items-center gap-2">
|
|
||||||
<Search class="h-5 w-5 text-primary" />
|
|
||||||
文件搜索模块
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div class="flex flex-col items-center justify-center h-64 text-muted-foreground">
|
|
||||||
<Search class="h-16 w-16 mb-4 opacity-50" />
|
|
||||||
<p>文件搜索功能开发中...</p>
|
|
||||||
<p class="text-sm mt-2">支持快速搜索弹窗、文件搜索、应用程序搜索等功能</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import type { ModuleConfig } from '@/types/module'
|
|
||||||
import type { SearchIndexItem } from '@/stores/searchIndex'
|
|
||||||
|
|
||||||
const searchItems: SearchIndexItem[] = [
|
|
||||||
{
|
|
||||||
title: '文件搜索',
|
|
||||||
description: '搜索本地文件',
|
|
||||||
keywords: ['文件', '搜索', 'finder', 'search', 'file']
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
export const moduleConfig: ModuleConfig = {
|
|
||||||
id: 'finder',
|
|
||||||
name: '文件搜索',
|
|
||||||
icon: 'finder',
|
|
||||||
description: '快速文件搜索、拼音模糊匹配',
|
|
||||||
category: 'tool',
|
|
||||||
defaultEnabled: true,
|
|
||||||
loader: () => import('./FinderModule.vue'),
|
|
||||||
searchItems,
|
|
||||||
order: 60
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
Camera,
|
Camera,
|
||||||
Activity,
|
Activity,
|
||||||
Download,
|
Download,
|
||||||
Search
|
Command
|
||||||
} from '@lucide/vue'
|
} from '@lucide/vue'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,7 +23,7 @@ export const moduleIconMap: Record<string, Component> = {
|
|||||||
screenshot: Camera,
|
screenshot: Camera,
|
||||||
monitor: Activity,
|
monitor: Activity,
|
||||||
downloader: Download,
|
downloader: Download,
|
||||||
finder: Search
|
quickpanel: Command
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取模块图标组件,未找到时回退到 Settings 图标 */
|
/** 获取模块图标组件,未找到时回退到 Settings 图标 */
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { moduleConfig as clipboard } from './clipboard'
|
|||||||
import { moduleConfig as screenshot } from './screenshot'
|
import { moduleConfig as screenshot } from './screenshot'
|
||||||
import { moduleConfig as monitor } from './monitor'
|
import { moduleConfig as monitor } from './monitor'
|
||||||
import { moduleConfig as downloader } from './downloader'
|
import { moduleConfig as downloader } from './downloader'
|
||||||
import { moduleConfig as finder } from './finder'
|
import { moduleConfig as quickpanel } from './quickpanel'
|
||||||
import { moduleConfig as general } from './general'
|
import { moduleConfig as general } from './general'
|
||||||
|
|
||||||
const allModules: ModuleConfig[] = [
|
const allModules: ModuleConfig[] = [
|
||||||
@@ -16,7 +16,7 @@ const allModules: ModuleConfig[] = [
|
|||||||
screenshot,
|
screenshot,
|
||||||
monitor,
|
monitor,
|
||||||
downloader,
|
downloader,
|
||||||
finder,
|
quickpanel,
|
||||||
general
|
general
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,760 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
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 { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion'
|
||||||
|
import { aggregateSearch, loadAppIconsForResults, recordHistoryItem, getMoreHistoryItems, getMoreHistoryCount, setFileIndexReady, type QPItem, type QPSubAction } from './providers'
|
||||||
|
|
||||||
|
// ===== 状态 =====
|
||||||
|
const query = ref('')
|
||||||
|
const inputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
const results = ref<QPItem[]>([])
|
||||||
|
const selectedIndex = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
|
// 子动作展开:展开的 item 索引,null 表示未展开
|
||||||
|
const subActionExpanded = ref<number | null>(null)
|
||||||
|
const subActionIndex = ref(0)
|
||||||
|
let unlistenFns: UnlistenFn[] = []
|
||||||
|
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
// ===== 历史分区(从 results 中分离历史项与其他结果) =====
|
||||||
|
const historyItems = computed(() => results.value.filter(r => r.group === '历史'))
|
||||||
|
const otherItems = computed(() => results.value.filter(r => r.group !== '历史'))
|
||||||
|
// Accordion 中的更多历史项(不参与键盘上下导航,仅鼠标点击)
|
||||||
|
const moreHistoryItems = ref<QPItem[]>([])
|
||||||
|
const moreHistoryCount = ref(0)
|
||||||
|
|
||||||
|
// ===== 历史频率(localStorage 持久化,用于排序加权) =====
|
||||||
|
const HISTORY_KEY = 'thing_quickpanel_history'
|
||||||
|
|
||||||
|
function loadHistory(): Record<string, number> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(HISTORY_KEY)
|
||||||
|
return raw ? JSON.parse(raw) : {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordHistory(id: string) {
|
||||||
|
if (!id) return
|
||||||
|
const history = loadHistory()
|
||||||
|
history[id] = (history[id] || 0) + 1
|
||||||
|
// 只保留最近 100 条
|
||||||
|
const entries = Object.entries(history).sort((a, b) => b[1] - a[1]).slice(0, 100)
|
||||||
|
localStorage.setItem(HISTORY_KEY, JSON.stringify(Object.fromEntries(entries)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对搜索结果应用历史频率加权后重新排序 */
|
||||||
|
function applyHistoryBoost(items: QPItem[]): QPItem[] {
|
||||||
|
const history = loadHistory()
|
||||||
|
return items
|
||||||
|
.map(item => ({
|
||||||
|
...item,
|
||||||
|
score: (item.score ?? 0) + Math.min(history[item.id] || 0, 5) * 0.05,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 搜索 =====
|
||||||
|
async function doSearch() {
|
||||||
|
const q = query.value.trim()
|
||||||
|
if (!q) {
|
||||||
|
// 空查询:显示命令快捷入口 + 系统操作 + 历史(置顶3条)
|
||||||
|
results.value = applyHistoryBoost(await aggregateSearch(''))
|
||||||
|
selectedIndex.value = 0
|
||||||
|
// 加载更多历史(Accordion 折叠区,不参与键盘导航)
|
||||||
|
moreHistoryItems.value = getMoreHistoryItems()
|
||||||
|
moreHistoryCount.value = getMoreHistoryCount()
|
||||||
|
// 后台加载应用图标(含历史中的图标)
|
||||||
|
void loadAppIconsForResults(results.value)
|
||||||
|
void loadAppIconsForResults(moreHistoryItems.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 非空查询:清空历史分区
|
||||||
|
moreHistoryItems.value = []
|
||||||
|
moreHistoryCount.value = 0
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const items = await aggregateSearch(q)
|
||||||
|
results.value = applyHistoryBoost(items)
|
||||||
|
selectedIndex.value = 0
|
||||||
|
// 后台加载应用图标(不阻塞结果显示)
|
||||||
|
void loadAppIconsForResults(results.value)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[quickpanel] 搜索失败:', e)
|
||||||
|
results.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防抖搜索
|
||||||
|
watch(query, () => {
|
||||||
|
if (searchTimer) clearTimeout(searchTimer)
|
||||||
|
collapseSubActions()
|
||||||
|
searchTimer = setTimeout(doSearch, 120)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== 执行与隐藏 =====
|
||||||
|
async function hideWindow() {
|
||||||
|
try {
|
||||||
|
await invoke('quickpanel_hide_popup')
|
||||||
|
} catch {
|
||||||
|
/* 忽略 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeItem(item: QPItem) {
|
||||||
|
recordHistory(item.id)
|
||||||
|
// 记录交互历史(用于历史 Provider 显示)
|
||||||
|
recordHistoryItem(item, query.value)
|
||||||
|
try {
|
||||||
|
await item.action()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[quickpanel] 执行失败:', e)
|
||||||
|
}
|
||||||
|
await hideWindow()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeSubAction(sub: QPSubAction) {
|
||||||
|
try {
|
||||||
|
await sub.action()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[quickpanel] 子动作执行失败:', e)
|
||||||
|
}
|
||||||
|
await hideWindow()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 子动作展开/收起
|
||||||
|
function toggleSubActions(idx: number) {
|
||||||
|
const item = results.value[idx]
|
||||||
|
if (!item?.subActions?.length) return
|
||||||
|
if (subActionExpanded.value === idx) {
|
||||||
|
subActionExpanded.value = null
|
||||||
|
} else {
|
||||||
|
subActionExpanded.value = idx
|
||||||
|
subActionIndex.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseSubActions() {
|
||||||
|
subActionExpanded.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前展开的子动作列表
|
||||||
|
function currentSubActions(): QPSubAction[] {
|
||||||
|
if (subActionExpanded.value === null) return []
|
||||||
|
return results.value[subActionExpanded.value]?.subActions || []
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 键盘导航 =====
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
const expanded = subActionExpanded.value !== null
|
||||||
|
const subs = currentSubActions()
|
||||||
|
|
||||||
|
if (expanded) {
|
||||||
|
// 子动作导航模式
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
subActionIndex.value = Math.min(subActionIndex.value + 1, subs.length - 1)
|
||||||
|
scrollSubActionIntoView()
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
subActionIndex.value = Math.max(subActionIndex.value - 1, 0)
|
||||||
|
scrollSubActionIntoView()
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
const sub = subs[subActionIndex.value]
|
||||||
|
if (sub) executeSubAction(sub)
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
collapseSubActions()
|
||||||
|
} else if (e.key === 'Tab') {
|
||||||
|
e.preventDefault()
|
||||||
|
collapseSubActions()
|
||||||
|
} else if (/^[1-9]$/.test(e.key)) {
|
||||||
|
// 数字键快速执行对应子动作(1-9)
|
||||||
|
e.preventDefault()
|
||||||
|
const idx = parseInt(e.key, 10) - 1
|
||||||
|
const sub = subs[idx]
|
||||||
|
if (sub) executeSubAction(sub)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 结果列表导航模式
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
selectedIndex.value = Math.min(selectedIndex.value + 1, results.value.length - 1)
|
||||||
|
scrollSelectedIntoView()
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
selectedIndex.value = Math.max(selectedIndex.value - 1, 0)
|
||||||
|
scrollSelectedIntoView()
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
const item = results.value[selectedIndex.value]
|
||||||
|
if (item) executeItem(item)
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
hideWindow()
|
||||||
|
} else if (e.key === 'Tab') {
|
||||||
|
// Tab 展开子动作
|
||||||
|
const item = results.value[selectedIndex.value]
|
||||||
|
if (item?.subActions?.length) {
|
||||||
|
e.preventDefault()
|
||||||
|
toggleSubActions(selectedIndex.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollSelectedIntoView() {
|
||||||
|
nextTick(() => {
|
||||||
|
const el = document.querySelector('.qp-item-selected') as HTMLElement | null
|
||||||
|
el?.scrollIntoView({ block: 'nearest' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollSubActionIntoView() {
|
||||||
|
nextTick(() => {
|
||||||
|
const el = document.querySelector('.qp-sub-selected') as HTMLElement | null
|
||||||
|
el?.scrollIntoView({ block: 'nearest' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onItemHover(idx: number) {
|
||||||
|
selectedIndex.value = idx
|
||||||
|
// hover 其他项时收起子动作
|
||||||
|
if (subActionExpanded.value !== null && subActionExpanded.value !== idx) {
|
||||||
|
collapseSubActions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSubActionHover(idx: number) {
|
||||||
|
subActionIndex.value = idx
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 图标映射 =====
|
||||||
|
/** 应用类条目(含历史中的应用)不显示 subtitle(路径),让布局更紧凑 */
|
||||||
|
const isAppLike = (item: QPItem) => !!item.iconPath
|
||||||
|
const groupIcon = (group: string) => {
|
||||||
|
if (group === '命令') return Command
|
||||||
|
if (group === '计算') return Calculator
|
||||||
|
if (group === '网页') return Globe
|
||||||
|
if (group === '系统') return Lock
|
||||||
|
if (group === '自定义') return Terminal
|
||||||
|
if (group === '历史') return History
|
||||||
|
return Search
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasResults = () => results.value.length > 0
|
||||||
|
|
||||||
|
// ===== 主题应用(与主应用同步,独立窗口需自行设置) =====
|
||||||
|
function readMainTheme(): { theme: string; effect: string } {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('thing_app_settings')
|
||||||
|
if (raw) {
|
||||||
|
const s = JSON.parse(raw)
|
||||||
|
return {
|
||||||
|
theme: s.theme ?? 'system',
|
||||||
|
effect: s.effect ?? 'mica',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* 忽略 */
|
||||||
|
}
|
||||||
|
return { theme: 'system', effect: 'mica' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveIsDark(theme: string): boolean {
|
||||||
|
if (theme === 'dark') return true
|
||||||
|
if (theme === 'light') return false
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyTheme() {
|
||||||
|
const root = document.documentElement
|
||||||
|
const { theme, effect } = readMainTheme()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tauriWin = getCurrentWindow()
|
||||||
|
if (theme === 'system') {
|
||||||
|
await tauriWin.setTheme(null)
|
||||||
|
} else {
|
||||||
|
await tauriWin.setTheme(theme as 'dark' | 'light')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* 非 Tauri 环境忽略 */
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDark = resolveIsDark(theme)
|
||||||
|
|
||||||
|
root.classList.remove('dark', 'effect-mica', 'effect-acrylic', 'effect-normal')
|
||||||
|
root.classList.add(`effect-${effect}`)
|
||||||
|
if (isDark) root.classList.add('dark')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tauriWin = getCurrentWindow()
|
||||||
|
await tauriWin.clearEffects()
|
||||||
|
if (effect === 'mica') {
|
||||||
|
await tauriWin.setEffects({
|
||||||
|
effects: [Effect.Mica],
|
||||||
|
state: EffectState.FollowsWindowActiveState,
|
||||||
|
color: isDark ? [30, 41, 59, 0] : [248, 250, 252, 0],
|
||||||
|
})
|
||||||
|
await tauriWin.setBackgroundColor('#00000000')
|
||||||
|
root.style.setProperty('--popup-bg', 'transparent')
|
||||||
|
} else if (effect === 'acrylic') {
|
||||||
|
await tauriWin.setEffects({
|
||||||
|
effects: [Effect.Acrylic],
|
||||||
|
state: EffectState.FollowsWindowActiveState,
|
||||||
|
color: isDark ? [30, 41, 59, 0] : [248, 250, 252, 0],
|
||||||
|
})
|
||||||
|
await tauriWin.setBackgroundColor('#00000000')
|
||||||
|
root.style.setProperty('--popup-bg', 'transparent')
|
||||||
|
} else {
|
||||||
|
await tauriWin.setBackgroundColor(isDark ? '#0f172a' : '#ffffff')
|
||||||
|
root.style.setProperty('--popup-bg', isDark ? '#0f172a' : '#ffffff')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* 非 Tauri 环境忽略 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
const onStorage = (e: StorageEvent) => {
|
||||||
|
if (e.key === 'thing_app_settings') applyTheme()
|
||||||
|
}
|
||||||
|
window.addEventListener('storage', onStorage)
|
||||||
|
unlistenFns.push(() => window.removeEventListener('storage', onStorage))
|
||||||
|
|
||||||
|
// 监听弹窗显示事件:重新同步主题 + 清空输入 + 加载初始结果
|
||||||
|
unlistenFns.push(await listen('quickpanel-show', async () => {
|
||||||
|
await applyTheme()
|
||||||
|
query.value = ''
|
||||||
|
await doSearch()
|
||||||
|
await nextTick()
|
||||||
|
inputRef.value?.focus()
|
||||||
|
}))
|
||||||
|
|
||||||
|
unlistenFns.push(await listen('quickpanel-hide', () => {
|
||||||
|
query.value = ''
|
||||||
|
results.value = []
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 初始加载(空查询显示快捷入口)
|
||||||
|
// 独立窗口需自行检查文件索引状态(主窗口的 setFileIndexReady 不共享到此上下文)
|
||||||
|
try {
|
||||||
|
const stats = await invoke<{ total: number }>('quickpanel_file_index_stats')
|
||||||
|
setFileIndexReady((stats?.total ?? 0) > 0)
|
||||||
|
} catch {
|
||||||
|
/* 索引未初始化,忽略 */
|
||||||
|
}
|
||||||
|
await doSearch()
|
||||||
|
await nextTick()
|
||||||
|
inputRef.value?.focus()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke('quickpanel_show_window')
|
||||||
|
} catch {
|
||||||
|
/* 忽略 */
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (searchTimer) clearTimeout(searchTimer)
|
||||||
|
unlistenFns.forEach((fn) => fn())
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="qp-root flex flex-col h-screen w-screen" @keydown="onKeydown">
|
||||||
|
<!-- 搜索输入 -->
|
||||||
|
<div class="qp-input-wrap">
|
||||||
|
<Search class="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
<input
|
||||||
|
ref="inputRef"
|
||||||
|
v-model="query"
|
||||||
|
class="qp-input"
|
||||||
|
placeholder="搜索命令、应用、文件…"
|
||||||
|
spellcheck="false"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<kbd class="qp-kbd">Esc</kbd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 结果区 -->
|
||||||
|
<div class="qp-results">
|
||||||
|
<div v-if="loading && !hasResults()" class="qp-empty">
|
||||||
|
<Loader2 class="h-6 w-6 animate-spin mb-2" />
|
||||||
|
<p class="text-sm">搜索中…</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!hasResults() && query.trim()" class="qp-empty">
|
||||||
|
<Search class="h-10 w-10 mb-3 opacity-40" />
|
||||||
|
<p class="text-sm">无匹配结果</p>
|
||||||
|
<p class="text-xs mt-1 opacity-60">按 Enter 在搜索引擎中查找</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!hasResults()" class="qp-empty">
|
||||||
|
<Command class="h-10 w-10 mb-3 opacity-40" />
|
||||||
|
<p class="text-sm">输入关键词开始搜索</p>
|
||||||
|
<p class="text-xs mt-1 opacity-60">命令 · 计算 · 系统 · 网页</p>
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<!-- 历史置顶项(可键盘导航) -->
|
||||||
|
<template v-for="(item, idx) in historyItems" :key="item.id">
|
||||||
|
<div
|
||||||
|
class="qp-item"
|
||||||
|
:class="{ 'qp-item-selected': idx === selectedIndex }"
|
||||||
|
@click="executeItem(item)"
|
||||||
|
@mouseenter="onItemHover(idx)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="item.iconUrl"
|
||||||
|
:src="item.iconUrl"
|
||||||
|
class="qp-app-icon shrink-0"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<component
|
||||||
|
v-else
|
||||||
|
:is="groupIcon(item.group)"
|
||||||
|
class="h-4 w-4 text-muted-foreground shrink-0"
|
||||||
|
:class="isAppLike(item) ? '' : 'mt-0.5'"
|
||||||
|
/>
|
||||||
|
<div class="flex-1 min-w-0 flex" :class="isAppLike(item) ? 'items-center' : 'flex-col'">
|
||||||
|
<p class="text-sm truncate">{{ item.title }}</p>
|
||||||
|
<p v-if="!isAppLike(item) && item.subtitle" class="text-xs text-muted-foreground truncate">{{ item.subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="qp-group-badge">{{ item.group }}</span>
|
||||||
|
<CornerDownLeft
|
||||||
|
v-if="idx === selectedIndex"
|
||||||
|
class="h-3.5 w-3.5 text-primary shrink-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 更多历史 Accordion(固定在历史下方,不参与键盘导航) -->
|
||||||
|
<Accordion
|
||||||
|
v-if="moreHistoryCount > 0"
|
||||||
|
type="single"
|
||||||
|
collapsible
|
||||||
|
class="qp-more-history"
|
||||||
|
>
|
||||||
|
<AccordionItem value="more" class="border-0">
|
||||||
|
<AccordionTrigger class="qp-more-trigger">
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<History class="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
更多历史({{ moreHistoryCount }} 条)
|
||||||
|
</span>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent class="qp-more-content">
|
||||||
|
<div
|
||||||
|
v-for="item in moreHistoryItems"
|
||||||
|
:key="item.id"
|
||||||
|
class="qp-item qp-more-item"
|
||||||
|
@click="executeItem(item)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="item.iconUrl"
|
||||||
|
:src="item.iconUrl"
|
||||||
|
class="qp-app-icon shrink-0"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<component
|
||||||
|
v-else
|
||||||
|
:is="groupIcon(item.group)"
|
||||||
|
class="h-4 w-4 text-muted-foreground shrink-0"
|
||||||
|
:class="isAppLike(item) ? '' : 'mt-0.5'"
|
||||||
|
/>
|
||||||
|
<div class="flex-1 min-w-0 flex" :class="isAppLike(item) ? 'items-center' : 'flex-col'">
|
||||||
|
<p class="text-sm truncate">{{ item.title }}</p>
|
||||||
|
<p v-if="!isAppLike(item) && item.subtitle" class="text-xs text-muted-foreground truncate">{{ item.subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="qp-group-badge">{{ item.group }}</span>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<!-- 其他结果(命令/应用/系统等,可键盘导航,索引偏移 historyItems.length) -->
|
||||||
|
<template v-for="(item, idx) in otherItems" :key="item.id">
|
||||||
|
<div
|
||||||
|
class="qp-item"
|
||||||
|
:class="{ 'qp-item-selected': (idx + historyItems.length) === selectedIndex }"
|
||||||
|
@click="executeItem(item)"
|
||||||
|
@mouseenter="onItemHover(idx + historyItems.length)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="item.iconUrl"
|
||||||
|
:src="item.iconUrl"
|
||||||
|
class="qp-app-icon shrink-0"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<component
|
||||||
|
v-else
|
||||||
|
:is="groupIcon(item.group)"
|
||||||
|
class="h-4 w-4 text-muted-foreground shrink-0"
|
||||||
|
:class="isAppLike(item) ? '' : 'mt-0.5'"
|
||||||
|
/>
|
||||||
|
<div class="flex-1 min-w-0 flex" :class="isAppLike(item) ? 'items-center' : 'flex-col'">
|
||||||
|
<p class="text-sm truncate">{{ item.title }}</p>
|
||||||
|
<p v-if="!isAppLike(item) && item.subtitle" class="text-xs text-muted-foreground truncate">{{ item.subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="qp-group-badge">{{ item.group }}</span>
|
||||||
|
<ChevronRight
|
||||||
|
v-if="item.subActions?.length && (idx + historyItems.length) !== selectedIndex"
|
||||||
|
class="h-3.5 w-3.5 text-muted-foreground shrink-0"
|
||||||
|
/>
|
||||||
|
<kbd
|
||||||
|
v-else-if="item.subActions?.length && (idx + historyItems.length) === selectedIndex"
|
||||||
|
class="qp-kbd shrink-0"
|
||||||
|
@click.stop="toggleSubActions(idx + historyItems.length)"
|
||||||
|
>Tab</kbd>
|
||||||
|
<CornerDownLeft
|
||||||
|
v-else-if="(idx + historyItems.length) === selectedIndex"
|
||||||
|
class="h-3.5 w-3.5 text-primary shrink-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- 子动作展开面板 -->
|
||||||
|
<div v-if="subActionExpanded === (idx + historyItems.length) && item.subActions?.length" class="qp-sub-panel">
|
||||||
|
<div
|
||||||
|
v-for="(sub, sIdx) in item.subActions"
|
||||||
|
:key="sub.id"
|
||||||
|
class="qp-sub-item"
|
||||||
|
:class="{ 'qp-sub-selected': sIdx === subActionIndex }"
|
||||||
|
@click="executeSubAction(sub)"
|
||||||
|
@mouseenter="onSubActionHover(sIdx)"
|
||||||
|
>
|
||||||
|
<span class="qp-sub-num">{{ sIdx + 1 }}</span>
|
||||||
|
<span class="flex-1 text-xs">{{ sub.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部提示 -->
|
||||||
|
<div class="qp-footer">
|
||||||
|
<span><kbd>↑</kbd><kbd>↓</kbd> 导航</span>
|
||||||
|
<span v-if="subActionExpanded === null"><kbd>Tab</kbd> 子动作</span>
|
||||||
|
<span v-else><kbd>1-9</kbd> 快捷执行</span>
|
||||||
|
<span><kbd>Enter</kbd> 执行</span>
|
||||||
|
<span><kbd>Esc</kbd> {{ subActionExpanded !== null ? '收起' : '关闭' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.qp-root {
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', 'PingFang SC', sans-serif;
|
||||||
|
background: var(--popup-bg, transparent);
|
||||||
|
color: var(--foreground);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-input-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
font-size: 15px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-input {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 15px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-input::placeholder {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-kbd {
|
||||||
|
background: var(--muted);
|
||||||
|
color: var(--foreground);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-results {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.08s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-app-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
margin-top: 1px;
|
||||||
|
object-fit: contain;
|
||||||
|
/* 命中缓存前占位,避免布局抖动 */
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-item:hover {
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-item-selected {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 更多历史 Accordion */
|
||||||
|
.qp-more-history {
|
||||||
|
margin: 0 6px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-more-trigger {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
min-height: 28px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
/* 覆盖 reka-ui 默认 py-4 */
|
||||||
|
padding-top: 6px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-more-trigger:hover {
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-more-content {
|
||||||
|
/* 覆盖 AccordionContent 默认 pb-4 */
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-more-item {
|
||||||
|
padding: 6px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-item-selected:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-group-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
background: var(--muted);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 子动作面板 */
|
||||||
|
.qp-sub-panel {
|
||||||
|
margin: 2px 0 4px 28px;
|
||||||
|
padding: 4px;
|
||||||
|
background: var(--muted);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-sub-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.08s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-sub-item:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-sub-selected {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-sub-num {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--accent-foreground);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-footer kbd {
|
||||||
|
background: var(--muted);
|
||||||
|
color: var(--foreground);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
margin-right: 2px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-results::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
.qp-results::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--muted-foreground);
|
||||||
|
opacity: 0.3;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.qp-results::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,549 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
import { open } from '@tauri-apps/plugin-dialog'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { Command, Zap, Keyboard, Globe, Monitor, MousePointer2, FolderTree, RefreshCw, Plus, X, Loader2, Terminal, Pencil, Check } from '@lucide/vue'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useModuleTabsStore } from '@/stores/moduleTabsStore'
|
||||||
|
import { setFileIndexReady, invalidateCustomCommandsCache } from './providers'
|
||||||
|
|
||||||
|
interface CustomCommand {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
command: string
|
||||||
|
args: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuickPanelSettings {
|
||||||
|
shortcut: string
|
||||||
|
popupPosition: string
|
||||||
|
searchEngine: string
|
||||||
|
indexDirs: string[]
|
||||||
|
customCommands: CustomCommand[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = reactive<QuickPanelSettings>({
|
||||||
|
shortcut: 'Alt+Space',
|
||||||
|
popupPosition: 'center',
|
||||||
|
searchEngine: 'bing',
|
||||||
|
indexDirs: [],
|
||||||
|
customCommands: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== 文件索引状态 =====
|
||||||
|
interface IndexStats {
|
||||||
|
total: number
|
||||||
|
lastBuiltAt: number
|
||||||
|
lastBuiltDirs: string[]
|
||||||
|
}
|
||||||
|
const indexStats = ref<IndexStats | null>(null)
|
||||||
|
const building = ref(false)
|
||||||
|
|
||||||
|
async function refreshStats() {
|
||||||
|
try {
|
||||||
|
indexStats.value = await invoke<IndexStats>('quickpanel_file_index_stats')
|
||||||
|
// 索引存在(total > 0)即标记为就绪
|
||||||
|
setFileIndexReady((indexStats.value?.total ?? 0) > 0)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[quickpanel] 获取索引状态失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildIndex() {
|
||||||
|
if (building.value) return
|
||||||
|
building.value = true
|
||||||
|
try {
|
||||||
|
const count = await invoke<number>('quickpanel_build_file_index')
|
||||||
|
toast.success(`索引完成,共 ${count} 条`)
|
||||||
|
await refreshStats()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[quickpanel] 索引构建失败:', e)
|
||||||
|
toast.error('索引构建失败')
|
||||||
|
} finally {
|
||||||
|
building.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addDir() {
|
||||||
|
const selected = await open({ directory: true, multiple: false })
|
||||||
|
if (typeof selected === 'string' && !form.indexDirs.includes(selected)) {
|
||||||
|
form.indexDirs.push(selected)
|
||||||
|
await saveSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDir(idx: number) {
|
||||||
|
form.indexDirs.splice(idx, 1)
|
||||||
|
void saveSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(t: number): string {
|
||||||
|
if (!t) return '未构建'
|
||||||
|
const d = new Date(t * 1000)
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const s = await invoke<QuickPanelSettings>('quickpanel_get_settings')
|
||||||
|
Object.assign(form, s)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[quickpanel] 读取设置失败:', e)
|
||||||
|
}
|
||||||
|
await refreshStats()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== 保存 =====
|
||||||
|
async function saveSettings() {
|
||||||
|
try {
|
||||||
|
await invoke('quickpanel_save_settings', { settings: { ...form } })
|
||||||
|
// 同步到 localStorage 供独立窗口读取
|
||||||
|
localStorage.setItem('thing_quickpanel_settings', JSON.stringify({ ...form }))
|
||||||
|
// 清除自定义命令缓存,使下次搜索重新加载
|
||||||
|
invalidateCustomCommandsCache()
|
||||||
|
toast.success('设置已保存')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[quickpanel] 保存设置失败:', e)
|
||||||
|
toast.error('保存设置失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 自定义命令管理 =====
|
||||||
|
const editingCmd = reactive<CustomCommand>({ id: '', title: '', command: '', args: [] })
|
||||||
|
const editingIdx = ref(-1) // -1 表示新增,>=0 表示编辑现有
|
||||||
|
const showEditor = ref(false)
|
||||||
|
|
||||||
|
function addCustomCommand() {
|
||||||
|
editingIdx.value = -1
|
||||||
|
Object.assign(editingCmd, { id: '', title: '', command: '', args: [] })
|
||||||
|
showEditor.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function editCustomCommand(idx: number) {
|
||||||
|
editingIdx.value = idx
|
||||||
|
const cmd = form.customCommands[idx]
|
||||||
|
Object.assign(editingCmd, { id: cmd.id, title: cmd.title, command: cmd.command, args: [...cmd.args] })
|
||||||
|
showEditor.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCustomCommand() {
|
||||||
|
if (!editingCmd.title.trim() || !editingCmd.command.trim()) {
|
||||||
|
toast.warning('标题和命令不能为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (editingIdx.value >= 0) {
|
||||||
|
// 编辑
|
||||||
|
form.customCommands[editingIdx.value] = { ...editingCmd }
|
||||||
|
} else {
|
||||||
|
// 新增
|
||||||
|
form.customCommands.push({
|
||||||
|
...editingCmd,
|
||||||
|
id: `cmd-${Date.now()}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
showEditor.value = false
|
||||||
|
void saveSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCustomCommand(idx: number) {
|
||||||
|
form.customCommands.splice(idx, 1)
|
||||||
|
void saveSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabsStore = useModuleTabsStore()
|
||||||
|
tabsStore.registerSave(saveSettings)
|
||||||
|
|
||||||
|
// ===== 快捷键录入器(与 ClipboardModule 同模式) =====
|
||||||
|
const recording = ref(false)
|
||||||
|
const recorderRef = ref<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
function displayShortcut(s: string): string {
|
||||||
|
if (!s) return ''
|
||||||
|
return s
|
||||||
|
.split('+')
|
||||||
|
.map(p => {
|
||||||
|
const t = p.trim()
|
||||||
|
if (!t) return ''
|
||||||
|
if (t.length === 1) return t.toUpperCase()
|
||||||
|
return t.charAt(0).toUpperCase() + t.slice(1)
|
||||||
|
})
|
||||||
|
.join(' + ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventToShortcut(e: KeyboardEvent): string | null {
|
||||||
|
const mods: string[] = []
|
||||||
|
if (e.ctrlKey) mods.push('ctrl')
|
||||||
|
if (e.altKey) mods.push('alt')
|
||||||
|
if (e.shiftKey) mods.push('shift')
|
||||||
|
if (e.metaKey) mods.push('super')
|
||||||
|
let main = ''
|
||||||
|
const code = e.code || ''
|
||||||
|
if (/^Key[A-Z]$/.test(code)) main = code.slice(3).toLowerCase()
|
||||||
|
else if (/^Digit[0-9]$/.test(code)) main = code.slice(5)
|
||||||
|
else if (/^F([1-9]|1[0-2])$/.test(code)) main = code.toLowerCase()
|
||||||
|
else if (code === 'Space') main = 'space'
|
||||||
|
else if (code === 'PrintScreen') main = 'printscreen'
|
||||||
|
else if (code.startsWith('Numpad')) main = code.slice(6).toLowerCase()
|
||||||
|
else return null
|
||||||
|
const isFunctionKey = /^f([1-9]|1[0-2])$/.test(main) || main === 'printscreen'
|
||||||
|
if (mods.length === 0 && !isFunctionKey) return null
|
||||||
|
return [...mods, main].join('+')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRecordKey(e: KeyboardEvent) {
|
||||||
|
if (!recording.value) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
recording.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (['Control', 'Alt', 'Shift', 'Meta'].includes(e.key)) return
|
||||||
|
const combo = eventToShortcut(e)
|
||||||
|
if (!combo) {
|
||||||
|
toast.warning('不支持的按键组合,请使用字母/数字/功能键 + 修饰键')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recording.value = false
|
||||||
|
form.shortcut = combo
|
||||||
|
void saveSettings()
|
||||||
|
toast.success(`快捷键已更新为 ${displayShortcut(combo)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRecord() {
|
||||||
|
recording.value = true
|
||||||
|
await nextTick()
|
||||||
|
recorderRef.value?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(recording, (on) => {
|
||||||
|
if (on) window.addEventListener('keydown', onRecordKey, true)
|
||||||
|
else window.removeEventListener('keydown', onRecordKey, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function clearShortcut() {
|
||||||
|
form.shortcut = ''
|
||||||
|
await saveSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('keydown', onRecordKey, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== 唤起测试 =====
|
||||||
|
async function testPopup() {
|
||||||
|
try {
|
||||||
|
await invoke('quickpanel_show_popup')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[quickpanel] 唤起失败:', e)
|
||||||
|
toast.error('唤起失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 选项配置 =====
|
||||||
|
const positionOptions = [
|
||||||
|
{ value: 'center', label: '屏幕中央', icon: Monitor },
|
||||||
|
{ value: 'cursor', label: '鼠标位置', icon: MousePointer2 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const engineOptions = [
|
||||||
|
{ value: 'google', label: 'Google' },
|
||||||
|
{ value: 'bing', label: 'Bing' },
|
||||||
|
{ value: 'baidu', label: '百度' },
|
||||||
|
]
|
||||||
|
|
||||||
|
async function changePosition(v: string) {
|
||||||
|
form.popupPosition = v
|
||||||
|
await saveSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeEngine(v: string) {
|
||||||
|
form.searchEngine = v
|
||||||
|
await saveSettings()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-full p-6 overflow-y-auto">
|
||||||
|
<div class="max-w-2xl space-y-4">
|
||||||
|
<!-- 标题与测试 -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<Command class="h-5 w-5 text-primary" />
|
||||||
|
快速面板
|
||||||
|
</h2>
|
||||||
|
<Button size="sm" @click="testPopup">
|
||||||
|
<Zap class="h-4 w-4 mr-1" />
|
||||||
|
测试唤起
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 快捷键 -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-sm flex items-center gap-2">
|
||||||
|
<Keyboard class="h-4 w-4" />
|
||||||
|
唤起快捷键
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-3">
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<div
|
||||||
|
ref="recorderRef"
|
||||||
|
class="hotkey-recorder"
|
||||||
|
:class="{ recording }"
|
||||||
|
tabindex="0"
|
||||||
|
@click="startRecord"
|
||||||
|
>
|
||||||
|
<template v-if="recording">按下快捷键…(Esc 取消)</template>
|
||||||
|
<template v-else-if="form.shortcut">
|
||||||
|
{{ displayShortcut(form.shortcut) }}
|
||||||
|
</template>
|
||||||
|
<template v-else>未设置(点击录入)</template>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
v-if="form.shortcut"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-8 text-muted-foreground"
|
||||||
|
@click="clearShortcut"
|
||||||
|
>清除</Button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
全局快捷键唤起快速面板。需至少一个修饰键 + 字母/数字/功能键。默认 Alt+Space
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 唤起位置 -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-sm">唤起位置</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<Button
|
||||||
|
v-for="opt in positionOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:variant="form.popupPosition === opt.value ? 'default' : 'outline'"
|
||||||
|
size="sm"
|
||||||
|
@click="changePosition(opt.value)"
|
||||||
|
>
|
||||||
|
<component :is="opt.icon" class="h-3.5 w-3.5 mr-1" />
|
||||||
|
{{ opt.label }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground mt-2">
|
||||||
|
屏幕中央:在鼠标所在显示器的工作区中央显示;鼠标位置:在光标附近显示
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 搜索引擎 -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-sm flex items-center gap-2">
|
||||||
|
<Globe class="h-4 w-4" />
|
||||||
|
默认搜索引擎
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<Button
|
||||||
|
v-for="opt in engineOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:variant="form.searchEngine === opt.value ? 'default' : 'outline'"
|
||||||
|
size="sm"
|
||||||
|
@click="changeEngine(opt.value)"
|
||||||
|
>
|
||||||
|
{{ opt.label }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground mt-2">
|
||||||
|
输入无匹配结果时,按 Enter 在默认引擎中搜索
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 文件索引 -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-sm flex items-center gap-2">
|
||||||
|
<FolderTree class="h-4 w-4" />
|
||||||
|
文件索引
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-3">
|
||||||
|
<!-- 索引状态 -->
|
||||||
|
<div class="flex items-center gap-3 text-xs flex-wrap">
|
||||||
|
<Badge variant="outline">{{ indexStats?.total ?? 0 }} 条</Badge>
|
||||||
|
<span class="text-muted-foreground">上次构建:{{ formatTime(indexStats?.lastBuiltAt ?? 0) }}</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="building"
|
||||||
|
class="ml-auto h-7"
|
||||||
|
@click="buildIndex"
|
||||||
|
>
|
||||||
|
<Loader2 v-if="building" class="h-3.5 w-3.5 mr-1 animate-spin" />
|
||||||
|
<RefreshCw v-else class="h-3.5 w-3.5 mr-1" />
|
||||||
|
{{ building ? '构建中…' : '重建索引' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 索引目录列表 -->
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<div
|
||||||
|
v-for="(dir, idx) in form.indexDirs"
|
||||||
|
:key="dir"
|
||||||
|
class="flex items-center gap-2 p-2 rounded-md bg-muted/40"
|
||||||
|
>
|
||||||
|
<FolderTree class="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||||
|
<span class="text-xs font-mono truncate flex-1" :title="dir">{{ dir }}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-6 w-6 shrink-0 hover:text-destructive"
|
||||||
|
@click="removeDir(idx)"
|
||||||
|
>
|
||||||
|
<X class="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div v-if="!form.indexDirs.length" class="text-xs text-muted-foreground py-2">
|
||||||
|
未配置索引目录(默认:桌面、文档、下载)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button size="sm" variant="outline" @click="addDir">
|
||||||
|
<Plus class="h-3.5 w-3.5 mr-1" />
|
||||||
|
添加目录
|
||||||
|
</Button>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
索引指定目录下的文件名,支持拼音/首字母搜索。重建索引后生效。
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 自定义命令 -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-sm flex items-center gap-2">
|
||||||
|
<Terminal class="h-4 w-4" />
|
||||||
|
自定义命令
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-3">
|
||||||
|
<!-- 命令列表 -->
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<div
|
||||||
|
v-for="(cmd, idx) in form.customCommands"
|
||||||
|
:key="cmd.id"
|
||||||
|
class="flex items-center gap-2 p-2 rounded-md bg-muted/40"
|
||||||
|
>
|
||||||
|
<Terminal class="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-xs font-medium truncate">{{ cmd.title }}</p>
|
||||||
|
<p class="text-xs text-muted-foreground font-mono truncate">{{ cmd.command }} {{ cmd.args.join(' ') }}</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" @click="editCustomCommand(idx)">
|
||||||
|
<Pencil class="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0 hover:text-destructive" @click="removeCustomCommand(idx)">
|
||||||
|
<X class="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div v-if="!form.customCommands.length" class="text-xs text-muted-foreground py-2">
|
||||||
|
暂无自定义命令
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button size="sm" variant="outline" @click="addCustomCommand">
|
||||||
|
<Plus class="h-3.5 w-3.5 mr-1" />
|
||||||
|
添加命令
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!-- 编辑面板 -->
|
||||||
|
<div v-if="showEditor" class="space-y-2 p-3 rounded-md border bg-muted/20">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="editingCmd.title"
|
||||||
|
class="flex-1 h-8 px-2 text-sm rounded border bg-background"
|
||||||
|
placeholder="标题(如:打开记事本)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="editingCmd.command"
|
||||||
|
class="flex-1 h-8 px-2 text-sm font-mono rounded border bg-background"
|
||||||
|
placeholder="命令路径(如:notepad.exe)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
:value="editingCmd.args.join(' ')"
|
||||||
|
@input="(e) => editingCmd.args = (e.target as HTMLInputElement).value.split(/\s+/).filter(Boolean)"
|
||||||
|
class="flex-1 h-8 px-2 text-sm font-mono rounded border bg-background"
|
||||||
|
placeholder="参数(空格分隔,如:-newwindow)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button size="sm" @click="saveCustomCommand">
|
||||||
|
<Check class="h-3.5 w-3.5 mr-1" />
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" @click="showEditor = false">取消</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
自定义可执行命令,在面板中按标题搜索即可运行。支持拼音/首字母匹配。
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 功能说明 -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-sm">当前能力</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-2">
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<Badge variant="secondary">命令</Badge>
|
||||||
|
<span class="text-muted-foreground">跳转到已启用模块</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<Badge variant="secondary">自定义</Badge>
|
||||||
|
<span class="text-muted-foreground">用户配置的可执行命令</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<Badge variant="secondary">应用</Badge>
|
||||||
|
<span class="text-muted-foreground">扫描开始菜单,拼音/首字母启动</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<Badge variant="secondary">文件</Badge>
|
||||||
|
<span class="text-muted-foreground">索引指定目录,快速定位文件</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<Badge variant="secondary">剪贴板</Badge>
|
||||||
|
<span class="text-muted-foreground">复用剪贴板历史,快速回填</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<Badge variant="secondary">计算</Badge>
|
||||||
|
<span class="text-muted-foreground">输入算式即算,Enter 复制结果</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<Badge variant="secondary">系统</Badge>
|
||||||
|
<span class="text-muted-foreground">锁屏、退出应用</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<Badge variant="secondary">网页</Badge>
|
||||||
|
<span class="text-muted-foreground">在默认引擎中搜索</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* 快速面板匹配引擎
|
||||||
|
*
|
||||||
|
* 策略:子序列 fuzzy 匹配 + 拼音全拼/首字母多形态。
|
||||||
|
* - 对每个文本生成三种匹配形态:原文、拼音全拼(连写)、拼音首字母
|
||||||
|
* - query 对每种形态做子序列匹配,连续命中 + 首字母命中加权
|
||||||
|
* - 取最高分作为该 item 的得分
|
||||||
|
*
|
||||||
|
* 拼音形态惰性计算并缓存(WeakMap),避免每次输入重算。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { pinyin } from 'pinyin-pro'
|
||||||
|
|
||||||
|
/** 一组待匹配的文本形态(原文 / 全拼或原文 / 首字母 / 多单词首字母) */
|
||||||
|
export type TextForms = readonly [string, string, string, string]
|
||||||
|
|
||||||
|
const formsCache = new WeakMap<object, TextForms>()
|
||||||
|
|
||||||
|
/** 判断字符串是否含 CJK 字符(需转拼音) */
|
||||||
|
function hasCJK(s: string): boolean {
|
||||||
|
return /[\u4e00-\u9fff]/.test(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提取英文字符串中各单词的首字母(如 "Visual Studio Code" → "vsc")。
|
||||||
|
* 单词边界:空格、连字符、下划线、点号;仅对以字母开头的单词取首字母。 */
|
||||||
|
function extractWordInitials(text: string): string {
|
||||||
|
const parts = text.split(/[\s\-_.]+/).filter(Boolean)
|
||||||
|
let initials = ''
|
||||||
|
for (const p of parts) {
|
||||||
|
// 跳过非字母开头的 token(如数字开头、纯符号)
|
||||||
|
if (/^[a-zA-Z]/.test(p)) {
|
||||||
|
initials += p.charAt(0).toLowerCase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return initials
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为文本生成匹配形态:[原文(小写), 拼音全拼(小写连写), 拼音首字母(小写), 多单词首字母(小写)]。
|
||||||
|
* 非中文文本:全拼与首字母回退为原文,多单词首字母仍独立计算(用于 "Visual Studio Code" → "vsc")。
|
||||||
|
* 结果按 host 对象缓存,避免重复计算。
|
||||||
|
*/
|
||||||
|
export function getTextForms(text: string, host: object): TextForms {
|
||||||
|
const cached = formsCache.get(host)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const lower = text.toLowerCase()
|
||||||
|
// 多单词首字母:无论中英文都计算,与拼音形态互补
|
||||||
|
const initials = extractWordInitials(text)
|
||||||
|
|
||||||
|
let forms: TextForms
|
||||||
|
if (!hasCJK(text)) {
|
||||||
|
// 纯英文:全拼与首字母回退为原文,多单词首字母独立
|
||||||
|
forms = [lower, lower, lower, initials]
|
||||||
|
} else {
|
||||||
|
// 拼音全拼数组,toneType:none 去声调
|
||||||
|
const full = pinyin(text, { toneType: 'none', type: 'array' }) as string[]
|
||||||
|
const fullStr = full.join('').toLowerCase()
|
||||||
|
const firstStr = full.map(s => s.charAt(0)).join('').toLowerCase()
|
||||||
|
forms = [lower, fullStr, firstStr, initials]
|
||||||
|
}
|
||||||
|
formsCache.set(host, forms)
|
||||||
|
return forms
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 子序列匹配评分。
|
||||||
|
* - 不匹配返回 -1
|
||||||
|
* - 基础分 = 命中字符数 / target 长度(越紧凑越高)
|
||||||
|
* - 连续命中加权(每段连续命中 +0.15)
|
||||||
|
* - 首字母命中加权(target[i] === query[0] 且 i==0 或前一个字符非字母 +0.1)
|
||||||
|
* - query 完全等于 target 时返回 1.5(精确匹配优先)
|
||||||
|
*/
|
||||||
|
export function fuzzyScore(query: string, target: string): number {
|
||||||
|
if (!query) return 0
|
||||||
|
if (!target) return -1
|
||||||
|
|
||||||
|
const q = query.toLowerCase()
|
||||||
|
const t = target.toLowerCase()
|
||||||
|
|
||||||
|
// 精确匹配
|
||||||
|
if (q === t) return 1.5
|
||||||
|
// 前缀匹配
|
||||||
|
if (t.startsWith(q)) return 1.2
|
||||||
|
// 包含匹配
|
||||||
|
if (t.includes(q)) return 1.0
|
||||||
|
|
||||||
|
// 子序列匹配
|
||||||
|
let qi = 0
|
||||||
|
let prevMatched = false
|
||||||
|
let score = 0
|
||||||
|
let consecutiveBonus = 0
|
||||||
|
|
||||||
|
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
|
||||||
|
if (t[ti] === q[qi]) {
|
||||||
|
// 命中
|
||||||
|
score += 1
|
||||||
|
// 连续命中加权
|
||||||
|
if (prevMatched) {
|
||||||
|
consecutiveBonus += 0.15
|
||||||
|
}
|
||||||
|
// 首字母命中加权(target 开头或前一字符为非字母)
|
||||||
|
if (qi === 0 && (ti === 0 || !/[a-z0-9]/.test(t[ti - 1]))) {
|
||||||
|
score += 0.1
|
||||||
|
}
|
||||||
|
prevMatched = true
|
||||||
|
qi++
|
||||||
|
} else {
|
||||||
|
prevMatched = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未完全匹配
|
||||||
|
if (qi < q.length) return -1
|
||||||
|
|
||||||
|
// 命中密度:命中字符占 target 比例(越短 target 越优先)
|
||||||
|
const density = q.length / t.length
|
||||||
|
// 归一化到 0~1 区间(基础命中分 + 连续加权 + 密度)
|
||||||
|
const finalScore = 0.5 + density * 0.3 + (score - q.length) * 0.05 + consecutiveBonus * 0.1
|
||||||
|
|
||||||
|
return Math.min(finalScore, 0.99)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对一组文本形态取最高匹配分。
|
||||||
|
*/
|
||||||
|
export function bestScore(query: string, forms: TextForms): number {
|
||||||
|
let best = -1
|
||||||
|
for (const form of forms) {
|
||||||
|
const s = fuzzyScore(query, form)
|
||||||
|
if (s > best) best = s
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { ModuleConfig } from '@/types/module'
|
||||||
|
import type { SearchIndexItem } from '@/stores/searchIndex'
|
||||||
|
|
||||||
|
const searchItems: SearchIndexItem[] = [
|
||||||
|
{
|
||||||
|
title: '快速面板',
|
||||||
|
description: '全局快捷键唤起命令面板',
|
||||||
|
keywords: ['快速面板', '快速启动', '搜索', '命令', 'quickpanel', 'launcher', 'spotlight']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const moduleConfig: ModuleConfig = {
|
||||||
|
id: 'quickpanel',
|
||||||
|
name: '快速面板',
|
||||||
|
icon: 'quickpanel',
|
||||||
|
description: '全局快捷键唤起的多源命令面板(命令/应用/文件/计算)',
|
||||||
|
category: 'tool',
|
||||||
|
defaultEnabled: true,
|
||||||
|
loader: () => import('./QuickPanelModule.vue'),
|
||||||
|
searchItems,
|
||||||
|
lifecycle: {
|
||||||
|
// 模块启用:读取设置并注册全局快捷键
|
||||||
|
onEnable: async () => {
|
||||||
|
const { invoke } = await import('@tauri-apps/api/core')
|
||||||
|
try {
|
||||||
|
const settings = await invoke<{ shortcut: string }>('quickpanel_get_settings')
|
||||||
|
if (settings.shortcut) {
|
||||||
|
await invoke('quickpanel_register_shortcut', { shortcut: settings.shortcut })
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[quickpanel] onEnable 注册快捷键失败:', e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// 模块禁用:注销全局快捷键
|
||||||
|
onDisable: async () => {
|
||||||
|
const { invoke } = await import('@tauri-apps/api/core')
|
||||||
|
try {
|
||||||
|
await invoke('quickpanel_unregister_shortcut')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[quickpanel] onDisable 注销快捷键失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
order: 60
|
||||||
|
}
|
||||||
@@ -0,0 +1,889 @@
|
|||||||
|
/**
|
||||||
|
* 快速面板 Provider:多源搜索结果聚合。
|
||||||
|
*
|
||||||
|
* 每个 Provider 实现统一 search(query) 接口,返回带 group 的 QPItem 列表。
|
||||||
|
* 引擎对结果统一打分排序,action 执行后由调用方隐藏窗口。
|
||||||
|
*
|
||||||
|
* 独立窗口约束:不加载主应用 store。
|
||||||
|
* - command Provider 从 localStorage 读取主应用写入的命令缓存,
|
||||||
|
* 执行时 emit `quickpanel-execute-command` 事件通知主窗口切换模块。
|
||||||
|
* - system/web/calc Provider 纯前端 + Rust invoke。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
import { emit } from '@tauri-apps/api/event'
|
||||||
|
import { getTextForms, bestScore, type TextForms } from './engine'
|
||||||
|
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||||
|
|
||||||
|
// ===== 结果项与 Provider 接口 =====
|
||||||
|
|
||||||
|
/** 子动作(项的右键/展开菜单) */
|
||||||
|
export interface QPSubAction {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
action: () => void | Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QPItem {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
group: string
|
||||||
|
score?: number
|
||||||
|
/** 应用图标 data URL('' = 加载中,undefined = 无图标项) */
|
||||||
|
iconUrl?: string
|
||||||
|
/** 应用路径(仅 app 项设置,用于按需获取图标) */
|
||||||
|
iconPath?: string
|
||||||
|
/** 执行动作(调用方在执行后负责隐藏窗口) */
|
||||||
|
action: () => void | Promise<void>
|
||||||
|
/** 子动作菜单(可选)。执行子动作后同样隐藏窗口 */
|
||||||
|
subActions?: QPSubAction[]
|
||||||
|
/** 用于历史记录的查询文本(仅历史项设置,点击历史时用此重新搜索恢复 action) */
|
||||||
|
historyQuery?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QPProvider {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
priority: number
|
||||||
|
/** 返回当前 query 的候选结果(引擎尚未打分,score 可留空) */
|
||||||
|
search(query: string): QPItem[] | Promise<QPItem[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 工具:为 item 构建匹配形态(用于引擎打分) =====
|
||||||
|
|
||||||
|
/** 由 title + keywords 组合出待匹配文本形态(host 对象用于缓存) */
|
||||||
|
function buildItemForms(title: string, keywords: string[] = []): TextForms {
|
||||||
|
const host = { title, keywords }
|
||||||
|
const combined = [title, ...keywords].join(' ')
|
||||||
|
return getTextForms(combined, host)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== command Provider:复用主应用模块搜索项 =====
|
||||||
|
|
||||||
|
const COMMANDS_KEY = 'thing_quickpanel_commands'
|
||||||
|
|
||||||
|
interface CachedCommand {
|
||||||
|
moduleId: string
|
||||||
|
moduleName: string
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
keywords: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCommands(): CachedCommand[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(COMMANDS_KEY)
|
||||||
|
if (!raw) return []
|
||||||
|
return JSON.parse(raw) as CachedCommand[]
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CommandProvider implements QPProvider {
|
||||||
|
id = 'command'
|
||||||
|
label = '命令'
|
||||||
|
priority = 100
|
||||||
|
|
||||||
|
search(query: string): QPItem[] {
|
||||||
|
const commands = loadCommands()
|
||||||
|
if (!query.trim() || !commands.length) {
|
||||||
|
// 无输入时返回前几条命令作为快捷入口
|
||||||
|
if (!query.trim()) {
|
||||||
|
return commands.slice(0, 6).map((c, i) => this.toItem(c, i))
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: Array<{ item: QPItem; score: number }> = []
|
||||||
|
commands.forEach((c, idx) => {
|
||||||
|
const forms = buildItemForms(c.title, c.keywords)
|
||||||
|
const score = bestScore(query, forms)
|
||||||
|
if (score >= 0) {
|
||||||
|
const item = this.toItem(c, idx)
|
||||||
|
results.push({ item, score })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
results.sort((a, b) => b.score - a.score)
|
||||||
|
return results.map(r => ({ ...r.item, score: r.score }))
|
||||||
|
}
|
||||||
|
|
||||||
|
private toItem(c: CachedCommand, idx: number): QPItem {
|
||||||
|
return {
|
||||||
|
id: `cmd-${c.moduleId}-${idx}`,
|
||||||
|
title: c.title,
|
||||||
|
subtitle: c.description || c.moduleName,
|
||||||
|
group: '命令',
|
||||||
|
action: async () => {
|
||||||
|
// 通知主窗口切换到对应模块
|
||||||
|
await emit('quickpanel-execute-command', { moduleId: c.moduleId })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== calc Provider:输入即算 =====
|
||||||
|
|
||||||
|
const CALC_RE = /^[\d\s+\-*/().%]+$/
|
||||||
|
|
||||||
|
class CalcProvider implements QPProvider {
|
||||||
|
id = 'calc'
|
||||||
|
label = '计算'
|
||||||
|
priority = 90
|
||||||
|
|
||||||
|
search(query: string): QPItem[] {
|
||||||
|
const trimmed = query.trim()
|
||||||
|
if (!trimmed) return []
|
||||||
|
// 必须至少包含一个运算符和一个数字
|
||||||
|
if (!CALC_RE.test(trimmed)) return []
|
||||||
|
if (!/[\d]/.test(trimmed) || !/[+\-*/%]/.test(trimmed)) return []
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 限制字符已由正则保证,用 Function 计算避免 eval 作用域污染
|
||||||
|
// eslint-disable-next-line no-new-func
|
||||||
|
const result = Function(`"use strict"; return (${trimmed})`)()
|
||||||
|
if (typeof result !== 'number' || !isFinite(result)) return []
|
||||||
|
const display = String(result)
|
||||||
|
return [{
|
||||||
|
id: 'calc-result',
|
||||||
|
title: display,
|
||||||
|
subtitle: `= ${trimmed}`,
|
||||||
|
group: '计算',
|
||||||
|
score: 0.95,
|
||||||
|
action: async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(display)
|
||||||
|
} catch {
|
||||||
|
/* 忽略剪贴板失败 */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}]
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== web Provider:默认搜索建议 =====
|
||||||
|
|
||||||
|
type SearchEngine = 'google' | 'bing' | 'baidu'
|
||||||
|
const ENGINE_URL: Record<SearchEngine, string> = {
|
||||||
|
google: 'https://www.google.com/search?q=',
|
||||||
|
bing: 'https://www.bing.com/search?q=',
|
||||||
|
baidu: 'https://www.baidu.com/s?wd=',
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSearchEngine(): SearchEngine {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('thing_quickpanel_settings')
|
||||||
|
if (raw) {
|
||||||
|
const s = JSON.parse(raw)
|
||||||
|
if (s.searchEngine && ENGINE_URL[s.searchEngine as SearchEngine]) {
|
||||||
|
return s.searchEngine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* 忽略 */
|
||||||
|
}
|
||||||
|
return 'bing'
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebProvider implements QPProvider {
|
||||||
|
id = 'web'
|
||||||
|
label = '网页'
|
||||||
|
priority = 50
|
||||||
|
|
||||||
|
search(query: string): QPItem[] {
|
||||||
|
const trimmed = query.trim()
|
||||||
|
if (!trimmed) return []
|
||||||
|
const engine = getSearchEngine()
|
||||||
|
return [{
|
||||||
|
id: 'web-search',
|
||||||
|
title: `搜索「${trimmed}」`,
|
||||||
|
subtitle: `在 ${engine} 中打开`,
|
||||||
|
group: '网页',
|
||||||
|
score: 0.3,
|
||||||
|
action: async () => {
|
||||||
|
try {
|
||||||
|
await openUrl(ENGINE_URL[engine] + encodeURIComponent(trimmed))
|
||||||
|
} catch {
|
||||||
|
/* 忽略 */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== system Provider:系统操作 =====
|
||||||
|
|
||||||
|
interface SystemCommandDef {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
subtitle: string
|
||||||
|
/** 额外关键词(英文命令名、中文别名等,用于匹配) */
|
||||||
|
keywords: string[]
|
||||||
|
command: string
|
||||||
|
args: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 内置系统命令。title 为中文主名,keywords 补充英文/别名,
|
||||||
|
* 拼音全拼与首字母由引擎从 title 的 CJK 部分自动推导。 */
|
||||||
|
const SYSTEM_COMMANDS: SystemCommandDef[] = [
|
||||||
|
{
|
||||||
|
id: 'sys-regedit',
|
||||||
|
title: '注册表编辑器',
|
||||||
|
subtitle: 'regedit',
|
||||||
|
keywords: ['regedit', '注册表', 'registry'],
|
||||||
|
command: 'regedit',
|
||||||
|
args: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sys-cmd',
|
||||||
|
title: '命令提示符',
|
||||||
|
subtitle: 'cmd',
|
||||||
|
keywords: ['cmd', '命令行', '终端', 'command'],
|
||||||
|
command: 'cmd',
|
||||||
|
args: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sys-powershell',
|
||||||
|
title: 'PowerShell',
|
||||||
|
subtitle: 'powershell',
|
||||||
|
keywords: ['powershell', 'pwsh'],
|
||||||
|
command: 'powershell',
|
||||||
|
args: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sys-taskmgr',
|
||||||
|
title: '任务管理器',
|
||||||
|
subtitle: 'taskmgr',
|
||||||
|
keywords: ['taskmgr', '任务管理', '进程'],
|
||||||
|
command: 'taskmgr',
|
||||||
|
args: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sys-explorer',
|
||||||
|
title: '资源管理器',
|
||||||
|
subtitle: 'explorer',
|
||||||
|
keywords: ['explorer', '文件管理器', '资源管理'],
|
||||||
|
command: 'explorer',
|
||||||
|
args: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sys-control',
|
||||||
|
title: '控制面板',
|
||||||
|
subtitle: 'control',
|
||||||
|
keywords: ['control', '控制面板', '设置'],
|
||||||
|
command: 'control',
|
||||||
|
args: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sys-shutdown',
|
||||||
|
title: '关机',
|
||||||
|
subtitle: 'shutdown /s /t 0',
|
||||||
|
keywords: ['shutdown', '关闭计算机', '关闭电脑', 'guanji'],
|
||||||
|
command: 'shutdown',
|
||||||
|
args: ['/s', '/t', '0'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sys-restart',
|
||||||
|
title: '重启',
|
||||||
|
subtitle: 'shutdown /r /t 0',
|
||||||
|
keywords: ['restart', 'reboot', '重新启动', '重启电脑', 'chongqi'],
|
||||||
|
command: 'shutdown',
|
||||||
|
args: ['/r', '/t', '0'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sys-shutdown-cancel',
|
||||||
|
title: '取消关机/重启',
|
||||||
|
subtitle: 'shutdown /a',
|
||||||
|
keywords: ['cancel', '取消', 'quxiao', 'abort'],
|
||||||
|
command: 'shutdown',
|
||||||
|
args: ['/a'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sys-hibernate',
|
||||||
|
title: '休眠',
|
||||||
|
subtitle: 'shutdown /h',
|
||||||
|
keywords: ['hibernate', '睡眠', 'xiu', 'mian'],
|
||||||
|
command: 'shutdown',
|
||||||
|
args: ['/h'],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
class SystemProvider implements QPProvider {
|
||||||
|
id = 'system'
|
||||||
|
label = '系统'
|
||||||
|
priority = 40
|
||||||
|
|
||||||
|
private buildItems(): QPItem[] {
|
||||||
|
const items: QPItem[] = SYSTEM_COMMANDS.map(def => ({
|
||||||
|
id: def.id,
|
||||||
|
title: def.title,
|
||||||
|
subtitle: def.subtitle,
|
||||||
|
group: '系统',
|
||||||
|
action: async () => {
|
||||||
|
try {
|
||||||
|
await invoke('quickpanel_run_system_command', {
|
||||||
|
command: def.command,
|
||||||
|
args: def.args,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[quickpanel] 系统命令失败:', e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
// 锁屏 + 退出 应用本身
|
||||||
|
items.push(
|
||||||
|
{
|
||||||
|
id: 'sys-lock',
|
||||||
|
title: '锁定屏幕',
|
||||||
|
subtitle: '立即锁定计算机',
|
||||||
|
group: '系统',
|
||||||
|
action: async () => {
|
||||||
|
try {
|
||||||
|
await invoke('quickpanel_lock_screen')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[quickpanel] 锁屏失败:', e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sys-quit',
|
||||||
|
title: '退出 Thing',
|
||||||
|
subtitle: '关闭应用程序',
|
||||||
|
group: '系统',
|
||||||
|
action: async () => {
|
||||||
|
try {
|
||||||
|
await invoke('quit_app')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[quickpanel] 退出失败:', e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 为带 keywords 的 item 构建匹配形态(title + keywords 合并) */
|
||||||
|
private itemForms(item: QPItem): TextForms {
|
||||||
|
const def = SYSTEM_COMMANDS.find(d => d.id === item.id)
|
||||||
|
return buildItemForms(item.title, def?.keywords ?? [])
|
||||||
|
}
|
||||||
|
|
||||||
|
search(query: string): QPItem[] {
|
||||||
|
const items = this.buildItems()
|
||||||
|
|
||||||
|
if (!query.trim()) return items
|
||||||
|
const scored: Array<{ item: QPItem; score: number }> = []
|
||||||
|
for (const item of items) {
|
||||||
|
const forms = this.itemForms(item)
|
||||||
|
const score = bestScore(query, forms)
|
||||||
|
if (score >= 0) scored.push({ item, score })
|
||||||
|
}
|
||||||
|
scored.sort((a, b) => b.score - a.score)
|
||||||
|
return scored.map(s => ({ ...s.item, score: s.score }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== app Provider:扫描开始菜单应用 =====
|
||||||
|
|
||||||
|
interface AppRecord {
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let appCache: AppRecord[] | null = null
|
||||||
|
let appCacheTime = 0
|
||||||
|
const APP_CACHE_TTL = 60_000 // 1 分钟缓存
|
||||||
|
|
||||||
|
async function loadApps(): Promise<AppRecord[]> {
|
||||||
|
if (appCache && Date.now() - appCacheTime < APP_CACHE_TTL) {
|
||||||
|
return appCache
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const apps = await invoke<AppRecord[]>('quickpanel_scan_apps')
|
||||||
|
appCache = apps
|
||||||
|
appCacheTime = Date.now()
|
||||||
|
return apps
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[quickpanel] 扫描应用失败:', e)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppProvider implements QPProvider {
|
||||||
|
id = 'app'
|
||||||
|
label = '应用'
|
||||||
|
priority = 95
|
||||||
|
|
||||||
|
async search(query: string): Promise<QPItem[]> {
|
||||||
|
const apps = await loadApps()
|
||||||
|
if (!query.trim()) {
|
||||||
|
// 空查询:不显示应用(避免列表过长),由命令入口承担
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const results: Array<{ item: QPItem; score: number }> = []
|
||||||
|
let idx = 0
|
||||||
|
for (const app of apps) {
|
||||||
|
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}`,
|
||||||
|
title: app.name,
|
||||||
|
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 {
|
||||||
|
/* 忽略 */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
score,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
results.sort((a, b) => b.score - a.score)
|
||||||
|
return results.slice(0, 15).map(r => ({ ...r.item, score: r.score }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 应用图标按需加载 =====
|
||||||
|
// 前端缓存(path -> dataUrl)。Rust 侧另有内存 + 磁盘缓存,此处仅避免重复 IPC。
|
||||||
|
|
||||||
|
const appIconCache = new Map<string, string>() // path -> dataUrl('' = 无图标)
|
||||||
|
|
||||||
|
/** 为搜索结果中带 iconPath 的项(应用、历史中的应用)按需加载图标(data URL),
|
||||||
|
* 并写入 item.iconUrl 触发响应式更新。
|
||||||
|
* 命中前端缓存时同步返回;否则异步调用 Rust 命令(命中 Rust 缓存则零开销)。 */
|
||||||
|
export async function loadAppIconsForResults(items: QPItem[]): Promise<void> {
|
||||||
|
const toLoad: QPItem[] = []
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item.iconPath) continue
|
||||||
|
if (item.iconUrl !== undefined) continue // 已设置(含加载中)
|
||||||
|
const cached = appIconCache.get(item.iconPath)
|
||||||
|
if (cached !== undefined) {
|
||||||
|
item.iconUrl = cached
|
||||||
|
} else {
|
||||||
|
item.iconUrl = '' // 标记加载中,避免重复请求
|
||||||
|
toLoad.push(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!toLoad.length) return
|
||||||
|
await Promise.all(
|
||||||
|
toLoad.map(async item => {
|
||||||
|
const path = item.iconPath!
|
||||||
|
try {
|
||||||
|
const url = await invoke<string | null>('quickpanel_get_app_icon', { path })
|
||||||
|
const u = url ?? ''
|
||||||
|
appIconCache.set(path, u)
|
||||||
|
item.iconUrl = u
|
||||||
|
} catch {
|
||||||
|
appIconCache.set(path, '')
|
||||||
|
item.iconUrl = ''
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清空前端图标缓存(Rust 端清理命令 quickpanel_clear_app_icon_cache 调用后可一并清空) */
|
||||||
|
export function invalidateAppIconCache() {
|
||||||
|
appIconCache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== file Provider:文件索引搜索 =====
|
||||||
|
|
||||||
|
interface FileRecord {
|
||||||
|
path: string
|
||||||
|
name: string
|
||||||
|
ext: string
|
||||||
|
size: number
|
||||||
|
isDir: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileIndexReady = false
|
||||||
|
|
||||||
|
class FileProvider implements QPProvider {
|
||||||
|
id = 'file'
|
||||||
|
label = '文件'
|
||||||
|
priority = 85
|
||||||
|
|
||||||
|
async search(query: string): Promise<QPItem[]> {
|
||||||
|
if (!query.trim() || query.trim().length < 2) return []
|
||||||
|
if (!fileIndexReady) return []
|
||||||
|
try {
|
||||||
|
const files = await invoke<FileRecord[]>('quickpanel_search_files', {
|
||||||
|
query: query.trim(),
|
||||||
|
limit: 20,
|
||||||
|
})
|
||||||
|
return files.map((f, idx) => {
|
||||||
|
const openFile = async () => {
|
||||||
|
try {
|
||||||
|
// 用系统默认程序打开;无关联应用时 Rust 端会 fallback 到「打开方式」对话框
|
||||||
|
await invoke('quickpanel_open_file', { path: f.path })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[quickpanel] 打开文件失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: `file-${idx}`,
|
||||||
|
title: f.name,
|
||||||
|
subtitle: f.path,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'copy-path',
|
||||||
|
label: '复制路径',
|
||||||
|
action: async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(f.path)
|
||||||
|
} catch {
|
||||||
|
/* 忽略 */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'delete',
|
||||||
|
label: '删除',
|
||||||
|
action: async () => {
|
||||||
|
try {
|
||||||
|
// 移到回收站:explorer.exe 不直接支持,用 PowerShell 或直接删除
|
||||||
|
// 这里用 Rust 命令删除(简化实现,实际移到回收站需 SHFileOperation)
|
||||||
|
await invoke('quickpanel_delete_file', { path: f.path })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[quickpanel] 删除失败:', e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[quickpanel] 文件搜索失败:', e)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 由设置页在索引构建完成后调用,启用 file Provider */
|
||||||
|
export function setFileIndexReady(ready: boolean) {
|
||||||
|
fileIndexReady = ready
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== clipboard Provider:复用剪贴板历史 =====
|
||||||
|
|
||||||
|
interface ClipboardSearchItem {
|
||||||
|
id: number
|
||||||
|
kind: string
|
||||||
|
preview: string
|
||||||
|
createdAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
class ClipboardProvider implements QPProvider {
|
||||||
|
id = 'clipboard'
|
||||||
|
label = '剪贴板'
|
||||||
|
priority = 70
|
||||||
|
|
||||||
|
async search(query: string): Promise<QPItem[]> {
|
||||||
|
if (!query.trim() || query.trim().length < 2) return []
|
||||||
|
try {
|
||||||
|
const items = await invoke<ClipboardSearchItem[]>('clipboard_search', {
|
||||||
|
query: query.trim(),
|
||||||
|
limit: 8,
|
||||||
|
offset: 0,
|
||||||
|
})
|
||||||
|
return items.map((c) => ({
|
||||||
|
id: `clip-${c.id}`,
|
||||||
|
title: c.preview.slice(0, 80),
|
||||||
|
subtitle: `${c.kind === 'text' ? '文本' : c.kind === 'image' ? '图片' : '文件'}`,
|
||||||
|
group: '剪贴板',
|
||||||
|
score: 0.5,
|
||||||
|
action: async () => {
|
||||||
|
try {
|
||||||
|
await invoke('clipboard_copy_back', { id: c.id })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[quickpanel] 复制失败:', e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
} catch {
|
||||||
|
// 剪贴板模块可能未启用,静默忽略
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== customCommand Provider:用户自定义命令 =====
|
||||||
|
|
||||||
|
interface CustomCommandConfig {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
command: string
|
||||||
|
args: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
let customCommandsCache: CustomCommandConfig[] | null = null
|
||||||
|
|
||||||
|
async function loadCustomCommands(): Promise<CustomCommandConfig[]> {
|
||||||
|
if (customCommandsCache) return customCommandsCache
|
||||||
|
try {
|
||||||
|
const s = await invoke<{ customCommands: CustomCommandConfig[] }>('quickpanel_get_settings')
|
||||||
|
customCommandsCache = s.customCommands || []
|
||||||
|
return customCommandsCache
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置页保存后调用,清除缓存使下次搜索重新加载 */
|
||||||
|
export function invalidateCustomCommandsCache() {
|
||||||
|
customCommandsCache = null
|
||||||
|
}
|
||||||
|
|
||||||
|
class CustomCommandProvider implements QPProvider {
|
||||||
|
id = 'custom'
|
||||||
|
label = '自定义'
|
||||||
|
priority = 92
|
||||||
|
|
||||||
|
async search(query: string): Promise<QPItem[]> {
|
||||||
|
const commands = await loadCustomCommands()
|
||||||
|
if (!query.trim()) return []
|
||||||
|
const results: Array<{ item: QPItem; score: number }> = []
|
||||||
|
for (const cmd of commands) {
|
||||||
|
const forms = buildItemForms(cmd.title)
|
||||||
|
const score = bestScore(query, forms)
|
||||||
|
if (score >= 0) {
|
||||||
|
results.push({
|
||||||
|
item: {
|
||||||
|
id: `custom-${cmd.id}`,
|
||||||
|
title: cmd.title,
|
||||||
|
subtitle: cmd.command,
|
||||||
|
group: '自定义',
|
||||||
|
action: async () => {
|
||||||
|
try {
|
||||||
|
await invoke('quickpanel_run_custom_command', {
|
||||||
|
command: cmd.command,
|
||||||
|
args: cmd.args,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[quickpanel] 自定义命令执行失败:', e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
score,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results.sort((a, b) => b.score - a.score)
|
||||||
|
return results.map(r => ({ ...r.item, score: r.score }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== history Provider:最近交互记录 =====
|
||||||
|
|
||||||
|
interface HistoryEntry {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
group: string
|
||||||
|
iconPath?: string
|
||||||
|
/** 记录时的查询文本,用于点击历史项时重新搜索恢复 action */
|
||||||
|
query: string
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const HISTORY_ITEMS_KEY = 'thing_quickpanel_history_items'
|
||||||
|
const HISTORY_MAX = 50
|
||||||
|
|
||||||
|
/** 空查询时默认展示的历史条数(置顶部分) */
|
||||||
|
export const HISTORY_PREVIEW_COUNT = 3
|
||||||
|
|
||||||
|
function loadHistoryEntries(): HistoryEntry[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(HISTORY_ITEMS_KEY)
|
||||||
|
if (!raw) return []
|
||||||
|
return JSON.parse(raw) as HistoryEntry[]
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveHistoryEntries(entries: HistoryEntry[]) {
|
||||||
|
localStorage.setItem(HISTORY_ITEMS_KEY, JSON.stringify(entries.slice(0, HISTORY_MAX)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将一条历史记录转换为可执行的 QPItem */
|
||||||
|
function buildHistoryItem(e: HistoryEntry): QPItem {
|
||||||
|
return {
|
||||||
|
id: `history-${e.id}`,
|
||||||
|
title: e.title,
|
||||||
|
subtitle: e.subtitle,
|
||||||
|
group: '历史',
|
||||||
|
iconPath: e.iconPath,
|
||||||
|
historyQuery: e.query,
|
||||||
|
action: async () => {
|
||||||
|
// 重新搜索恢复 action 并执行
|
||||||
|
try {
|
||||||
|
const results = await aggregateSearch(e.query)
|
||||||
|
// 按 id 精确匹配原 item
|
||||||
|
const target = results.find(r => r.id === e.id) ?? results.find(r => r.title === e.title)
|
||||||
|
if (target) {
|
||||||
|
await target.action()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[quickpanel] 历史项执行失败:', err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 记录一次交互。在 QuickPanel.vue 执行 item 时调用。
|
||||||
|
* query 为执行时的搜索文本(用于后续重建 action)。 */
|
||||||
|
export function recordHistoryItem(item: QPItem, query: string) {
|
||||||
|
if (!item.id || item.group === '历史') return // 历史项自身不重复记录
|
||||||
|
const entries = loadHistoryEntries()
|
||||||
|
// 去重:同 id 移除旧的,插到头部
|
||||||
|
const filtered = entries.filter(e => e.id !== item.id)
|
||||||
|
filtered.unshift({
|
||||||
|
id: item.id,
|
||||||
|
title: item.title,
|
||||||
|
subtitle: item.subtitle,
|
||||||
|
group: item.group,
|
||||||
|
iconPath: item.iconPath,
|
||||||
|
query: query || item.title,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})
|
||||||
|
saveHistoryEntries(filtered.slice(0, HISTORY_MAX))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清空历史记录 */
|
||||||
|
export function clearHistory() {
|
||||||
|
localStorage.removeItem(HISTORY_ITEMS_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取置顶的历史项(最近 N 条),用于空查询时在结果列表顶部显示 */
|
||||||
|
export function getTopHistoryItems(): QPItem[] {
|
||||||
|
const entries = loadHistoryEntries()
|
||||||
|
return entries.slice(0, HISTORY_PREVIEW_COUNT).map(buildHistoryItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取置顶历史之后的剩余历史项,用于 Accordion 折叠显示 */
|
||||||
|
export function getMoreHistoryItems(): QPItem[] {
|
||||||
|
const entries = loadHistoryEntries()
|
||||||
|
return entries.slice(HISTORY_PREVIEW_COUNT).map(buildHistoryItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取剩余历史数量(用于 Accordion 标题显示) */
|
||||||
|
export function getMoreHistoryCount(): number {
|
||||||
|
const entries = loadHistoryEntries()
|
||||||
|
return Math.max(0, entries.length - HISTORY_PREVIEW_COUNT)
|
||||||
|
}
|
||||||
|
|
||||||
|
class HistoryProvider implements QPProvider {
|
||||||
|
id = 'history'
|
||||||
|
label = '历史'
|
||||||
|
priority = 99 // 最高优先级,空查询时显示在最前
|
||||||
|
|
||||||
|
async search(query: string): Promise<QPItem[]> {
|
||||||
|
if (query.trim()) return [] // 历史只在空查询时显示
|
||||||
|
// 只返回置顶3条,剩余由 Accordion 承载
|
||||||
|
return getTopHistoryItems()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Provider 注册 =====
|
||||||
|
|
||||||
|
let providers: QPProvider[] | null = null
|
||||||
|
|
||||||
|
export function getProviders(): QPProvider[] {
|
||||||
|
if (!providers) {
|
||||||
|
providers = [
|
||||||
|
new HistoryProvider(),
|
||||||
|
new CommandProvider(),
|
||||||
|
new CustomCommandProvider(),
|
||||||
|
new AppProvider(),
|
||||||
|
new FileProvider(),
|
||||||
|
new ClipboardProvider(),
|
||||||
|
new CalcProvider(),
|
||||||
|
new SystemProvider(),
|
||||||
|
new WebProvider(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return providers
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 聚合搜索:并行调用各 Provider,合并结果,按 score 降序排序。
|
||||||
|
* 空查询时返回 command Provider 的快捷入口 + system Provider 的固定项。
|
||||||
|
*/
|
||||||
|
export async function aggregateSearch(query: string): Promise<QPItem[]> {
|
||||||
|
const all = getProviders()
|
||||||
|
const results = await Promise.all(all.map(p => Promise.resolve(p.search(query))))
|
||||||
|
const merged: QPItem[] = []
|
||||||
|
results.forEach((items, idx) => {
|
||||||
|
items.forEach(item => {
|
||||||
|
// 未打分的项赋予基础分(按 provider 优先级递减)
|
||||||
|
if (item.score === undefined) {
|
||||||
|
item.score = (10 - idx) * 0.01
|
||||||
|
}
|
||||||
|
merged.push(item)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
merged.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
|
||||||
|
return merged
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user