快速面板模块

This commit is contained in:
zhongluofeng
2026-08-04 13:37:42 +08:00
parent 5656193b5c
commit 126f8896b6
23 changed files with 4048 additions and 52 deletions
+549
View File
@@ -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>