托盘
This commit is contained in:
@@ -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+C、Alt+V、Shift+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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user