Files
Thing/src/modules/clipboard/ClipboardModule.vue
T
2026-08-11 17:19:36 +08:00

653 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>