2191 lines
68 KiB
Vue
2191 lines
68 KiB
Vue
<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>
|