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

578 lines
20 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, reactive, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { open } from '@tauri-apps/plugin-dialog'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { toast } from 'vue-sonner'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
import { Command, Zap, Keyboard, Globe, Monitor, MousePointer2, FolderTree, RefreshCw, Plus, X, Loader2, Terminal, Pencil, Check } from '@lucide/vue'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { useModuleTabsStore } from '@/stores/moduleTabsStore'
import { setFileIndexReady, invalidateCustomCommandsCache } from './providers'
import { STORAGE_KEYS, EVENTS } from '@/lib/constants'
interface CustomCommand {
id: string
title: string
command: string
args: string[]
}
interface QuickPanelSettings {
shortcut: string
popupPosition: string
searchEngine: string
indexDirs: string[]
customCommands: CustomCommand[]
}
const form = reactive<QuickPanelSettings>({
shortcut: 'Alt+Space',
popupPosition: 'center',
searchEngine: 'bing',
indexDirs: [],
customCommands: [],
})
// ===== 文件索引状态 =====
interface IndexStats {
total: number
lastBuiltAt: number
lastBuiltDirs: string[]
}
const indexStats = ref<IndexStats | null>(null)
const building = ref(false)
// 索引构建完成事件监听器(onUnmounted 时注销)
let indexUpdatedUnlisten: UnlistenFn | null = null
async function refreshStats() {
try {
indexStats.value = await commands.quickpanelFileIndexStats()
// 索引存在(total > 0)即标记为就绪
setFileIndexReady((indexStats.value?.total ?? 0) > 0)
} catch (e) {
console.error('[quickpanel] 获取索引状态失败:', e)
}
}
async function buildIndex() {
if (building.value) return
building.value = true
try {
const count = await commands.quickpanelBuildFileIndex()
toast.success(`索引完成,共 ${count} 条`)
await refreshStats()
} catch (e) {
console.error('[quickpanel] 索引构建失败:', e)
toast.error('索引构建失败')
} finally {
building.value = false
}
}
async function addDir() {
const selected = await open({ directory: true, multiple: false })
if (typeof selected === 'string' && !form.indexDirs.includes(selected)) {
form.indexDirs.push(selected)
await saveSettings()
}
}
function removeDir(idx: number) {
form.indexDirs.splice(idx, 1)
void saveSettings()
}
function formatTime(t: number): string {
if (!t) return '未构建'
const d = new Date(t * 1000)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
onMounted(async () => {
try {
const s = await commands.quickpanelGetSettings()
Object.assign(form, s)
} catch (e) {
console.error('[quickpanel] 读取设置失败:', e)
}
await refreshStats()
// 监听索引构建完成事件(闲时自动建立/重建):刷新统计,无需手动刷新
indexUpdatedUnlisten = await listen<number>(EVENTS.quickpanelIndexUpdated, () => {
void refreshStats()
})
})
// ===== 保存 =====
async function saveSettings() {
try {
await commands.quickpanelSaveSettings({ ...form })
// 同步到 localStorage 供独立窗口读取
localStorage.setItem(STORAGE_KEYS.quickpanelSettings, JSON.stringify({ ...form }))
// 清除自定义命令缓存,使下次搜索重新加载
invalidateCustomCommandsCache()
toast.success('设置已保存')
} catch (e) {
console.error('[quickpanel] 保存设置失败:', e)
toast.error('保存设置失败')
}
}
// ===== 自定义命令管理 =====
const editingCmd = reactive<CustomCommand>({ id: '', title: '', command: '', args: [] })
const editingIdx = ref(-1) // -1 表示新增,>=0 表示编辑现有
const showEditor = ref(false)
function addCustomCommand() {
editingIdx.value = -1
Object.assign(editingCmd, { id: '', title: '', command: '', args: [] })
showEditor.value = true
}
function editCustomCommand(idx: number) {
editingIdx.value = idx
const cmd = form.customCommands[idx]
Object.assign(editingCmd, { id: cmd.id, title: cmd.title, command: cmd.command, args: [...cmd.args] })
showEditor.value = true
}
function saveCustomCommand() {
if (!editingCmd.title.trim() || !editingCmd.command.trim()) {
toast.warning('标题和命令不能为空')
return
}
if (editingIdx.value >= 0) {
// 编辑
form.customCommands[editingIdx.value] = { ...editingCmd }
} else {
// 新增
form.customCommands.push({
...editingCmd,
id: `cmd-${Date.now()}`,
})
}
showEditor.value = false
void saveSettings()
}
function removeCustomCommand(idx: number) {
form.customCommands.splice(idx, 1)
void saveSettings()
}
const tabsStore = useModuleTabsStore()
tabsStore.registerSave(saveSettings)
// ===== 快捷键录入器(与 ClipboardModule 同模式) =====
const recording = ref(false)
const recorderRef = ref<HTMLDivElement | null>(null)
function displayShortcut(s: string): string {
if (!s) return ''
return s
.split('+')
.map(p => {
const t = p.trim()
if (!t) return ''
if (t.length === 1) return t.toUpperCase()
return t.charAt(0).toUpperCase() + t.slice(1)
})
.join(' + ')
}
function eventToShortcut(e: KeyboardEvent): string | null {
const mods: string[] = []
if (e.ctrlKey) mods.push('ctrl')
if (e.altKey) mods.push('alt')
if (e.shiftKey) mods.push('shift')
if (e.metaKey) mods.push('super')
let main = ''
const code = e.code || ''
if (/^Key[A-Z]$/.test(code)) main = code.slice(3).toLowerCase()
else if (/^Digit[0-9]$/.test(code)) main = code.slice(5)
else if (/^F([1-9]|1[0-2])$/.test(code)) main = code.toLowerCase()
else if (code === 'Space') main = 'space'
else if (code === 'PrintScreen') main = 'printscreen'
else if (code.startsWith('Numpad')) main = code.slice(6).toLowerCase()
else return null
const isFunctionKey = /^f([1-9]|1[0-2])$/.test(main) || main === 'printscreen'
if (mods.length === 0 && !isFunctionKey) return null
return [...mods, main].join('+')
}
function onRecordKey(e: KeyboardEvent) {
if (!recording.value) return
e.preventDefault()
e.stopPropagation()
if (e.key === 'Escape') {
recording.value = false
return
}
if (['Control', 'Alt', 'Shift', 'Meta'].includes(e.key)) return
const combo = eventToShortcut(e)
if (!combo) {
toast.warning('不支持的按键组合,请使用字母/数字/功能键 + 修饰键')
return
}
recording.value = false
form.shortcut = combo
void saveSettings()
toast.success(`快捷键已更新为 ${displayShortcut(combo)}`)
}
async function startRecord() {
recording.value = true
await nextTick()
recorderRef.value?.focus()
}
watch(recording, (on) => {
if (on) window.addEventListener('keydown', onRecordKey, true)
else window.removeEventListener('keydown', onRecordKey, true)
})
async function clearShortcut() {
form.shortcut = ''
await saveSettings()
}
onUnmounted(() => {
window.removeEventListener('keydown', onRecordKey, true)
indexUpdatedUnlisten?.()
// 注销保存处理函数与标签状态,防止其他模块 activeTab=settings 时误执行本模块 saveSettings
tabsStore.unregisterTabs()
})
// ===== 唤起测试 =====
async function testPopup() {
try {
await commands.quickpanelShowPopup()
} catch (e) {
console.error('[quickpanel] 唤起失败:', e)
toast.error('唤起失败')
}
}
// ===== 选项配置 =====
const positionOptions = [
{ value: 'center', label: '屏幕中央', icon: Monitor },
{ value: 'cursor', label: '鼠标位置', icon: MousePointer2 },
]
const engineOptions = [
{ value: 'google', label: 'Google' },
{ value: 'bing', label: 'Bing' },
{ value: 'baidu', label: '百度' },
]
async function changePosition(v: string) {
form.popupPosition = v
await saveSettings()
}
async function changeEngine(v: string) {
form.searchEngine = v
await saveSettings()
}
</script>
<template>
<div class="h-full p-6 overflow-y-auto">
<div class="max-w-2xl space-y-4">
<!-- 标题与测试 -->
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold flex items-center gap-2">
<Command class="size-5 text-primary" />
快速面板
</h2>
<Button size="sm" @click="testPopup">
<Zap class="size-4 mr-1" />
测试唤起
</Button>
</div>
<!-- 快捷键 -->
<Card>
<CardHeader>
<CardTitle class="text-sm flex items-center gap-2">
<Keyboard class="size-4" />
唤起快捷键
</CardTitle>
</CardHeader>
<CardContent class="space-y-3">
<div class="flex items-center gap-2 flex-wrap">
<div
ref="recorderRef"
class="hotkey-recorder"
:class="{ recording }"
tabindex="0"
@click="startRecord"
>
<template v-if="recording">按下快捷键…(Esc 取消</template>
<template v-else-if="form.shortcut">
{{ displayShortcut(form.shortcut) }}
</template>
<template v-else>未设置点击录入</template>
</div>
<Button
v-if="form.shortcut"
variant="ghost"
size="sm"
class="h-8 text-muted-foreground"
@click="clearShortcut"
>清除</Button>
</div>
<p class="text-xs text-muted-foreground">
全局快捷键唤起快速面板需至少一个修饰键 + 字母/数字/功能键默认 Alt+Space
</p>
</CardContent>
</Card>
<!-- 唤起位置 -->
<Card>
<CardHeader>
<CardTitle class="text-sm">唤起位置</CardTitle>
</CardHeader>
<CardContent>
<div class="flex items-center gap-1.5">
<Button
v-for="opt in positionOptions"
:key="opt.value"
:variant="form.popupPosition === opt.value ? 'default' : 'outline'"
size="sm"
@click="changePosition(opt.value)"
>
<component :is="opt.icon" class="h-3.5 w-3.5 mr-1" />
{{ opt.label }}
</Button>
</div>
<p class="text-xs text-muted-foreground mt-2">
屏幕中央在鼠标所在显示器的工作区中央显示鼠标位置在光标附近显示
</p>
</CardContent>
</Card>
<!-- 搜索引擎 -->
<Card>
<CardHeader>
<CardTitle class="text-sm flex items-center gap-2">
<Globe class="size-4" />
默认搜索引擎
</CardTitle>
</CardHeader>
<CardContent>
<div class="flex items-center gap-1.5">
<Button
v-for="opt in engineOptions"
:key="opt.value"
:variant="form.searchEngine === opt.value ? 'default' : 'outline'"
size="sm"
@click="changeEngine(opt.value)"
>
{{ opt.label }}
</Button>
</div>
<p class="text-xs text-muted-foreground mt-2">
输入无匹配结果时 Enter 在默认引擎中搜索
</p>
</CardContent>
</Card>
<!-- 文件索引 -->
<Card>
<CardHeader>
<CardTitle class="text-sm flex items-center gap-2">
<FolderTree class="size-4" />
文件索引
</CardTitle>
</CardHeader>
<CardContent class="space-y-3">
<!-- 索引状态 -->
<div class="flex items-center gap-3 text-xs flex-wrap">
<Badge variant="outline">{{ indexStats?.total ?? 0 }} </Badge>
<span class="text-muted-foreground">上次构建{{ formatTime(indexStats?.lastBuiltAt ?? 0) }}</span>
<Button
size="sm"
variant="outline"
:disabled="building"
class="ml-auto h-7"
@click="buildIndex"
>
<Loader2 v-if="building" class="h-3.5 w-3.5 mr-1 animate-spin" />
<RefreshCw v-else class="h-3.5 w-3.5 mr-1" />
{{ building ? '构建中…' : '重建索引' }}
</Button>
</div>
<!-- 索引目录列表 -->
<div class="space-y-1.5">
<div
v-for="(dir, idx) in form.indexDirs"
:key="dir"
class="flex items-center gap-2 p-2 rounded-md bg-muted/40"
>
<FolderTree class="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span class="text-xs font-mono truncate flex-1">{{ dir }}</span>
<Button
variant="ghost"
size="icon"
class="size-6 shrink-0 hover:text-destructive"
@click="removeDir(idx)"
>
<X class="size-3" />
</Button>
</div>
<div v-if="!form.indexDirs.length" class="text-xs text-muted-foreground py-2">
未配置索引目录默认桌面文档下载
</div>
</div>
<Button size="sm" variant="outline" @click="addDir">
<Plus class="h-3.5 w-3.5 mr-1" />
添加目录
</Button>
<p class="text-xs text-muted-foreground">
索引指定目录下的文件名支持拼音/首字母搜索重建索引后生效
</p>
</CardContent>
</Card>
<!-- 自定义命令 -->
<Card>
<CardHeader>
<CardTitle class="text-sm flex items-center gap-2">
<Terminal class="size-4" />
自定义命令
</CardTitle>
</CardHeader>
<CardContent class="space-y-3">
<!-- 命令列表 -->
<div class="space-y-1.5">
<div
v-for="(cmd, idx) in form.customCommands"
:key="cmd.id"
class="flex items-center gap-2 p-2 rounded-md bg-muted/40"
>
<Terminal class="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<div class="flex-1 min-w-0">
<p class="text-xs font-medium truncate">{{ cmd.title }}</p>
<p class="text-xs text-muted-foreground font-mono truncate">{{ cmd.command }} {{ cmd.args.join(' ') }}</p>
</div>
<Button variant="ghost" size="icon" class="size-6 shrink-0" @click="editCustomCommand(idx)">
<Pencil class="size-3" />
</Button>
<Button variant="ghost" size="icon" class="size-6 shrink-0 hover:text-destructive" @click="removeCustomCommand(idx)">
<X class="size-3" />
</Button>
</div>
<div v-if="!form.customCommands.length" class="text-xs text-muted-foreground py-2">
暂无自定义命令
</div>
</div>
<Button size="sm" variant="outline" @click="addCustomCommand">
<Plus class="h-3.5 w-3.5 mr-1" />
添加命令
</Button>
<!-- 编辑面板 -->
<div v-if="showEditor" class="space-y-2 p-3 rounded-md border bg-muted/20">
<div class="flex items-center gap-2">
<input
v-model="editingCmd.title"
class="flex-1 h-8 px-2 text-sm rounded border bg-background"
placeholder="标题(如:打开记事本)"
/>
</div>
<div class="flex items-center gap-2">
<input
v-model="editingCmd.command"
class="flex-1 h-8 px-2 text-sm font-mono rounded border bg-background"
placeholder="命令路径(如:notepad.exe"
/>
</div>
<div class="flex items-center gap-2">
<input
:value="editingCmd.args.join(' ')"
@input="(e) => editingCmd.args = (e.target as HTMLInputElement).value.split(/\s+/).filter(Boolean)"
class="flex-1 h-8 px-2 text-sm font-mono rounded border bg-background"
placeholder="参数(空格分隔,如:-newwindow"
/>
</div>
<div class="flex items-center gap-2">
<Button size="sm" @click="saveCustomCommand">
<Check class="h-3.5 w-3.5 mr-1" />
保存
</Button>
<Button size="sm" variant="ghost" @click="showEditor = false">取消</Button>
</div>
</div>
<p class="text-xs text-muted-foreground">
自定义可执行命令在面板中按标题搜索即可运行支持拼音/首字母匹配
</p>
</CardContent>
</Card>
<!-- 功能说明 -->
<Card>
<CardHeader>
<CardTitle class="text-sm">当前能力</CardTitle>
</CardHeader>
<CardContent class="space-y-2">
<div class="flex items-center gap-2 text-sm">
<Badge variant="secondary">设置</Badge>
<span class="text-muted-foreground">本程序模块导航与退出应用搜索时显示不占用默认视图</span>
</div>
<div class="flex items-center gap-2 text-sm">
<Badge variant="secondary">自定义</Badge>
<span class="text-muted-foreground">用户配置的可执行命令</span>
</div>
<div class="flex items-center gap-2 text-sm">
<Badge variant="secondary">应用</Badge>
<span class="text-muted-foreground">扫描开始菜单拼音/首字母启动</span>
</div>
<div class="flex items-center gap-2 text-sm">
<Badge variant="secondary">文件</Badge>
<span class="text-muted-foreground">索引指定目录快速定位文件支持打开/显示/复制路径/删除.lnk 按应用处理</span>
</div>
<div class="flex items-center gap-2 text-sm">
<Badge variant="secondary">目录操作</Badge>
<span class="text-muted-foreground">文件资源管理器批量处理批量解压正则批量重命名实时预览)、筛选批量删除</span>
</div>
<div class="flex items-center gap-2 text-sm">
<Badge variant="secondary">历史</Badge>
<span class="text-muted-foreground">记录最近交互空查询优先展示</span>
</div>
<div class="flex items-center gap-2 text-sm">
<Badge variant="secondary">剪贴板</Badge>
<span class="text-muted-foreground">复用剪贴板历史快速回填</span>
</div>
<div class="flex items-center gap-2 text-sm">
<Badge variant="secondary">计算</Badge>
<span class="text-muted-foreground">输入算式即算Enter 复制结果</span>
</div>
<div class="flex items-center gap-2 text-sm">
<Badge variant="secondary">换算</Badge>
<span class="text-muted-foreground">单位/货币/时间/温度换算 1m1Mbps1$</span>
</div>
<div class="flex items-center gap-2 text-sm">
<Badge variant="secondary">快捷</Badge>
<span class="text-muted-foreground">hosts回收站系统工具等常用位置</span>
</div>
<div class="flex items-center gap-2 text-sm">
<Badge variant="secondary">系统</Badge>
<span class="text-muted-foreground">系统命令注册表CMD/PowerShell任务管理器控制面板关机/重启/休眠及锁屏打开面板默认显示</span>
</div>
<div class="flex items-center gap-2 text-sm">
<Badge variant="secondary">网页</Badge>
<span class="text-muted-foreground">在默认引擎中搜索</span>
</div>
</CardContent>
</Card>
</div>
</div>
</template>