653 lines
25 KiB
Vue
653 lines
25 KiB
Vue
<script setup lang="ts">
|
||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||
import {
|
||
ClipboardList, Copy, Pin, PinOff, Trash2, Search, Image as ImageIcon,
|
||
FileText, Files, Settings as SettingsIcon, Loader2, Eraser, Keyboard,
|
||
} from '@lucide/vue'
|
||
import { toast } from 'vue-sonner'
|
||
import { useClipboardStore, type ClipboardItem, type ClipboardKind, type ClipboardItemDetail } from '@/stores/clipboardStore'
|
||
import { useModuleTabs } from '@/lib/use-module-tabs'
|
||
import { useModuleTabsStore } from '@/stores/moduleTabsStore'
|
||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||
import { Card, CardContent } from '@/components/ui/card'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Switch } from '@/components/ui/switch'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { Label } from '@/components/ui/label'
|
||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
||
import {
|
||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||
} from '@/components/ui/dialog'
|
||
import {
|
||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription,
|
||
AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
||
} from '@/components/ui/alert-dialog'
|
||
import {
|
||
Pagination, PaginationContent, PaginationItem, PaginationEllipsis,
|
||
} from '@/components/ui/pagination'
|
||
|
||
const store = useClipboardStore()
|
||
|
||
const activeTab = ref('history')
|
||
const tabsStore = useModuleTabsStore()
|
||
const tabsListRef = useModuleTabs('clipboard', activeTab, [
|
||
{ value: 'history', label: '历史' },
|
||
{ value: 'pinned', label: '固定' },
|
||
{ value: 'settings', label: '设置' },
|
||
])
|
||
|
||
// 历史搜索/过滤
|
||
const searchQuery = ref('')
|
||
const kindFilter = ref<'all' | ClipboardKind>('all')
|
||
const PAGE_SIZE = 50
|
||
const currentPage = ref(1)
|
||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||
|
||
const totalPages = computed(() =>
|
||
Math.max(1, Math.ceil(store.historyTotal / PAGE_SIZE))
|
||
)
|
||
|
||
const loadPage = async () => {
|
||
if (searchQuery.value.trim()) {
|
||
await store.searchPage(searchQuery.value.trim(), currentPage.value, PAGE_SIZE)
|
||
} else {
|
||
await store.fetchHistoryPage({ kind: kindFilter.value, page: currentPage.value, pageSize: PAGE_SIZE })
|
||
}
|
||
}
|
||
|
||
watch([searchQuery, kindFilter], () => {
|
||
// 过滤/搜索变化时回到第一页
|
||
currentPage.value = 1
|
||
if (searchTimer) clearTimeout(searchTimer)
|
||
searchTimer = setTimeout(loadPage, 300)
|
||
})
|
||
|
||
const gotoPage = async (p: number) => {
|
||
currentPage.value = Math.min(Math.max(1, p), totalPages.value)
|
||
await loadPage()
|
||
}
|
||
|
||
// 详情弹窗
|
||
const detailOpen = ref(false)
|
||
const detailLoading = ref(false)
|
||
const detail = ref<ClipboardItemDetail | null>(null)
|
||
const openDetail = async (item: ClipboardItem) => {
|
||
detailOpen.value = true
|
||
detailLoading.value = true
|
||
detail.value = null
|
||
const d = await store.getItem(item.id)
|
||
detail.value = d
|
||
detailLoading.value = false
|
||
}
|
||
|
||
/** 根据 base64 前缀判断图片 MIME 类型(dib_to_png 可能直接透传 PNG/JPEG) */
|
||
const imageSrc = computed(() => {
|
||
const b64 = detail.value?.imageBase64
|
||
if (!b64) return ''
|
||
// PNG base64 以 iVBORw0KGgo 开头,JPEG 以 /9j/ 开头
|
||
const mime = b64.startsWith('/9j/') ? 'image/jpeg' : 'image/png'
|
||
return `data:${mime};base64,${b64}`
|
||
})
|
||
|
||
// 清空确认
|
||
const clearOpen = ref(false)
|
||
|
||
// 设置表单(本地副本,保存时提交)
|
||
const form = ref({ ...store.settings })
|
||
watch(() => store.settings, (s) => { form.value = { ...s } }, { deep: true })
|
||
|
||
const handleEnabledToggle = async (val: boolean) => {
|
||
form.value.enabled = val
|
||
try {
|
||
await store.saveSettings({ ...form.value })
|
||
toast.success(val ? '已开启剪贴板监听' : '已停止剪贴板监听')
|
||
} catch {
|
||
toast.error('切换监听失败')
|
||
}
|
||
}
|
||
|
||
const handleSaveSettings = async () => {
|
||
try {
|
||
await store.saveSettings({ ...form.value })
|
||
toast.success('设置已保存')
|
||
} catch {
|
||
toast.error('保存设置失败')
|
||
}
|
||
}
|
||
|
||
// 注册保存处理函数到标签栏 store(TitleBar 保存按钮调用)
|
||
tabsStore.registerSave(handleSaveSettings)
|
||
|
||
const handleShortcutChange = async () => {
|
||
// 快捷键变化立即保存并注册(不等点击"保存设置")
|
||
try {
|
||
await store.saveSettings({ ...form.value })
|
||
toast.success(`快捷键已更新为 ${form.value.shortcut || '(已禁用)'}`)
|
||
} catch {
|
||
toast.error('快捷键注册失败,可能被其他程序占用')
|
||
}
|
||
}
|
||
|
||
// ===== 快捷键录入器 =====
|
||
const recording = ref(false)
|
||
const recorderRef = ref<HTMLDivElement | null>(null)
|
||
|
||
/** 把存储的快捷键字符串格式化为展示形式:alt+v → Alt + V */
|
||
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(' + ')
|
||
}
|
||
|
||
/** 把键盘事件转为 Tauri 快捷键字符串(小写,+ 分隔) */
|
||
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
|
||
// 必须至少一个修饰键(功能键 F1-F12 / PrintScreen 例外)
|
||
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
|
||
void commitShortcut(combo)
|
||
}
|
||
|
||
async function commitShortcut(combo: string) {
|
||
form.value.shortcut = combo
|
||
await handleShortcutChange()
|
||
}
|
||
|
||
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.value.shortcut = ''
|
||
await handleShortcutChange()
|
||
}
|
||
|
||
const handleTestPopup = async () => {
|
||
try {
|
||
await store.showPopup()
|
||
} catch {
|
||
toast.error('打开弹窗失败')
|
||
}
|
||
}
|
||
|
||
// 操作
|
||
const handleCopy = async (item: ClipboardItem) => {
|
||
try {
|
||
await store.copyBack(item.id)
|
||
toast.success('已复制到剪贴板')
|
||
} catch {
|
||
toast.error('复制失败')
|
||
}
|
||
}
|
||
const handlePin = async (item: ClipboardItem) => {
|
||
await store.setPinned(item.id, !item.pinned)
|
||
toast.success(item.pinned ? '已取消固定' : '已固定')
|
||
// 固定/取消后当前页数据变化,重新加载
|
||
await loadPage()
|
||
}
|
||
const handleDelete = async (item: ClipboardItem) => {
|
||
await store.remove(item.id)
|
||
toast.success('已删除')
|
||
// 删除后总数可能变化,若当前页已空则回退一页
|
||
if (!store.history.length && currentPage.value > 1) {
|
||
currentPage.value -= 1
|
||
}
|
||
await loadPage()
|
||
}
|
||
const handleClear = async () => {
|
||
clearOpen.value = false
|
||
await store.clear()
|
||
currentPage.value = 1
|
||
await loadPage()
|
||
toast.success('已清空历史')
|
||
}
|
||
|
||
// 显示辅助(kind 来自 bindings 生成的 string,按字符串比较)
|
||
const kindIcon = (k: string) => {
|
||
if (k === 'text') return FileText
|
||
if (k === 'image') return ImageIcon
|
||
return Files
|
||
}
|
||
const kindLabel = (k: string) => (k === 'text' ? '文本' : k === 'image' ? '图片' : '文件')
|
||
const kindBadgeClass = (k: string) =>
|
||
k === 'text'
|
||
? 'border-blue-500/40 bg-blue-500/10 text-blue-600 dark:text-blue-400'
|
||
: k === 'image'
|
||
? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
|
||
: 'border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-400'
|
||
const formatSize = (bytes: number) => {
|
||
if (!bytes) return ''
|
||
if (bytes < 1024) return `${bytes} B`
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||
}
|
||
const formatTime = (ms: number) => {
|
||
const diff = Date.now() - ms
|
||
if (diff < 60_000) return '刚刚'
|
||
if (diff < 3600_000) return `${Math.floor(diff / 60_000)} 分钟前`
|
||
if (diff < 86_400_000) return `${Math.floor(diff / 3600_000)} 小时前`
|
||
if (diff < 7 * 86_400_000) return `${Math.floor(diff / 86_400_000)} 天前`
|
||
const d = new Date(ms)
|
||
return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||
}
|
||
|
||
const historyList = computed(() => store.history)
|
||
const pinnedList = computed(() => store.pinned)
|
||
|
||
onMounted(async () => {
|
||
await store.init()
|
||
await Promise.all([loadPage(), store.refreshPinned()])
|
||
form.value = { ...store.settings }
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
store.dispose()
|
||
window.removeEventListener('keydown', onRecordKey, true)
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="h-full p-6 overflow-hidden flex flex-col">
|
||
<Tabs v-model="activeTab" class="flex-1 min-h-0 flex flex-col">
|
||
<div ref="tabsListRef" class="shrink-0">
|
||
<TabsList class="grid w-full max-w-md grid-cols-3 !bg-transparent !p-0 !shadow-none">
|
||
<TabsTrigger value="history" class="gap-1.5"><ClipboardList class="size-3.5" />历史</TabsTrigger>
|
||
<TabsTrigger value="pinned" class="gap-1.5"><Pin class="size-3.5" />固定</TabsTrigger>
|
||
<TabsTrigger value="settings" class="gap-1.5"><SettingsIcon class="size-3.5" />设置</TabsTrigger>
|
||
</TabsList>
|
||
</div>
|
||
|
||
<!-- 历史 -->
|
||
<TabsContent value="history" class="flex-1 min-h-0 flex flex-col mt-4 tab-animate">
|
||
<div class="flex items-center gap-2 mb-3 shrink-0">
|
||
<div class="relative flex-1 max-w-sm">
|
||
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||
<Input v-model="searchQuery" placeholder="搜索历史..." class="pl-8" />
|
||
</div>
|
||
<div class="flex items-center gap-1">
|
||
<Button
|
||
v-for="k in (['all','text','image','files'] as const)"
|
||
:key="k"
|
||
:variant="kindFilter === k ? 'default' : 'outline'"
|
||
size="sm"
|
||
@click="kindFilter = k"
|
||
>
|
||
{{ k === 'all' ? '全部' : k === 'text' ? '文本' : k === 'image' ? '图片' : '文件' }}
|
||
</Button>
|
||
</div>
|
||
<div class="flex-1" />
|
||
<Badge variant="secondary">
|
||
{{ store.historyTotal }} 条
|
||
</Badge>
|
||
<Button variant="outline" size="sm" @click="clearOpen = true" :disabled="!historyList.length">
|
||
<Eraser class="size-4 mr-1" />清空
|
||
</Button>
|
||
</div>
|
||
|
||
<!-- 分页 + 快捷键提示(置于列表上方,分页靠左,快捷弹窗提示靠右) -->
|
||
<div class="flex items-center justify-between pb-2 shrink-0">
|
||
<template v-if="totalPages > 1">
|
||
<Pagination
|
||
v-slot="{ page }"
|
||
:page="currentPage"
|
||
:total="store.historyTotal"
|
||
:items-per-page="PAGE_SIZE"
|
||
:sibling-count="1"
|
||
show-edges
|
||
@update:page="gotoPage"
|
||
class="justify-start"
|
||
>
|
||
<PaginationContent v-slot="{ items }" class="gap-1">
|
||
<template v-for="(item, index) in items" :key="index">
|
||
<PaginationItem
|
||
v-if="item.type === 'page'"
|
||
:value="item.value"
|
||
:is-active="item.value === page"
|
||
size="icon"
|
||
class="size-7 text-xs"
|
||
>
|
||
{{ item.value }}
|
||
</PaginationItem>
|
||
<PaginationEllipsis v-else class="size-7" />
|
||
</template>
|
||
</PaginationContent>
|
||
</Pagination>
|
||
</template>
|
||
<div v-else />
|
||
<span class="text-xs text-muted-foreground shrink-0">
|
||
快捷弹窗:<span v-if="form.shortcut" class="font-medium text-foreground">{{ displayShortcut(form.shortcut) }}</span><span v-else>未设置</span>
|
||
</span>
|
||
</div>
|
||
|
||
<ScrollArea class="flex-1 min-h-0">
|
||
<div class="space-y-1.5 pr-2">
|
||
<div
|
||
v-if="!historyList.length"
|
||
class="flex flex-col items-center justify-center text-muted-foreground py-12"
|
||
>
|
||
<ClipboardList class="size-12 mb-3 opacity-40" />
|
||
<p class="text-sm">暂无历史记录,复制内容后将自动收录</p>
|
||
</div>
|
||
<Card
|
||
v-for="item in historyList"
|
||
:key="item.id"
|
||
class="group hover:shadow-md transition-shadow py-0"
|
||
>
|
||
<CardContent class="flex items-center gap-3 px-3 py-2">
|
||
<component :is="kindIcon(item.kind)" class="size-4 text-muted-foreground shrink-0" />
|
||
<div class="flex-1 min-w-0 cursor-pointer" @click="openDetail(item)">
|
||
<p class="text-sm break-all line-clamp-1">{{ item.preview }}</p>
|
||
<div class="flex items-center gap-2 mt-0.5 text-xs text-muted-foreground flex-wrap">
|
||
<Badge variant="outline" :class="['px-1.5 py-0 text-[10px] border', kindBadgeClass(item.kind)]">{{ kindLabel(item.kind) }}</Badge>
|
||
<span>{{ formatTime(item.createdAt) }}</span>
|
||
<span v-if="item.size">· {{ formatSize(item.size) }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition">
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<Button variant="ghost" size="icon" class="size-7" @click.stop="handleCopy(item)">
|
||
<Copy class="h-3.5 w-3.5" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>复制</TooltipContent>
|
||
</Tooltip>
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<Button variant="ghost" size="icon" class="size-7" @click.stop="handlePin(item)">
|
||
<component :is="item.pinned ? PinOff : Pin" class="h-3.5 w-3.5" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>{{ item.pinned ? '取消固定' : '固定' }}</TooltipContent>
|
||
</Tooltip>
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<Button variant="ghost" size="icon" class="size-7 hover:text-destructive" @click.stop="handleDelete(item)">
|
||
<Trash2 class="h-3.5 w-3.5" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>删除</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</ScrollArea>
|
||
</TabsContent>
|
||
|
||
<!-- 固定 -->
|
||
<TabsContent value="pinned" class="flex-1 min-h-0 flex flex-col mt-4 tab-animate">
|
||
<div class="flex-1 min-h-0 overflow-y-auto space-y-1.5 pr-1">
|
||
<div
|
||
v-if="!pinnedList.length"
|
||
class="flex flex-col items-center justify-center h-full text-muted-foreground"
|
||
>
|
||
<Pin class="size-12 mb-3 opacity-40" />
|
||
<p class="text-sm">暂无固定条目</p>
|
||
<p class="text-xs mt-1">鼠标悬停历史条目,点击图钉按钮即可固定</p>
|
||
</div>
|
||
<Card v-for="item in pinnedList" :key="item.id" class="group hover:shadow-md transition-shadow py-0">
|
||
<CardContent class="flex items-center gap-3 px-3 py-2">
|
||
<component :is="kindIcon(item.kind)" class="size-4 text-primary shrink-0" />
|
||
<div class="flex-1 min-w-0 cursor-pointer" @click="openDetail(item)">
|
||
<p class="text-sm break-all line-clamp-1">{{ item.preview }}</p>
|
||
<div class="flex items-center gap-2 mt-0.5 text-xs text-muted-foreground flex-wrap">
|
||
<Badge variant="outline" :class="['px-1.5 py-0 text-[10px] border', kindBadgeClass(item.kind)]">{{ kindLabel(item.kind) }}</Badge>
|
||
<span>{{ formatTime(item.createdAt) }}</span>
|
||
<span v-if="item.size">· {{ formatSize(item.size) }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition">
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<Button variant="ghost" size="icon" class="size-7" @click.stop="handleCopy(item)">
|
||
<Copy class="h-3.5 w-3.5" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>复制</TooltipContent>
|
||
</Tooltip>
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<Button variant="ghost" size="icon" class="size-7" @click.stop="handlePin(item)">
|
||
<PinOff class="h-3.5 w-3.5" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>取消固定</TooltipContent>
|
||
</Tooltip>
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<Button variant="ghost" size="icon" class="size-7 hover:text-destructive" @click.stop="handleDelete(item)">
|
||
<Trash2 class="h-3.5 w-3.5" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>删除</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</TabsContent>
|
||
|
||
<!-- 设置 -->
|
||
<TabsContent value="settings" class="flex-1 min-h-0 overflow-y-auto mt-4 tab-animate">
|
||
<div class="max-w-xl space-y-4">
|
||
<div class="flex items-center">
|
||
<h3 class="text-base font-medium flex items-center gap-2">
|
||
<SettingsIcon class="size-4" />基本设置
|
||
</h3>
|
||
</div>
|
||
|
||
<Card>
|
||
<CardContent class="space-y-4 p-4">
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<Label>启用剪贴板监听</Label>
|
||
<p class="text-xs text-muted-foreground mt-1">关闭后将停止记录剪贴板内容</p>
|
||
</div>
|
||
<Switch
|
||
:model-value="form.enabled"
|
||
@update:model-value="handleEnabledToggle"
|
||
/>
|
||
</div>
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<Label>记录文本</Label>
|
||
<p class="text-xs text-muted-foreground mt-1">收录复制/剪切的文本</p>
|
||
</div>
|
||
<Switch :model-value="form.recordText" @update:model-value="form.recordText = $event" />
|
||
</div>
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<Label>记录图片</Label>
|
||
<p class="text-xs text-muted-foreground mt-1">收录复制的图片(截图等)</p>
|
||
</div>
|
||
<Switch :model-value="form.recordImage" @update:model-value="form.recordImage = $event" />
|
||
</div>
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<Label>记录文件</Label>
|
||
<p class="text-xs text-muted-foreground mt-1">收录复制的文件列表</p>
|
||
</div>
|
||
<Switch :model-value="form.recordFiles" @update:model-value="form.recordFiles = $event" />
|
||
</div>
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<Label>内容去重</Label>
|
||
<p class="text-xs text-muted-foreground mt-1">相同内容只保留一条,重复复制将置顶</p>
|
||
</div>
|
||
<Switch :model-value="form.dedup" @update:model-value="form.dedup = $event" />
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardContent class="space-y-4 p-4">
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div class="space-y-2">
|
||
<Label>最大历史条数</Label>
|
||
<Input v-model.number="form.maxItems" type="number" min="50" max="10000" />
|
||
<p class="text-xs text-muted-foreground">超出后自动清理最旧的非固定项</p>
|
||
</div>
|
||
<div class="space-y-2">
|
||
<Label>图片大小上限 (KB)</Label>
|
||
<Input v-model.number="form.maxImageKb" type="number" min="0" />
|
||
<p class="text-xs text-muted-foreground">0 表示不限制</p>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardContent class="space-y-4 p-4">
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<Label class="flex items-center gap-2">
|
||
<Keyboard class="size-4" />快捷弹窗快捷键
|
||
</Label>
|
||
<p class="text-xs text-muted-foreground mt-1">全局快捷键触发鼠标位置历史弹窗,留空禁用</p>
|
||
</div>
|
||
<Button variant="outline" size="sm" @click="handleTestPopup">测试弹窗</Button>
|
||
</div>
|
||
<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+V</p>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</TabsContent>
|
||
</Tabs>
|
||
|
||
<!-- 详情弹窗 -->
|
||
<Dialog v-model:open="detailOpen">
|
||
<DialogContent class="max-w-2xl max-h-[80vh] flex flex-col">
|
||
<DialogHeader>
|
||
<DialogTitle>内容详情</DialogTitle>
|
||
<DialogDescription>{{ detail ? kindLabel(detail.kind as ClipboardKind) : '' }}</DialogDescription>
|
||
</DialogHeader>
|
||
<div class="flex-1 min-h-0 overflow-auto">
|
||
<div v-if="detailLoading" class="flex items-center justify-center py-12">
|
||
<Loader2 class="size-6 animate-spin text-muted-foreground" />
|
||
</div>
|
||
<template v-else-if="detail">
|
||
<img
|
||
v-if="detail.kind === 'image' && imageSrc"
|
||
:src="imageSrc"
|
||
class="max-w-full max-h-[60vh] mx-auto rounded"
|
||
alt="剪贴板图片"
|
||
/>
|
||
<p v-else-if="detail.kind === 'image'" class="text-sm text-muted-foreground text-center py-8">
|
||
图片预览不可用
|
||
</p>
|
||
<pre v-else-if="detail.kind === 'text'" class="text-sm whitespace-pre-wrap break-all font-mono bg-muted/50 p-3 rounded">{{ detail.content }}</pre>
|
||
<ul v-else-if="detail.kind === 'files'" class="space-y-1 text-sm">
|
||
<li
|
||
v-for="(p, i) in (parseFiles(detail.content))"
|
||
:key="i"
|
||
class="font-mono break-all p-2 rounded bg-muted/50"
|
||
>{{ p }}</li>
|
||
</ul>
|
||
</template>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<!-- 清空确认 -->
|
||
<AlertDialog v-model:open="clearOpen">
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>清空历史记录?</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
将删除所有非固定历史条目,此操作不可撤销。
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||
<AlertDialogAction class="bg-destructive text-destructive-foreground hover:bg-destructive/90" @click="handleClear">
|
||
清空
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script lang="ts">
|
||
function parseFiles(content: string | null): string[] {
|
||
if (!content) return []
|
||
try {
|
||
return JSON.parse(content) as string[]
|
||
} catch {
|
||
return []
|
||
}
|
||
}
|
||
export default {}
|
||
</script>
|