截图模块初始化

This commit is contained in:
zhongluofeng
2026-07-31 18:30:55 +08:00
parent 9d8f963cc6
commit 66575c6166
16 changed files with 1011 additions and 481 deletions
+54 -20
View File
@@ -3,12 +3,12 @@ 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 { useClipboardStore, type ClipboardItem, type ClipboardKind, type ClipboardItemDetail } from '@/stores/clipboardStore'
import { useModuleTabs } from '@/lib/useModuleTabs'
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'
@@ -22,6 +22,9 @@ 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()
@@ -66,7 +69,7 @@ const gotoPage = async (p: number) => {
// 详情弹窗
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 detail = ref<ClipboardItemDetail | null>(null)
const openDetail = async (item: ClipboardItem) => {
detailOpen.value = true
detailLoading.value = true
@@ -76,6 +79,15 @@ const openDetail = async (item: ClipboardItem) => {
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)
@@ -226,7 +238,6 @@ onUnmounted(() => {
</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">
@@ -234,10 +245,40 @@ onUnmounted(() => {
</Button>
</div>
<div class="flex-1 min-h-0 overflow-y-auto space-y-1.5 pr-1">
<!-- 分页置于列表上方靠左显示 -->
<div v-if="totalPages > 1" class="flex items-center justify-start pb-2 shrink-0">
<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>
</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 h-full text-muted-foreground"
class="flex flex-col items-center justify-center text-muted-foreground py-12"
>
<ClipboardList class="h-12 w-12 mb-3 opacity-40" />
<p class="text-sm">暂无历史记录复制内容后将自动收录</p>
@@ -270,18 +311,8 @@ onUnmounted(() => {
</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>
</div>
</ScrollArea>
</TabsContent>
<!-- 固定 -->
@@ -428,11 +459,14 @@ onUnmounted(() => {
</div>
<template v-else-if="detail">
<img
v-if="detail.kind === 'image' && detail.imageBase64"
:src="`data:image/png;base64,${detail.imageBase64}`"
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
+189 -183
View File
@@ -6,9 +6,13 @@ import { getCurrentWindow } from '@tauri-apps/api/window'
import { Effect, EffectState } from '@tauri-apps/api/window'
import {
ClipboardList, Pin, PinOff, Trash2, Search, Image as ImageIcon,
FileText, Files, Loader2, ChevronLeft, ChevronRight,
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 端对应的数据结构(camelCase =====
type ClipboardKind = 'text' | 'image' | 'files'
@@ -123,10 +127,14 @@ 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()
@@ -169,6 +177,62 @@ const formatTime = (ms: number) => {
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 invoke<{ imageBase64: string | null } | null>('clipboard_get_item', { id: 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 } {
@@ -271,6 +335,10 @@ onMounted(async () => {
await applyTheme()
searchQuery.value = ''
currentPage.value = 1
// 重置预览状态,清空缓存避免历史图片占用内存
cancelHoverTimer()
previewVisible.value = false
imageCache.clear()
await loadData()
await nextTick()
searchInputRef.value?.focus()
@@ -290,74 +358,102 @@ onMounted(async () => {
})
onUnmounted(() => {
cancelHoverTimer()
unlistenFns.forEach((fn) => fn())
})
</script>
<template>
<div class="popup-root flex flex-col h-screen w-screen" @keydown="onKeydown">
<!-- 搜索栏 -->
<div class="popup-header shrink-0">
<div class="search-wrap">
<Search class="h-4 w-4 text-muted-foreground" />
<input
<!-- 搜索栏与剪切板主页统一样式 -->
<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="search-input"
class="pl-8 text-sm"
spellcheck="false"
/>
</div>
<span class="popup-count">{{ total }} </span>
<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 v-if="loading && !hasItems" class="popup-empty">
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
</div>
<div v-else-if="!hasItems" class="popup-empty">
<ClipboardList class="h-10 w-10 mb-2 opacity-40" />
<p class="text-sm text-muted-foreground">
{{ searchQuery ? '无匹配结果' : '暂无历史记录' }}
</p>
</div>
<div
v-for="(item, idx) in items"
:key="item.id"
class="popup-item"
:class="{ 'popup-item-selected': idx === selectedIndex }"
@click="selectAndPaste(item)"
@mouseenter="selectedIndex = idx"
>
<component :is="kindIcon(item.kind)" class="popup-item-icon" />
<div class="popup-item-body">
<p class="popup-item-preview">{{ item.preview }}</p>
<div class="popup-item-meta">
<span class="popup-item-kind" :class="kindBadgeClass(item.kind)">{{ kindLabel(item.kind) }}</span>
<span>{{ formatTime(item.createdAt) }}</span>
</div>
<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 class="popup-item-actions">
<button class="popup-action-btn" title="固定" @click="togglePin(item, $event)">
<component :is="item.pinned ? PinOff : Pin" class="h-3.5 w-3.5" />
</button>
<button class="popup-action-btn hover:text-destructive" title="删除" @click="deleteItem(item, $event)">
<Trash2 class="h-3.5 w-3.5" />
</button>
<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="popup-pagination">
<button class="popup-page-btn" :disabled="currentPage <= 1" @click="gotoPage(currentPage - 1)">
<ChevronLeft class="h-3.5 w-3.5" />
</button>
<span class="popup-page-info">{{ currentPage }} / {{ totalPages }}</span>
<button class="popup-page-btn" :disabled="currentPage >= totalPages" @click="gotoPage(currentPage + 1)">
<ChevronRight class="h-3.5 w-3.5" />
</button>
<!-- 分页与剪切板历史统一样式 -->
<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>
<!-- 底部提示 -->
@@ -377,49 +473,12 @@ onUnmounted(() => {
color: var(--foreground);
border-radius: 8px;
overflow: hidden;
}
/* 搜索栏 */
.popup-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
}
.search-wrap {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
background: var(--input);
border-radius: 6px;
padding: 6px 10px;
}
.search-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--foreground);
font-size: 13px;
}
.search-input::placeholder {
color: var(--muted-foreground);
}
.popup-count {
font-size: 11px;
color: var(--muted-foreground);
white-space: nowrap;
position: relative;
}
/* 列表 */
.popup-list {
padding: 4px;
padding: 0;
}
/* reka-ui ScrollAreaViewport 内部会出现一个 div,需保证高度撑满 */
@@ -427,66 +486,26 @@ onUnmounted(() => {
min-height: 100%;
}
/* 条目卡片样式(与主界面 Card 统一) */
.popup-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 10px;
border-radius: 6px;
gap: 12px;
padding: 8px 12px;
border-radius: var(--radius);
cursor: pointer;
/* 预留边框空间,避免 hover/选中时布局抖动 */
border: 1px solid transparent;
/* 统一 hover 和 selected 为同一高亮效果,避免错乱 */
transition: background-color 0.1s, border-color 0.1s;
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);
}
/* 鼠标悬停和键盘选中统一使用 accent 高亮 + 反色边框增强提醒 */
.popup-item:hover,
.popup-item-selected {
background: var(--accent);
border-color: var(--foreground);
}
.popup-item-icon {
width: 18px;
height: 18px;
margin-top: 1px;
color: var(--muted-foreground);
flex-shrink: 0;
}
.popup-item-body {
flex: 1;
min-width: 0;
}
.popup-item-preview {
font-size: 13px;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
word-break: break-all;
}
.popup-item-meta {
display: flex;
align-items: center;
gap: 8px;
margin-top: 3px;
font-size: 11px;
color: var(--muted-foreground);
}
.popup-item-kind {
background: var(--secondary);
color: var(--secondary-foreground);
padding: 0 5px;
border-radius: 3px;
font-size: 10px;
border: 1px solid transparent;
}
/* 类型 badge 配色(与主界面一致) */
@@ -522,7 +541,6 @@ onUnmounted(() => {
gap: 2px;
opacity: 0;
transition: opacity 0.1s;
flex-shrink: 0;
}
/* 选中项和悬停项都显示操作按钮 */
@@ -535,27 +553,20 @@ onUnmounted(() => {
background: transparent;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 4px;
color: var(--muted-foreground);
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);
}
/* 空状态 */
.popup-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 200px;
color: var(--muted-foreground);
}
/* 空状态(使用 Tailwind 类,无需额外 CSS */
/* 底部 */
.popup-footer {
@@ -568,42 +579,37 @@ onUnmounted(() => {
color: var(--muted-foreground);
}
/* 分页 */
.popup-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 4px 12px;
border-top: 1px solid var(--border);
}
.popup-page-btn {
background: transparent;
/* 图片悬停预览浮层:固定在弹窗右上角,不遮挡列表操作 */
.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);
cursor: pointer;
padding: 3px;
border-radius: 4px;
color: var(--foreground);
display: flex;
align-items: center;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
background: var(--popover, var(--background));
pointer-events: none;
}
.popup-page-btn:hover:not(:disabled) {
background: var(--accent);
.popup-preview img {
display: block;
max-width: 100%;
max-height: 180px;
object-fit: contain;
}
.popup-page-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
/* 预览浮层淡入淡出 */
.popup-preview-enter-active,
.popup-preview-leave-active {
transition: opacity 0.15s ease;
}
.popup-page-info {
font-size: 11px;
color: var(--muted-foreground);
font-variant-numeric: tabular-nums;
min-width: 50px;
text-align: center;
.popup-preview-enter-from,
.popup-preview-leave-to {
opacity: 0;
}
.popup-footer kbd {