Files
Thing/src/modules/quickpanel/QuickPanel.vue
T

2191 lines
68 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, emit, type UnlistenFn } from '@tauri-apps/api/event'
import { getCurrentWindow, LogicalSize, Effect, EffectState } from '@tauri-apps/api/window'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
import type { ArchiveInfo, DeleteResult, ExtractResult, FileEntry, RenamePreview, RenameResult } from '@/lib/bindings'
import { Search, Command, Loader2, CornerDownLeft, Calculator, Globe, Lock, ChevronRight, ChevronLeft, History, FolderOpen, Ruler, Trash2, Terminal, Archive as ArchiveIcon, Regex, FileText, Settings } from '@lucide/vue'
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion'
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { Switch } from '@/components/ui/switch'
import { aggregateSearch, loadAppIconsForResults, recordHistoryItem, getMoreHistoryItems, getMoreHistoryCount, setFileIndexReady, type QPItem, type QPSubAction } from './providers'
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
import HistoryPicker from './HistoryPicker.vue'
// ===== 状态 =====
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
// ===== 当前目录文件操作(快捷键在资源管理器中按下时检测) =====
// quickpanel-show 事件负载携带的 Explorer 当前目录
const explorerDir = ref('')
// 面板模式:none=搜索 / extract=批量解压 / rename=批量重命名 / delete=批量删除
const actionMode = ref<'none' | 'extract' | 'rename' | 'delete'>('none')
// 面板模式下的窗口高度(比搜索态更高,容纳列表+输入)
const ACTION_HEIGHT = 620
// 跳过下一次 query 变更触发的防抖搜索(面板 show/hide 重置输入时使用,避免重复搜索)
let skipNextSearch = false
// 批量解压进度事件负载:specta 不导出事件类型,需在此与 Rust 端 actions.rs 同名结构体保持同步
interface ExtractProgress {
done: number
total: number
current: string
ok: boolean
error: string
}
// ===== 输入历史(localStorage 持久化) =====
function loadStrList(key: string): string[] {
try {
const raw = JSON.parse(localStorage.getItem(key) ?? '[]')
return Array.isArray(raw) ? raw.filter((x): x is string => typeof x === 'string') : []
} catch {
return []
}
}
function saveHistory(key: string, list: string[]) {
try {
localStorage.setItem(key, JSON.stringify(list.slice(0, 10)))
} catch { /* 忽略存储失败 */ }
}
function pushHistory(list: string[], val: string): string[] {
const v = val.trim()
if (!v) return list
return [v, ...list.filter(x => x !== v)].slice(0, 10)
}
const pwdHistory = ref<string[]>(loadStrList(STORAGE_KEYS.quickpanelPwdHistory))
const pwdFavs = ref<string[]>(loadStrList(STORAGE_KEYS.quickpanelPwdFavs))
const matchHistory = ref<string[]>(loadStrList(STORAGE_KEYS.quickpanelRenameMatchHistory))
const matchFavs = ref<string[]>(loadStrList(STORAGE_KEYS.quickpanelRenameMatchFavs))
const replaceHistory = ref<string[]>(loadStrList(STORAGE_KEYS.quickpanelRenameReplaceHistory))
const replaceFavs = ref<string[]>(loadStrList(STORAGE_KEYS.quickpanelRenameReplaceFavs))
const deleteFilterHistory = ref<string[]>(loadStrList(STORAGE_KEYS.quickpanelDeleteFilterHistory))
const deleteFilterFavs = ref<string[]>(loadStrList(STORAGE_KEYS.quickpanelDeleteFilterFavs))
// 输入框 focus 时展开历史下拉
const pwdHistoryOpen = ref(false)
const matchHistoryOpen = ref(false)
const replaceHistoryOpen = ref(false)
const deleteFilterHistoryOpen = ref(false)
function pickPwd(v: string) {
extractPassword.value = v
pwdHistoryOpen.value = false
}
function togglePwdFav(v: string) {
pwdFavs.value = pwdFavs.value.includes(v)
? pwdFavs.value.filter(x => x !== v)
: [...pwdFavs.value, v]
saveHistory(STORAGE_KEYS.quickpanelPwdFavs, pwdFavs.value)
}
function pickMatch(v: string) {
renamePattern.value = v
matchHistoryOpen.value = false
}
function toggleMatchFav(v: string) {
matchFavs.value = matchFavs.value.includes(v)
? matchFavs.value.filter(x => x !== v)
: [...matchFavs.value, v]
saveHistory(STORAGE_KEYS.quickpanelRenameMatchFavs, matchFavs.value)
}
function pickReplace(v: string) {
renameReplacement.value = v
replaceHistoryOpen.value = false
}
function toggleReplaceFav(v: string) {
replaceFavs.value = replaceFavs.value.includes(v)
? replaceFavs.value.filter(x => x !== v)
: [...replaceFavs.value, v]
saveHistory(STORAGE_KEYS.quickpanelRenameReplaceFavs, replaceFavs.value)
}
function pickDeleteFilter(v: string) {
deleteFilter.value = v
deleteFilterHistoryOpen.value = false
}
function toggleDeleteFilterFav(v: string) {
deleteFilterFavs.value = deleteFilterFavs.value.includes(v)
? deleteFilterFavs.value.filter(x => x !== v)
: [...deleteFilterFavs.value, v]
saveHistory(STORAGE_KEYS.quickpanelDeleteFilterFavs, deleteFilterFavs.value)
}
// 批量解压面板状态
const archives = ref<ArchiveInfo[]>([])
const archiveSelected = ref<Set<string>>(new Set())
const extractPassword = ref('')
const extractIntoSub = ref(true)
const extracting = ref(false)
const extractProgress = ref<ExtractProgress | null>(null)
const extractResults = ref<ExtractResult[] | null>(null)
// 批量重命名面板状态
const dirFiles = ref<FileEntry[]>([])
const renamePattern = ref('')
const renameReplacement = ref('')
const renamePreviews = ref<RenamePreview[] | null>(null)
const renameSelected = ref<Set<string>>(new Set())
const renameError = ref('')
const renaming = ref(false)
const renameResults = ref<RenameResult[] | null>(null)
const renameOkCount = ref(0)
let renameTimer: ReturnType<typeof setTimeout> | null = null
// ===== 历史分区(从 results 中分离历史项与其他结果) =====
// 展示顺序:目录操作(批量解压/重命名/删除)> 历史 > 更多历史 > 其他
const dirActionItems = computed(() => results.value.filter(r => r.group === '目录操作'))
const historyItems = computed(() => results.value.filter(r => r.group === '历史'))
const otherItems = computed(() => results.value.filter(r => r.group !== '历史' && 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) {
// 空查询:当前目录文件操作(若检测到 Explorer 目录)+ 历史置顶 + 系统相关条目
// (程序相关设置不参与默认展示;所有 Provider 空查询零 IPC,首屏即时)
const items = await aggregateSearch('')
if (seq !== searchSeq) return // 过期请求丢弃
const dirItems = getExplorerActions()
results.value = applyHistoryBoost([...dirItems, ...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, () => {
// show/hide 重置输入时跳过(由事件处理器显式搜索/清空)
if (skipNextSearch) {
skipNextSearch = false
return
}
// 面板模式下输入搜索词:先退出面板,恢复窗口高度(不立即搜索,由下方防抖统一触发)
if (actionMode.value !== 'none') {
exitMode(true)
}
if (searchTimer) clearTimeout(searchTimer)
collapseSubActions()
searchTimer = setTimeout(doSearch, 120)
})
// ===== 当前目录文件操作(批量解压 / 批量重命名) =====
/** 空查询时注入到结果顶部的目录操作项 */
function getExplorerActions(): QPItem[] {
if (!explorerDir.value) return []
return [
{
id: 'fa-extract',
title: '批量解压',
subtitle: explorerDir.value,
group: '目录操作',
score: 100000,
action: () => enterExtractMode(),
},
{
id: 'fa-rename',
title: '批量重命名',
subtitle: '正则匹配文件名 · 实时预览',
group: '目录操作',
score: 99999,
action: () => enterRenameMode(),
},
{
id: 'fa-delete',
title: '批量删除',
subtitle: '按名称/正则筛选 · 删除勾选文件',
group: '目录操作',
score: 99998,
action: () => enterDeleteMode(),
},
]
}
async function setWindowHeight(h: number) {
try {
await getCurrentWindow().setSize(new LogicalSize(600, h))
} catch {
/* 非 Tauri 环境忽略 */
}
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
}
async function enterExtractMode() {
actionMode.value = 'extract'
await setWindowHeight(ACTION_HEIGHT)
await loadArchives()
await nextTick()
// 聚焦到解压密码输入框,直接键入密码后回车执行
document.querySelector<HTMLInputElement>('.qp-fa-pwd')?.focus()
}
async function enterRenameMode() {
actionMode.value = 'rename'
await setWindowHeight(ACTION_HEIGHT)
await loadDirFiles()
await nextTick()
document.querySelector<HTMLInputElement>('.qp-fa-pattern')?.focus()
}
async function enterDeleteMode() {
actionMode.value = 'delete'
deleteReset()
await setWindowHeight(ACTION_HEIGHT)
await loadDeleteFiles()
await nextTick()
document.querySelector<HTMLInputElement>('.qp-fa-delete-filter')?.focus()
}
async function exitMode(skipSearch = false) {
if (actionMode.value === 'none') return
actionMode.value = 'none'
extractReset()
renameReset()
deleteReset()
await setWindowHeight(420)
// watch(query) 路径跳过:稍后防抖会用新 query 搜索,避免双重搜索
if (!skipSearch) await doSearch()
}
function extractReset() {
archives.value = []
archiveSelected.value = new Set()
extractPassword.value = ''
extractIntoSub.value = true
extracting.value = false
extractProgress.value = null
extractResults.value = null
}
function renameReset() {
dirFiles.value = []
renamePattern.value = ''
renameReplacement.value = ''
renamePreviews.value = null
renameSelected.value = new Set()
renameError.value = ''
renaming.value = false
renameResults.value = null
if (renameTimer) {
clearTimeout(renameTimer)
renameTimer = null
}
}
// --- 批量删除 ---
const deleteFilter = ref('')
const deleteForce = ref(false)
const deleteSelected = ref<Set<string>>(new Set())
const deleting = ref(false)
const deleteResults = ref<DeleteResult[] | null>(null)
// 按名称/正则筛选当前目录条目(dirFiles,含文件与文件夹),空筛选或非法正则时为全量
const deleteFiltered = computed<FileEntry[]>(() => {
const q = deleteFilter.value.trim()
if (!q) return dirFiles.value
let re: RegExp | null = null
try {
re = new RegExp(q, 'i')
} catch {
re = null
}
if (re) return dirFiles.value.filter(f => re!.test(f.name))
// 非法正则退化为普通包含匹配
return dirFiles.value.filter(f => f.name.toLowerCase().includes(q.toLowerCase()))
})
// 列出当前目录全部条目(含文件夹,供批量删除子目录)
async function loadDeleteFiles() {
if (!explorerDir.value) return
try {
const list = await commands.quickpanelListDir(explorerDir.value)
dirFiles.value = list
} catch (e) {
console.error('[quickpanel] 读取目录失败:', e)
dirFiles.value = []
}
}
function toggleDeleteSelect(path: string) {
const s = new Set(deleteSelected.value)
if (s.has(path)) s.delete(path)
else s.add(path)
deleteSelected.value = s
}
function toggleAllDeleteSelect() {
const allChecked = deleteFiltered.value.length > 0 && deleteFiltered.value.every(f => deleteSelected.value.has(f.path))
deleteSelected.value = allChecked ? new Set() : new Set(deleteFiltered.value.map(f => f.path))
}
async function startDelete() {
const items = deleteFiltered.value.filter(f => deleteSelected.value.has(f.path))
if (!items.length || deleting.value) return
deleting.value = true
deleteResults.value = null
try {
const results = await commands.quickpanelDeleteFiles(
items.map(f => f.path),
deleteForce.value,
)
// 记录筛选历史(无论成败,只要执行过删除就记)
const filt = deleteFilter.value.trim()
if (filt) {
deleteFilterHistory.value = pushHistory(deleteFilterHistory.value, filt)
saveHistory(STORAGE_KEYS.quickpanelDeleteFilterHistory, deleteFilterHistory.value)
}
const failed = results.filter(r => !r.ok)
if (failed.length === 0) {
// 全部成功:直接关闭快速面板
await hideWindow()
return
}
// 部分失败:仅展示失败项及原因,并刷新目录,移除已成功删除的项,便于重试
deleteResults.value = failed
const deletedPaths = new Set(results.filter(r => r.ok).map(r => r.path))
if (deletedPaths.size) {
await loadDeleteFiles()
deleteSelected.value = new Set(
[...deleteSelected.value].filter(p => !deletedPaths.has(p)),
)
}
} catch (e) {
console.error('[quickpanel] 批量删除失败:', e)
} finally {
deleting.value = false
}
}
function deleteReset() {
dirFiles.value = []
deleteFilter.value = ''
deleteForce.value = false
deleteSelected.value = new Set()
deleting.value = false
deleteResults.value = null
}
// --- 批量解压 ---
async function loadArchives() {
if (!explorerDir.value) return
try {
const list = await commands.quickpanelListArchives(explorerDir.value)
archives.value = list
archiveSelected.value = new Set(list.map(a => a.path))
} catch (e) {
console.error('[quickpanel] 读取压缩包失败:', e)
archives.value = []
}
}
function toggleArchive(path: string) {
const s = new Set(archiveSelected.value)
if (s.has(path)) s.delete(path)
else s.add(path)
archiveSelected.value = s
}
function toggleAllArchives() {
archiveSelected.value =
archiveSelected.value.size === archives.value.length && archives.value.length > 0
? new Set()
: new Set(archives.value.map(a => a.path))
}
async function startExtract() {
const files = [...archiveSelected.value]
if (!files.length || extracting.value) return
// 记录解压密码到历史
const pwd = extractPassword.value.trim()
if (pwd) {
pwdHistory.value = pushHistory(pwdHistory.value, pwd)
saveHistory(STORAGE_KEYS.quickpanelPwdHistory, pwdHistory.value)
}
extracting.value = true
extractProgress.value = null
extractResults.value = null
try {
const results = await commands.quickpanelBatchExtract(
files,
explorerDir.value,
pwd || null,
extractIntoSub.value,
)
extractResults.value = results
} catch (e) {
console.error('[quickpanel] 批量解压失败:', e)
extractResults.value = [{ name: '批量解压失败', path: '', ok: false, error: String(e) }]
} finally {
extracting.value = false
}
}
const extractPct = computed(() => {
const p = extractProgress.value
if (!p || !p.total) return 0
return Math.round((p.done / p.total) * 100)
})
const extractOkCount = computed(() => (extractResults.value ?? []).filter(r => r.ok).length)
// --- 批量重命名 ---
async function loadDirFiles() {
if (!explorerDir.value) return
try {
const list = await commands.quickpanelListDir(explorerDir.value)
dirFiles.value = list.filter(f => !f.isDir)
} catch (e) {
console.error('[quickpanel] 读取目录失败:', e)
dirFiles.value = []
}
}
async function doRenamePreview() {
const pattern = renamePattern.value.trim()
if (!pattern || !dirFiles.value.length) {
renamePreviews.value = null
renameError.value = ''
return
}
try {
const list = await commands.quickpanelPreviewRename(
dirFiles.value.map(f => f.path),
pattern,
renameReplacement.value,
)
renamePreviews.value = list
// 预览刷新后默认全勾选有效项
renameSelected.value = new Set(list.filter(p => !p.error).map(p => p.path))
renameError.value = ''
} catch (e) {
renamePreviews.value = null
renameError.value = String(e)
}
}
function toggleRenameSelect(path: string) {
const next = new Set(renameSelected.value)
if (next.has(path)) next.delete(path)
else next.add(path)
renameSelected.value = next
}
function toggleAllRenameSelect() {
const list = (renamePreviews.value ?? []).filter(p => !p.error)
const allChecked = list.length > 0 && list.every(p => renameSelected.value.has(p.path))
renameSelected.value = allChecked ? new Set() : new Set(list.map(p => p.path))
}
watch([renamePattern, renameReplacement], () => {
// 输入变动:隐藏执行结果,恢复匹配预览
if (renameResults.value) renameResults.value = null
if (renameTimer) clearTimeout(renameTimer)
renameTimer = setTimeout(doRenamePreview, 250)
})
const renameSummary = computed(() => {
if (renameError.value) return renameError.value
const list = renamePreviews.value
if (!list) return '输入正则后实时预览,可用 $1 引用分组'
const valid = list.filter(p => !p.error)
const selected = valid.filter(p => renameSelected.value.has(p.path)).length
return list.length ? `匹配 ${list.length} 个文件,已选 ${selected} 个` : '无匹配文件'
})
async function startRename() {
const items = (renamePreviews.value ?? []).filter(
p => !p.error && renameSelected.value.has(p.path),
)
if (!items.length || renaming.value) return
renaming.value = true
renameResults.value = null
renameOkCount.value = 0
try {
const results = await commands.quickpanelApplyRename(
items.map(p => ({ path: p.path, oldName: p.oldName, newName: p.newName })),
)
// 执行成功后记录匹配/替换历史
const pat = renamePattern.value.trim()
if (pat) {
matchHistory.value = pushHistory(matchHistory.value, pat)
saveHistory(STORAGE_KEYS.quickpanelRenameMatchHistory, matchHistory.value)
}
const rep = renameReplacement.value
if (rep.trim()) {
replaceHistory.value = pushHistory(replaceHistory.value, rep)
saveHistory(STORAGE_KEYS.quickpanelRenameReplaceHistory, replaceHistory.value)
}
const failed = results.filter(r => !r.ok)
renameOkCount.value = results.length - failed.length
if (failed.length === 0) {
// 全部成功:直接关闭快速面板
await hideWindow()
} else {
// 仅显示失败项及原因
renameResults.value = failed
}
} catch (e) {
console.error('[quickpanel] 批量重命名失败:', e)
} finally {
renaming.value = false
}
}
// ===== 执行与隐藏 =====
async function hideWindow() {
try {
await commands.quickpanelHidePopup()
} catch {
/* 忽略 */
}
}
/** 关闭面板并打开主界面快速面板设置页(复用 quickpanel-execute-command 跳转机制) */
async function openSettings() {
await hideWindow()
try {
await emit(EVENTS.quickpanelExecuteCommand, { moduleId: 'quickpanel' })
} catch (e) {
console.error('[quickpanel] 打开设置失败:', e)
}
}
async function executeItem(item: QPItem) {
// 目录操作(批量解压/重命名):进入面板模式,不关闭窗口
if (item.id === 'fa-extract' || item.id === 'fa-rename' || item.id === 'fa-delete') {
await item.action()
return
}
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
}
// 文件操作面板模式:Esc 返回搜索
if (actionMode.value !== 'none') {
if (e.key === 'Escape') {
e.preventDefault()
exitMode()
}
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 Settings
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
if (group === '目录操作') return FolderOpen
return Search
}
/** 类型 badge 颜色映射(柔和色块风格,跟随亮/暗主题) */
const GROUP_COLORS: Record<string, string> = {
设置: 'bg-indigo-500/15 text-indigo-600 dark:text-indigo-400',
计算: 'bg-amber-500/15 text-amber-600 dark:text-amber-400',
网页: 'bg-rose-500/15 text-rose-600 dark:text-rose-400',
系统: 'bg-slate-500/15 text-slate-600 dark:text-slate-400',
自定义: 'bg-orange-500/15 text-orange-600 dark:text-orange-400',
历史: 'bg-violet-500/15 text-violet-600 dark:text-violet-400',
快捷: 'bg-sky-500/15 text-sky-600 dark:text-sky-400',
换算: 'bg-teal-500/15 text-teal-600 dark:text-teal-400',
目录操作: 'bg-cyan-500/15 text-cyan-600 dark:text-cyan-400',
应用: 'bg-blue-500/15 text-blue-600 dark:text-blue-400',
文件: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400',
剪贴板: 'bg-pink-500/15 text-pink-600 dark:text-pink-400',
}
function groupBadgeClass(group: string): string {
return GROUP_COLORS[group] ?? 'bg-muted text-muted-foreground'
}
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 环境忽略 */
}
}
/** 检查文件索引状态(后台自动构建完成后启用文件搜索)。首次调用需打开 SQLite,宜后台执行 */
async function refreshIndexReady() {
try {
const stats = await commands.quickpanelFileIndexStats()
setFileIndexReady((stats?.total ?? 0) > 0)
} catch {
/* 索引未初始化,忽略 */
}
}
onMounted(async () => {
await applyTheme()
// 注册到 window:批量操作模式下被聚焦的输入框卸载后焦点会落到 body,
// 根 div 的冒泡监听会失效(事件不再经过 .qp-root),改用 window 保证 Esc 等始终生效
window.addEventListener('keydown', onKeydown, true)
unlistenFns.push(() => window.removeEventListener('keydown', onKeydown, true))
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))
// 监听弹窗显示事件:更新 Explorer 当前目录 + 清空输入 + 加载初始结果。
// 主题同步与索引状态检查均不阻塞首屏结果(索引首次查询需打开 SQLite)
unlistenFns.push(await listen<{ dir: string | null }>(EVENTS.quickpanelShow, async (e) => {
explorerDir.value = e.payload?.dir ?? ''
void applyTheme()
void refreshIndexReady()
// 若上次关闭时停留在面板模式,恢复搜索态和窗口高度
if (actionMode.value !== 'none') {
actionMode.value = 'none'
extractReset()
renameReset()
deleteReset()
await setWindowHeight(420)
}
// 丢弃残留的防抖搜索;重置输入不触发新搜索(下方显式搜索)
if (searchTimer) {
clearTimeout(searchTimer)
searchTimer = null
}
if (query.value !== '') {
skipNextSearch = true
query.value = ''
}
await doSearch()
await nextTick()
inputRef.value?.focus()
}))
// 批量解压进度事件(面板模式下实时更新进度条)
unlistenFns.push(await listen<ExtractProgress>(EVENTS.quickpanelExtractProgress, (e) => {
if (actionMode.value === 'extract') extractProgress.value = e.payload
}))
unlistenFns.push(await listen(EVENTS.quickpanelHide, () => {
// 取消未触发的防抖搜索;重置输入时跳过搜索(面板已隐藏,避免无效搜索)
if (searchTimer) {
clearTimeout(searchTimer)
searchTimer = null
}
if (query.value !== '') {
skipNextSearch = true
query.value = ''
}
results.value = []
}))
// 先显示窗口(兜底重建路径依赖此调用才真正显示):让面板尽快出现,
// 后续数据加载(索引统计/初始搜索)不阻塞显示,避免 dev 下首屏渲染慢导致"唤不出"。
try {
await commands.quickpanelShowWindow()
} catch {
/* 忽略 */
}
// 初始加载(空查询显示历史置顶 + 系统条目;Provider 空查询零 IPC,即时渲染)
// 独立窗口需自行检查文件索引状态(主窗口的 setFileIndexReady 不共享到此上下文)
void refreshIndexReady()
await doSearch()
await nextTick()
inputRef.value?.focus()
})
onUnmounted(() => {
if (searchTimer) clearTimeout(searchTimer)
unlistenFns.forEach((fn) => fn())
})
</script>
<template>
<div class="qp-root flex flex-col h-screen w-screen">
<TooltipProvider>
<!-- 搜索输入 -->
<div class="qp-input-wrap">
<Search class="size-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>
<button class="qp-settings-btn" title="打开快速面板设置" @click="openSettings">
<Settings class="size-4" />
</button>
</div>
<!-- 结果区 -->
<div class="qp-results">
<!-- 批量解压面板 -->
<template v-if="actionMode === 'extract'">
<div class="qp-action-head">
<button class="qp-back-btn" title="返回搜索" @click="exitMode()"><ChevronLeft class="size-4" /></button>
<div class="min-w-0 flex-1">
<p class="qp-action-title">批量解压</p>
<p class="qp-action-dir truncate" :title="explorerDir">{{ explorerDir }}</p>
</div>
</div>
<div v-if="!archives.length" class="qp-empty">
<ArchiveIcon class="size-10 mb-3 opacity-40" />
<p class="text-sm">当前目录没有压缩包</p>
</div>
<template v-else>
<div class="qp-fa-list">
<div class="qp-fa-row qp-fa-row-head">
<span class="text-xs text-muted-foreground">已选 {{ archiveSelected.size }} / {{ archives.length }}</span>
<button class="qp-link-btn" @click="toggleAllArchives">
{{ archiveSelected.size === archives.length && archives.length > 0 ? '全不选' : '全选' }}
</button>
</div>
<div class="qp-fa-scroll">
<label
v-for="a in archives"
:key="a.path"
class="qp-fa-item"
:class="{ 'qp-fa-checked': archiveSelected.has(a.path) }"
>
<input
type="checkbox"
class="qp-fa-checkbox"
:checked="archiveSelected.has(a.path)"
@change="toggleArchive(a.path)"
/>
<ArchiveIcon class="size-4 text-muted-foreground shrink-0" />
<span class="flex-1 min-w-0 text-sm truncate">{{ a.name }}</span>
<span class="qp-fa-size">{{ formatSize(a.size) }}</span>
</label>
</div>
</div>
<div class="qp-fa-form">
<div class="qp-fa-row">
<span class="qp-fa-label">统一密码</span>
<div class="qp-fa-input-group">
<input
v-model="extractPassword"
type="password"
class="qp-fa-input qp-fa-pwd"
placeholder="留空表示无密码"
spellcheck="false"
autocomplete="off"
@keydown.enter.prevent="startExtract"
@focus="pwdHistoryOpen = true"
@blur="pwdHistoryOpen = false"
/>
<HistoryPicker :open="pwdHistoryOpen" :items="pwdHistory" :favs="pwdFavs" @pick="pickPwd" @fav="togglePwdFav" />
</div>
</div>
<div class="qp-fa-row">
<span class="qp-fa-label">解压位置</span>
<div class="qp-fa-radios">
<label class="qp-fa-radio">
<input type="radio" :checked="extractIntoSub" @change="extractIntoSub = true" />
同名子文件夹
</label>
<label class="qp-fa-radio">
<input type="radio" :checked="!extractIntoSub" @change="extractIntoSub = false" />
当前目录
</label>
</div>
</div>
<div class="qp-fa-row">
<span class="qp-fa-label"></span>
<button class="qp-fa-primary" :disabled="extracting || !archiveSelected.size" @click="startExtract">
<Loader2 v-if="extracting" class="size-4 animate-spin" />
<template v-if="extracting">解压中 {{ extractProgress?.done ?? 0 }}/{{ extractProgress?.total ?? archiveSelected.size }}</template>
<template v-else>解压 {{ archiveSelected.size }} 个压缩包</template>
</button>
</div>
<div v-if="extractProgress" class="qp-fa-progress-wrap">
<div class="qp-fa-progress" :style="{ width: extractPct + '%' }"></div>
<span class="qp-fa-progress-text">{{ extractPct }}%</span>
</div>
<div v-if="extractResults" class="qp-fa-results">
<p class="qp-fa-results-sum">{{ extractOkCount }} 成功 · {{ extractResults.length - extractOkCount }} 失败</p>
<div class="qp-fa-scroll">
<div
v-for="(r, i) in extractResults"
:key="i"
class="qp-fa-result"
:class="r.ok ? 'qp-fa-ok' : 'qp-fa-err'"
>
<span class="flex-1 min-w-0 text-xs truncate">{{ r.name }}</span>
<span class="text-xs">{{ r.ok ? '完成' : r.error }}</span>
</div>
</div>
</div>
</div>
</template>
</template>
<!-- 批量重命名面板 -->
<template v-else-if="actionMode === 'rename'">
<div class="qp-action-head">
<button class="qp-back-btn" title="返回搜索" @click="exitMode()"><ChevronLeft class="size-4" /></button>
<div class="min-w-0 flex-1">
<p class="qp-action-title">批量重命名</p>
<p class="qp-action-dir truncate" :title="explorerDir">{{ explorerDir }}</p>
</div>
</div>
<div class="qp-fa-form">
<div class="qp-fa-row">
<span class="qp-fa-label">匹配</span>
<div class="qp-fa-input-group">
<input
v-model="renamePattern"
class="qp-fa-input qp-fa-pattern mono"
placeholder="正则,如 ^IMG_(\d+)"
spellcheck="false"
autocomplete="off"
@keydown.enter.prevent="startRename"
@focus="matchHistoryOpen = true"
@blur="matchHistoryOpen = false"
/>
<HistoryPicker :open="matchHistoryOpen" :items="matchHistory" :favs="matchFavs" @pick="pickMatch" @fav="toggleMatchFav" />
</div>
</div>
<div class="qp-fa-row">
<span class="qp-fa-label">替换为</span>
<div class="qp-fa-input-group">
<input
v-model="renameReplacement"
class="qp-fa-input mono"
placeholder="$1 引用分组,留空为删除匹配"
spellcheck="false"
autocomplete="off"
@keydown.enter.prevent="startRename"
@focus="replaceHistoryOpen = true"
@blur="replaceHistoryOpen = false"
/>
<HistoryPicker :open="replaceHistoryOpen" :items="replaceHistory" :favs="replaceFavs" @pick="pickReplace" @fav="toggleReplaceFav" />
</div>
</div>
<div class="qp-fa-row">
<span class="qp-fa-label">范围</span>
<span class="text-xs text-muted-foreground">{{ dirFiles.length }} 个文件</span>
</div>
<div class="qp-fa-row">
<span class="qp-fa-label"></span>
<p class="qp-fa-summary" :class="{ 'text-destructive': !!renameError }">{{ renameSummary }}</p>
</div>
<div class="qp-fa-row">
<span class="qp-fa-label"></span>
<button
class="qp-fa-primary"
:disabled="renaming || !(renamePreviews ?? []).some(p => !p.error && renameSelected.has(p.path))"
@click="startRename"
>
<Loader2 v-if="renaming" class="size-4 animate-spin" />
执行重命名
</button>
</div>
</div>
<div class="qp-fa-list" v-show="!renameResults">
<div class="qp-fa-row qp-fa-row-head">
<span class="text-xs text-muted-foreground">
已选 {{ (renamePreviews ?? []).filter(p => !p.error && renameSelected.has(p.path)).length }} / {{ (renamePreviews ?? []).filter(p => !p.error).length }}
</span>
<button class="qp-link-btn" @click="toggleAllRenameSelect">全选 / 全不选</button>
</div>
<div class="qp-fa-scroll">
<label
v-for="p in renamePreviews ?? []"
:key="p.path"
class="qp-fa-item"
:class="{ 'qp-fa-checked': !p.error && renameSelected.has(p.path), 'qp-fa-err': !!p.error }"
>
<input
type="checkbox"
class="qp-fa-checkbox"
:checked="!p.error && renameSelected.has(p.path)"
:disabled="!!p.error"
@change="toggleRenameSelect(p.path)"
/>
<Regex class="size-4 text-muted-foreground shrink-0" />
<div class="flex-1 min-w-0">
<p class="text-xs text-muted-foreground truncate">{{ p.oldName }}</p>
<p class="text-xs truncate" :class="p.error ? 'text-destructive' : 'text-primary'">{{ p.error || p.newName }}</p>
</div>
</label>
<div v-if="renamePreviews && !renamePreviews.length && !renameError" class="qp-fa-empty-tip">无匹配文件</div>
</div>
</div>
<div v-if="renameResults" class="qp-fa-results">
<p class="qp-fa-results-sum">{{ renameResults.length }} 个文件重命名失败已成功 {{ renameOkCount }} </p>
<div class="qp-fa-scroll">
<div
v-for="(r, i) in renameResults"
:key="i"
class="qp-fa-result qp-fa-err"
>
<span class="flex-1 min-w-0 text-xs truncate">{{ r.oldName }} {{ r.newName }}</span>
<span class="text-xs">{{ r.error }}</span>
</div>
</div>
</div>
</template>
<!-- 批量删除面板 -->
<template v-else-if="actionMode === 'delete'">
<div class="qp-action-head">
<button class="qp-back-btn" title="返回搜索" @click="exitMode()"><ChevronLeft class="size-4" /></button>
<div class="min-w-0 flex-1">
<p class="qp-action-title">批量删除</p>
<p class="qp-action-dir truncate" :title="explorerDir">{{ explorerDir }}</p>
</div>
</div>
<div class="qp-fa-form">
<div class="qp-fa-row">
<span class="qp-fa-label">筛选</span>
<div class="qp-fa-input-group">
<input
v-model="deleteFilter"
class="qp-fa-input qp-fa-delete-filter mono"
placeholder="按名称筛选文件/文件夹,支持正则,如 \.tmp$"
spellcheck="false"
autocomplete="off"
@keydown.enter.prevent="startDelete"
@focus="deleteFilterHistoryOpen = true"
@blur="deleteFilterHistoryOpen = false"
/>
<HistoryPicker :open="deleteFilterHistoryOpen" :items="deleteFilterHistory" :favs="deleteFilterFavs" @pick="pickDeleteFilter" @fav="toggleDeleteFilterFav" />
</div>
</div>
<div class="qp-fa-row">
<span class="qp-fa-label">范围</span>
<span class="text-xs text-muted-foreground">筛选出 {{ deleteFiltered.length }} </span>
</div>
<div class="qp-fa-row">
<span class="qp-fa-label"></span>
<div class="qp-fa-delete-actions">
<button
class="qp-fa-primary qp-fa-danger"
:disabled="deleting || !deleteSelected.size || !deleteFiltered.length"
@click="startDelete"
>
<Loader2 v-if="deleting" class="size-4 animate-spin" />
<Trash2 v-else class="size-4" />
删除 {{ deleteSelected.size }}
</button>
<label class="qp-fa-force">
<Switch v-model="deleteForce" :disabled="deleting" />
<span>强行删除</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child><span class="qp-fa-hint">?</span></TooltipTrigger>
<TooltipContent>强行删除清除只读属性后永久删除不经过回收站被其他进程锁定的文件仍会失败并显示原因</TooltipContent>
</Tooltip>
</TooltipProvider>
</label>
</div>
</div>
</div>
<div v-if="deleteResults" class="qp-fa-results">
<p class="qp-fa-results-sum">{{ deleteResults.length }} 项删除失败可修正后重试</p>
<div class="qp-fa-scroll qp-fa-results-scroll">
<div
v-for="(r, i) in deleteResults"
:key="i"
class="qp-fa-result qp-fa-err"
>
<span class="flex-1 min-w-0 text-xs truncate">{{ r.name }}</span>
<span class="text-xs">{{ r.error }}</span>
</div>
</div>
</div>
<div class="qp-fa-list">
<div class="qp-fa-row qp-fa-row-head">
<span class="text-xs text-muted-foreground">已选 {{ deleteSelected.size }} / {{ deleteFiltered.length }}</span>
<button class="qp-link-btn" @click="toggleAllDeleteSelect">全选 / 全不选</button>
</div>
<div class="qp-fa-scroll">
<label
v-for="f in deleteFiltered"
:key="f.path"
class="qp-fa-item"
:class="{ 'qp-fa-checked': deleteSelected.has(f.path) }"
>
<input
type="checkbox"
class="qp-fa-checkbox"
:checked="deleteSelected.has(f.path)"
@change="toggleDeleteSelect(f.path)"
/>
<FolderOpen v-if="f.isDir" class="size-4 text-amber-500 shrink-0" />
<FileText v-else class="size-4 text-muted-foreground shrink-0" />
<div class="flex-1 min-w-0">
<p class="text-xs truncate">{{ f.name }}</p>
<p class="text-[10px] text-muted-foreground truncate">{{ f.isDir ? '文件夹' : formatSize(f.size) }}</p>
</div>
</label>
<div v-if="!deleteFiltered.length" class="qp-fa-empty-tip">无匹配条目</div>
</div>
</div>
</template>
<!-- 搜索态 -->
<template v-else>
<div v-if="loading && !hasResults()" class="qp-empty">
<Loader2 class="size-6 animate-spin mb-2" />
<p class="text-sm">搜索中</p>
</div>
<div v-else-if="!hasResults() && query.trim()" class="qp-empty">
<Search class="size-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="size-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 dirActionItems" :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="size-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" :class="groupBadgeClass(item.group)">{{ item.group }}</span>
<CornerDownLeft
v-if="idx === selectedIndex"
class="h-3.5 w-3.5 text-primary shrink-0"
/>
</div>
</template>
<!-- 历史置顶项可键盘导航索引偏移 dirActionItems.length -->
<template v-for="(item, idx) in historyItems" :key="item.id">
<div
class="qp-item"
:class="{ 'qp-item-selected': (idx + dirActionItems.length) === selectedIndex }"
@click="executeItem(item)"
@mouseenter="onItemHover(idx + dirActionItems.length)"
>
<img
v-if="item.iconUrl"
:src="item.iconUrl"
class="qp-app-icon shrink-0"
alt=""
/>
<component
v-else
:is="groupIcon(item.group)"
class="size-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" :class="groupBadgeClass(item.group)">{{ item.group }}</span>
<CornerDownLeft
v-if="(idx + dirActionItems.length) === 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="size-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" :class="groupBadgeClass(item.group)">{{ item.group }}</span>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<!-- 其他结果设置/应用/系统等可键盘导航索引偏移 dirActionItems.length + historyItems.length -->
<template v-for="(item, idx) in otherItems" :key="item.id">
<div
class="qp-item"
:class="{ 'qp-item-selected': (idx + dirActionItems.length + historyItems.length) === selectedIndex }"
@click="executeItem(item)"
@mouseenter="onItemHover(idx + dirActionItems.length + 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="size-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" :class="groupBadgeClass(item.group)">{{ item.group }}</span>
<ChevronRight
v-if="item.subActions?.length && (idx + dirActionItems.length + historyItems.length) !== selectedIndex"
class="h-3.5 w-3.5 text-muted-foreground shrink-0"
/>
<kbd
v-else-if="item.subActions?.length && (idx + dirActionItems.length + historyItems.length) === selectedIndex"
class="qp-kbd shrink-0"
@click.stop="toggleSubActions(idx + dirActionItems.length + historyItems.length)"
>Tab</kbd>
<CornerDownLeft
v-else-if="(idx + dirActionItems.length + historyItems.length) === selectedIndex"
class="h-3.5 w-3.5 text-primary shrink-0"
/>
</div>
<!-- 子动作展开面板 -->
<div v-if="subActionExpanded === (idx + dirActionItems.length + 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>
</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="size-4 shrink-0 text-destructive" />
<p class="qp-confirm-title">确定删除</p>
</div>
<Tooltip>
<TooltipTrigger as-child>
<p class="qp-confirm-name truncate">{{ pendingDelete.name }}</p>
</TooltipTrigger>
<TooltipContent>{{ pendingDelete.name }}</TooltipContent>
</Tooltip>
<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>
</TooltipProvider>
</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-settings-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 6px;
color: var(--muted-foreground);
transition: background 0.15s, color 0.15s;
}
.qp-settings-btn:hover {
background: var(--muted);
color: var(--foreground);
}
.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-action-head {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
margin-bottom: 6px;
}
.qp-back-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 6px;
border: none;
background: var(--muted);
color: var(--foreground);
cursor: pointer;
flex-shrink: 0;
transition: background 0.1s;
}
.qp-back-btn:hover {
background: var(--accent);
}
.qp-action-title {
font-size: 14px;
font-weight: 600;
color: var(--foreground);
line-height: 1.3;
}
.qp-action-dir {
font-size: 11px;
color: var(--muted-foreground);
line-height: 1.3;
}
.qp-fa-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.qp-fa-row {
display: flex;
align-items: center;
gap: 8px;
padding: 2px 0;
}
.qp-fa-row-head {
justify-content: space-between;
padding: 2px 4px;
}
.qp-fa-scroll {
max-height: 168px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--muted);
padding: 2px;
}
.qp-fa-item {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 8px;
border-radius: 5px;
cursor: pointer;
transition: background 0.08s;
}
.qp-fa-item:hover {
background: var(--accent);
}
.qp-fa-checked {
background: var(--accent);
}
.qp-fa-checkbox {
accent-color: var(--primary);
width: 13px;
height: 13px;
cursor: pointer;
flex-shrink: 0;
}
.qp-fa-size {
font-size: 11px;
color: var(--muted-foreground);
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.qp-fa-form {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 6px;
}
.qp-fa-label {
width: 56px;
flex-shrink: 0;
font-size: 12px;
color: var(--muted-foreground);
text-align: right;
}
.qp-fa-input-group {
position: relative;
flex: 1;
min-width: 0;
}
.qp-fa-input {
display: block;
width: 100%;
min-width: 0;
height: 28px;
padding: 0 8px;
font-size: 13px;
color: var(--foreground);
background: var(--muted);
border: 1px solid var(--border);
border-radius: 6px;
outline: none;
transition: border-color 0.1s;
}
.qp-fa-input:focus {
border-color: var(--primary);
}
.qp-fa-input::placeholder {
color: var(--muted-foreground);
}
.qp-fa-radios {
display: flex;
align-items: center;
gap: 14px;
flex: 1;
}
.qp-fa-radio {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
color: var(--foreground);
cursor: pointer;
}
.qp-fa-radio input {
accent-color: var(--primary);
cursor: pointer;
}
.qp-fa-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 30px;
padding: 0 16px;
font-size: 13px;
font-weight: 500;
color: white;
background: var(--primary);
border: none;
border-radius: 6px;
cursor: pointer;
transition: opacity 0.12s;
}
.qp-fa-primary:hover:not(:disabled) {
opacity: 0.9;
}
.qp-fa-primary:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.qp-fa-primary.qp-fa-danger {
background: var(--destructive);
}
.qp-fa-delete-actions {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.qp-fa-force {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--muted-foreground);
cursor: pointer;
user-select: none;
}
.qp-fa-hint {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border-radius: 50%;
font-size: 10px;
color: var(--muted-foreground);
background: var(--muted);
border: 1px solid var(--border);
cursor: help;
}
.qp-fa-progress-wrap {
position: relative;
height: 20px;
margin-top: 4px;
background: var(--muted);
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
}
.qp-fa-progress {
height: 100%;
background: var(--primary);
opacity: 0.85;
transition: width 0.2s ease;
}
.qp-fa-progress-text {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: var(--foreground);
}
.qp-fa-results {
margin-top: 6px;
display: flex;
flex-direction: column;
gap: 2px;
}
/* 删除结果区:限高,避免遮挡下方可重试的列表 */
.qp-fa-results-scroll {
max-height: 96px;
}
.qp-fa-results-sum {
font-size: 11px;
color: var(--muted-foreground);
padding: 0 2px;
}
.qp-fa-result {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border-radius: 5px;
font-size: 12px;
}
.qp-fa-ok {
color: var(--foreground);
}
.qp-fa-err {
color: var(--destructive);
}
.qp-fa-summary {
flex: 1;
font-size: 11px;
color: var(--muted-foreground);
line-height: 1.4;
}
.qp-fa-empty-tip {
padding: 10px;
text-align: center;
font-size: 12px;
color: var(--muted-foreground);
}
.qp-link-btn {
background: none;
border: none;
padding: 2px 4px;
font-size: 12px;
color: var(--primary);
cursor: pointer;
}
.qp-link-btn:hover {
text-decoration: underline;
}
.mono {
font-family: Consolas, 'Cascadia Mono', 'Courier New', monospace;
}
.qp-fa-scroll::-webkit-scrollbar {
width: 6px;
}
.qp-fa-scroll::-webkit-scrollbar-thumb {
background: var(--muted-foreground);
opacity: 0.3;
border-radius: 3px;
}
.qp-fa-scroll::-webkit-scrollbar-track {
background: transparent;
}
.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;
padding: 1px 6px;
border-radius: 3px;
flex-shrink: 0;
margin-top: 2px;
/* 颜色由 groupBadgeClass 的 tailwind 类控制(不同类型不同颜色) */
line-height: 16px;
}
/* 子动作面板 */
.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>