Files
Thing/src/modules/clipboard/ClipboardPopup.vue
T
2026-08-06 10:33:16 +08:00

638 lines
19 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, nextTick, watch } from 'vue'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
import { Effect, EffectState } from '@tauri-apps/api/window'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
import {
ClipboardList, Pin, PinOff, Trash2, Search, Image as ImageIcon,
FileText, Files, Loader2,
} from '@lucide/vue'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Input } from '@/components/ui/input'
import {
Pagination, PaginationContent, PaginationItem, PaginationEllipsis,
} from '@/components/ui/pagination'
// ===== 与 Rust 端对应的数据结构(bindings 提供,camelCase =====
// kind 为 bindings 生成的 string,前端按字符串比较即可
import type { ClipboardItem, HistoryPage } from '@/lib/bindings'
// ===== 状态 =====
const items = ref<ClipboardItem[]>([])
const total = ref(0)
const currentPage = ref(1)
const PAGE_SIZE = 50
const searchQuery = ref('')
const selectedIndex = ref(0)
const loading = ref(false)
const searchInputRef = ref<HTMLInputElement | null>(null)
let unlistenFns: UnlistenFn[] = []
let searchTimer: ReturnType<typeof setTimeout> | null = null
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
// ===== 数据加载 =====
/** 加载请求序号:翻页/搜索快速操作时丢弃过期请求结果,避免旧请求覆盖新结果 */
let loadSeq = 0
async function loadData() {
const seq = ++loadSeq
loading.value = true
try {
const q = searchQuery.value.trim()
const offset = (currentPage.value - 1) * PAGE_SIZE
let res: HistoryPage
if (q) {
res = await commands.clipboardSearch(q, PAGE_SIZE, offset)
} else {
res = await commands.clipboardGetHistory(PAGE_SIZE, offset, 'all')
}
if (seq !== loadSeq) return // 过期请求丢弃
items.value = res.items
total.value = res.total
selectedIndex.value = 0
} catch (e) {
if (seq !== loadSeq) return
console.error('[clipboard-popup] 加载失败:', e)
} finally {
// 仅最新请求可结束 loading,避免旧请求提前清除新请求的加载态
if (seq === loadSeq) loading.value = false
}
if (seq === loadSeq) {
await nextTick()
scrollSelectedIntoView()
}
}
async function gotoPage(p: number) {
currentPage.value = Math.min(Math.max(1, p), totalPages.value)
await loadData()
}
// 防抖搜索
watch(searchQuery, () => {
currentPage.value = 1
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(loadData, 200)
})
// ===== 选择与粘贴 =====
/// 选中条目 → 写回剪贴板 → 隐藏窗口 → 模拟 Ctrl+V 粘贴到原窗口
async function selectAndPaste(item: ClipboardItem) {
try {
await commands.clipboardCopyBack(item.id)
// paste_to_target 会先隐藏窗口,再延迟模拟 Ctrl+V
await commands.clipboardPasteToTarget()
} catch (e) {
console.error('[clipboard-popup] 粘贴失败:', e)
// 失败时至少隐藏窗口
await hideWindow()
}
}
async function togglePin(item: ClipboardItem, ev: Event) {
ev.stopPropagation()
try {
await commands.clipboardSetPinned(item.id, !item.pinned)
await loadData()
} catch (e) {
console.error('[clipboard-popup] 固定失败:', e)
}
}
async function deleteItem(item: ClipboardItem, ev: Event) {
ev.stopPropagation()
try {
await commands.clipboardDelete(item.id)
items.value = items.value.filter((i) => i.id !== item.id)
} catch (e) {
console.error('[clipboard-popup] 删除失败:', e)
}
}
async function hideWindow() {
try {
await commands.clipboardHidePopup()
} catch {
/* 忽略 */
}
}
// ===== 键盘导航 =====
function onKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault()
selectedIndex.value = Math.min(selectedIndex.value + 1, items.value.length - 1)
cancelHoverTimer()
previewVisible.value = false
scrollSelectedIntoView()
} else if (e.key === 'ArrowUp') {
e.preventDefault()
selectedIndex.value = Math.max(selectedIndex.value - 1, 0)
cancelHoverTimer()
previewVisible.value = false
scrollSelectedIntoView()
} else if (e.key === 'Enter') {
e.preventDefault()
const item = items.value[selectedIndex.value]
if (item) selectAndPaste(item)
} else if (e.key === 'Escape') {
e.preventDefault()
hideWindow()
}
}
function scrollSelectedIntoView() {
nextTick(() => {
const el = document.querySelector('.popup-item-selected') as HTMLElement | null
el?.scrollIntoView({ block: 'nearest' })
})
}
// ===== 显示辅助(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'
? 'badge-text'
: k === 'image'
? 'badge-image'
: 'badge-files'
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)}小时前`
const d = new Date(ms)
return `${d.getMonth() + 1}/${d.getDate()}`
}
const hasItems = computed(() => items.value.length > 0)
// ===== 图片悬停预览(悬停 100ms 后显示缩略图) =====
const previewSrc = ref('')
const previewVisible = ref(false)
let hoverTimer: ReturnType<typeof setTimeout> | null = null
// 缓存已加载的图片 id → dataUrl,避免重复请求
const imageCache = new Map<number, string>()
/** 根据 base64 前缀判断 MIME 类型 */
function buildImageDataUrl(b64: string): string {
const mime = b64.startsWith('/9j/') ? 'image/jpeg' : 'image/png'
return `data:${mime};base64,${b64}`
}
async function onItemHover(idx: number, item: ClipboardItem) {
selectedIndex.value = idx
// 仅图片类型触发预览
if (item.kind !== 'image') {
cancelHoverTimer()
previewVisible.value = false
return
}
// 先取消之前的定时器和预览
cancelHoverTimer()
// 100ms 后加载并显示(快速响应悬停意图)
hoverTimer = setTimeout(async () => {
try {
let src = imageCache.get(item.id)
if (!src) {
const detail = await commands.clipboardGetItem(item.id)
if (detail?.imageBase64) {
src = buildImageDataUrl(detail.imageBase64)
imageCache.set(item.id, src)
}
}
if (src) {
previewSrc.value = src
previewVisible.value = true
}
} catch {
/* 忽略加载失败 */
}
}, 100)
}
function cancelHoverTimer() {
if (hoverTimer) {
clearTimeout(hoverTimer)
hoverTimer = null
}
}
function onItemLeave() {
cancelHoverTimer()
previewVisible.value = false
}
// ===== 主题应用(与主应用同步) =====
/** 从 localStorage 读取主应用的主题设置 */
function readMainTheme(): { theme: string; effect: string } {
try {
const raw = localStorage.getItem(STORAGE_KEYS.appSettings)
if (raw) {
const s = JSON.parse(raw)
return {
theme: s.theme ?? 'system',
effect: s.effect ?? 'mica',
}
}
} catch {
/* 忽略 */
}
return { theme: 'system', effect: 'mica' }
}
/** 判断当前是否应为深色主题。
* 弹窗是独立窗口,主应用的 setTheme 不影响弹窗的 matchMedia
* 因此 system 模式下用 matchMedia 是可靠的。 */
function resolveIsDark(theme: string): boolean {
if (theme === 'dark') return true
if (theme === 'light') return false
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
/** 应用主题和窗口效果(与主应用同步)。
* 关键:先 setTheme 让窗口主题正确,再用通用 Effect.Mica(自动跟随窗口主题深浅)。 */
async function applyTheme() {
const root = document.documentElement
const { theme, effect } = readMainTheme()
// 1. 先设置窗口原生主题(system → null 跟随系统)
try {
const tauriWin = getCurrentWindow()
if (theme === 'system') {
await tauriWin.setTheme(null)
} else {
await tauriWin.setTheme(theme as 'dark' | 'light')
}
} catch {
/* 非 Tauri 环境忽略 */
}
// 2. 窗口主题已正确,用 matchMedia 判断深浅(弹窗自身不受主应用污染)
const isDark = resolveIsDark(theme)
// 3. 设置 DOM class
root.classList.remove('dark', 'effect-mica', 'effect-acrylic', 'effect-normal')
root.classList.add(`effect-${effect}`)
if (isDark) root.classList.add('dark')
// 4. 设置窗口效果与背景色
// 弹窗窗口创建时 transparent=true,透明窗口下原生背景色不显示,
// 需在实际可见的 DOM 元素(.popup-root)上设置背景色。
// 用 CSS 变量 --popup-bg 控制,mica/acrylic 模式下保持透明。
try {
const tauriWin = getCurrentWindow()
await tauriWin.clearEffects()
if (effect === 'mica') {
await tauriWin.setEffects({
effects: [Effect.Mica],
state: EffectState.FollowsWindowActiveState,
color: isDark ? [30, 41, 59, 0] : [248, 250, 252, 0],
})
await tauriWin.setBackgroundColor('#00000000')
root.style.setProperty('--popup-bg', 'transparent')
} else if (effect === 'acrylic') {
await tauriWin.setEffects({
effects: [Effect.Acrylic],
state: EffectState.FollowsWindowActiveState,
color: isDark ? [30, 41, 59, 0] : [248, 250, 252, 0],
})
await tauriWin.setBackgroundColor('#00000000')
root.style.setProperty('--popup-bg', 'transparent')
} else {
// 普通模式:透明窗口下原生背景不显示,由 DOM 提供背景色
await tauriWin.setBackgroundColor(isDark ? '#0f172a' : '#ffffff')
root.style.setProperty('--popup-bg', isDark ? '#0f172a' : '#ffffff')
}
} catch {
/* 非 Tauri 环境忽略 */
}
}
onMounted(async () => {
// 先应用主题(含窗口效果)
await applyTheme()
// 监听系统主题变化(仅在 system 模式下有意义)
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const onThemeChange = () => applyTheme()
mq.addEventListener('change', onThemeChange)
unlistenFns.push(() => mq.removeEventListener('change', onThemeChange))
// 监听弹窗显示事件:每次显示时重新同步主题 + 刷新数据
unlistenFns.push(await listen(EVENTS.clipboardPopupShow, async () => {
// 主应用可能切换了主题,每次显示前重新应用
await applyTheme()
searchQuery.value = ''
currentPage.value = 1
// 重置预览状态,清空缓存避免历史图片占用内存
cancelHoverTimer()
previewVisible.value = false
imageCache.clear()
await loadData()
await nextTick()
searchInputRef.value?.focus()
}))
// 加载初始数据
await loadData()
await nextTick()
searchInputRef.value?.focus()
// 主题和数据都就绪后,调用 Rust 端显示窗口
try {
await commands.clipboardShowWindow()
} catch {
/* 忽略 */
}
})
onUnmounted(() => {
cancelHoverTimer()
unlistenFns.forEach((fn) => fn())
})
</script>
<template>
<div class="popup-root flex flex-col h-screen w-screen" @keydown="onKeydown">
<!-- 搜索栏与剪切板主页统一样式 -->
<div class="flex items-center gap-2 px-3 py-2 border-b border-border 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
ref="searchInputRef"
v-model="searchQuery"
placeholder="搜索剪贴板历史..."
class="pl-8 text-sm"
spellcheck="false"
/>
</div>
<span class="text-xs text-muted-foreground whitespace-nowrap">{{ total }} </span>
</div>
<!-- 图片悬停预览浮层 -->
<Transition name="popup-preview">
<div v-if="previewVisible && previewSrc" class="popup-preview">
<img :src="previewSrc" alt="预览" />
</div>
</Transition>
<!-- 列表 -->
<ScrollArea class="popup-list flex-1 min-h-0">
<div class="space-y-1.5 p-2">
<div v-if="loading && !hasItems" class="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Loader2 class="h-6 w-6 animate-spin" />
</div>
<div v-else-if="!hasItems" class="flex flex-col items-center justify-center py-12 text-muted-foreground">
<ClipboardList class="h-10 w-10 mb-2 opacity-40" />
<p class="text-sm">
{{ searchQuery ? '无匹配结果' : '暂无历史记录' }}
</p>
</div>
<div
v-for="(item, idx) in items"
:key="item.id"
class="popup-item group"
:class="{ 'popup-item-selected': idx === selectedIndex }"
@click="selectAndPaste(item)"
@mouseenter="onItemHover(idx, item)"
@mouseleave="onItemLeave"
>
<component :is="kindIcon(item.kind)" class="h-4 w-4 text-muted-foreground shrink-0 mt-0.5" />
<div class="flex-1 min-w-0">
<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">
<span class="popup-item-kind px-1.5 py-0 text-[10px] border rounded-sm" :class="kindBadgeClass(item.kind)">{{ kindLabel(item.kind) }}</span>
<span>{{ formatTime(item.createdAt) }}</span>
</div>
</div>
<div class="popup-item-actions shrink-0">
<button class="popup-action-btn h-7 w-7" title="固定" @click="togglePin(item, $event)">
<component :is="item.pinned ? PinOff : Pin" class="h-3.5 w-3.5" />
</button>
<button class="popup-action-btn h-7 w-7 hover:text-destructive" title="删除" @click="deleteItem(item, $event)">
<Trash2 class="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
</ScrollArea>
<!-- 分页与剪切板历史统一样式 -->
<div v-if="totalPages > 1" class="flex items-center justify-center gap-1 px-2 py-1 border-t border-border">
<Pagination
v-slot="{ page }"
:page="currentPage"
:total="total"
:items-per-page="PAGE_SIZE"
:sibling-count="1"
show-edges
@update:page="gotoPage"
>
<PaginationContent v-slot="{ items: pageItems }" class="gap-1">
<template v-for="(item, index) in pageItems" :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>
</div>
<!-- 底部提示 -->
<div class="popup-footer shrink-0">
<span><kbd></kbd><kbd></kbd> 导航</span>
<span><kbd>Enter</kbd> 粘贴</span>
<span><kbd>Esc</kbd> 关闭</span>
</div>
</div>
</template>
<style scoped>
/* 弹窗根容器:背景色由 --popup-bg 控制(mica/acrylic 透明,普通模式不透明) */
.popup-root {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', 'PingFang SC', sans-serif;
background: var(--popup-bg, transparent);
color: var(--foreground);
border-radius: 8px;
overflow: hidden;
position: relative;
}
/* 列表 */
.popup-list {
padding: 0;
}
/* reka-ui ScrollAreaViewport 内部会出现一个 div,需保证高度撑满 */
.popup-list :deep([data-slot="scroll-area-viewport"] > div) {
min-height: 100%;
}
/* 条目卡片样式(与主界面 Card 统一) */
.popup-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 8px 12px;
border-radius: var(--radius);
cursor: pointer;
border: 1px solid var(--border);
background: var(--card);
transition: box-shadow 0.2s, background-color 0.1s;
}
/* hover 使用 shadow(同主界面 hover:shadow-md),键盘选中保留轻微高亮 */
.popup-item:hover {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
}
.popup-item-selected {
background: var(--accent);
}
/* 类型 badge 配色(与主界面一致) */
.popup-item-kind.badge-text {
background: rgba(59, 130, 246, 0.12);
color: rgb(37, 99, 235);
border-color: rgba(59, 130, 246, 0.4);
}
.popup-item-kind.badge-image {
background: rgba(16, 185, 129, 0.12);
color: rgb(5, 150, 105);
border-color: rgba(16, 185, 129, 0.4);
}
.popup-item-kind.badge-files {
background: rgba(245, 158, 11, 0.12);
color: rgb(217, 119, 6);
border-color: rgba(245, 158, 11, 0.4);
}
/* 深色主题下调整 badge 文字色 */
.dark .popup-item-kind.badge-text {
color: rgb(96, 165, 250);
}
.dark .popup-item-kind.badge-image {
color: rgb(52, 211, 153);
}
.dark .popup-item-kind.badge-files {
color: rgb(251, 191, 36);
}
.popup-item-actions {
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 0.1s;
}
/* 选中项和悬停项都显示操作按钮 */
.popup-item:hover .popup-item-actions,
.popup-item-selected .popup-item-actions {
opacity: 1;
}
.popup-action-btn {
background: transparent;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
color: var(--muted-foreground);
transition: background-color 0.1s, color 0.1s;
}
.popup-action-btn:hover {
background: var(--muted);
color: var(--foreground);
}
/* 空状态(使用 Tailwind 类,无需额外 CSS */
/* 底部 */
.popup-footer {
display: flex;
justify-content: center;
gap: 16px;
padding: 6px 12px;
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--muted-foreground);
}
/* 图片悬停预览浮层:固定在弹窗右上角,不遮挡列表操作 */
.popup-preview {
position: absolute;
top: 50px;
right: 10px;
z-index: 100;
max-width: 180px;
max-height: 180px;
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--border);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
background: var(--popover, var(--background));
pointer-events: none;
}
.popup-preview img {
display: block;
max-width: 100%;
max-height: 180px;
object-fit: contain;
}
/* 预览浮层淡入淡出 */
.popup-preview-enter-active,
.popup-preview-leave-active {
transition: opacity 0.15s ease;
}
.popup-preview-enter-from,
.popup-preview-leave-to {
opacity: 0;
}
.popup-footer kbd {
background: var(--muted);
color: var(--foreground);
padding: 1px 5px;
border-radius: 3px;
font-size: 10px;
margin-right: 2px;
font-family: inherit;
}
/* 滚动条 */
.popup-list::-webkit-scrollbar {
width: 6px;
}
.popup-list::-webkit-scrollbar-thumb {
background: var(--muted-foreground);
opacity: 0.3;
border-radius: 3px;
}
.popup-list::-webkit-scrollbar-track {
background: transparent;
}
</style>