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
+633
View File
@@ -0,0 +1,633 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
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,
} from '@lucide/vue'
import { ScrollArea } from '@/components/ui/scroll-area'
// ===== 与 Rust 端对应的数据结构(camelCase =====
type ClipboardKind = 'text' | 'image' | 'files'
interface ClipboardItem {
id: number
kind: ClipboardKind
preview: string
size: number
pinned: boolean
pinnedOrder: number | null
createdAt: number
}
interface HistoryPage {
items: ClipboardItem[]
total: number
}
// ===== 状态 =====
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)))
// ===== 数据加载 =====
async function loadData() {
loading.value = true
try {
const q = searchQuery.value.trim()
const offset = (currentPage.value - 1) * PAGE_SIZE
let res: HistoryPage
if (q) {
res = await invoke<HistoryPage>('clipboard_search', { query: q, limit: PAGE_SIZE, offset })
} else {
res = await invoke<HistoryPage>('clipboard_get_history', { limit: PAGE_SIZE, offset, kind: 'all' })
}
items.value = res.items
total.value = res.total
selectedIndex.value = 0
} catch (e) {
console.error('[clipboard-popup] 加载失败:', e)
} finally {
loading.value = false
}
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 invoke('clipboard_copy_back', { id: item.id })
// paste_to_target 会先隐藏窗口,再延迟模拟 Ctrl+V
await invoke('clipboard_paste_to_target')
} catch (e) {
console.error('[clipboard-popup] 粘贴失败:', e)
// 失败时至少隐藏窗口
await hideWindow()
}
}
async function togglePin(item: ClipboardItem, ev: Event) {
ev.stopPropagation()
try {
await invoke('clipboard_set_pinned', { id: item.id, pinned: !item.pinned })
await loadData()
} catch (e) {
console.error('[clipboard-popup] 固定失败:', e)
}
}
async function deleteItem(item: ClipboardItem, ev: Event) {
ev.stopPropagation()
try {
await invoke('clipboard_delete', { id: item.id })
items.value = items.value.filter((i) => i.id !== item.id)
} catch (e) {
console.error('[clipboard-popup] 删除失败:', e)
}
}
async function hideWindow() {
try {
await invoke('clipboard_hide_popup')
} catch {
/* 忽略 */
}
}
// ===== 键盘导航 =====
function onKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault()
selectedIndex.value = Math.min(selectedIndex.value + 1, items.value.length - 1)
scrollSelectedIntoView()
} else if (e.key === 'ArrowUp') {
e.preventDefault()
selectedIndex.value = Math.max(selectedIndex.value - 1, 0)
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' })
})
}
// ===== 显示辅助 =====
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'
? '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)
// ===== 主题应用(与主应用同步) =====
/** 从 localStorage 读取主应用的主题设置 */
function readMainTheme(): { theme: string; effect: string } {
try {
const raw = localStorage.getItem('thing_app_settings')
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('clipboard-popup-show', async () => {
// 主应用可能切换了主题,每次显示前重新应用
await applyTheme()
searchQuery.value = ''
currentPage.value = 1
await loadData()
await nextTick()
searchInputRef.value?.focus()
}))
// 加载初始数据
await loadData()
await nextTick()
searchInputRef.value?.focus()
// 主题和数据都就绪后,调用 Rust 端显示窗口
try {
await invoke('clipboard_show_window')
} catch {
/* 忽略 */
}
})
onUnmounted(() => {
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
ref="searchInputRef"
v-model="searchQuery"
placeholder="搜索剪贴板历史..."
class="search-input"
spellcheck="false"
/>
</div>
<span class="popup-count">{{ total }} </span>
</div>
<!-- 列表 -->
<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>
<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>
</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>
<!-- 底部提示 -->
<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;
}
/* 搜索栏 */
.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;
}
/* 列表 */
.popup-list {
padding: 4px;
}
/* reka-ui ScrollAreaViewport 内部会出现一个 div,需保证高度撑满 */
.popup-list :deep([data-slot="scroll-area-viewport"] > div) {
min-height: 100%;
}
.popup-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
/* 预留边框空间,避免 hover/选中时布局抖动 */
border: 1px solid transparent;
/* 统一 hover 和 selected 为同一高亮效果,避免错乱 */
transition: background-color 0.1s, border-color 0.1s;
}
/* 鼠标悬停和键盘选中统一使用 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 配色(与主界面一致) */
.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;
flex-shrink: 0;
}
/* 选中项和悬停项都显示操作按钮 */
.popup-item:hover .popup-item-actions,
.popup-item-selected .popup-item-actions {
opacity: 1;
}
.popup-action-btn {
background: transparent;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 4px;
color: var(--muted-foreground);
display: flex;
align-items: center;
}
.popup-action-btn:hover {
background: var(--muted);
}
/* 空状态 */
.popup-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 200px;
color: var(--muted-foreground);
}
/* 底部 */
.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-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;
border: 1px solid var(--border);
cursor: pointer;
padding: 3px;
border-radius: 4px;
color: var(--foreground);
display: flex;
align-items: center;
}
.popup-page-btn:hover:not(:disabled) {
background: var(--accent);
}
.popup-page-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.popup-page-info {
font-size: 11px;
color: var(--muted-foreground);
font-variant-numeric: tabular-nums;
min-width: 50px;
text-align: center;
}
.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>