902 lines
26 KiB
Vue
902 lines
26 KiB
Vue
<script setup lang="ts">
|
||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||
import { getCurrentWindow, Effect, EffectState } from '@tauri-apps/api/window'
|
||
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts)
|
||
import { commands } from '@/lib/bindings'
|
||
import { Search, Command, Loader2, CornerDownLeft, Calculator, Globe, Lock, ChevronRight, Terminal, History, FolderOpen, Ruler, Trash2 } 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'
|
||
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
|
||
|
||
// ===== 状态 =====
|
||
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)
|
||
// 待删除确认项(文件/文件夹删除前弹窗确认)
|
||
const pendingDelete = ref<{ path: string; isDir: boolean; name: string } | null>(null)
|
||
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 = STORAGE_KEYS.quickpanelHistory
|
||
|
||
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))
|
||
}
|
||
|
||
// ===== 搜索 =====
|
||
/** 搜索请求序号:每次 doSearch 自增,过期请求(序号落后)结果直接丢弃,防止慢请求覆盖新结果 */
|
||
let searchSeq = 0
|
||
|
||
async function doSearch() {
|
||
const seq = ++searchSeq
|
||
const q = query.value.trim()
|
||
if (!q) {
|
||
// 空查询:显示命令快捷入口 + 系统操作 + 历史(置顶3条)
|
||
const items = await aggregateSearch('')
|
||
if (seq !== searchSeq) return // 过期请求丢弃
|
||
results.value = applyHistoryBoost(items)
|
||
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)
|
||
if (seq !== searchSeq) return // 过期请求丢弃,不覆盖新结果
|
||
results.value = applyHistoryBoost(items)
|
||
selectedIndex.value = 0
|
||
// 后台加载应用图标(不阻塞结果显示)
|
||
void loadAppIconsForResults(results.value)
|
||
} catch (e) {
|
||
if (seq !== searchSeq) return
|
||
console.error('[quickpanel] 搜索失败:', e)
|
||
results.value = []
|
||
} finally {
|
||
// 仅最新请求可结束 loading,避免旧请求提前清除新请求的加载态
|
||
if (seq === searchSeq) loading.value = false
|
||
}
|
||
}
|
||
|
||
// 防抖搜索
|
||
watch(query, () => {
|
||
if (searchTimer) clearTimeout(searchTimer)
|
||
collapseSubActions()
|
||
searchTimer = setTimeout(doSearch, 120)
|
||
})
|
||
|
||
// ===== 执行与隐藏 =====
|
||
async function hideWindow() {
|
||
try {
|
||
await commands.quickpanelHidePopup()
|
||
} 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, item?: QPItem) {
|
||
// 删除类子动作:先弹窗确认,确认后由 confirmDelete 执行并隐藏窗口
|
||
if (sub.id === 'delete' && item?.deleteInfo) {
|
||
pendingDelete.value = { ...item.deleteInfo, name: item.title }
|
||
return
|
||
}
|
||
try {
|
||
await sub.action()
|
||
} catch (e) {
|
||
console.error('[quickpanel] 子动作执行失败:', e)
|
||
}
|
||
await hideWindow()
|
||
}
|
||
|
||
// ===== 删除确认弹窗 =====
|
||
function cancelDelete() {
|
||
pendingDelete.value = null
|
||
}
|
||
|
||
async function confirmDelete() {
|
||
const pd = pendingDelete.value
|
||
if (!pd) return
|
||
pendingDelete.value = null
|
||
try {
|
||
await commands.quickpanelDeleteFile(pd.path)
|
||
} 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) {
|
||
// 删除确认弹窗打开时:Esc 取消,Enter 确认
|
||
if (pendingDelete.value) {
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault()
|
||
cancelDelete()
|
||
} else if (e.key === 'Enter') {
|
||
e.preventDefault()
|
||
confirmDelete()
|
||
}
|
||
return
|
||
}
|
||
|
||
const expanded = subActionExpanded.value !== null
|
||
const subs = currentSubActions()
|
||
const expandedItem = expanded ? results.value[subActionExpanded.value!] : undefined
|
||
|
||
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, expandedItem)
|
||
} 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, expandedItem)
|
||
}
|
||
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
|
||
if (group === '快捷') return FolderOpen
|
||
if (group === '换算') return Ruler
|
||
return Search
|
||
}
|
||
|
||
const hasResults = () => results.value.length > 0
|
||
|
||
// ===== 主题应用(与主应用同步,独立窗口需自行设置) =====
|
||
function readMainTheme(): { theme: string; effect: string } {
|
||
try {
|
||
const raw = localStorage.getItem(STORAGE_KEYS.appSettings)
|
||
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 === STORAGE_KEYS.appSettings) applyTheme()
|
||
}
|
||
window.addEventListener('storage', onStorage)
|
||
unlistenFns.push(() => window.removeEventListener('storage', onStorage))
|
||
|
||
// 监听弹窗显示事件:重新同步主题 + 清空输入 + 加载初始结果
|
||
unlistenFns.push(await listen(EVENTS.quickpanelShow, async () => {
|
||
await applyTheme()
|
||
query.value = ''
|
||
await doSearch()
|
||
await nextTick()
|
||
inputRef.value?.focus()
|
||
}))
|
||
|
||
unlistenFns.push(await listen(EVENTS.quickpanelHide, () => {
|
||
query.value = ''
|
||
results.value = []
|
||
}))
|
||
|
||
// 初始加载(空查询显示快捷入口)
|
||
// 独立窗口需自行检查文件索引状态(主窗口的 setFileIndexReady 不共享到此上下文)
|
||
try {
|
||
const stats = await commands.quickpanelFileIndexStats()
|
||
setFileIndexReady((stats?.total ?? 0) > 0)
|
||
} catch {
|
||
/* 索引未初始化,忽略 */
|
||
}
|
||
await doSearch()
|
||
await nextTick()
|
||
inputRef.value?.focus()
|
||
|
||
try {
|
||
await commands.quickpanelShowWindow()
|
||
} 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, item)"
|
||
@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 v-if="pendingDelete" class="qp-confirm-mask" @click.self="cancelDelete">
|
||
<div class="qp-confirm-box" role="alertdialog" aria-modal="true">
|
||
<div class="qp-confirm-head">
|
||
<Trash2 class="h-4 w-4 shrink-0 text-destructive" />
|
||
<p class="qp-confirm-title">确定删除?</p>
|
||
</div>
|
||
<p class="qp-confirm-name truncate" :title="pendingDelete.name">{{ pendingDelete.name }}</p>
|
||
<p class="qp-confirm-tip">将移动到回收站{{ pendingDelete.isDir ? '(含所有子内容)' : '' }},删除后可在回收站中还原。</p>
|
||
<div class="flex items-center justify-end gap-2 mt-4">
|
||
<button class="qp-confirm-btn" @click="cancelDelete">取消</button>
|
||
<button class="qp-confirm-btn qp-confirm-danger" @click="confirmDelete">删除</button>
|
||
</div>
|
||
</div>
|
||
</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;
|
||
}
|
||
|
||
/* ===== 删除确认弹窗 ===== */
|
||
.qp-confirm-mask {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 50;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: rgba(0, 0, 0, 0.45);
|
||
border-radius: 10px;
|
||
backdrop-filter: blur(2px);
|
||
}
|
||
|
||
.qp-confirm-box {
|
||
width: 300px;
|
||
max-width: 86%;
|
||
padding: 16px;
|
||
border-radius: var(--radius);
|
||
border: 1px solid var(--border);
|
||
background: var(--card);
|
||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35);
|
||
}
|
||
|
||
.qp-confirm-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.qp-confirm-title {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--foreground);
|
||
}
|
||
|
||
.qp-confirm-name {
|
||
margin-top: 10px;
|
||
font-size: 13px;
|
||
color: var(--foreground);
|
||
}
|
||
|
||
.qp-confirm-tip {
|
||
margin-top: 4px;
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
color: var(--muted-foreground);
|
||
}
|
||
|
||
.qp-confirm-btn {
|
||
padding: 5px 14px;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
border-radius: 6px;
|
||
border: 1px solid var(--border);
|
||
background: var(--muted);
|
||
color: var(--foreground);
|
||
cursor: pointer;
|
||
transition: background 0.15s ease;
|
||
}
|
||
|
||
.qp-confirm-btn:hover {
|
||
background: var(--accent);
|
||
}
|
||
|
||
.qp-confirm-danger {
|
||
background: var(--destructive);
|
||
border-color: transparent;
|
||
color: white;
|
||
}
|
||
|
||
.qp-confirm-danger:hover {
|
||
background: var(--destructive);
|
||
opacity: 0.9;
|
||
}
|
||
</style>
|