快速面板模块

This commit is contained in:
zhongluofeng
2026-08-04 16:00:57 +08:00
parent 126f8896b6
commit c7578a2e6b
7 changed files with 954 additions and 117 deletions
+545 -53
View File
@@ -38,8 +38,12 @@ export interface QPItem {
action: () => void | Promise<void>
/** 子动作菜单(可选)。执行子动作后同样隐藏窗口 */
subActions?: QPSubAction[]
/** 删除确认信息(仅可删除项设置,如文件/文件夹,用于弹窗确认后执行删除) */
deleteInfo?: { path: string; isDir: boolean }
/** 用于历史记录的查询文本(仅历史项设置,点击历史时用此重新搜索恢复 action) */
historyQuery?: string
/** 应用可靠性排序(仅 group='应用' 项设置,越小越可靠:开始菜单 0 / 桌面 1 / 其他 2) */
appRank?: number
}
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:扫描开始菜单应用 =====
interface AppRecord {
@@ -430,17 +499,6 @@ class AppProvider implements QPProvider {
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}`,
@@ -448,32 +506,9 @@ class AppProvider implements QPProvider {
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 {
/* 忽略 */
}
},
},
],
action: makeAppLaunch(app.path),
subActions: makeAppSubActions(app.path),
appRank: 0, // 开始菜单:最可靠来源
},
score,
})
@@ -554,9 +589,26 @@ class FileProvider implements QPProvider {
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 端会 fallback 到「打开方式」对话框
// 目录:Rust 端用 explorer.exe 打开;文件:默认程序打开无关联 fallback 打开方式
await invoke('quickpanel_open_file', { path: f.path })
} catch (e) {
console.error('[quickpanel] 打开文件失败:', e)
@@ -569,23 +621,26 @@ class FileProvider implements QPProvider {
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)
}
},
},
...(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: '复制路径',
@@ -602,8 +657,7 @@ class FileProvider implements QPProvider {
label: '删除',
action: async () => {
try {
// 移到回收站explorer.exe 不直接支持,用 PowerShell 或直接删除
// 这里用 Rust 命令删除(简化实现,实际移到回收站需 SHFileOperation
// 移到回收站PowerShell + Microsoft.VisualBasic
await invoke('quickpanel_delete_file', { path: f.path })
} catch (e) {
console.error('[quickpanel] 删除失败:', e)
@@ -611,6 +665,7 @@ class FileProvider implements QPProvider {
},
},
],
deleteInfo: { path: f.path, isDir: f.isDir },
}
})
} catch (e) {
@@ -846,6 +901,399 @@ class HistoryProvider implements QPProvider {
}
}
// ===== special ProviderWindows 常用快捷位置 =====
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
@@ -860,6 +1308,8 @@ export function getProviders(): QPProvider[] {
new FileProvider(),
new ClipboardProvider(),
new CalcProvider(),
new UnitProvider(),
new SpecialProvider(),
new SystemProvider(),
new WebProvider(),
]
@@ -884,6 +1334,48 @@ export async function aggregateSearch(query: string): Promise<QPItem[]> {
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
}