快速面板模块
This commit is contained in:
@@ -65,10 +65,11 @@ use clipboard::{
|
|||||||
use quickpanel::{
|
use quickpanel::{
|
||||||
quickpanel_build_file_index, quickpanel_clear_app_icon_cache, quickpanel_delete_file,
|
quickpanel_build_file_index, quickpanel_clear_app_icon_cache, quickpanel_delete_file,
|
||||||
quickpanel_file_index_stats, quickpanel_get_app_icon, quickpanel_get_settings,
|
quickpanel_file_index_stats, quickpanel_get_app_icon, quickpanel_get_settings,
|
||||||
quickpanel_hide_popup, quickpanel_init_file_index, quickpanel_lock_screen,
|
quickpanel_get_special_locations, quickpanel_hide_popup, quickpanel_init_file_index,
|
||||||
quickpanel_open_file, quickpanel_register_shortcut, quickpanel_reveal_in_explorer,
|
quickpanel_lock_screen, quickpanel_open_file, quickpanel_open_special,
|
||||||
quickpanel_run_custom_command, quickpanel_run_system_command, quickpanel_save_settings,
|
quickpanel_register_shortcut, quickpanel_reveal_in_explorer, quickpanel_run_custom_command,
|
||||||
quickpanel_scan_apps, quickpanel_search_files, quickpanel_show_popup, quickpanel_show_window,
|
quickpanel_run_system_command, quickpanel_save_settings, quickpanel_scan_apps,
|
||||||
|
quickpanel_search_files, quickpanel_show_popup, quickpanel_show_window,
|
||||||
quickpanel_unregister_shortcut,
|
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};
|
||||||
@@ -225,6 +226,8 @@ pub fn run() {
|
|||||||
quickpanel_delete_file,
|
quickpanel_delete_file,
|
||||||
quickpanel_run_custom_command,
|
quickpanel_run_custom_command,
|
||||||
quickpanel_run_system_command,
|
quickpanel_run_system_command,
|
||||||
|
quickpanel_get_special_locations,
|
||||||
|
quickpanel_open_special,
|
||||||
tray_menu_action,
|
tray_menu_action,
|
||||||
tray_menu_hide,
|
tray_menu_hide,
|
||||||
tray_menu_ready,
|
tray_menu_ready,
|
||||||
|
|||||||
@@ -188,63 +188,33 @@ pub fn quickpanel_reveal_in_explorer(path: String) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 用系统默认程序打开文件(ShellExecuteW)。
|
/// 用系统默认程序打开文件/文件夹。
|
||||||
/// 无关联应用时,自动 fallback 到「打开方式」对话框(verb: openas)。
|
/// - 目录:explorer.exe 直接打开(修复索引目录点击后未打开的问题)
|
||||||
|
/// - 文件:ShellExecuteW open,无关联应用时自动 fallback 到「打开方式」对话框(verb: openas)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn quickpanel_open_file(path: String) -> Result<(), String> {
|
pub fn quickpanel_open_file(path: String) -> Result<(), String> {
|
||||||
#[cfg(windows)]
|
super::special_locations::open_path(&path)
|
||||||
{
|
}
|
||||||
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('/', "\\");
|
/// 获取 Windows 常用快捷位置(hosts、回收站、此电脑、用户目录、系统管理工具等)
|
||||||
let wide_path: Vec<u16> = OsStr::new(&normalized)
|
#[tauri::command]
|
||||||
.encode_wide()
|
pub fn quickpanel_get_special_locations() -> Vec<super::special_locations::SpecialLocation> {
|
||||||
.chain(std::iter::once(0))
|
super::special_locations::get_special_locations()
|
||||||
.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 {
|
/// 打开快捷位置(kind: file | shell | cmd)
|
||||||
let mut hinst = ShellExecuteW(
|
#[tauri::command]
|
||||||
0 as HWND,
|
pub fn quickpanel_open_special(
|
||||||
wide_open.as_ptr(),
|
kind: String,
|
||||||
wide_path.as_ptr(),
|
target: String,
|
||||||
std::ptr::null(),
|
args: Vec<String>,
|
||||||
std::ptr::null(),
|
) -> Result<(), String> {
|
||||||
SW_SHOWNORMAL,
|
match kind.as_str() {
|
||||||
);
|
"shell" => super::special_locations::open_shell(&target),
|
||||||
if hinst <= 32 {
|
"cmd" => super::special_locations::open_cmd(&target, &args),
|
||||||
hinst = ShellExecuteW(
|
_ => super::special_locations::open_path(&target),
|
||||||
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]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -9,14 +9,16 @@ pub mod commands;
|
|||||||
pub mod file_index;
|
pub mod file_index;
|
||||||
pub mod icon_extractor;
|
pub mod icon_extractor;
|
||||||
pub mod popup;
|
pub mod popup;
|
||||||
|
pub mod special_locations;
|
||||||
|
|
||||||
pub use commands::{
|
pub use commands::{
|
||||||
quickpanel_build_file_index, quickpanel_clear_app_icon_cache, quickpanel_delete_file,
|
quickpanel_build_file_index, quickpanel_clear_app_icon_cache, quickpanel_delete_file,
|
||||||
quickpanel_file_index_stats, quickpanel_get_app_icon, quickpanel_get_settings,
|
quickpanel_file_index_stats, quickpanel_get_app_icon, quickpanel_get_settings,
|
||||||
quickpanel_hide_popup, quickpanel_init_file_index, quickpanel_lock_screen,
|
quickpanel_get_special_locations, quickpanel_hide_popup, quickpanel_init_file_index,
|
||||||
quickpanel_open_file, quickpanel_register_shortcut, quickpanel_reveal_in_explorer,
|
quickpanel_lock_screen, quickpanel_open_file, quickpanel_open_special,
|
||||||
quickpanel_run_custom_command, quickpanel_run_system_command, quickpanel_save_settings,
|
quickpanel_register_shortcut, quickpanel_reveal_in_explorer, quickpanel_run_custom_command,
|
||||||
quickpanel_scan_apps, quickpanel_search_files, quickpanel_show_popup, quickpanel_show_window,
|
quickpanel_run_system_command, quickpanel_save_settings, quickpanel_scan_apps,
|
||||||
|
quickpanel_search_files, quickpanel_show_popup, quickpanel_show_window,
|
||||||
quickpanel_unregister_shortcut,
|
quickpanel_unregister_shortcut,
|
||||||
};
|
};
|
||||||
pub use popup::{ensure_window, load_settings, register_shortcut};
|
pub use popup::{ensure_window, load_settings, register_shortcut};
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
//! 快捷位置:Windows 常用系统位置/工具。
|
||||||
|
//!
|
||||||
|
//! 提供两类能力:
|
||||||
|
//! - `get_special_locations`:返回预定义列表(hosts、回收站、此电脑、用户目录、系统管理工具),
|
||||||
|
//! 路径在 Rust 侧解析(环境变量 / dirs crate),前端只负责展示与搜索。
|
||||||
|
//! - 打开逻辑:目录走 explorer.exe(比 ShellExecute "open" 目录更可靠),
|
||||||
|
//! 文件走 ShellExecuteW(open + openas fallback),shell 位置与系统工具走 ShellExecuteW。
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
/// 快捷位置条目
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SpecialLocation {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub subtitle: String,
|
||||||
|
pub keywords: Vec<String>,
|
||||||
|
/// file: 真实文件/文件夹路径;shell: explorer 打开的 shell 路径;cmd: 可执行命令
|
||||||
|
pub kind: String,
|
||||||
|
pub target: String,
|
||||||
|
pub args: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sl(
|
||||||
|
id: &str,
|
||||||
|
title: &str,
|
||||||
|
subtitle: &str,
|
||||||
|
keywords: &[&str],
|
||||||
|
kind: &str,
|
||||||
|
target: &str,
|
||||||
|
args: &[&str],
|
||||||
|
) -> SpecialLocation {
|
||||||
|
SpecialLocation {
|
||||||
|
id: id.to_string(),
|
||||||
|
title: title.to_string(),
|
||||||
|
subtitle: subtitle.to_string(),
|
||||||
|
keywords: keywords.iter().map(|s| s.to_string()).collect(),
|
||||||
|
kind: kind.to_string(),
|
||||||
|
target: target.to_string(),
|
||||||
|
args: args.iter().map(|s| s.to_string()).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Windows 系统目录(%SystemRoot%)
|
||||||
|
fn system_root() -> String {
|
||||||
|
std::env::var("SystemRoot")
|
||||||
|
.or_else(|_| std::env::var("windir"))
|
||||||
|
.unwrap_or_else(|_| "C:\\Windows".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dir_str(d: Option<PathBuf>) -> Option<String> {
|
||||||
|
d.map(|p| p.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 返回全部快捷位置(路径已在 Rust 侧解析,避免前端硬编码)
|
||||||
|
pub fn get_special_locations() -> Vec<SpecialLocation> {
|
||||||
|
let mut list = Vec::new();
|
||||||
|
|
||||||
|
// ===== shell 位置(explorer 打开,CLSID 格式) =====
|
||||||
|
list.push(sl(
|
||||||
|
"sp-recycle-bin",
|
||||||
|
"回收站",
|
||||||
|
"Windows 回收站",
|
||||||
|
&["recycle", "bin", "trash", "回收站", "垃圾桶", "垃圾箱"],
|
||||||
|
"shell",
|
||||||
|
"::{645FF040-5081-101B-9F08-00AA002F954E}",
|
||||||
|
&[],
|
||||||
|
));
|
||||||
|
list.push(sl(
|
||||||
|
"sp-this-pc",
|
||||||
|
"此电脑",
|
||||||
|
"我的电脑",
|
||||||
|
&["此电脑", "我的电脑", "computer", "pc", "thispc"],
|
||||||
|
"shell",
|
||||||
|
"::{20D04FE0-3AEA-1069-A2D8-08002B30309D}",
|
||||||
|
&[],
|
||||||
|
));
|
||||||
|
list.push(sl(
|
||||||
|
"sp-network",
|
||||||
|
"网络",
|
||||||
|
"网络位置",
|
||||||
|
&["网络", "network", "网上邻居"],
|
||||||
|
"shell",
|
||||||
|
"::{208D2C60-3AEA-1069-A2D7-08002B30309D}",
|
||||||
|
&[],
|
||||||
|
));
|
||||||
|
|
||||||
|
// ===== 常用文件 =====
|
||||||
|
let hosts = format!("{}\\System32\\drivers\\etc\\hosts", system_root());
|
||||||
|
list.push(sl(
|
||||||
|
"sp-hosts",
|
||||||
|
"hosts 文件",
|
||||||
|
&hosts,
|
||||||
|
&["hosts", "host", "主机文件", "域名映射"],
|
||||||
|
"file",
|
||||||
|
&hosts,
|
||||||
|
&[],
|
||||||
|
));
|
||||||
|
|
||||||
|
// ===== 用户目录(真实路径) =====
|
||||||
|
let user_dirs: &[(&str, &str, &[&str], Option<PathBuf>)] = &[
|
||||||
|
("sp-documents", "我的文档", &["文档", "我的文档", "documents", "docs"], dirs::document_dir()),
|
||||||
|
("sp-desktop", "桌面", &["桌面", "desktop"], dirs::desktop_dir()),
|
||||||
|
("sp-downloads", "下载", &["下载", "download", "downloads"], dirs::download_dir()),
|
||||||
|
("sp-pictures", "图片", &["图片", "照片", "pictures", "photos"], dirs::picture_dir()),
|
||||||
|
("sp-music", "音乐", &["音乐", "music"], dirs::audio_dir()),
|
||||||
|
("sp-videos", "视频", &["视频", "videos"], dirs::video_dir()),
|
||||||
|
];
|
||||||
|
for (id, title, keywords, path) in user_dirs {
|
||||||
|
if let Some(p) = dir_str(path.clone()) {
|
||||||
|
list.push(sl(id, title, &p, keywords, "file", &p, &[]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 系统管理工具(ShellExecuteW 直接启动 .msc/.cpl/.exe) =====
|
||||||
|
let tools: &[(&str, &str, &[&str], &str)] = &[
|
||||||
|
("sp-devmgmt", "设备管理器", &["设备管理器", "设备", "devmgmt", "device"], "devmgmt.msc"),
|
||||||
|
("sp-diskmgmt", "磁盘管理", &["磁盘管理", "磁盘", "diskmgmt", "disk"], "diskmgmt.msc"),
|
||||||
|
("sp-compmgmt", "计算机管理", &["计算机管理", "compmgmt"], "compmgmt.msc"),
|
||||||
|
("sp-services", "服务", &["服务", "services", "service"], "services.msc"),
|
||||||
|
("sp-perfmon", "性能监视器", &["性能监视器", "性能", "perfmon"], "perfmon.msc"),
|
||||||
|
("sp-msinfo", "系统信息", &["系统信息", "msinfo", "systeminfo"], "msinfo32.exe"),
|
||||||
|
("sp-ncpa", "网络连接", &["网络连接", "ncpa", "网卡"], "ncpa.cpl"),
|
||||||
|
("sp-appwiz", "程序和功能", &["程序和功能", "卸载", "appwiz", "uninstall"], "appwiz.cpl"),
|
||||||
|
("sp-powercfg", "电源选项", &["电源选项", "电源", "powercfg", "power"], "powercfg.cpl"),
|
||||||
|
("sp-osk", "屏幕键盘", &["屏幕键盘", "键盘", "osk", "virtual keyboard"], "osk.exe"),
|
||||||
|
("sp-magnify", "放大镜", &["放大镜", "magnify", "magnifier"], "magnify.exe"),
|
||||||
|
];
|
||||||
|
for (id, title, keywords, exe) in tools {
|
||||||
|
list.push(sl(id, title, exe, keywords, "cmd", exe, &[]));
|
||||||
|
}
|
||||||
|
list.push(sl(
|
||||||
|
"sp-envvar",
|
||||||
|
"环境变量",
|
||||||
|
"rundll32 sysdm.cpl,EditEnvironmentVariables",
|
||||||
|
&["环境变量", "环境", "env", "environment"],
|
||||||
|
"cmd",
|
||||||
|
"rundll32.exe",
|
||||||
|
&["sysdm.cpl,EditEnvironmentVariables"],
|
||||||
|
));
|
||||||
|
|
||||||
|
list
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 打开逻辑 =====
|
||||||
|
|
||||||
|
/// ShellExecuteW 调用(verb 可指定,如 "open" / "openas")
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn shell_execute_verb(exe: &str, params: &str, verb: &str) -> Result<(), String> {
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::os::windows::ffi::OsStrExt;
|
||||||
|
use windows_sys::Win32::Foundation::HWND;
|
||||||
|
use windows_sys::Win32::UI::Shell::ShellExecuteW;
|
||||||
|
use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL;
|
||||||
|
|
||||||
|
let wide_exe: Vec<u16> = OsStr::new(exe)
|
||||||
|
.encode_wide()
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect();
|
||||||
|
let wide_params: Vec<u16> = OsStr::new(params)
|
||||||
|
.encode_wide()
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect();
|
||||||
|
let wide_verb: Vec<u16> = OsStr::new(verb)
|
||||||
|
.encode_wide()
|
||||||
|
.chain(std::iter::once(0))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let hinst = ShellExecuteW(
|
||||||
|
0 as HWND,
|
||||||
|
wide_verb.as_ptr(),
|
||||||
|
wide_exe.as_ptr(),
|
||||||
|
wide_params.as_ptr(),
|
||||||
|
std::ptr::null(),
|
||||||
|
SW_SHOWNORMAL,
|
||||||
|
);
|
||||||
|
// ShellExecuteW 返回值 <= 32 表示错误
|
||||||
|
if hinst <= 32 {
|
||||||
|
return Err(format!("打开失败 (code: {})", hinst as i32));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn shell_execute_verb(_exe: &str, _params: &str, _verb: &str) -> Result<(), String> {
|
||||||
|
Err("当前平台不支持".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打开文件/文件夹路径:
|
||||||
|
/// - 目录:explorer.exe 直接打开(比 ShellExecute "open" 目录更可靠,修复索引目录点击不生效问题)
|
||||||
|
/// - 文件:ShellExecuteW open,无关联程序时 fallback 到「打开方式」对话框
|
||||||
|
pub fn open_path(path: &str) -> Result<(), String> {
|
||||||
|
let p = std::path::Path::new(path);
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
if p.is_dir() {
|
||||||
|
use crate::process_manager::setup_creation_flags;
|
||||||
|
let mut cmd = std::process::Command::new("explorer.exe");
|
||||||
|
cmd.arg(path);
|
||||||
|
setup_creation_flags(&mut cmd);
|
||||||
|
return cmd
|
||||||
|
.spawn()
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| format!("打开文件夹失败: {}", e));
|
||||||
|
}
|
||||||
|
let params = String::new();
|
||||||
|
if shell_execute_verb(path, ¶ms, "open").is_err() {
|
||||||
|
return shell_execute_verb(path, ¶ms, "openas");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
let _ = p;
|
||||||
|
Err("当前平台不支持".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打开 shell 位置(explorer shell: 或 ::{CLSID})
|
||||||
|
pub fn open_shell(target: &str) -> Result<(), String> {
|
||||||
|
shell_execute_verb("explorer.exe", target, "open")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打开系统工具(.msc / .cpl / .exe,参数空格连接)
|
||||||
|
pub fn open_cmd(exe: &str, args: &[String]) -> Result<(), String> {
|
||||||
|
let params = args.join(" ");
|
||||||
|
shell_execute_verb(exe, ¶ms, "open")
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
|||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||||
import { getCurrentWindow, Effect, EffectState } from '@tauri-apps/api/window'
|
import { getCurrentWindow, Effect, EffectState } from '@tauri-apps/api/window'
|
||||||
import { Search, Command, Loader2, CornerDownLeft, Calculator, Globe, Lock, ChevronRight, Terminal, History } from '@lucide/vue'
|
import { Search, Command, Loader2, CornerDownLeft, Calculator, Globe, Lock, ChevronRight, Terminal, History, FolderOpen, Ruler, Trash2 } from '@lucide/vue'
|
||||||
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion'
|
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion'
|
||||||
import { aggregateSearch, loadAppIconsForResults, recordHistoryItem, getMoreHistoryItems, getMoreHistoryCount, setFileIndexReady, type QPItem, type QPSubAction } from './providers'
|
import { aggregateSearch, loadAppIconsForResults, recordHistoryItem, getMoreHistoryItems, getMoreHistoryCount, setFileIndexReady, type QPItem, type QPSubAction } from './providers'
|
||||||
|
|
||||||
@@ -16,6 +16,8 @@ const loading = ref(false)
|
|||||||
// 子动作展开:展开的 item 索引,null 表示未展开
|
// 子动作展开:展开的 item 索引,null 表示未展开
|
||||||
const subActionExpanded = ref<number | null>(null)
|
const subActionExpanded = ref<number | null>(null)
|
||||||
const subActionIndex = ref(0)
|
const subActionIndex = ref(0)
|
||||||
|
// 待删除确认项(文件/文件夹删除前弹窗确认)
|
||||||
|
const pendingDelete = ref<{ path: string; isDir: boolean; name: string } | null>(null)
|
||||||
let unlistenFns: UnlistenFn[] = []
|
let unlistenFns: UnlistenFn[] = []
|
||||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
@@ -119,7 +121,12 @@ async function executeItem(item: QPItem) {
|
|||||||
await hideWindow()
|
await hideWindow()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeSubAction(sub: QPSubAction) {
|
async function executeSubAction(sub: QPSubAction, item?: QPItem) {
|
||||||
|
// 删除类子动作:先弹窗确认,确认后由 confirmDelete 执行并隐藏窗口
|
||||||
|
if (sub.id === 'delete' && item?.deleteInfo) {
|
||||||
|
pendingDelete.value = { ...item.deleteInfo, name: item.title }
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await sub.action()
|
await sub.action()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -128,6 +135,23 @@ async function executeSubAction(sub: QPSubAction) {
|
|||||||
await hideWindow()
|
await hideWindow()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 删除确认弹窗 =====
|
||||||
|
function cancelDelete() {
|
||||||
|
pendingDelete.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
const pd = pendingDelete.value
|
||||||
|
if (!pd) return
|
||||||
|
pendingDelete.value = null
|
||||||
|
try {
|
||||||
|
await invoke('quickpanel_delete_file', { path: pd.path })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[quickpanel] 删除失败:', e)
|
||||||
|
}
|
||||||
|
await hideWindow()
|
||||||
|
}
|
||||||
|
|
||||||
// 子动作展开/收起
|
// 子动作展开/收起
|
||||||
function toggleSubActions(idx: number) {
|
function toggleSubActions(idx: number) {
|
||||||
const item = results.value[idx]
|
const item = results.value[idx]
|
||||||
@@ -152,8 +176,21 @@ function currentSubActions(): QPSubAction[] {
|
|||||||
|
|
||||||
// ===== 键盘导航 =====
|
// ===== 键盘导航 =====
|
||||||
function onKeydown(e: KeyboardEvent) {
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
// 删除确认弹窗打开时:Esc 取消,Enter 确认
|
||||||
|
if (pendingDelete.value) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
cancelDelete()
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
confirmDelete()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const expanded = subActionExpanded.value !== null
|
const expanded = subActionExpanded.value !== null
|
||||||
const subs = currentSubActions()
|
const subs = currentSubActions()
|
||||||
|
const expandedItem = expanded ? results.value[subActionExpanded.value!] : undefined
|
||||||
|
|
||||||
if (expanded) {
|
if (expanded) {
|
||||||
// 子动作导航模式
|
// 子动作导航模式
|
||||||
@@ -168,7 +205,7 @@ function onKeydown(e: KeyboardEvent) {
|
|||||||
} else if (e.key === 'Enter') {
|
} else if (e.key === 'Enter') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const sub = subs[subActionIndex.value]
|
const sub = subs[subActionIndex.value]
|
||||||
if (sub) executeSubAction(sub)
|
if (sub) executeSubAction(sub, expandedItem)
|
||||||
} else if (e.key === 'Escape') {
|
} else if (e.key === 'Escape') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
collapseSubActions()
|
collapseSubActions()
|
||||||
@@ -180,7 +217,7 @@ function onKeydown(e: KeyboardEvent) {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const idx = parseInt(e.key, 10) - 1
|
const idx = parseInt(e.key, 10) - 1
|
||||||
const sub = subs[idx]
|
const sub = subs[idx]
|
||||||
if (sub) executeSubAction(sub)
|
if (sub) executeSubAction(sub, expandedItem)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -247,6 +284,8 @@ const groupIcon = (group: string) => {
|
|||||||
if (group === '系统') return Lock
|
if (group === '系统') return Lock
|
||||||
if (group === '自定义') return Terminal
|
if (group === '自定义') return Terminal
|
||||||
if (group === '历史') return History
|
if (group === '历史') return History
|
||||||
|
if (group === '快捷') return FolderOpen
|
||||||
|
if (group === '换算') return Ruler
|
||||||
return Search
|
return Search
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,7 +570,7 @@ onUnmounted(() => {
|
|||||||
:key="sub.id"
|
:key="sub.id"
|
||||||
class="qp-sub-item"
|
class="qp-sub-item"
|
||||||
:class="{ 'qp-sub-selected': sIdx === subActionIndex }"
|
:class="{ 'qp-sub-selected': sIdx === subActionIndex }"
|
||||||
@click="executeSubAction(sub)"
|
@click="executeSubAction(sub, item)"
|
||||||
@mouseenter="onSubActionHover(sIdx)"
|
@mouseenter="onSubActionHover(sIdx)"
|
||||||
>
|
>
|
||||||
<span class="qp-sub-num">{{ sIdx + 1 }}</span>
|
<span class="qp-sub-num">{{ sIdx + 1 }}</span>
|
||||||
@@ -550,6 +589,22 @@ onUnmounted(() => {
|
|||||||
<span><kbd>Enter</kbd> 执行</span>
|
<span><kbd>Enter</kbd> 执行</span>
|
||||||
<span><kbd>Esc</kbd> {{ subActionExpanded !== null ? '收起' : '关闭' }}</span>
|
<span><kbd>Esc</kbd> {{ subActionExpanded !== null ? '收起' : '关闭' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 删除确认弹窗 -->
|
||||||
|
<div v-if="pendingDelete" class="qp-confirm-mask" @click.self="cancelDelete">
|
||||||
|
<div class="qp-confirm-box" role="alertdialog" aria-modal="true">
|
||||||
|
<div class="qp-confirm-head">
|
||||||
|
<Trash2 class="h-4 w-4 shrink-0 text-destructive" />
|
||||||
|
<p class="qp-confirm-title">确定删除?</p>
|
||||||
|
</div>
|
||||||
|
<p class="qp-confirm-name truncate" :title="pendingDelete.name">{{ pendingDelete.name }}</p>
|
||||||
|
<p class="qp-confirm-tip">将移动到回收站{{ pendingDelete.isDir ? '(含所有子内容)' : '' }},删除后可在回收站中还原。</p>
|
||||||
|
<div class="flex items-center justify-end gap-2 mt-4">
|
||||||
|
<button class="qp-confirm-btn" @click="cancelDelete">取消</button>
|
||||||
|
<button class="qp-confirm-btn qp-confirm-danger" @click="confirmDelete">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -757,4 +812,79 @@ onUnmounted(() => {
|
|||||||
.qp-results::-webkit-scrollbar-track {
|
.qp-results::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== 删除确认弹窗 ===== */
|
||||||
|
.qp-confirm-mask {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
border-radius: 10px;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-confirm-box {
|
||||||
|
width: 300px;
|
||||||
|
max-width: 86%;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--card);
|
||||||
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-confirm-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-confirm-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-confirm-name {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-confirm-tip {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-confirm-btn {
|
||||||
|
padding: 5px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--muted);
|
||||||
|
color: var(--foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-confirm-btn:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-confirm-danger {
|
||||||
|
background: var(--destructive);
|
||||||
|
border-color: transparent;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qp-confirm-danger:hover {
|
||||||
|
background: var(--destructive);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -534,6 +534,14 @@ async function changeEngine(v: string) {
|
|||||||
<Badge variant="secondary">计算</Badge>
|
<Badge variant="secondary">计算</Badge>
|
||||||
<span class="text-muted-foreground">输入算式即算,Enter 复制结果</span>
|
<span class="text-muted-foreground">输入算式即算,Enter 复制结果</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<Badge variant="secondary">换算</Badge>
|
||||||
|
<span class="text-muted-foreground">单位/货币/时间/温度换算(如 1m、1Mbps、1$)</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<Badge variant="secondary">快捷</Badge>
|
||||||
|
<span class="text-muted-foreground">hosts、回收站、系统工具等常用位置</span>
|
||||||
|
</div>
|
||||||
<div class="flex items-center gap-2 text-sm">
|
<div class="flex items-center gap-2 text-sm">
|
||||||
<Badge variant="secondary">系统</Badge>
|
<Badge variant="secondary">系统</Badge>
|
||||||
<span class="text-muted-foreground">锁屏、退出应用</span>
|
<span class="text-muted-foreground">锁屏、退出应用</span>
|
||||||
|
|||||||
@@ -38,8 +38,12 @@ export interface QPItem {
|
|||||||
action: () => void | Promise<void>
|
action: () => void | Promise<void>
|
||||||
/** 子动作菜单(可选)。执行子动作后同样隐藏窗口 */
|
/** 子动作菜单(可选)。执行子动作后同样隐藏窗口 */
|
||||||
subActions?: QPSubAction[]
|
subActions?: QPSubAction[]
|
||||||
|
/** 删除确认信息(仅可删除项设置,如文件/文件夹,用于弹窗确认后执行删除) */
|
||||||
|
deleteInfo?: { path: string; isDir: boolean }
|
||||||
/** 用于历史记录的查询文本(仅历史项设置,点击历史时用此重新搜索恢复 action) */
|
/** 用于历史记录的查询文本(仅历史项设置,点击历史时用此重新搜索恢复 action) */
|
||||||
historyQuery?: string
|
historyQuery?: string
|
||||||
|
/** 应用可靠性排序(仅 group='应用' 项设置,越小越可靠:开始菜单 0 / 桌面 1 / 其他 2) */
|
||||||
|
appRank?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QPProvider {
|
export interface QPProvider {
|
||||||
@@ -387,6 +391,71 @@ class SystemProvider implements QPProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 应用通用:构建启动动作与子动作(开始菜单 / 文件索引 .lnk 共用) =====
|
||||||
|
|
||||||
|
/** 启动一个应用(.lnk / .exe 等),通过 Rust spawn 子进程 */
|
||||||
|
function makeAppLaunch(path: string) {
|
||||||
|
return async () => {
|
||||||
|
try {
|
||||||
|
// .lnk 文件不能用 openUrl 打开,需直接 spawn
|
||||||
|
await invoke('quickpanel_run_custom_command', { command: path, args: [] })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[quickpanel] 启动应用失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 应用项的标准子动作:启动 / 在资源管理器中显示 / 复制路径(+ 可选删除) */
|
||||||
|
function makeAppSubActions(path: string, includeDelete = false): QPSubAction[] {
|
||||||
|
const launch = makeAppLaunch(path)
|
||||||
|
const subs: QPSubAction[] = [
|
||||||
|
{ id: 'launch', label: '启动', action: launch },
|
||||||
|
{
|
||||||
|
id: 'reveal',
|
||||||
|
label: '在资源管理器中显示',
|
||||||
|
action: async () => {
|
||||||
|
try {
|
||||||
|
await invoke('quickpanel_reveal_in_explorer', { path })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[quickpanel] 资源管理器显示失败:', e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'copy-path',
|
||||||
|
label: '复制路径',
|
||||||
|
action: async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(path)
|
||||||
|
} catch {
|
||||||
|
/* 忽略 */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if (includeDelete) {
|
||||||
|
subs.push({
|
||||||
|
id: 'delete',
|
||||||
|
label: '删除',
|
||||||
|
action: async () => {
|
||||||
|
try {
|
||||||
|
await invoke('quickpanel_delete_file', { path })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[quickpanel] 删除失败:', e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return subs
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据路径推断应用可靠性排序:桌面 1 / 其他位置 2(开始菜单由调用方直接给 0) */
|
||||||
|
function appRankFromPath(path: string): number {
|
||||||
|
const p = path.toLowerCase()
|
||||||
|
if (p.includes('\\desktop\\') || p.includes('/desktop/')) return 1
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
// ===== app Provider:扫描开始菜单应用 =====
|
// ===== app Provider:扫描开始菜单应用 =====
|
||||||
|
|
||||||
interface AppRecord {
|
interface AppRecord {
|
||||||
@@ -430,17 +499,6 @@ class AppProvider implements QPProvider {
|
|||||||
const forms = buildItemForms(app.name)
|
const forms = buildItemForms(app.name)
|
||||||
const score = bestScore(query, forms)
|
const score = bestScore(query, forms)
|
||||||
if (score >= 0) {
|
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({
|
results.push({
|
||||||
item: {
|
item: {
|
||||||
id: `app-${idx}`,
|
id: `app-${idx}`,
|
||||||
@@ -448,32 +506,9 @@ class AppProvider implements QPProvider {
|
|||||||
subtitle: app.path,
|
subtitle: app.path,
|
||||||
group: '应用',
|
group: '应用',
|
||||||
iconPath: app.path,
|
iconPath: app.path,
|
||||||
action: launch,
|
action: makeAppLaunch(app.path),
|
||||||
subActions: [
|
subActions: makeAppSubActions(app.path),
|
||||||
{ id: 'launch', label: '启动', action: launch },
|
appRank: 0, // 开始菜单:最可靠来源
|
||||||
{
|
|
||||||
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,
|
score,
|
||||||
})
|
})
|
||||||
@@ -554,9 +589,26 @@ class FileProvider implements QPProvider {
|
|||||||
limit: 20,
|
limit: 20,
|
||||||
})
|
})
|
||||||
return files.map((f, idx) => {
|
return files.map((f, idx) => {
|
||||||
|
// .lnk 快捷方式按应用处理:带图标、用启动命令,并与开始菜单应用统一去重
|
||||||
|
// 注意:Rust 返回的 ext 不带点(如 "lnk"),这里直接按文件名判断最稳妥
|
||||||
|
const isLnk = !f.isDir && f.name.toLowerCase().endsWith('.lnk')
|
||||||
|
if (isLnk) {
|
||||||
|
return {
|
||||||
|
id: `file-app-${idx}`,
|
||||||
|
title: f.name,
|
||||||
|
subtitle: f.path,
|
||||||
|
group: '应用',
|
||||||
|
score: 0.55, // 略低于开始菜单应用(0.6+),去重时让位于开始菜单
|
||||||
|
iconPath: f.path,
|
||||||
|
action: makeAppLaunch(f.path),
|
||||||
|
subActions: makeAppSubActions(f.path, true),
|
||||||
|
deleteInfo: { path: f.path, isDir: false },
|
||||||
|
appRank: appRankFromPath(f.path),
|
||||||
|
}
|
||||||
|
}
|
||||||
const openFile = async () => {
|
const openFile = async () => {
|
||||||
try {
|
try {
|
||||||
// 用系统默认程序打开;无关联应用时 Rust 端会 fallback 到「打开方式」对话框
|
// 目录:Rust 端用 explorer.exe 打开;文件:默认程序打开(无关联时 fallback 打开方式)
|
||||||
await invoke('quickpanel_open_file', { path: f.path })
|
await invoke('quickpanel_open_file', { path: f.path })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[quickpanel] 打开文件失败:', e)
|
console.error('[quickpanel] 打开文件失败:', e)
|
||||||
@@ -569,13 +621,16 @@ class FileProvider implements QPProvider {
|
|||||||
group: '文件',
|
group: '文件',
|
||||||
score: 0.6,
|
score: 0.6,
|
||||||
action: openFile,
|
action: openFile,
|
||||||
|
// 目录:打开即导航到该目录,无需再提供「在资源管理器中显示」,避免重复
|
||||||
subActions: [
|
subActions: [
|
||||||
{
|
{
|
||||||
id: 'open',
|
id: 'open',
|
||||||
label: f.isDir ? '打开文件夹' : '打开',
|
label: f.isDir ? '打开文件夹' : '打开',
|
||||||
action: openFile,
|
action: openFile,
|
||||||
},
|
},
|
||||||
{
|
...(f.isDir
|
||||||
|
? []
|
||||||
|
: [{
|
||||||
id: 'reveal',
|
id: 'reveal',
|
||||||
label: '在资源管理器中显示',
|
label: '在资源管理器中显示',
|
||||||
action: async () => {
|
action: async () => {
|
||||||
@@ -585,7 +640,7 @@ class FileProvider implements QPProvider {
|
|||||||
console.error('[quickpanel] 资源管理器显示失败:', e)
|
console.error('[quickpanel] 资源管理器显示失败:', e)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
}]),
|
||||||
{
|
{
|
||||||
id: 'copy-path',
|
id: 'copy-path',
|
||||||
label: '复制路径',
|
label: '复制路径',
|
||||||
@@ -602,8 +657,7 @@ class FileProvider implements QPProvider {
|
|||||||
label: '删除',
|
label: '删除',
|
||||||
action: async () => {
|
action: async () => {
|
||||||
try {
|
try {
|
||||||
// 移到回收站:explorer.exe 不直接支持,用 PowerShell 或直接删除
|
// 移到回收站(PowerShell + Microsoft.VisualBasic)
|
||||||
// 这里用 Rust 命令删除(简化实现,实际移到回收站需 SHFileOperation)
|
|
||||||
await invoke('quickpanel_delete_file', { path: f.path })
|
await invoke('quickpanel_delete_file', { path: f.path })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[quickpanel] 删除失败:', e)
|
console.error('[quickpanel] 删除失败:', e)
|
||||||
@@ -611,6 +665,7 @@ class FileProvider implements QPProvider {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
deleteInfo: { path: f.path, isDir: f.isDir },
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -846,6 +901,399 @@ class HistoryProvider implements QPProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== special Provider:Windows 常用快捷位置 =====
|
||||||
|
|
||||||
|
interface SpecialLocation {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
subtitle: string
|
||||||
|
keywords: string[]
|
||||||
|
/** file: 真实路径;shell: explorer 打开的 shell 路径;cmd: 可执行命令 */
|
||||||
|
kind: 'file' | 'shell' | 'cmd'
|
||||||
|
target: string
|
||||||
|
args: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
let specialCache: SpecialLocation[] | null = null
|
||||||
|
let specialCacheTime = 0
|
||||||
|
const SPECIAL_CACHE_TTL = 60_000 // 1 分钟缓存
|
||||||
|
|
||||||
|
async function loadSpecials(): Promise<SpecialLocation[]> {
|
||||||
|
if (specialCache && Date.now() - specialCacheTime < SPECIAL_CACHE_TTL) {
|
||||||
|
return specialCache
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const list = await invoke<SpecialLocation[]>('quickpanel_get_special_locations')
|
||||||
|
specialCache = list
|
||||||
|
specialCacheTime = Date.now()
|
||||||
|
return list
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[quickpanel] 获取快捷位置失败:', e)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SpecialProvider implements QPProvider {
|
||||||
|
id = 'special'
|
||||||
|
label = '快捷'
|
||||||
|
priority = 60
|
||||||
|
|
||||||
|
async search(query: string): Promise<QPItem[]> {
|
||||||
|
const list = await loadSpecials()
|
||||||
|
if (!list.length) return []
|
||||||
|
if (!query.trim()) return [] // 空查询不占用列表,由用户主动搜索
|
||||||
|
|
||||||
|
const open = async (s: SpecialLocation) => {
|
||||||
|
try {
|
||||||
|
await invoke('quickpanel_open_special', {
|
||||||
|
kind: s.kind,
|
||||||
|
target: s.target,
|
||||||
|
args: s.args,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[quickpanel] 打开快捷位置失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: QPItem[] = list.map(s => ({
|
||||||
|
id: `sp-${s.id}`,
|
||||||
|
title: s.title,
|
||||||
|
subtitle: s.subtitle,
|
||||||
|
group: '快捷',
|
||||||
|
action: () => open(s),
|
||||||
|
subActions:
|
||||||
|
s.kind === 'file'
|
||||||
|
? [
|
||||||
|
{ id: 'open', label: '打开', action: () => open(s) },
|
||||||
|
{
|
||||||
|
id: 'reveal',
|
||||||
|
label: '在资源管理器中显示',
|
||||||
|
action: async () => {
|
||||||
|
try {
|
||||||
|
await invoke('quickpanel_reveal_in_explorer', { path: s.target })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[quickpanel] 资源管理器显示失败:', e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'copy-path',
|
||||||
|
label: '复制路径',
|
||||||
|
action: async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(s.target)
|
||||||
|
} catch {
|
||||||
|
/* 忽略 */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: undefined,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const scored: Array<{ item: QPItem; score: number }> = []
|
||||||
|
items.forEach((item, idx) => {
|
||||||
|
const forms = buildItemForms(item.title, list[idx].keywords)
|
||||||
|
const score = bestScore(query, forms)
|
||||||
|
if (score >= 0) scored.push({ item, score })
|
||||||
|
})
|
||||||
|
scored.sort((a, b) => b.score - a.score)
|
||||||
|
return scored.slice(0, 8).map(s => ({ ...s.item, score: s.score }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== unit Provider:单位 / 货币 / 时间 / 温度换算 =====
|
||||||
|
|
||||||
|
interface UnitDef {
|
||||||
|
/** 可匹配的符号(含中文),小写优先;带 exactCase 的单位只做精确大小写匹配 */
|
||||||
|
symbols: string[]
|
||||||
|
label: string
|
||||||
|
/** 与基准单位的换算系数(基准单位 = 1) */
|
||||||
|
factor: number
|
||||||
|
/** 仅精确大小写匹配(如小写 m = 米,避免与 MB 混淆) */
|
||||||
|
exactCase?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UnitCategory {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
units: UnitDef[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNIT_CATEGORIES: UnitCategory[] = [
|
||||||
|
{
|
||||||
|
id: 'length',
|
||||||
|
name: '长度',
|
||||||
|
units: [
|
||||||
|
{ symbols: ['m', 'meter', 'meters', '米', '公尺'], label: '米', factor: 1, exactCase: true },
|
||||||
|
{ symbols: ['km', 'kilometer', 'kilometers', '千米', '公里'], label: '千米', factor: 1000 },
|
||||||
|
{ symbols: ['cm', 'centimeter', 'centimeters', '厘米'], label: '厘米', factor: 0.01 },
|
||||||
|
{ symbols: ['mm', 'millimeter', 'millimeters', '毫米'], label: '毫米', factor: 0.001 },
|
||||||
|
{ symbols: ['in', 'inch', 'inches', '英寸'], label: '英寸', factor: 0.0254 },
|
||||||
|
{ symbols: ['ft', 'foot', 'feet', '英尺'], label: '英尺', factor: 0.3048 },
|
||||||
|
{ symbols: ['yd', 'yard', 'yards', '码'], label: '码', factor: 0.9144 },
|
||||||
|
{ symbols: ['mi', 'mile', 'miles', '英里'], label: '英里', factor: 1609.344 },
|
||||||
|
{ symbols: ['里', 'li'], label: '里', factor: 500 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'data',
|
||||||
|
name: '数据',
|
||||||
|
units: [
|
||||||
|
{ symbols: ['b', 'byte', 'bytes', '字节'], label: '字节', factor: 1 },
|
||||||
|
{ symbols: ['kb', 'kib', 'kilobyte', 'kilobytes', '千字节'], label: 'KB', factor: 1024 },
|
||||||
|
{ symbols: ['mb', 'mib', 'megabyte', 'megabytes', '兆字节'], label: 'MB', factor: 1024 ** 2 },
|
||||||
|
{ symbols: ['gb', 'gib', 'gigabyte', 'gigabytes', '吉字节'], label: 'GB', factor: 1024 ** 3 },
|
||||||
|
{ symbols: ['tb', 'tib', 'terabyte', 'terabytes', '太字节'], label: 'TB', factor: 1024 ** 4 },
|
||||||
|
{ symbols: ['bit', 'bits', '比特'], label: 'bit', factor: 1 / 8 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'speed',
|
||||||
|
name: '网速',
|
||||||
|
units: [
|
||||||
|
{ symbols: ['bps', '比特/秒'], label: 'bps', factor: 1 },
|
||||||
|
{ symbols: ['kbps', '千比特/秒'], label: 'Kbps', factor: 1024 },
|
||||||
|
{ symbols: ['mbps', '兆比特/秒'], label: 'Mbps', factor: 1024 ** 2 },
|
||||||
|
{ symbols: ['gbps', '吉比特/秒'], label: 'Gbps', factor: 1024 ** 3 },
|
||||||
|
{ symbols: ['b/s'], label: 'B/s', factor: 8 },
|
||||||
|
{ symbols: ['kb/s'], label: 'KB/s', factor: 8 * 1024 },
|
||||||
|
{ symbols: ['mb/s'], label: 'MB/s', factor: 8 * 1024 ** 2 },
|
||||||
|
{ symbols: ['gb/s'], label: 'GB/s', factor: 8 * 1024 ** 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'time',
|
||||||
|
name: '时间',
|
||||||
|
units: [
|
||||||
|
{ symbols: ['s', 'sec', 'secs', 'second', 'seconds', '秒'], label: '秒', factor: 1 },
|
||||||
|
{ symbols: ['min', 'mins', 'minute', 'minutes', '分钟', '分'], label: '分钟', factor: 60 },
|
||||||
|
{ symbols: ['h', 'hr', 'hrs', 'hour', 'hours', '小时', '时'], label: '小时', factor: 3600 },
|
||||||
|
{ symbols: ['day', 'days', '天', '日'], label: '天', factor: 86400 },
|
||||||
|
{ symbols: ['week', 'weeks', '周', '星期'], label: '周', factor: 604800 },
|
||||||
|
{ symbols: ['year', 'years', '年'], label: '年', factor: 31536000 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'weight',
|
||||||
|
name: '重量',
|
||||||
|
units: [
|
||||||
|
{ symbols: ['kg', '千克', '公斤'], label: '千克', factor: 1 },
|
||||||
|
{ symbols: ['g', 'gram', 'grams', '克'], label: '克', factor: 0.001 },
|
||||||
|
{ symbols: ['mg', 'milligram', '毫克'], label: '毫克', factor: 1e-6 },
|
||||||
|
{ symbols: ['t', 'ton', 'tons', '吨'], label: '吨', factor: 1000 },
|
||||||
|
{ symbols: ['lb', 'lbs', 'pound', 'pounds', '磅'], label: '磅', factor: 0.45359237 },
|
||||||
|
{ symbols: ['oz', 'ounce', 'ounces', '盎司'], label: '盎司', factor: 0.028349523125 },
|
||||||
|
{ symbols: ['斤', 'jin'], label: '斤', factor: 0.5 },
|
||||||
|
{ symbols: ['两', 'liang'], label: '两', factor: 0.05 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// ===== 货币换算(汇率动态获取,带本地缓存与兜底值) =====
|
||||||
|
|
||||||
|
const DEFAULT_CURRENCY_RATES: Record<string, number> = {
|
||||||
|
usd: 1,
|
||||||
|
cny: 7.2,
|
||||||
|
eur: 0.92,
|
||||||
|
gbp: 0.78,
|
||||||
|
jpy: 156,
|
||||||
|
hkd: 7.8,
|
||||||
|
}
|
||||||
|
const CURRENCY_CACHE_KEY = 'thing_quickpanel_currency_rates'
|
||||||
|
|
||||||
|
function getCurrencyRates(): Record<string, number> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(CURRENCY_CACHE_KEY)
|
||||||
|
if (raw) {
|
||||||
|
const p = JSON.parse(raw)
|
||||||
|
if (p?.rates && Date.now() - p.ts < 24 * 3600 * 1000) return p.rates
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* 忽略损坏缓存 */
|
||||||
|
}
|
||||||
|
return DEFAULT_CURRENCY_RATES
|
||||||
|
}
|
||||||
|
|
||||||
|
let currencyRefreshing = false
|
||||||
|
/** 后台刷新汇率(失败静默,继续用缓存/兜底值),结果写入 localStorage 供下次使用 */
|
||||||
|
async function refreshCurrencyRates() {
|
||||||
|
if (currencyRefreshing) return
|
||||||
|
currencyRefreshing = true
|
||||||
|
try {
|
||||||
|
const res = await fetch('https://open.er-api.com/v6/latest/USD')
|
||||||
|
const data = await res.json()
|
||||||
|
if (data?.result === 'success' && data.rates) {
|
||||||
|
const r = data.rates as Record<string, number | undefined>
|
||||||
|
const rates: Record<string, number> = {
|
||||||
|
usd: 1,
|
||||||
|
cny: r.CNY ?? DEFAULT_CURRENCY_RATES.cny,
|
||||||
|
eur: r.EUR ?? DEFAULT_CURRENCY_RATES.eur,
|
||||||
|
gbp: r.GBP ?? DEFAULT_CURRENCY_RATES.gbp,
|
||||||
|
jpy: r.JPY ?? DEFAULT_CURRENCY_RATES.jpy,
|
||||||
|
hkd: r.HKD ?? DEFAULT_CURRENCY_RATES.hkd,
|
||||||
|
}
|
||||||
|
localStorage.setItem(CURRENCY_CACHE_KEY, JSON.stringify({ ts: Date.now(), rates }))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* 网络失败,继续使用默认/缓存汇率 */
|
||||||
|
} finally {
|
||||||
|
currencyRefreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 动态构建货币类别(基准 = 美元;factor 为「1 单位该货币 = ? 美元」) */
|
||||||
|
function getCurrencyCategory(): UnitCategory {
|
||||||
|
const r = getCurrencyRates()
|
||||||
|
const perUsd = (v: number) => (v > 0 ? 1 / v : 0)
|
||||||
|
return {
|
||||||
|
id: 'currency',
|
||||||
|
name: '货币',
|
||||||
|
units: [
|
||||||
|
{ symbols: ['$', 'usd', '美元', '美金', '美刀'], label: '美元', factor: 1 },
|
||||||
|
{ symbols: ['¥', '¥', 'rmb', 'cny', '元', '人民币'], label: '人民币', factor: perUsd(r.cny) },
|
||||||
|
{ symbols: ['€', 'eur', '欧元'], label: '欧元', factor: perUsd(r.eur) },
|
||||||
|
{ symbols: ['£', 'gbp', '英镑'], label: '英镑', factor: perUsd(r.gbp) },
|
||||||
|
{ symbols: ['jpy', '日元', '日圆'], label: '日元', factor: perUsd(r.jpy) },
|
||||||
|
{ symbols: ['hkd', '港币', '港元'], label: '港元', factor: perUsd(r.hkd) },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 温度匹配(仿射换算,单独处理) */
|
||||||
|
function matchTemperature(token: string): 'C' | 'F' | 'K' | null {
|
||||||
|
const t = token.toLowerCase().replace(/°/g, '')
|
||||||
|
if (['c', 'celsius', '摄氏度', '摄氏'].includes(t)) return 'C'
|
||||||
|
if (['f', 'fahrenheit', '华氏度', '华氏'].includes(t)) return 'F'
|
||||||
|
if (['kelvin', '开尔文'].includes(t)) return 'K'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在(普通 + 货币)类别中匹配单位 token */
|
||||||
|
function matchUnit(
|
||||||
|
token: string,
|
||||||
|
categories: UnitCategory[],
|
||||||
|
): { cat: UnitCategory; unit: UnitDef } | null {
|
||||||
|
// 第一轮:精确大小写匹配
|
||||||
|
for (const cat of categories) {
|
||||||
|
for (const unit of cat.units) {
|
||||||
|
if (unit.symbols.some(s => s === token)) return { cat, unit }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 第二轮:大小写不敏感;exactCase 单位(如 m=米)跳过,避免 "1M" 误判为 1 米
|
||||||
|
const lower = token.toLowerCase()
|
||||||
|
for (const cat of categories) {
|
||||||
|
for (const unit of cat.units) {
|
||||||
|
if (unit.exactCase) continue
|
||||||
|
if (unit.symbols.some(s => s.toLowerCase() === lower)) return { cat, unit }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 数值格式化(去掉多余的浮点尾巴) */
|
||||||
|
function formatUnitValue(v: number): string {
|
||||||
|
if (!isFinite(v)) return ''
|
||||||
|
if (v === 0) return '0'
|
||||||
|
const abs = Math.abs(v)
|
||||||
|
if (abs >= 1e12) return v.toExponential(2)
|
||||||
|
if (abs >= 1e6) return Number(v.toFixed(0)).toLocaleString('en-US')
|
||||||
|
if (abs >= 1000) return Number(v.toFixed(1)).toLocaleString('en-US')
|
||||||
|
if (abs >= 100) return Number(v.toFixed(1)).toString()
|
||||||
|
if (abs >= 1) return Number(v.toFixed(2)).toString()
|
||||||
|
if (abs >= 1e-4) return Number(v.toFixed(4)).toString()
|
||||||
|
return v.toExponential(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 结果展示优先级:整数 > 常见量级(1~1000) > 其他 */
|
||||||
|
function unitNiceRank(v: number): number {
|
||||||
|
if (Number.isInteger(v)) return 0
|
||||||
|
const abs = Math.abs(v)
|
||||||
|
if (abs >= 1 && abs < 1000) return 1
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUnitResultItem(
|
||||||
|
value: number,
|
||||||
|
fromLabel: string,
|
||||||
|
catName: string,
|
||||||
|
toLabel: string,
|
||||||
|
toValue: number,
|
||||||
|
idx: number,
|
||||||
|
): QPItem {
|
||||||
|
const text = `${formatUnitValue(toValue)} ${toLabel}`
|
||||||
|
return {
|
||||||
|
id: `unit-${catName}-${idx}`,
|
||||||
|
title: text,
|
||||||
|
subtitle: `${value} ${fromLabel}(${catName}换算)`,
|
||||||
|
group: '换算',
|
||||||
|
score: 0.85,
|
||||||
|
action: async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
} catch {
|
||||||
|
/* 忽略 */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class UnitProvider implements QPProvider {
|
||||||
|
id = 'unit'
|
||||||
|
label = '换算'
|
||||||
|
priority = 80
|
||||||
|
|
||||||
|
async search(query: string): Promise<QPItem[]> {
|
||||||
|
const trimmed = query.trim()
|
||||||
|
if (!trimmed) return []
|
||||||
|
const m = trimmed.match(/^(\d+(?:\.\d+)?)\s*(.+)$/)
|
||||||
|
if (!m) return []
|
||||||
|
const value = parseFloat(m[1])
|
||||||
|
if (!isFinite(value) || value <= 0) return []
|
||||||
|
const token = m[2].trim()
|
||||||
|
if (!token) return []
|
||||||
|
|
||||||
|
// 温度(仿射换算)
|
||||||
|
const tFrom = matchTemperature(token)
|
||||||
|
if (tFrom) {
|
||||||
|
const celsius =
|
||||||
|
tFrom === 'C' ? value : tFrom === 'F' ? ((value - 32) * 5) / 9 : value - 273.15
|
||||||
|
const convs: Array<{ label: string; v: number }> = [
|
||||||
|
{ label: '摄氏度', v: celsius },
|
||||||
|
{ label: '华氏度', v: (celsius * 9) / 5 + 32 },
|
||||||
|
{ label: '开尔文', v: celsius + 273.15 },
|
||||||
|
]
|
||||||
|
return convs
|
||||||
|
.filter(c => !(tFrom === 'C' && c.label === '摄氏度') && !(tFrom === 'F' && c.label === '华氏度') && !(tFrom === 'K' && c.label === '开尔文'))
|
||||||
|
.map((c, i) => buildUnitResultItem(value, `${tFrom}°`, '温度', c.label, c.v, i))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 普通单位 / 货币
|
||||||
|
const currencyCat = getCurrencyCategory()
|
||||||
|
const categories = [...UNIT_CATEGORIES, currencyCat]
|
||||||
|
const matched = matchUnit(token, categories)
|
||||||
|
if (!matched) return []
|
||||||
|
const { cat, unit } = matched
|
||||||
|
if (cat.id === 'currency') {
|
||||||
|
// 命中货币:后台刷新一次汇率,不阻塞本次结果
|
||||||
|
void refreshCurrencyRates()
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = value * unit.factor
|
||||||
|
const results: Array<{ item: QPItem; rank: number }> = []
|
||||||
|
for (const u of cat.units) {
|
||||||
|
if (u === unit) continue
|
||||||
|
const v = base / u.factor
|
||||||
|
results.push({
|
||||||
|
item: buildUnitResultItem(value, unit.label, cat.name, u.label, v, results.length),
|
||||||
|
rank: unitNiceRank(v),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
results.sort((a, b) => a.rank - b.rank)
|
||||||
|
return results.slice(0, 8).map(r => r.item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Provider 注册 =====
|
// ===== Provider 注册 =====
|
||||||
|
|
||||||
let providers: QPProvider[] | null = null
|
let providers: QPProvider[] | null = null
|
||||||
@@ -860,6 +1308,8 @@ export function getProviders(): QPProvider[] {
|
|||||||
new FileProvider(),
|
new FileProvider(),
|
||||||
new ClipboardProvider(),
|
new ClipboardProvider(),
|
||||||
new CalcProvider(),
|
new CalcProvider(),
|
||||||
|
new UnitProvider(),
|
||||||
|
new SpecialProvider(),
|
||||||
new SystemProvider(),
|
new SystemProvider(),
|
||||||
new WebProvider(),
|
new WebProvider(),
|
||||||
]
|
]
|
||||||
@@ -884,6 +1334,48 @@ export async function aggregateSearch(query: string): Promise<QPItem[]> {
|
|||||||
merged.push(item)
|
merged.push(item)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
merged.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
|
|
||||||
return merged
|
// 去重:所有来源的「应用」(含文件索引中的 .lnk)按名称归并,保留可靠性最高的来源
|
||||||
|
// 可靠性:开始菜单(appRank 0) > 桌面(1) > 其他位置(2);同可靠性时保留分数更高的
|
||||||
|
// (如 "TRAE Work CN" 在开始菜单 + 桌面 + 某索引目录都有 .lnk,只留开始菜单那条)
|
||||||
|
const appKey = (title: string): string => {
|
||||||
|
let t = title.trim().toLowerCase()
|
||||||
|
if (t.endsWith('.lnk')) t = t.slice(0, -4).trim()
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
// 应用候选:应用分组,以及文件分组中的 .lnk 快捷方式
|
||||||
|
const isAppLike = (item: QPItem): boolean => {
|
||||||
|
if (item.group === '应用') return true
|
||||||
|
if (item.group === '文件' && item.title && item.title.toLowerCase().endsWith('.lnk')) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const bestAppByKey = new Map<string, QPItem>()
|
||||||
|
for (const item of merged) {
|
||||||
|
if (!isAppLike(item) || !item.title) continue
|
||||||
|
const key = appKey(item.title)
|
||||||
|
const prev = bestAppByKey.get(key)
|
||||||
|
if (!prev) {
|
||||||
|
bestAppByKey.set(key, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 比较可靠性:appRank 越小越可靠;文件分组 .lnk 无 appRank 时按路径推断
|
||||||
|
const rankOf = (i: QPItem): number => {
|
||||||
|
if (i.appRank !== undefined) return i.appRank
|
||||||
|
if (i.group === '文件') return appRankFromPath(i.subtitle ?? '')
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
const rankA = rankOf(item)
|
||||||
|
const rankB = rankOf(prev)
|
||||||
|
if (rankA < rankB || (rankA === rankB && (item.score ?? 0) > (prev.score ?? 0))) {
|
||||||
|
bestAppByKey.set(key, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const keptAppIds = new Set(Array.from(bestAppByKey.values()).map(i => i.id))
|
||||||
|
const deduped = merged.filter(item => {
|
||||||
|
if (!isAppLike(item)) return true
|
||||||
|
return keptAppIds.has(item.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
deduped.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
|
||||||
|
return deduped
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user