快速面板模块

This commit is contained in:
zhongluofeng
2026-08-04 13:37:42 +08:00
parent 5656193b5c
commit 126f8896b6
23 changed files with 4048 additions and 52 deletions
+889
View File
@@ -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
}