562 lines
19 KiB
Vue
562 lines
19 KiB
Vue
<script setup lang="ts">
|
||
import { ref, reactive, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||
import { open } from '@tauri-apps/plugin-dialog'
|
||
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 } 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)
|
||
|
||
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()
|
||
})
|
||
|
||
// ===== 保存 =====
|
||
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)
|
||
// 注销保存处理函数与标签状态,防止其他模块 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">索引指定目录,快速定位文件</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">单位/货币/时间/温度换算(如 1m、1Mbps、1$)</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">锁屏、退出应用</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>
|