This commit is contained in:
zhongluofeng
2026-07-30 09:09:29 +08:00
parent 74902c4cec
commit f452322aad
32 changed files with 4586 additions and 115 deletions
+473 -18
View File
@@ -1,24 +1,479 @@
<script setup lang="ts">
import { ClipboardList } from '@lucide/vue'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import {
ClipboardList, Copy, Pin, PinOff, Trash2, Search, Image as ImageIcon,
FileText, Files, Settings as SettingsIcon, Loader2, Eraser, Check, Keyboard,
ChevronLeft, ChevronRight,
} from '@lucide/vue'
import { toast } from 'vue-sonner'
import { useClipboardStore, type ClipboardItem, type ClipboardKind } from '@/stores/clipboardStore'
import { useModuleTabs } from '@/lib/useModuleTabs'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
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 {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from '@/components/ui/dialog'
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription,
AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from '@/components/ui/alert-dialog'
const store = useClipboardStore()
const activeTab = ref('history')
const tabsListRef = useModuleTabs(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<ReturnType<typeof Object> & { content?: string | null; imageBase64?: string | null; kind?: string } | 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
}
// 清空确认
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('保存设置失败')
}
}
const handleShortcutChange = async () => {
// 快捷键变化立即保存并注册(不等点击"保存设置")
try {
await store.saveSettings({ ...form.value })
toast.success(`快捷键已更新为 ${form.value.shortcut || '(已禁用)'}`)
} catch {
toast.error('快捷键注册失败,可能被其他程序占用')
}
}
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('已清空历史')
}
// 显示辅助
const kindIcon = (k: ClipboardKind) => {
if (k === 'text') return FileText
if (k === 'image') return ImageIcon
return Files
}
const kindLabel = (k: ClipboardKind) => (k === 'text' ? '文本' : k === 'image' ? '图片' : '文件')
const kindBadgeClass = (k: ClipboardKind) =>
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()
})
</script>
<template>
<div class="h-full p-6 overflow-y-auto">
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<ClipboardList class="h-5 w-5 text-primary" />
剪贴板增强模块
</CardTitle>
</CardHeader>
<CardContent>
<div class="flex flex-col items-center justify-center h-64 text-muted-foreground">
<ClipboardList class="h-16 w-16 mb-4 opacity-50" />
<p>剪贴板增强功能开发中...</p>
<p class="text-sm mt-2">支持剪贴板历史记录搜索固定常用条目等功能</p>
<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 h-4 w-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">
<Loader2 v-if="store.status.running" class="h-3 w-3 mr-1 animate-spin" />
{{ store.historyTotal }}
</Badge>
<Button variant="outline" size="sm" @click="clearOpen = true" :disabled="!historyList.length">
<Eraser class="h-4 w-4 mr-1" />清空
</Button>
</div>
</CardContent>
</Card>
<div class="flex-1 min-h-0 overflow-y-auto space-y-1.5 pr-1">
<div
v-if="!historyList.length"
class="flex flex-col items-center justify-center h-full text-muted-foreground"
>
<ClipboardList class="h-12 w-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="h-4 w-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" :title="item.preview">{{ 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">
<Button variant="ghost" size="icon" class="h-7 w-7" title="复制" @click.stop="handleCopy(item)">
<Copy class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-7 w-7" :title="item.pinned ? '取消固定' : '固定'" @click.stop="handlePin(item)">
<component :is="item.pinned ? PinOff : Pin" class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-7 w-7 hover:text-destructive" title="删除" @click.stop="handleDelete(item)">
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
</Card>
</div>
<!-- 分页 -->
<div v-if="totalPages > 1" class="flex items-center justify-center gap-2 pt-3 shrink-0">
<Button variant="outline" size="icon" class="h-7 w-7" :disabled="currentPage <= 1" @click="gotoPage(currentPage - 1)">
<ChevronLeft class="h-4 w-4" />
</Button>
<span class="text-xs text-muted-foreground tabular-nums">{{ currentPage }} / {{ totalPages }}</span>
<Button variant="outline" size="icon" class="h-7 w-7" :disabled="currentPage >= totalPages" @click="gotoPage(currentPage + 1)">
<ChevronRight class="h-4 w-4" />
</Button>
</div>
</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="h-12 w-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="h-4 w-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" :title="item.preview">{{ 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">
<Button variant="ghost" size="icon" class="h-7 w-7" title="复制" @click.stop="handleCopy(item)">
<Copy class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-7 w-7" title="取消固定" @click.stop="handlePin(item)">
<PinOff class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-7 w-7 hover:text-destructive" title="删除" @click.stop="handleDelete(item)">
<Trash2 class="h-3.5 w-3.5" />
</Button>
</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 justify-between">
<h3 class="text-base font-medium flex items-center gap-2">
<SettingsIcon class="h-4 w-4" />基本设置
</h3>
<Button @click="handleSaveSettings"><Check class="h-4 w-4 mr-1" />保存设置</Button>
</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="h-4 w-4" />快捷弹窗快捷键
</Label>
<p class="text-xs text-muted-foreground mt-1">全局快捷键触发鼠标位置历史弹窗留空禁用</p>
</div>
<Button variant="outline" size="sm" @click="handleTestPopup">测试弹窗</Button>
</div>
<Input
v-model="form.shortcut"
placeholder="如 Alt+V / Ctrl+Shift+V"
@change="handleShortcutChange"
/>
<p class="text-xs text-muted-foreground">格式修饰键+主键 Ctrl+CAlt+VShift+F1修改后自动生效</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="h-6 w-6 animate-spin text-muted-foreground" />
</div>
<template v-else-if="detail">
<img
v-if="detail.kind === 'image' && detail.imageBase64"
:src="`data:image/png;base64,${detail.imageBase64}`"
class="max-w-full max-h-[60vh] mx-auto rounded"
alt="剪贴板图片"
/>
<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>
</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>