Files
Thing/src/modules/quickpanel/QuickPanel.vue
T
2026-08-06 10:33:16 +08:00

902 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>