快速面板模块
This commit is contained in:
@@ -0,0 +1,549 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { toast } from 'vue-sonner'
|
||||
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'
|
||||
|
||||
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)
|
||||
|
||||
async function refreshStats() {
|
||||
try {
|
||||
indexStats.value = await invoke<IndexStats>('quickpanel_file_index_stats')
|
||||
// 索引存在(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 invoke<number>('quickpanel_build_file_index')
|
||||
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 invoke<QuickPanelSettings>('quickpanel_get_settings')
|
||||
Object.assign(form, s)
|
||||
} catch (e) {
|
||||
console.error('[quickpanel] 读取设置失败:', e)
|
||||
}
|
||||
await refreshStats()
|
||||
})
|
||||
|
||||
// ===== 保存 =====
|
||||
async function saveSettings() {
|
||||
try {
|
||||
await invoke('quickpanel_save_settings', { settings: { ...form } })
|
||||
// 同步到 localStorage 供独立窗口读取
|
||||
localStorage.setItem('thing_quickpanel_settings', 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)
|
||||
})
|
||||
|
||||
// ===== 唤起测试 =====
|
||||
async function testPopup() {
|
||||
try {
|
||||
await invoke('quickpanel_show_popup')
|
||||
} 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="h-5 w-5 text-primary" />
|
||||
快速面板
|
||||
</h2>
|
||||
<Button size="sm" @click="testPopup">
|
||||
<Zap class="h-4 w-4 mr-1" />
|
||||
测试唤起
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 快捷键 -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-sm flex items-center gap-2">
|
||||
<Keyboard class="h-4 w-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="h-4 w-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="h-4 w-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" :title="dir">{{ dir }}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6 shrink-0 hover:text-destructive"
|
||||
@click="removeDir(idx)"
|
||||
>
|
||||
<X class="h-3 w-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="h-4 w-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="h-6 w-6 shrink-0" @click="editCustomCommand(idx)">
|
||||
<Pencil class="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0 hover:text-destructive" @click="removeCustomCommand(idx)">
|
||||
<X class="h-3 w-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">索引指定目录,快速定位文件</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">锁屏、退出应用</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>
|
||||
Reference in New Issue
Block a user