1382 lines
42 KiB
TypeScript
1382 lines
42 KiB
TypeScript
/**
|
||
* 快速面板 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[]
|
||
/** 删除确认信息(仅可删除项设置,如文件/文件夹,用于弹窗确认后执行删除) */
|
||
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<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 }))
|
||
}
|
||
}
|
||
|
||
// ===== 应用通用:构建启动动作与子动作(开始菜单 / 文件索引 .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<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) {
|
||
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<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) => {
|
||
// .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<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()
|
||
}
|
||
}
|
||
|
||
// ===== 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 注册 =====
|
||
|
||
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<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)
|
||
})
|
||
})
|
||
|
||
// 去重:所有来源的「应用」(含文件索引中的 .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
|
||
}
|