/** * 快速面板 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 } export interface QPItem { id: string title: string subtitle?: string group: string score?: number /** 应用图标 data URL('' = 加载中,undefined = 无图标项) */ iconUrl?: string /** 应用路径(仅 app 项设置,用于按需获取图标) */ iconPath?: string /** 执行动作(调用方在执行后负责隐藏窗口) */ action: () => void | Promise /** 子动作菜单(可选)。执行子动作后同样隐藏窗口 */ subActions?: QPSubAction[] /** 删除确认信息(仅可删除项设置,如文件/文件夹,用于弹窗确认后执行删除) */ deleteInfo?: { path: string; isDir: boolean } /** 用于历史记录的查询文本(仅历史项设置,点击历史时用此重新搜索恢复 action) */ historyQuery?: string /** 应用可靠性排序(仅 group='应用' 项设置,越小越可靠:开始菜单 0 / 桌面 1 / 其他 2) */ appRank?: number } export interface QPProvider { id: string label: string priority: number /** 返回当前 query 的候选结果(引擎尚未打分,score 可留空) */ search(query: string): QPItem[] | Promise } // ===== 工具:为 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 = { 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 })) } } // ===== 应用通用:构建启动动作与子动作(开始菜单 / 文件索引 .lnk 共用) ===== /** 启动一个应用(.lnk / .exe 等),通过 Rust spawn 子进程 */ function makeAppLaunch(path: string) { return async () => { try { // .lnk 文件不能用 openUrl 打开,需直接 spawn await invoke('quickpanel_run_custom_command', { command: path, args: [] }) } catch (e) { console.error('[quickpanel] 启动应用失败:', e) } } } /** 应用项的标准子动作:启动 / 在资源管理器中显示 / 复制路径(+ 可选删除) */ function makeAppSubActions(path: string, includeDelete = false): QPSubAction[] { const launch = makeAppLaunch(path) const subs: QPSubAction[] = [ { id: 'launch', label: '启动', action: launch }, { id: 'reveal', label: '在资源管理器中显示', action: async () => { try { await invoke('quickpanel_reveal_in_explorer', { path }) } catch (e) { console.error('[quickpanel] 资源管理器显示失败:', e) } }, }, { id: 'copy-path', label: '复制路径', action: async () => { try { await navigator.clipboard.writeText(path) } catch { /* 忽略 */ } }, }, ] if (includeDelete) { subs.push({ id: 'delete', label: '删除', action: async () => { try { await invoke('quickpanel_delete_file', { path }) } catch (e) { console.error('[quickpanel] 删除失败:', e) } }, }) } return subs } /** 根据路径推断应用可靠性排序:桌面 1 / 其他位置 2(开始菜单由调用方直接给 0) */ function appRankFromPath(path: string): number { const p = path.toLowerCase() if (p.includes('\\desktop\\') || p.includes('/desktop/')) return 1 return 2 } // ===== app Provider:扫描开始菜单应用 ===== interface AppRecord { name: string path: string } let appCache: AppRecord[] | null = null let appCacheTime = 0 const APP_CACHE_TTL = 60_000 // 1 分钟缓存 async function loadApps(): Promise { if (appCache && Date.now() - appCacheTime < APP_CACHE_TTL) { return appCache } try { const apps = await invoke('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 { 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) { results.push({ item: { id: `app-${idx}`, title: app.name, subtitle: app.path, group: '应用', iconPath: app.path, action: makeAppLaunch(app.path), subActions: makeAppSubActions(app.path), appRank: 0, // 开始菜单:最可靠来源 }, 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() // path -> dataUrl('' = 无图标) /** 为搜索结果中带 iconPath 的项(应用、历史中的应用)按需加载图标(data URL), * 并写入 item.iconUrl 触发响应式更新。 * 命中前端缓存时同步返回;否则异步调用 Rust 命令(命中 Rust 缓存则零开销)。 */ export async function loadAppIconsForResults(items: QPItem[]): Promise { 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('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 { if (!query.trim() || query.trim().length < 2) return [] if (!fileIndexReady) return [] try { const files = await invoke('quickpanel_search_files', { query: query.trim(), limit: 20, }) return files.map((f, idx) => { // .lnk 快捷方式按应用处理:带图标、用启动命令,并与开始菜单应用统一去重 // 注意:Rust 返回的 ext 不带点(如 "lnk"),这里直接按文件名判断最稳妥 const isLnk = !f.isDir && f.name.toLowerCase().endsWith('.lnk') if (isLnk) { return { id: `file-app-${idx}`, title: f.name, subtitle: f.path, group: '应用', score: 0.55, // 略低于开始菜单应用(0.6+),去重时让位于开始菜单 iconPath: f.path, action: makeAppLaunch(f.path), subActions: makeAppSubActions(f.path, true), deleteInfo: { path: f.path, isDir: false }, appRank: appRankFromPath(f.path), } } const openFile = async () => { try { // 目录:Rust 端用 explorer.exe 打开;文件:默认程序打开(无关联时 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, }, ...(f.isDir ? [] : [{ id: 'reveal', label: '在资源管理器中显示', action: async () => { try { await invoke('quickpanel_reveal_in_explorer', { path: f.path }) } catch (e) { console.error('[quickpanel] 资源管理器显示失败:', e) } }, }]), { id: 'copy-path', label: '复制路径', action: async () => { try { await navigator.clipboard.writeText(f.path) } catch { /* 忽略 */ } }, }, { id: 'delete', label: '删除', action: async () => { try { // 移到回收站(PowerShell + Microsoft.VisualBasic) await invoke('quickpanel_delete_file', { path: f.path }) } catch (e) { console.error('[quickpanel] 删除失败:', e) } }, }, ], deleteInfo: { path: f.path, isDir: f.isDir }, } }) } 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 { if (!query.trim() || query.trim().length < 2) return [] try { const items = await invoke('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 { 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 { 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 { if (query.trim()) return [] // 历史只在空查询时显示 // 只返回置顶3条,剩余由 Accordion 承载 return getTopHistoryItems() } } // ===== special Provider:Windows 常用快捷位置 ===== interface SpecialLocation { id: string title: string subtitle: string keywords: string[] /** file: 真实路径;shell: explorer 打开的 shell 路径;cmd: 可执行命令 */ kind: 'file' | 'shell' | 'cmd' target: string args: string[] } let specialCache: SpecialLocation[] | null = null let specialCacheTime = 0 const SPECIAL_CACHE_TTL = 60_000 // 1 分钟缓存 async function loadSpecials(): Promise { if (specialCache && Date.now() - specialCacheTime < SPECIAL_CACHE_TTL) { return specialCache } try { const list = await invoke('quickpanel_get_special_locations') specialCache = list specialCacheTime = Date.now() return list } catch (e) { console.error('[quickpanel] 获取快捷位置失败:', e) return [] } } class SpecialProvider implements QPProvider { id = 'special' label = '快捷' priority = 60 async search(query: string): Promise { const list = await loadSpecials() if (!list.length) return [] if (!query.trim()) return [] // 空查询不占用列表,由用户主动搜索 const open = async (s: SpecialLocation) => { try { await invoke('quickpanel_open_special', { kind: s.kind, target: s.target, args: s.args, }) } catch (e) { console.error('[quickpanel] 打开快捷位置失败:', e) } } const items: QPItem[] = list.map(s => ({ id: `sp-${s.id}`, title: s.title, subtitle: s.subtitle, group: '快捷', action: () => open(s), subActions: s.kind === 'file' ? [ { id: 'open', label: '打开', action: () => open(s) }, { id: 'reveal', label: '在资源管理器中显示', action: async () => { try { await invoke('quickpanel_reveal_in_explorer', { path: s.target }) } catch (e) { console.error('[quickpanel] 资源管理器显示失败:', e) } }, }, { id: 'copy-path', label: '复制路径', action: async () => { try { await navigator.clipboard.writeText(s.target) } catch { /* 忽略 */ } }, }, ] : undefined, })) const scored: Array<{ item: QPItem; score: number }> = [] items.forEach((item, idx) => { const forms = buildItemForms(item.title, list[idx].keywords) const score = bestScore(query, forms) if (score >= 0) scored.push({ item, score }) }) scored.sort((a, b) => b.score - a.score) return scored.slice(0, 8).map(s => ({ ...s.item, score: s.score })) } } // ===== unit Provider:单位 / 货币 / 时间 / 温度换算 ===== interface UnitDef { /** 可匹配的符号(含中文),小写优先;带 exactCase 的单位只做精确大小写匹配 */ symbols: string[] label: string /** 与基准单位的换算系数(基准单位 = 1) */ factor: number /** 仅精确大小写匹配(如小写 m = 米,避免与 MB 混淆) */ exactCase?: boolean } interface UnitCategory { id: string name: string units: UnitDef[] } const UNIT_CATEGORIES: UnitCategory[] = [ { id: 'length', name: '长度', units: [ { symbols: ['m', 'meter', 'meters', '米', '公尺'], label: '米', factor: 1, exactCase: true }, { symbols: ['km', 'kilometer', 'kilometers', '千米', '公里'], label: '千米', factor: 1000 }, { symbols: ['cm', 'centimeter', 'centimeters', '厘米'], label: '厘米', factor: 0.01 }, { symbols: ['mm', 'millimeter', 'millimeters', '毫米'], label: '毫米', factor: 0.001 }, { symbols: ['in', 'inch', 'inches', '英寸'], label: '英寸', factor: 0.0254 }, { symbols: ['ft', 'foot', 'feet', '英尺'], label: '英尺', factor: 0.3048 }, { symbols: ['yd', 'yard', 'yards', '码'], label: '码', factor: 0.9144 }, { symbols: ['mi', 'mile', 'miles', '英里'], label: '英里', factor: 1609.344 }, { symbols: ['里', 'li'], label: '里', factor: 500 }, ], }, { id: 'data', name: '数据', units: [ { symbols: ['b', 'byte', 'bytes', '字节'], label: '字节', factor: 1 }, { symbols: ['kb', 'kib', 'kilobyte', 'kilobytes', '千字节'], label: 'KB', factor: 1024 }, { symbols: ['mb', 'mib', 'megabyte', 'megabytes', '兆字节'], label: 'MB', factor: 1024 ** 2 }, { symbols: ['gb', 'gib', 'gigabyte', 'gigabytes', '吉字节'], label: 'GB', factor: 1024 ** 3 }, { symbols: ['tb', 'tib', 'terabyte', 'terabytes', '太字节'], label: 'TB', factor: 1024 ** 4 }, { symbols: ['bit', 'bits', '比特'], label: 'bit', factor: 1 / 8 }, ], }, { id: 'speed', name: '网速', units: [ { symbols: ['bps', '比特/秒'], label: 'bps', factor: 1 }, { symbols: ['kbps', '千比特/秒'], label: 'Kbps', factor: 1024 }, { symbols: ['mbps', '兆比特/秒'], label: 'Mbps', factor: 1024 ** 2 }, { symbols: ['gbps', '吉比特/秒'], label: 'Gbps', factor: 1024 ** 3 }, { symbols: ['b/s'], label: 'B/s', factor: 8 }, { symbols: ['kb/s'], label: 'KB/s', factor: 8 * 1024 }, { symbols: ['mb/s'], label: 'MB/s', factor: 8 * 1024 ** 2 }, { symbols: ['gb/s'], label: 'GB/s', factor: 8 * 1024 ** 3 }, ], }, { id: 'time', name: '时间', units: [ { symbols: ['s', 'sec', 'secs', 'second', 'seconds', '秒'], label: '秒', factor: 1 }, { symbols: ['min', 'mins', 'minute', 'minutes', '分钟', '分'], label: '分钟', factor: 60 }, { symbols: ['h', 'hr', 'hrs', 'hour', 'hours', '小时', '时'], label: '小时', factor: 3600 }, { symbols: ['day', 'days', '天', '日'], label: '天', factor: 86400 }, { symbols: ['week', 'weeks', '周', '星期'], label: '周', factor: 604800 }, { symbols: ['year', 'years', '年'], label: '年', factor: 31536000 }, ], }, { id: 'weight', name: '重量', units: [ { symbols: ['kg', '千克', '公斤'], label: '千克', factor: 1 }, { symbols: ['g', 'gram', 'grams', '克'], label: '克', factor: 0.001 }, { symbols: ['mg', 'milligram', '毫克'], label: '毫克', factor: 1e-6 }, { symbols: ['t', 'ton', 'tons', '吨'], label: '吨', factor: 1000 }, { symbols: ['lb', 'lbs', 'pound', 'pounds', '磅'], label: '磅', factor: 0.45359237 }, { symbols: ['oz', 'ounce', 'ounces', '盎司'], label: '盎司', factor: 0.028349523125 }, { symbols: ['斤', 'jin'], label: '斤', factor: 0.5 }, { symbols: ['两', 'liang'], label: '两', factor: 0.05 }, ], }, ] // ===== 货币换算(汇率动态获取,带本地缓存与兜底值) ===== const DEFAULT_CURRENCY_RATES: Record = { usd: 1, cny: 7.2, eur: 0.92, gbp: 0.78, jpy: 156, hkd: 7.8, } const CURRENCY_CACHE_KEY = 'thing_quickpanel_currency_rates' function getCurrencyRates(): Record { try { const raw = localStorage.getItem(CURRENCY_CACHE_KEY) if (raw) { const p = JSON.parse(raw) if (p?.rates && Date.now() - p.ts < 24 * 3600 * 1000) return p.rates } } catch { /* 忽略损坏缓存 */ } return DEFAULT_CURRENCY_RATES } let currencyRefreshing = false /** 后台刷新汇率(失败静默,继续用缓存/兜底值),结果写入 localStorage 供下次使用 */ async function refreshCurrencyRates() { if (currencyRefreshing) return currencyRefreshing = true try { const res = await fetch('https://open.er-api.com/v6/latest/USD') const data = await res.json() if (data?.result === 'success' && data.rates) { const r = data.rates as Record const rates: Record = { usd: 1, cny: r.CNY ?? DEFAULT_CURRENCY_RATES.cny, eur: r.EUR ?? DEFAULT_CURRENCY_RATES.eur, gbp: r.GBP ?? DEFAULT_CURRENCY_RATES.gbp, jpy: r.JPY ?? DEFAULT_CURRENCY_RATES.jpy, hkd: r.HKD ?? DEFAULT_CURRENCY_RATES.hkd, } localStorage.setItem(CURRENCY_CACHE_KEY, JSON.stringify({ ts: Date.now(), rates })) } } catch { /* 网络失败,继续使用默认/缓存汇率 */ } finally { currencyRefreshing = false } } /** 动态构建货币类别(基准 = 美元;factor 为「1 单位该货币 = ? 美元」) */ function getCurrencyCategory(): UnitCategory { const r = getCurrencyRates() const perUsd = (v: number) => (v > 0 ? 1 / v : 0) return { id: 'currency', name: '货币', units: [ { symbols: ['$', 'usd', '美元', '美金', '美刀'], label: '美元', factor: 1 }, { symbols: ['¥', '¥', 'rmb', 'cny', '元', '人民币'], label: '人民币', factor: perUsd(r.cny) }, { symbols: ['€', 'eur', '欧元'], label: '欧元', factor: perUsd(r.eur) }, { symbols: ['£', 'gbp', '英镑'], label: '英镑', factor: perUsd(r.gbp) }, { symbols: ['jpy', '日元', '日圆'], label: '日元', factor: perUsd(r.jpy) }, { symbols: ['hkd', '港币', '港元'], label: '港元', factor: perUsd(r.hkd) }, ], } } /** 温度匹配(仿射换算,单独处理) */ function matchTemperature(token: string): 'C' | 'F' | 'K' | null { const t = token.toLowerCase().replace(/°/g, '') if (['c', 'celsius', '摄氏度', '摄氏'].includes(t)) return 'C' if (['f', 'fahrenheit', '华氏度', '华氏'].includes(t)) return 'F' if (['kelvin', '开尔文'].includes(t)) return 'K' return null } /** 在(普通 + 货币)类别中匹配单位 token */ function matchUnit( token: string, categories: UnitCategory[], ): { cat: UnitCategory; unit: UnitDef } | null { // 第一轮:精确大小写匹配 for (const cat of categories) { for (const unit of cat.units) { if (unit.symbols.some(s => s === token)) return { cat, unit } } } // 第二轮:大小写不敏感;exactCase 单位(如 m=米)跳过,避免 "1M" 误判为 1 米 const lower = token.toLowerCase() for (const cat of categories) { for (const unit of cat.units) { if (unit.exactCase) continue if (unit.symbols.some(s => s.toLowerCase() === lower)) return { cat, unit } } } return null } /** 数值格式化(去掉多余的浮点尾巴) */ function formatUnitValue(v: number): string { if (!isFinite(v)) return '' if (v === 0) return '0' const abs = Math.abs(v) if (abs >= 1e12) return v.toExponential(2) if (abs >= 1e6) return Number(v.toFixed(0)).toLocaleString('en-US') if (abs >= 1000) return Number(v.toFixed(1)).toLocaleString('en-US') if (abs >= 100) return Number(v.toFixed(1)).toString() if (abs >= 1) return Number(v.toFixed(2)).toString() if (abs >= 1e-4) return Number(v.toFixed(4)).toString() return v.toExponential(2) } /** 结果展示优先级:整数 > 常见量级(1~1000) > 其他 */ function unitNiceRank(v: number): number { if (Number.isInteger(v)) return 0 const abs = Math.abs(v) if (abs >= 1 && abs < 1000) return 1 return 2 } function buildUnitResultItem( value: number, fromLabel: string, catName: string, toLabel: string, toValue: number, idx: number, ): QPItem { const text = `${formatUnitValue(toValue)} ${toLabel}` return { id: `unit-${catName}-${idx}`, title: text, subtitle: `${value} ${fromLabel}(${catName}换算)`, group: '换算', score: 0.85, action: async () => { try { await navigator.clipboard.writeText(text) } catch { /* 忽略 */ } }, } } class UnitProvider implements QPProvider { id = 'unit' label = '换算' priority = 80 async search(query: string): Promise { const trimmed = query.trim() if (!trimmed) return [] const m = trimmed.match(/^(\d+(?:\.\d+)?)\s*(.+)$/) if (!m) return [] const value = parseFloat(m[1]) if (!isFinite(value) || value <= 0) return [] const token = m[2].trim() if (!token) return [] // 温度(仿射换算) const tFrom = matchTemperature(token) if (tFrom) { const celsius = tFrom === 'C' ? value : tFrom === 'F' ? ((value - 32) * 5) / 9 : value - 273.15 const convs: Array<{ label: string; v: number }> = [ { label: '摄氏度', v: celsius }, { label: '华氏度', v: (celsius * 9) / 5 + 32 }, { label: '开尔文', v: celsius + 273.15 }, ] return convs .filter(c => !(tFrom === 'C' && c.label === '摄氏度') && !(tFrom === 'F' && c.label === '华氏度') && !(tFrom === 'K' && c.label === '开尔文')) .map((c, i) => buildUnitResultItem(value, `${tFrom}°`, '温度', c.label, c.v, i)) } // 普通单位 / 货币 const currencyCat = getCurrencyCategory() const categories = [...UNIT_CATEGORIES, currencyCat] const matched = matchUnit(token, categories) if (!matched) return [] const { cat, unit } = matched if (cat.id === 'currency') { // 命中货币:后台刷新一次汇率,不阻塞本次结果 void refreshCurrencyRates() } const base = value * unit.factor const results: Array<{ item: QPItem; rank: number }> = [] for (const u of cat.units) { if (u === unit) continue const v = base / u.factor results.push({ item: buildUnitResultItem(value, unit.label, cat.name, u.label, v, results.length), rank: unitNiceRank(v), }) } results.sort((a, b) => a.rank - b.rank) return results.slice(0, 8).map(r => r.item) } } // ===== Provider 注册 ===== let providers: QPProvider[] | null = null export function getProviders(): QPProvider[] { if (!providers) { providers = [ new HistoryProvider(), new CommandProvider(), new CustomCommandProvider(), new AppProvider(), new FileProvider(), new ClipboardProvider(), new CalcProvider(), new UnitProvider(), new SpecialProvider(), new SystemProvider(), new WebProvider(), ] } return providers } /** * 聚合搜索:并行调用各 Provider,合并结果,按 score 降序排序。 * 空查询时返回 command Provider 的快捷入口 + system Provider 的固定项。 */ export async function aggregateSearch(query: string): Promise { 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) }) }) // 去重:所有来源的「应用」(含文件索引中的 .lnk)按名称归并,保留可靠性最高的来源 // 可靠性:开始菜单(appRank 0) > 桌面(1) > 其他位置(2);同可靠性时保留分数更高的 // (如 "TRAE Work CN" 在开始菜单 + 桌面 + 某索引目录都有 .lnk,只留开始菜单那条) const appKey = (title: string): string => { let t = title.trim().toLowerCase() if (t.endsWith('.lnk')) t = t.slice(0, -4).trim() return t } // 应用候选:应用分组,以及文件分组中的 .lnk 快捷方式 const isAppLike = (item: QPItem): boolean => { if (item.group === '应用') return true if (item.group === '文件' && item.title && item.title.toLowerCase().endsWith('.lnk')) return true return false } const bestAppByKey = new Map() for (const item of merged) { if (!isAppLike(item) || !item.title) continue const key = appKey(item.title) const prev = bestAppByKey.get(key) if (!prev) { bestAppByKey.set(key, item) continue } // 比较可靠性:appRank 越小越可靠;文件分组 .lnk 无 appRank 时按路径推断 const rankOf = (i: QPItem): number => { if (i.appRank !== undefined) return i.appRank if (i.group === '文件') return appRankFromPath(i.subtitle ?? '') return 2 } const rankA = rankOf(item) const rankB = rankOf(prev) if (rankA < rankB || (rankA === rankB && (item.score ?? 0) > (prev.score ?? 0))) { bestAppByKey.set(key, item) } } const keptAppIds = new Set(Array.from(bestAppByKey.values()).map(i => i.id)) const deduped = merged.filter(item => { if (!isAppLike(item)) return true return keptAppIds.has(item.id) }) deduped.sort((a, b) => (b.score ?? 0) - (a.score ?? 0)) return deduped }