快速面板模块
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user