快速面板模块

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
-24
View File
@@ -1,24 +0,0 @@
<script setup lang="ts">
import { Search } from '@lucide/vue'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
</script>
<template>
<div class="h-full p-6 overflow-y-auto">
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Search class="h-5 w-5 text-primary" />
文件搜索模块
</CardTitle>
</CardHeader>
<CardContent>
<div class="flex flex-col items-center justify-center h-64 text-muted-foreground">
<Search class="h-16 w-16 mb-4 opacity-50" />
<p>文件搜索功能开发中...</p>
<p class="text-sm mt-2">支持快速搜索弹窗文件搜索应用程序搜索等功能</p>
</div>
</CardContent>
</Card>
</div>
</template>
-22
View File
@@ -1,22 +0,0 @@
import type { ModuleConfig } from '@/types/module'
import type { SearchIndexItem } from '@/stores/searchIndex'
const searchItems: SearchIndexItem[] = [
{
title: '文件搜索',
description: '搜索本地文件',
keywords: ['文件', '搜索', 'finder', 'search', 'file']
}
]
export const moduleConfig: ModuleConfig = {
id: 'finder',
name: '文件搜索',
icon: 'finder',
description: '快速文件搜索、拼音模糊匹配',
category: 'tool',
defaultEnabled: true,
loader: () => import('./FinderModule.vue'),
searchItems,
order: 60
}
+2 -2
View File
@@ -6,7 +6,7 @@ import {
Camera,
Activity,
Download,
Search
Command
} from '@lucide/vue'
/**
@@ -23,7 +23,7 @@ export const moduleIconMap: Record<string, Component> = {
screenshot: Camera,
monitor: Activity,
downloader: Download,
finder: Search
quickpanel: Command
}
/** 获取模块图标组件,未找到时回退到 Settings 图标 */
+2 -2
View File
@@ -7,7 +7,7 @@ import { moduleConfig as clipboard } from './clipboard'
import { moduleConfig as screenshot } from './screenshot'
import { moduleConfig as monitor } from './monitor'
import { moduleConfig as downloader } from './downloader'
import { moduleConfig as finder } from './finder'
import { moduleConfig as quickpanel } from './quickpanel'
import { moduleConfig as general } from './general'
const allModules: ModuleConfig[] = [
@@ -16,7 +16,7 @@ const allModules: ModuleConfig[] = [
screenshot,
monitor,
downloader,
finder,
quickpanel,
general
]
+760
View File
@@ -0,0 +1,760 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { getCurrentWindow, Effect, EffectState } from '@tauri-apps/api/window'
import { Search, Command, Loader2, CornerDownLeft, Calculator, Globe, Lock, ChevronRight, Terminal, History } from '@lucide/vue'
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion'
import { aggregateSearch, loadAppIconsForResults, recordHistoryItem, getMoreHistoryItems, getMoreHistoryCount, setFileIndexReady, type QPItem, type QPSubAction } from './providers'
// ===== 状态 =====
const query = ref('')
const inputRef = ref<HTMLInputElement | null>(null)
const results = ref<QPItem[]>([])
const selectedIndex = ref(0)
const loading = ref(false)
// 子动作展开:展开的 item 索引,null 表示未展开
const subActionExpanded = ref<number | null>(null)
const subActionIndex = ref(0)
let unlistenFns: UnlistenFn[] = []
let searchTimer: ReturnType<typeof setTimeout> | null = null
// ===== 历史分区(从 results 中分离历史项与其他结果) =====
const historyItems = computed(() => results.value.filter(r => r.group === '历史'))
const otherItems = computed(() => results.value.filter(r => r.group !== '历史'))
// Accordion 中的更多历史项(不参与键盘上下导航,仅鼠标点击)
const moreHistoryItems = ref<QPItem[]>([])
const moreHistoryCount = ref(0)
// ===== 历史频率(localStorage 持久化,用于排序加权) =====
const HISTORY_KEY = 'thing_quickpanel_history'
function loadHistory(): Record<string, number> {
try {
const raw = localStorage.getItem(HISTORY_KEY)
return raw ? JSON.parse(raw) : {}
} catch {
return {}
}
}
function recordHistory(id: string) {
if (!id) return
const history = loadHistory()
history[id] = (history[id] || 0) + 1
// 只保留最近 100 条
const entries = Object.entries(history).sort((a, b) => b[1] - a[1]).slice(0, 100)
localStorage.setItem(HISTORY_KEY, JSON.stringify(Object.fromEntries(entries)))
}
/** 对搜索结果应用历史频率加权后重新排序 */
function applyHistoryBoost(items: QPItem[]): QPItem[] {
const history = loadHistory()
return items
.map(item => ({
...item,
score: (item.score ?? 0) + Math.min(history[item.id] || 0, 5) * 0.05,
}))
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
}
// ===== 搜索 =====
async function doSearch() {
const q = query.value.trim()
if (!q) {
// 空查询:显示命令快捷入口 + 系统操作 + 历史(置顶3条)
results.value = applyHistoryBoost(await aggregateSearch(''))
selectedIndex.value = 0
// 加载更多历史(Accordion 折叠区,不参与键盘导航)
moreHistoryItems.value = getMoreHistoryItems()
moreHistoryCount.value = getMoreHistoryCount()
// 后台加载应用图标(含历史中的图标)
void loadAppIconsForResults(results.value)
void loadAppIconsForResults(moreHistoryItems.value)
return
}
// 非空查询:清空历史分区
moreHistoryItems.value = []
moreHistoryCount.value = 0
loading.value = true
try {
const items = await aggregateSearch(q)
results.value = applyHistoryBoost(items)
selectedIndex.value = 0
// 后台加载应用图标(不阻塞结果显示)
void loadAppIconsForResults(results.value)
} catch (e) {
console.error('[quickpanel] 搜索失败:', e)
results.value = []
} finally {
loading.value = false
}
}
// 防抖搜索
watch(query, () => {
if (searchTimer) clearTimeout(searchTimer)
collapseSubActions()
searchTimer = setTimeout(doSearch, 120)
})
// ===== 执行与隐藏 =====
async function hideWindow() {
try {
await invoke('quickpanel_hide_popup')
} catch {
/* 忽略 */
}
}
async function executeItem(item: QPItem) {
recordHistory(item.id)
// 记录交互历史(用于历史 Provider 显示)
recordHistoryItem(item, query.value)
try {
await item.action()
} catch (e) {
console.error('[quickpanel] 执行失败:', e)
}
await hideWindow()
}
async function executeSubAction(sub: QPSubAction) {
try {
await sub.action()
} catch (e) {
console.error('[quickpanel] 子动作执行失败:', e)
}
await hideWindow()
}
// 子动作展开/收起
function toggleSubActions(idx: number) {
const item = results.value[idx]
if (!item?.subActions?.length) return
if (subActionExpanded.value === idx) {
subActionExpanded.value = null
} else {
subActionExpanded.value = idx
subActionIndex.value = 0
}
}
function collapseSubActions() {
subActionExpanded.value = null
}
// 当前展开的子动作列表
function currentSubActions(): QPSubAction[] {
if (subActionExpanded.value === null) return []
return results.value[subActionExpanded.value]?.subActions || []
}
// ===== 键盘导航 =====
function onKeydown(e: KeyboardEvent) {
const expanded = subActionExpanded.value !== null
const subs = currentSubActions()
if (expanded) {
// 子动作导航模式
if (e.key === 'ArrowDown') {
e.preventDefault()
subActionIndex.value = Math.min(subActionIndex.value + 1, subs.length - 1)
scrollSubActionIntoView()
} else if (e.key === 'ArrowUp') {
e.preventDefault()
subActionIndex.value = Math.max(subActionIndex.value - 1, 0)
scrollSubActionIntoView()
} else if (e.key === 'Enter') {
e.preventDefault()
const sub = subs[subActionIndex.value]
if (sub) executeSubAction(sub)
} else if (e.key === 'Escape') {
e.preventDefault()
collapseSubActions()
} else if (e.key === 'Tab') {
e.preventDefault()
collapseSubActions()
} else if (/^[1-9]$/.test(e.key)) {
// 数字键快速执行对应子动作(1-9)
e.preventDefault()
const idx = parseInt(e.key, 10) - 1
const sub = subs[idx]
if (sub) executeSubAction(sub)
}
return
}
// 结果列表导航模式
if (e.key === 'ArrowDown') {
e.preventDefault()
selectedIndex.value = Math.min(selectedIndex.value + 1, results.value.length - 1)
scrollSelectedIntoView()
} else if (e.key === 'ArrowUp') {
e.preventDefault()
selectedIndex.value = Math.max(selectedIndex.value - 1, 0)
scrollSelectedIntoView()
} else if (e.key === 'Enter') {
e.preventDefault()
const item = results.value[selectedIndex.value]
if (item) executeItem(item)
} else if (e.key === 'Escape') {
e.preventDefault()
hideWindow()
} else if (e.key === 'Tab') {
// Tab 展开子动作
const item = results.value[selectedIndex.value]
if (item?.subActions?.length) {
e.preventDefault()
toggleSubActions(selectedIndex.value)
}
}
}
function scrollSelectedIntoView() {
nextTick(() => {
const el = document.querySelector('.qp-item-selected') as HTMLElement | null
el?.scrollIntoView({ block: 'nearest' })
})
}
function scrollSubActionIntoView() {
nextTick(() => {
const el = document.querySelector('.qp-sub-selected') as HTMLElement | null
el?.scrollIntoView({ block: 'nearest' })
})
}
function onItemHover(idx: number) {
selectedIndex.value = idx
// hover 其他项时收起子动作
if (subActionExpanded.value !== null && subActionExpanded.value !== idx) {
collapseSubActions()
}
}
function onSubActionHover(idx: number) {
subActionIndex.value = idx
}
// ===== 图标映射 =====
/** 应用类条目(含历史中的应用)不显示 subtitle(路径),让布局更紧凑 */
const isAppLike = (item: QPItem) => !!item.iconPath
const groupIcon = (group: string) => {
if (group === '命令') return Command
if (group === '计算') return Calculator
if (group === '网页') return Globe
if (group === '系统') return Lock
if (group === '自定义') return Terminal
if (group === '历史') return History
return Search
}
const hasResults = () => results.value.length > 0
// ===== 主题应用(与主应用同步,独立窗口需自行设置) =====
function readMainTheme(): { theme: string; effect: string } {
try {
const raw = localStorage.getItem('thing_app_settings')
if (raw) {
const s = JSON.parse(raw)
return {
theme: s.theme ?? 'system',
effect: s.effect ?? 'mica',
}
}
} catch {
/* 忽略 */
}
return { theme: 'system', effect: 'mica' }
}
function resolveIsDark(theme: string): boolean {
if (theme === 'dark') return true
if (theme === 'light') return false
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
async function applyTheme() {
const root = document.documentElement
const { theme, effect } = readMainTheme()
try {
const tauriWin = getCurrentWindow()
if (theme === 'system') {
await tauriWin.setTheme(null)
} else {
await tauriWin.setTheme(theme as 'dark' | 'light')
}
} catch {
/* 非 Tauri 环境忽略 */
}
const isDark = resolveIsDark(theme)
root.classList.remove('dark', 'effect-mica', 'effect-acrylic', 'effect-normal')
root.classList.add(`effect-${effect}`)
if (isDark) root.classList.add('dark')
try {
const tauriWin = getCurrentWindow()
await tauriWin.clearEffects()
if (effect === 'mica') {
await tauriWin.setEffects({
effects: [Effect.Mica],
state: EffectState.FollowsWindowActiveState,
color: isDark ? [30, 41, 59, 0] : [248, 250, 252, 0],
})
await tauriWin.setBackgroundColor('#00000000')
root.style.setProperty('--popup-bg', 'transparent')
} else if (effect === 'acrylic') {
await tauriWin.setEffects({
effects: [Effect.Acrylic],
state: EffectState.FollowsWindowActiveState,
color: isDark ? [30, 41, 59, 0] : [248, 250, 252, 0],
})
await tauriWin.setBackgroundColor('#00000000')
root.style.setProperty('--popup-bg', 'transparent')
} else {
await tauriWin.setBackgroundColor(isDark ? '#0f172a' : '#ffffff')
root.style.setProperty('--popup-bg', isDark ? '#0f172a' : '#ffffff')
}
} catch {
/* 非 Tauri 环境忽略 */
}
}
onMounted(async () => {
await applyTheme()
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const onThemeChange = () => applyTheme()
mq.addEventListener('change', onThemeChange)
unlistenFns.push(() => mq.removeEventListener('change', onThemeChange))
const onStorage = (e: StorageEvent) => {
if (e.key === 'thing_app_settings') applyTheme()
}
window.addEventListener('storage', onStorage)
unlistenFns.push(() => window.removeEventListener('storage', onStorage))
// 监听弹窗显示事件:重新同步主题 + 清空输入 + 加载初始结果
unlistenFns.push(await listen('quickpanel-show', async () => {
await applyTheme()
query.value = ''
await doSearch()
await nextTick()
inputRef.value?.focus()
}))
unlistenFns.push(await listen('quickpanel-hide', () => {
query.value = ''
results.value = []
}))
// 初始加载(空查询显示快捷入口)
// 独立窗口需自行检查文件索引状态(主窗口的 setFileIndexReady 不共享到此上下文)
try {
const stats = await invoke<{ total: number }>('quickpanel_file_index_stats')
setFileIndexReady((stats?.total ?? 0) > 0)
} catch {
/* 索引未初始化,忽略 */
}
await doSearch()
await nextTick()
inputRef.value?.focus()
try {
await invoke('quickpanel_show_window')
} catch {
/* 忽略 */
}
})
onUnmounted(() => {
if (searchTimer) clearTimeout(searchTimer)
unlistenFns.forEach((fn) => fn())
})
</script>
<template>
<div class="qp-root flex flex-col h-screen w-screen" @keydown="onKeydown">
<!-- 搜索输入 -->
<div class="qp-input-wrap">
<Search class="h-4 w-4 text-muted-foreground shrink-0" />
<input
ref="inputRef"
v-model="query"
class="qp-input"
placeholder="搜索命令、应用、文件…"
spellcheck="false"
autocomplete="off"
/>
<kbd class="qp-kbd">Esc</kbd>
</div>
<!-- 结果区 -->
<div class="qp-results">
<div v-if="loading && !hasResults()" class="qp-empty">
<Loader2 class="h-6 w-6 animate-spin mb-2" />
<p class="text-sm">搜索中</p>
</div>
<div v-else-if="!hasResults() && query.trim()" class="qp-empty">
<Search class="h-10 w-10 mb-3 opacity-40" />
<p class="text-sm">无匹配结果</p>
<p class="text-xs mt-1 opacity-60"> Enter 在搜索引擎中查找</p>
</div>
<div v-else-if="!hasResults()" class="qp-empty">
<Command class="h-10 w-10 mb-3 opacity-40" />
<p class="text-sm">输入关键词开始搜索</p>
<p class="text-xs mt-1 opacity-60">命令 · 计算 · 系统 · 网页</p>
</div>
<template v-else>
<!-- 历史置顶项可键盘导航 -->
<template v-for="(item, idx) in historyItems" :key="item.id">
<div
class="qp-item"
:class="{ 'qp-item-selected': idx === selectedIndex }"
@click="executeItem(item)"
@mouseenter="onItemHover(idx)"
>
<img
v-if="item.iconUrl"
:src="item.iconUrl"
class="qp-app-icon shrink-0"
alt=""
/>
<component
v-else
:is="groupIcon(item.group)"
class="h-4 w-4 text-muted-foreground shrink-0"
:class="isAppLike(item) ? '' : 'mt-0.5'"
/>
<div class="flex-1 min-w-0 flex" :class="isAppLike(item) ? 'items-center' : 'flex-col'">
<p class="text-sm truncate">{{ item.title }}</p>
<p v-if="!isAppLike(item) && item.subtitle" class="text-xs text-muted-foreground truncate">{{ item.subtitle }}</p>
</div>
<span class="qp-group-badge">{{ item.group }}</span>
<CornerDownLeft
v-if="idx === selectedIndex"
class="h-3.5 w-3.5 text-primary shrink-0"
/>
</div>
</template>
<!-- 更多历史 Accordion固定在历史下方不参与键盘导航 -->
<Accordion
v-if="moreHistoryCount > 0"
type="single"
collapsible
class="qp-more-history"
>
<AccordionItem value="more" class="border-0">
<AccordionTrigger class="qp-more-trigger">
<span class="flex items-center gap-2">
<History class="h-3.5 w-3.5 text-muted-foreground" />
更多历史{{ moreHistoryCount }}
</span>
</AccordionTrigger>
<AccordionContent class="qp-more-content">
<div
v-for="item in moreHistoryItems"
:key="item.id"
class="qp-item qp-more-item"
@click="executeItem(item)"
>
<img
v-if="item.iconUrl"
:src="item.iconUrl"
class="qp-app-icon shrink-0"
alt=""
/>
<component
v-else
:is="groupIcon(item.group)"
class="h-4 w-4 text-muted-foreground shrink-0"
:class="isAppLike(item) ? '' : 'mt-0.5'"
/>
<div class="flex-1 min-w-0 flex" :class="isAppLike(item) ? 'items-center' : 'flex-col'">
<p class="text-sm truncate">{{ item.title }}</p>
<p v-if="!isAppLike(item) && item.subtitle" class="text-xs text-muted-foreground truncate">{{ item.subtitle }}</p>
</div>
<span class="qp-group-badge">{{ item.group }}</span>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<!-- 其他结果命令/应用/系统等可键盘导航索引偏移 historyItems.length -->
<template v-for="(item, idx) in otherItems" :key="item.id">
<div
class="qp-item"
:class="{ 'qp-item-selected': (idx + historyItems.length) === selectedIndex }"
@click="executeItem(item)"
@mouseenter="onItemHover(idx + historyItems.length)"
>
<img
v-if="item.iconUrl"
:src="item.iconUrl"
class="qp-app-icon shrink-0"
alt=""
/>
<component
v-else
:is="groupIcon(item.group)"
class="h-4 w-4 text-muted-foreground shrink-0"
:class="isAppLike(item) ? '' : 'mt-0.5'"
/>
<div class="flex-1 min-w-0 flex" :class="isAppLike(item) ? 'items-center' : 'flex-col'">
<p class="text-sm truncate">{{ item.title }}</p>
<p v-if="!isAppLike(item) && item.subtitle" class="text-xs text-muted-foreground truncate">{{ item.subtitle }}</p>
</div>
<span class="qp-group-badge">{{ item.group }}</span>
<ChevronRight
v-if="item.subActions?.length && (idx + historyItems.length) !== selectedIndex"
class="h-3.5 w-3.5 text-muted-foreground shrink-0"
/>
<kbd
v-else-if="item.subActions?.length && (idx + historyItems.length) === selectedIndex"
class="qp-kbd shrink-0"
@click.stop="toggleSubActions(idx + historyItems.length)"
>Tab</kbd>
<CornerDownLeft
v-else-if="(idx + historyItems.length) === selectedIndex"
class="h-3.5 w-3.5 text-primary shrink-0"
/>
</div>
<!-- 子动作展开面板 -->
<div v-if="subActionExpanded === (idx + historyItems.length) && item.subActions?.length" class="qp-sub-panel">
<div
v-for="(sub, sIdx) in item.subActions"
:key="sub.id"
class="qp-sub-item"
:class="{ 'qp-sub-selected': sIdx === subActionIndex }"
@click="executeSubAction(sub)"
@mouseenter="onSubActionHover(sIdx)"
>
<span class="qp-sub-num">{{ sIdx + 1 }}</span>
<span class="flex-1 text-xs">{{ sub.label }}</span>
</div>
</div>
</template>
</template>
</div>
<!-- 底部提示 -->
<div class="qp-footer">
<span><kbd></kbd><kbd></kbd> 导航</span>
<span v-if="subActionExpanded === null"><kbd>Tab</kbd> 子动作</span>
<span v-else><kbd>1-9</kbd> 快捷执行</span>
<span><kbd>Enter</kbd> 执行</span>
<span><kbd>Esc</kbd> {{ subActionExpanded !== null ? '收起' : '关闭' }}</span>
</div>
</div>
</template>
<style scoped>
.qp-root {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', 'PingFang SC', sans-serif;
background: var(--popup-bg, transparent);
color: var(--foreground);
border-radius: 10px;
overflow: hidden;
position: relative;
}
.qp-input-wrap {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
font-size: 15px;
border-bottom: 1px solid var(--border);
}
.qp-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--foreground);
font-size: 15px;
font-family: inherit;
}
.qp-input::placeholder {
color: var(--muted-foreground);
}
.qp-kbd {
background: var(--muted);
color: var(--foreground);
padding: 1px 6px;
border-radius: 3px;
font-size: 10px;
font-family: inherit;
}
.qp-results {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 6px;
}
.qp-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--muted-foreground);
text-align: center;
padding: 20px;
}
.qp-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 12px;
border-radius: var(--radius);
cursor: pointer;
transition: background-color 0.08s;
}
.qp-app-icon {
width: 18px;
height: 18px;
margin-top: 1px;
object-fit: contain;
/* 命中缓存前占位,避免布局抖动 */
background: transparent;
}
.qp-item:hover {
background: var(--muted);
}
.qp-item-selected {
background: var(--accent);
}
/* 更多历史 Accordion */
.qp-more-history {
margin: 0 6px 4px;
}
.qp-more-trigger {
padding: 6px 12px;
font-size: 12px;
font-weight: 500;
color: var(--muted-foreground);
min-height: 28px;
border-radius: var(--radius);
/* 覆盖 reka-ui 默认 py-4 */
padding-top: 6px;
padding-bottom: 6px;
}
.qp-more-trigger:hover {
background: var(--muted);
}
.qp-more-content {
/* 覆盖 AccordionContent 默认 pb-4 */
padding-top: 0;
padding-bottom: 2px;
}
.qp-more-item {
padding: 6px 12px;
}
.qp-item-selected:hover {
background: var(--accent);
}
.qp-group-badge {
font-size: 10px;
color: var(--muted-foreground);
background: var(--muted);
padding: 1px 6px;
border-radius: 3px;
flex-shrink: 0;
margin-top: 2px;
}
/* 子动作面板 */
.qp-sub-panel {
margin: 2px 0 4px 28px;
padding: 4px;
background: var(--muted);
border-radius: var(--radius);
border: 1px solid var(--border);
}
.qp-sub-item {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 8px;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.08s;
}
.qp-sub-item:hover {
background: var(--accent);
}
.qp-sub-selected {
background: var(--accent);
}
.qp-sub-num {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 3px;
background: var(--accent-foreground);
color: var(--accent);
font-size: 10px;
font-weight: 600;
flex-shrink: 0;
}
.qp-footer {
display: flex;
justify-content: center;
gap: 16px;
padding: 8px 12px;
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--muted-foreground);
}
.qp-footer kbd {
background: var(--muted);
color: var(--foreground);
padding: 1px 5px;
border-radius: 3px;
font-size: 10px;
margin-right: 2px;
font-family: inherit;
}
.qp-results::-webkit-scrollbar {
width: 6px;
}
.qp-results::-webkit-scrollbar-thumb {
background: var(--muted-foreground);
opacity: 0.3;
border-radius: 3px;
}
.qp-results::-webkit-scrollbar-track {
background: transparent;
}
</style>
+549
View File
@@ -0,0 +1,549 @@
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { open } from '@tauri-apps/plugin-dialog'
import { toast } from 'vue-sonner'
import { Command, Zap, Keyboard, Globe, Monitor, MousePointer2, FolderTree, RefreshCw, Plus, X, Loader2, Terminal, Pencil, Check } from '@lucide/vue'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { useModuleTabsStore } from '@/stores/moduleTabsStore'
import { setFileIndexReady, invalidateCustomCommandsCache } from './providers'
interface CustomCommand {
id: string
title: string
command: string
args: string[]
}
interface QuickPanelSettings {
shortcut: string
popupPosition: string
searchEngine: string
indexDirs: string[]
customCommands: CustomCommand[]
}
const form = reactive<QuickPanelSettings>({
shortcut: 'Alt+Space',
popupPosition: 'center',
searchEngine: 'bing',
indexDirs: [],
customCommands: [],
})
// ===== 文件索引状态 =====
interface IndexStats {
total: number
lastBuiltAt: number
lastBuiltDirs: string[]
}
const indexStats = ref<IndexStats | null>(null)
const building = ref(false)
async function refreshStats() {
try {
indexStats.value = await invoke<IndexStats>('quickpanel_file_index_stats')
// 索引存在(total > 0)即标记为就绪
setFileIndexReady((indexStats.value?.total ?? 0) > 0)
} catch (e) {
console.error('[quickpanel] 获取索引状态失败:', e)
}
}
async function buildIndex() {
if (building.value) return
building.value = true
try {
const count = await invoke<number>('quickpanel_build_file_index')
toast.success(`索引完成,共 ${count}`)
await refreshStats()
} catch (e) {
console.error('[quickpanel] 索引构建失败:', e)
toast.error('索引构建失败')
} finally {
building.value = false
}
}
async function addDir() {
const selected = await open({ directory: true, multiple: false })
if (typeof selected === 'string' && !form.indexDirs.includes(selected)) {
form.indexDirs.push(selected)
await saveSettings()
}
}
function removeDir(idx: number) {
form.indexDirs.splice(idx, 1)
void saveSettings()
}
function formatTime(t: number): string {
if (!t) return '未构建'
const d = new Date(t * 1000)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
onMounted(async () => {
try {
const s = await invoke<QuickPanelSettings>('quickpanel_get_settings')
Object.assign(form, s)
} catch (e) {
console.error('[quickpanel] 读取设置失败:', e)
}
await refreshStats()
})
// ===== 保存 =====
async function saveSettings() {
try {
await invoke('quickpanel_save_settings', { settings: { ...form } })
// 同步到 localStorage 供独立窗口读取
localStorage.setItem('thing_quickpanel_settings', JSON.stringify({ ...form }))
// 清除自定义命令缓存,使下次搜索重新加载
invalidateCustomCommandsCache()
toast.success('设置已保存')
} catch (e) {
console.error('[quickpanel] 保存设置失败:', e)
toast.error('保存设置失败')
}
}
// ===== 自定义命令管理 =====
const editingCmd = reactive<CustomCommand>({ id: '', title: '', command: '', args: [] })
const editingIdx = ref(-1) // -1 表示新增,>=0 表示编辑现有
const showEditor = ref(false)
function addCustomCommand() {
editingIdx.value = -1
Object.assign(editingCmd, { id: '', title: '', command: '', args: [] })
showEditor.value = true
}
function editCustomCommand(idx: number) {
editingIdx.value = idx
const cmd = form.customCommands[idx]
Object.assign(editingCmd, { id: cmd.id, title: cmd.title, command: cmd.command, args: [...cmd.args] })
showEditor.value = true
}
function saveCustomCommand() {
if (!editingCmd.title.trim() || !editingCmd.command.trim()) {
toast.warning('标题和命令不能为空')
return
}
if (editingIdx.value >= 0) {
// 编辑
form.customCommands[editingIdx.value] = { ...editingCmd }
} else {
// 新增
form.customCommands.push({
...editingCmd,
id: `cmd-${Date.now()}`,
})
}
showEditor.value = false
void saveSettings()
}
function removeCustomCommand(idx: number) {
form.customCommands.splice(idx, 1)
void saveSettings()
}
const tabsStore = useModuleTabsStore()
tabsStore.registerSave(saveSettings)
// ===== 快捷键录入器(与 ClipboardModule 同模式) =====
const recording = ref(false)
const recorderRef = ref<HTMLDivElement | null>(null)
function displayShortcut(s: string): string {
if (!s) return ''
return s
.split('+')
.map(p => {
const t = p.trim()
if (!t) return ''
if (t.length === 1) return t.toUpperCase()
return t.charAt(0).toUpperCase() + t.slice(1)
})
.join(' + ')
}
function eventToShortcut(e: KeyboardEvent): string | null {
const mods: string[] = []
if (e.ctrlKey) mods.push('ctrl')
if (e.altKey) mods.push('alt')
if (e.shiftKey) mods.push('shift')
if (e.metaKey) mods.push('super')
let main = ''
const code = e.code || ''
if (/^Key[A-Z]$/.test(code)) main = code.slice(3).toLowerCase()
else if (/^Digit[0-9]$/.test(code)) main = code.slice(5)
else if (/^F([1-9]|1[0-2])$/.test(code)) main = code.toLowerCase()
else if (code === 'Space') main = 'space'
else if (code === 'PrintScreen') main = 'printscreen'
else if (code.startsWith('Numpad')) main = code.slice(6).toLowerCase()
else return null
const isFunctionKey = /^f([1-9]|1[0-2])$/.test(main) || main === 'printscreen'
if (mods.length === 0 && !isFunctionKey) return null
return [...mods, main].join('+')
}
function onRecordKey(e: KeyboardEvent) {
if (!recording.value) return
e.preventDefault()
e.stopPropagation()
if (e.key === 'Escape') {
recording.value = false
return
}
if (['Control', 'Alt', 'Shift', 'Meta'].includes(e.key)) return
const combo = eventToShortcut(e)
if (!combo) {
toast.warning('不支持的按键组合,请使用字母/数字/功能键 + 修饰键')
return
}
recording.value = false
form.shortcut = combo
void saveSettings()
toast.success(`快捷键已更新为 ${displayShortcut(combo)}`)
}
async function startRecord() {
recording.value = true
await nextTick()
recorderRef.value?.focus()
}
watch(recording, (on) => {
if (on) window.addEventListener('keydown', onRecordKey, true)
else window.removeEventListener('keydown', onRecordKey, true)
})
async function clearShortcut() {
form.shortcut = ''
await saveSettings()
}
onUnmounted(() => {
window.removeEventListener('keydown', onRecordKey, true)
})
// ===== 唤起测试 =====
async function testPopup() {
try {
await invoke('quickpanel_show_popup')
} catch (e) {
console.error('[quickpanel] 唤起失败:', e)
toast.error('唤起失败')
}
}
// ===== 选项配置 =====
const positionOptions = [
{ value: 'center', label: '屏幕中央', icon: Monitor },
{ value: 'cursor', label: '鼠标位置', icon: MousePointer2 },
]
const engineOptions = [
{ value: 'google', label: 'Google' },
{ value: 'bing', label: 'Bing' },
{ value: 'baidu', label: '百度' },
]
async function changePosition(v: string) {
form.popupPosition = v
await saveSettings()
}
async function changeEngine(v: string) {
form.searchEngine = v
await saveSettings()
}
</script>
<template>
<div class="h-full p-6 overflow-y-auto">
<div class="max-w-2xl space-y-4">
<!-- 标题与测试 -->
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold flex items-center gap-2">
<Command class="h-5 w-5 text-primary" />
快速面板
</h2>
<Button size="sm" @click="testPopup">
<Zap class="h-4 w-4 mr-1" />
测试唤起
</Button>
</div>
<!-- 快捷键 -->
<Card>
<CardHeader>
<CardTitle class="text-sm flex items-center gap-2">
<Keyboard class="h-4 w-4" />
唤起快捷键
</CardTitle>
</CardHeader>
<CardContent class="space-y-3">
<div class="flex items-center gap-2 flex-wrap">
<div
ref="recorderRef"
class="hotkey-recorder"
:class="{ recording }"
tabindex="0"
@click="startRecord"
>
<template v-if="recording">按下快捷键Esc 取消</template>
<template v-else-if="form.shortcut">
{{ displayShortcut(form.shortcut) }}
</template>
<template v-else>未设置点击录入</template>
</div>
<Button
v-if="form.shortcut"
variant="ghost"
size="sm"
class="h-8 text-muted-foreground"
@click="clearShortcut"
>清除</Button>
</div>
<p class="text-xs text-muted-foreground">
全局快捷键唤起快速面板需至少一个修饰键 + 字母/数字/功能键默认 Alt+Space
</p>
</CardContent>
</Card>
<!-- 唤起位置 -->
<Card>
<CardHeader>
<CardTitle class="text-sm">唤起位置</CardTitle>
</CardHeader>
<CardContent>
<div class="flex items-center gap-1.5">
<Button
v-for="opt in positionOptions"
:key="opt.value"
:variant="form.popupPosition === opt.value ? 'default' : 'outline'"
size="sm"
@click="changePosition(opt.value)"
>
<component :is="opt.icon" class="h-3.5 w-3.5 mr-1" />
{{ opt.label }}
</Button>
</div>
<p class="text-xs text-muted-foreground mt-2">
屏幕中央在鼠标所在显示器的工作区中央显示鼠标位置在光标附近显示
</p>
</CardContent>
</Card>
<!-- 搜索引擎 -->
<Card>
<CardHeader>
<CardTitle class="text-sm flex items-center gap-2">
<Globe class="h-4 w-4" />
默认搜索引擎
</CardTitle>
</CardHeader>
<CardContent>
<div class="flex items-center gap-1.5">
<Button
v-for="opt in engineOptions"
:key="opt.value"
:variant="form.searchEngine === opt.value ? 'default' : 'outline'"
size="sm"
@click="changeEngine(opt.value)"
>
{{ opt.label }}
</Button>
</div>
<p class="text-xs text-muted-foreground mt-2">
输入无匹配结果时 Enter 在默认引擎中搜索
</p>
</CardContent>
</Card>
<!-- 文件索引 -->
<Card>
<CardHeader>
<CardTitle class="text-sm flex items-center gap-2">
<FolderTree class="h-4 w-4" />
文件索引
</CardTitle>
</CardHeader>
<CardContent class="space-y-3">
<!-- 索引状态 -->
<div class="flex items-center gap-3 text-xs flex-wrap">
<Badge variant="outline">{{ indexStats?.total ?? 0 }} </Badge>
<span class="text-muted-foreground">上次构建{{ formatTime(indexStats?.lastBuiltAt ?? 0) }}</span>
<Button
size="sm"
variant="outline"
:disabled="building"
class="ml-auto h-7"
@click="buildIndex"
>
<Loader2 v-if="building" class="h-3.5 w-3.5 mr-1 animate-spin" />
<RefreshCw v-else class="h-3.5 w-3.5 mr-1" />
{{ building ? '构建中…' : '重建索引' }}
</Button>
</div>
<!-- 索引目录列表 -->
<div class="space-y-1.5">
<div
v-for="(dir, idx) in form.indexDirs"
:key="dir"
class="flex items-center gap-2 p-2 rounded-md bg-muted/40"
>
<FolderTree class="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span class="text-xs font-mono truncate flex-1" :title="dir">{{ dir }}</span>
<Button
variant="ghost"
size="icon"
class="h-6 w-6 shrink-0 hover:text-destructive"
@click="removeDir(idx)"
>
<X class="h-3 w-3" />
</Button>
</div>
<div v-if="!form.indexDirs.length" class="text-xs text-muted-foreground py-2">
未配置索引目录默认桌面文档下载
</div>
</div>
<Button size="sm" variant="outline" @click="addDir">
<Plus class="h-3.5 w-3.5 mr-1" />
添加目录
</Button>
<p class="text-xs text-muted-foreground">
索引指定目录下的文件名支持拼音/首字母搜索重建索引后生效
</p>
</CardContent>
</Card>
<!-- 自定义命令 -->
<Card>
<CardHeader>
<CardTitle class="text-sm flex items-center gap-2">
<Terminal class="h-4 w-4" />
自定义命令
</CardTitle>
</CardHeader>
<CardContent class="space-y-3">
<!-- 命令列表 -->
<div class="space-y-1.5">
<div
v-for="(cmd, idx) in form.customCommands"
:key="cmd.id"
class="flex items-center gap-2 p-2 rounded-md bg-muted/40"
>
<Terminal class="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<div class="flex-1 min-w-0">
<p class="text-xs font-medium truncate">{{ cmd.title }}</p>
<p class="text-xs text-muted-foreground font-mono truncate">{{ cmd.command }} {{ cmd.args.join(' ') }}</p>
</div>
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" @click="editCustomCommand(idx)">
<Pencil class="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0 hover:text-destructive" @click="removeCustomCommand(idx)">
<X class="h-3 w-3" />
</Button>
</div>
<div v-if="!form.customCommands.length" class="text-xs text-muted-foreground py-2">
暂无自定义命令
</div>
</div>
<Button size="sm" variant="outline" @click="addCustomCommand">
<Plus class="h-3.5 w-3.5 mr-1" />
添加命令
</Button>
<!-- 编辑面板 -->
<div v-if="showEditor" class="space-y-2 p-3 rounded-md border bg-muted/20">
<div class="flex items-center gap-2">
<input
v-model="editingCmd.title"
class="flex-1 h-8 px-2 text-sm rounded border bg-background"
placeholder="标题(如:打开记事本)"
/>
</div>
<div class="flex items-center gap-2">
<input
v-model="editingCmd.command"
class="flex-1 h-8 px-2 text-sm font-mono rounded border bg-background"
placeholder="命令路径(如:notepad.exe"
/>
</div>
<div class="flex items-center gap-2">
<input
:value="editingCmd.args.join(' ')"
@input="(e) => editingCmd.args = (e.target as HTMLInputElement).value.split(/\s+/).filter(Boolean)"
class="flex-1 h-8 px-2 text-sm font-mono rounded border bg-background"
placeholder="参数(空格分隔,如:-newwindow"
/>
</div>
<div class="flex items-center gap-2">
<Button size="sm" @click="saveCustomCommand">
<Check class="h-3.5 w-3.5 mr-1" />
保存
</Button>
<Button size="sm" variant="ghost" @click="showEditor = false">取消</Button>
</div>
</div>
<p class="text-xs text-muted-foreground">
自定义可执行命令在面板中按标题搜索即可运行支持拼音/首字母匹配
</p>
</CardContent>
</Card>
<!-- 功能说明 -->
<Card>
<CardHeader>
<CardTitle class="text-sm">当前能力</CardTitle>
</CardHeader>
<CardContent class="space-y-2">
<div class="flex items-center gap-2 text-sm">
<Badge variant="secondary">命令</Badge>
<span class="text-muted-foreground">跳转到已启用模块</span>
</div>
<div class="flex items-center gap-2 text-sm">
<Badge variant="secondary">自定义</Badge>
<span class="text-muted-foreground">用户配置的可执行命令</span>
</div>
<div class="flex items-center gap-2 text-sm">
<Badge variant="secondary">应用</Badge>
<span class="text-muted-foreground">扫描开始菜单拼音/首字母启动</span>
</div>
<div class="flex items-center gap-2 text-sm">
<Badge variant="secondary">文件</Badge>
<span class="text-muted-foreground">索引指定目录快速定位文件</span>
</div>
<div class="flex items-center gap-2 text-sm">
<Badge variant="secondary">剪贴板</Badge>
<span class="text-muted-foreground">复用剪贴板历史快速回填</span>
</div>
<div class="flex items-center gap-2 text-sm">
<Badge variant="secondary">计算</Badge>
<span class="text-muted-foreground">输入算式即算Enter 复制结果</span>
</div>
<div class="flex items-center gap-2 text-sm">
<Badge variant="secondary">系统</Badge>
<span class="text-muted-foreground">锁屏退出应用</span>
</div>
<div class="flex items-center gap-2 text-sm">
<Badge variant="secondary">网页</Badge>
<span class="text-muted-foreground">在默认引擎中搜索</span>
</div>
</CardContent>
</Card>
</div>
</div>
</template>
+134
View File
@@ -0,0 +1,134 @@
/**
* 快速面板匹配引擎
*
* 策略:子序列 fuzzy 匹配 + 拼音全拼/首字母多形态。
* - 对每个文本生成三种匹配形态:原文、拼音全拼(连写)、拼音首字母
* - query 对每种形态做子序列匹配,连续命中 + 首字母命中加权
* - 取最高分作为该 item 的得分
*
* 拼音形态惰性计算并缓存(WeakMap),避免每次输入重算。
*/
import { pinyin } from 'pinyin-pro'
/** 一组待匹配的文本形态(原文 / 全拼或原文 / 首字母 / 多单词首字母) */
export type TextForms = readonly [string, string, string, string]
const formsCache = new WeakMap<object, TextForms>()
/** 判断字符串是否含 CJK 字符(需转拼音) */
function hasCJK(s: string): boolean {
return /[\u4e00-\u9fff]/.test(s)
}
/** 提取英文字符串中各单词的首字母(如 "Visual Studio Code" → "vsc")。
* 单词边界:空格、连字符、下划线、点号;仅对以字母开头的单词取首字母。 */
function extractWordInitials(text: string): string {
const parts = text.split(/[\s\-_.]+/).filter(Boolean)
let initials = ''
for (const p of parts) {
// 跳过非字母开头的 token(如数字开头、纯符号)
if (/^[a-zA-Z]/.test(p)) {
initials += p.charAt(0).toLowerCase()
}
}
return initials
}
/**
* 为文本生成匹配形态:[原文(小写), 拼音全拼(小写连写), 拼音首字母(小写), 多单词首字母(小写)]。
* 非中文文本:全拼与首字母回退为原文,多单词首字母仍独立计算(用于 "Visual Studio Code" → "vsc")。
* 结果按 host 对象缓存,避免重复计算。
*/
export function getTextForms(text: string, host: object): TextForms {
const cached = formsCache.get(host)
if (cached) return cached
const lower = text.toLowerCase()
// 多单词首字母:无论中英文都计算,与拼音形态互补
const initials = extractWordInitials(text)
let forms: TextForms
if (!hasCJK(text)) {
// 纯英文:全拼与首字母回退为原文,多单词首字母独立
forms = [lower, lower, lower, initials]
} else {
// 拼音全拼数组,toneType:none 去声调
const full = pinyin(text, { toneType: 'none', type: 'array' }) as string[]
const fullStr = full.join('').toLowerCase()
const firstStr = full.map(s => s.charAt(0)).join('').toLowerCase()
forms = [lower, fullStr, firstStr, initials]
}
formsCache.set(host, forms)
return forms
}
/**
* 子序列匹配评分。
* - 不匹配返回 -1
* - 基础分 = 命中字符数 / target 长度(越紧凑越高)
* - 连续命中加权(每段连续命中 +0.15)
* - 首字母命中加权(target[i] === query[0] 且 i==0 或前一个字符非字母 +0.1)
* - query 完全等于 target 时返回 1.5(精确匹配优先)
*/
export function fuzzyScore(query: string, target: string): number {
if (!query) return 0
if (!target) return -1
const q = query.toLowerCase()
const t = target.toLowerCase()
// 精确匹配
if (q === t) return 1.5
// 前缀匹配
if (t.startsWith(q)) return 1.2
// 包含匹配
if (t.includes(q)) return 1.0
// 子序列匹配
let qi = 0
let prevMatched = false
let score = 0
let consecutiveBonus = 0
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
if (t[ti] === q[qi]) {
// 命中
score += 1
// 连续命中加权
if (prevMatched) {
consecutiveBonus += 0.15
}
// 首字母命中加权(target 开头或前一字符为非字母)
if (qi === 0 && (ti === 0 || !/[a-z0-9]/.test(t[ti - 1]))) {
score += 0.1
}
prevMatched = true
qi++
} else {
prevMatched = false
}
}
// 未完全匹配
if (qi < q.length) return -1
// 命中密度:命中字符占 target 比例(越短 target 越优先)
const density = q.length / t.length
// 归一化到 0~1 区间(基础命中分 + 连续加权 + 密度)
const finalScore = 0.5 + density * 0.3 + (score - q.length) * 0.05 + consecutiveBonus * 0.1
return Math.min(finalScore, 0.99)
}
/**
* 对一组文本形态取最高匹配分。
*/
export function bestScore(query: string, forms: TextForms): number {
let best = -1
for (const form of forms) {
const s = fuzzyScore(query, form)
if (s > best) best = s
}
return best
}
+45
View File
@@ -0,0 +1,45 @@
import type { ModuleConfig } from '@/types/module'
import type { SearchIndexItem } from '@/stores/searchIndex'
const searchItems: SearchIndexItem[] = [
{
title: '快速面板',
description: '全局快捷键唤起命令面板',
keywords: ['快速面板', '快速启动', '搜索', '命令', 'quickpanel', 'launcher', 'spotlight']
}
]
export const moduleConfig: ModuleConfig = {
id: 'quickpanel',
name: '快速面板',
icon: 'quickpanel',
description: '全局快捷键唤起的多源命令面板(命令/应用/文件/计算)',
category: 'tool',
defaultEnabled: true,
loader: () => import('./QuickPanelModule.vue'),
searchItems,
lifecycle: {
// 模块启用:读取设置并注册全局快捷键
onEnable: async () => {
const { invoke } = await import('@tauri-apps/api/core')
try {
const settings = await invoke<{ shortcut: string }>('quickpanel_get_settings')
if (settings.shortcut) {
await invoke('quickpanel_register_shortcut', { shortcut: settings.shortcut })
}
} catch (e) {
console.error('[quickpanel] onEnable 注册快捷键失败:', e)
}
},
// 模块禁用:注销全局快捷键
onDisable: async () => {
const { invoke } = await import('@tauri-apps/api/core')
try {
await invoke('quickpanel_unregister_shortcut')
} catch (e) {
console.error('[quickpanel] onDisable 注销快捷键失败:', e)
}
}
},
order: 60
}
+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
}