托盘
This commit is contained in:
+50
-1
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, shallowRef, computed, watch, type Component } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, shallowRef, computed, watch, type Component } from 'vue'
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import TitleBar from '@/components/layout/TitleBar.vue'
|
||||
import Sidebar from '@/components/layout/Sidebar.vue'
|
||||
import ModuleContainer from '@/components/layout/ModuleContainer.vue'
|
||||
@@ -8,6 +9,7 @@ import { useAppStore } from '@/stores/appStore'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { moduleRegistry } from '@/modules/registry'
|
||||
import type { ModuleMeta } from '@/types/module'
|
||||
import { pendingNewDownload, pendingOpenSettings } from '@/lib/trayEvents'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
@@ -31,6 +33,14 @@ const activeModule = ref('')
|
||||
|
||||
const activeComponent = shallowRef<Component | null>(null)
|
||||
|
||||
/** 预加载的监控模块组件(用于隐藏预渲染,确保 OSD 在启动时创建) */
|
||||
const monitorComponent = shallowRef<Component | null>(null)
|
||||
|
||||
/** 监控模块是否已启用 */
|
||||
const monitorEnabled = computed(() =>
|
||||
appStore.modules.find(m => m.id === 'monitor')?.enabled ?? false
|
||||
)
|
||||
|
||||
/** 当前可显示的模块(内置模块 + 已启用的用户模块),按用户排序显示 */
|
||||
const availableModules = computed<NavModule[]>(() => {
|
||||
const enabledIds = appStore.enabledModules.map(m => m.id)
|
||||
@@ -116,9 +126,43 @@ const resolveDefaultModule = (): string => {
|
||||
|
||||
onMounted(async () => {
|
||||
await appStore.init().catch(e => console.error('App init error:', e))
|
||||
|
||||
// 预加载监控模块组件,用于隐藏预渲染
|
||||
// 这样即使启动时默认模块不是监控,MonitorModule 的 onMounted 也会执行
|
||||
// 从而在应用启动时自动创建 OSD 窗口(如果 OSD 配置已开启)
|
||||
if (monitorEnabled.value) {
|
||||
monitorComponent.value = await moduleRegistry.loadComponent('monitor')
|
||||
}
|
||||
|
||||
const defaultModule = resolveDefaultModule()
|
||||
activeModule.value = defaultModule
|
||||
loadModule(defaultModule)
|
||||
|
||||
// 监听托盘菜单事件
|
||||
// tray:toggle-osd 由 MonitorModule 直接监听(预渲染实例始终挂载)
|
||||
trayUnlisteners.push(
|
||||
await listen('tray:new-download', () => {
|
||||
// 设置标志位,DownloaderModule 挂载后消费
|
||||
pendingNewDownload.value = true
|
||||
// 切换到下载模块(如果未启用则切换到设置)
|
||||
const enabledIds = appStore.enabledModules.map(m => m.id)
|
||||
if (enabledIds.includes('downloader') || moduleRegistry.getConfig('downloader')?.builtin) {
|
||||
handleModuleChange('downloader')
|
||||
}
|
||||
})
|
||||
)
|
||||
trayUnlisteners.push(
|
||||
await listen('tray:open-settings', () => {
|
||||
pendingOpenSettings.value = true
|
||||
handleModuleChange('settings')
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const trayUnlisteners: UnlistenFn[] = []
|
||||
|
||||
onUnmounted(() => {
|
||||
trayUnlisteners.forEach(fn => fn())
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -136,5 +180,10 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
<Toaster position="bottom-right" rich-colors close-button :style="{ zIndex: 99999 }" />
|
||||
<!-- 预渲染监控模块(隐藏):确保 OSD 窗口在应用启动时创建,不依赖用户切换到监控模块。
|
||||
当 activeModule === 'monitor' 时不渲染(由 ModuleContainer 正常渲染),避免重复实例 -->
|
||||
<div v-if="monitorComponent && monitorEnabled && activeModule !== 'monitor'" style="display:none">
|
||||
<component :is="monitorComponent" />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { Search, Settings, ChevronRight } from '@lucide/vue'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { useSearchStore, type SearchItem } from '@/stores/searchStore'
|
||||
import { useModuleTabsStore } from '@/stores/moduleTabsStore'
|
||||
@@ -146,6 +147,10 @@ onMounted(async () => {
|
||||
} catch {
|
||||
// 非 Tauri 环境忽略
|
||||
}
|
||||
// 延迟修复 snap-layout 子窗口背景(等待插件初始化创建子窗口)
|
||||
setTimeout(() => {
|
||||
invoke('fix_snap_background').catch(() => {})
|
||||
}, 1000)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
/**
|
||||
* 托盘菜单触发的待处理动作
|
||||
*
|
||||
* Rust 后端通过 Tauri 事件通知前端执行模块切换 / 对话框打开等操作。
|
||||
* 由于目标模块可能尚未挂载(延迟加载),使用 ref 作为共享标志位,
|
||||
* 模块在 onMounted 中检查并消费。
|
||||
*/
|
||||
|
||||
/** 待打开新建下载对话框(由托盘"新建下载"触发) */
|
||||
export const pendingNewDownload = ref(false)
|
||||
|
||||
/** 待切换到设置模块(由托盘"常规设置"触发) */
|
||||
export const pendingOpenSettings = ref(false)
|
||||
+11
-5
@@ -16,15 +16,21 @@ window.addEventListener('unhandledrejection', (event) => {
|
||||
})
|
||||
|
||||
// ===== OSD 窗口模式检测 =====
|
||||
// 通过 URL hash 识别:#osd-overlay(悬浮窗)
|
||||
// OSD 窗口是精简的独立 Vue 应用,不加载主应用的 store 和模块
|
||||
const osdHash = window.location.hash
|
||||
if (osdHash === '#osd-overlay') {
|
||||
logger.info(`OSD 窗口启动: ${osdHash}`)
|
||||
// 通过 URL hash 识别:#osd-overlay(悬浮窗)/ #clipboard-popup(剪贴板快捷弹窗)
|
||||
// 这些窗口是精简的独立 Vue 应用,不加载主应用的 store 和模块
|
||||
const winHash = window.location.hash
|
||||
if (winHash === '#osd-overlay') {
|
||||
logger.info(`OSD 窗口启动: ${winHash}`)
|
||||
void import('./modules/monitor/OsdWindow.vue').then(({ default: OsdWindow }) => {
|
||||
const app = createApp(OsdWindow)
|
||||
app.mount('#app')
|
||||
})
|
||||
} else if (winHash === '#clipboard-popup') {
|
||||
logger.info(`剪贴板弹窗窗口启动: ${winHash}`)
|
||||
void import('./modules/clipboard/ClipboardPopup.vue').then(({ default: ClipboardPopup }) => {
|
||||
const app = createApp(ClipboardPopup)
|
||||
app.mount('#app')
|
||||
})
|
||||
} else {
|
||||
// ===== 主应用模式 =====
|
||||
void import('./App.vue').then(async ({ default: App }) => {
|
||||
|
||||
@@ -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>
|
||||
@@ -11,9 +11,10 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { open as openDialog } from '@tauri-apps/plugin-dialog'
|
||||
import { useDownloaderStore, type DownloadTask, type TaskStatus } from '@/stores/downloaderStore'
|
||||
import { useDownloaderStore, type DownloadTask, type TaskStatus, type CheckUrlResult } from '@/stores/downloaderStore'
|
||||
import { useModuleTabs } from '@/lib/useModuleTabs'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { pendingNewDownload } from '@/lib/trayEvents'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -257,6 +258,15 @@ const handleOpenDir = async (task: DownloadTask) => {
|
||||
}
|
||||
|
||||
// ===== 添加下载 =====
|
||||
/** 重复确认对话框状态 */
|
||||
const duplicateDialogState = ref<{
|
||||
open: boolean
|
||||
url: string
|
||||
result: CheckUrlResult | null
|
||||
pendingUrls: string[]
|
||||
currentIndex: number
|
||||
}>({ open: false, url: '', result: null, pendingUrls: [], currentIndex: 0 })
|
||||
|
||||
const handleAddDownload = async () => {
|
||||
const text = addUriText.value.trim()
|
||||
if (!text) {
|
||||
@@ -272,23 +282,23 @@ const handleAddDownload = async () => {
|
||||
addingTask.value = true
|
||||
try {
|
||||
const dir = addDir.value.trim() || undefined
|
||||
let successCount = 0
|
||||
for (const uri of uris) {
|
||||
try {
|
||||
await store.addTask(uri, undefined, dir)
|
||||
successCount++
|
||||
} catch (e) {
|
||||
logger.error(`添加 ${uri} 失败: ` + e)
|
||||
}
|
||||
}
|
||||
if (successCount > 0) {
|
||||
toast.success(`已添加 ${successCount} 个下载任务`)
|
||||
addUriText.value = ''
|
||||
addDir.value = ''
|
||||
addDialogOpen.value = false
|
||||
activeTab.value = 'tasks'
|
||||
// 检查是否启用重复检查
|
||||
const checkEnabled = store.settings?.checkDuplicate ?? true
|
||||
if (checkEnabled) {
|
||||
// 逐个检查重复
|
||||
await processUrlsWithCheck(uris, dir)
|
||||
} else {
|
||||
toast.error('添加任务失败')
|
||||
// 直接添加
|
||||
let successCount = 0
|
||||
for (const uri of uris) {
|
||||
try {
|
||||
await store.addTask(uri, undefined, dir, undefined, true)
|
||||
successCount++
|
||||
} catch (e) {
|
||||
logger.error(`添加 ${uri} 失败: ` + e)
|
||||
}
|
||||
}
|
||||
finishAdd(successCount)
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('添加失败: ' + e)
|
||||
@@ -297,6 +307,146 @@ const handleAddDownload = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 逐个检查 URL 重复性,发现重复时弹出确认对话框 */
|
||||
async function processUrlsWithCheck(urls: string[], dir: string | undefined) {
|
||||
let successCount = 0
|
||||
for (let i = 0; i < urls.length; i++) {
|
||||
const uri = urls[i]
|
||||
try {
|
||||
const result = await store.checkUrl(uri, dir)
|
||||
if (!result.ok) {
|
||||
// 探测失败:直接添加(后端会标记为 error)
|
||||
try {
|
||||
await store.addTask(uri, undefined, dir, undefined, true)
|
||||
successCount++
|
||||
} catch (e) {
|
||||
logger.error(`添加 ${uri} 失败: ` + e)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (result.duplicate === 'none') {
|
||||
// 无重复:直接添加(自动重命名以防磁盘文件冲突)
|
||||
try {
|
||||
await store.addTask(uri, undefined, dir, undefined, true)
|
||||
successCount++
|
||||
} catch (e) {
|
||||
logger.error(`添加 ${uri} 失败: ` + e)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// 发现重复:弹出确认对话框,暂停处理
|
||||
duplicateDialogState.value = {
|
||||
open: true,
|
||||
url: uri,
|
||||
result,
|
||||
pendingUrls: urls,
|
||||
currentIndex: i,
|
||||
}
|
||||
return // 等待用户确认后继续
|
||||
} catch (e) {
|
||||
logger.error(`检查 ${uri} 失败: ` + e)
|
||||
// 检查失败:直接添加
|
||||
try {
|
||||
await store.addTask(uri, undefined, dir, undefined, true)
|
||||
successCount++
|
||||
} catch (e2) {
|
||||
logger.error(`添加 ${uri} 失败: ` + e2)
|
||||
}
|
||||
}
|
||||
}
|
||||
finishAdd(successCount)
|
||||
}
|
||||
|
||||
/** 完成添加:显示提示并关闭对话框 */
|
||||
function finishAdd(successCount: number) {
|
||||
if (successCount > 0) {
|
||||
toast.success(`已添加 ${successCount} 个下载任务`)
|
||||
addUriText.value = ''
|
||||
addDir.value = ''
|
||||
addDialogOpen.value = false
|
||||
activeTab.value = 'tasks'
|
||||
} else {
|
||||
toast.error('添加任务失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 重复确认对话框:用户选择"仍然下载"(自动重命名) */
|
||||
const onDuplicateConfirm = async () => {
|
||||
const { url, pendingUrls, currentIndex } = duplicateDialogState.value
|
||||
const dir = addDir.value.trim() || undefined
|
||||
duplicateDialogState.value.open = false
|
||||
|
||||
let successCount = 0
|
||||
try {
|
||||
await store.addTask(url, undefined, dir, undefined, true) // autoRename=true
|
||||
successCount++
|
||||
} catch (e) {
|
||||
logger.error(`添加 ${url} 失败: ` + e)
|
||||
}
|
||||
|
||||
// 继续处理剩余 URL
|
||||
const remaining = pendingUrls.slice(currentIndex + 1)
|
||||
for (const uri of remaining) {
|
||||
try {
|
||||
const result = await store.checkUrl(uri, dir)
|
||||
if (result.ok && result.duplicate !== 'none') {
|
||||
// 又发现重复,再次弹出确认
|
||||
duplicateDialogState.value = {
|
||||
open: true,
|
||||
url: uri,
|
||||
result,
|
||||
pendingUrls,
|
||||
currentIndex: pendingUrls.indexOf(uri),
|
||||
}
|
||||
// 已添加的成功数通过闭包传递不太方便,直接在这里 finish 后再继续
|
||||
// 简化处理:保存 successCount 到 state,下一次确认时累加
|
||||
duplicateSuccessCount.value = successCount
|
||||
return
|
||||
}
|
||||
await store.addTask(uri, undefined, dir, undefined, true)
|
||||
successCount++
|
||||
} catch (e) {
|
||||
logger.error(`添加 ${uri} 失败: ` + e)
|
||||
}
|
||||
}
|
||||
finishAdd(successCount)
|
||||
}
|
||||
|
||||
/** 累积的成功计数(跨多次重复确认) */
|
||||
const duplicateSuccessCount = ref(0)
|
||||
|
||||
/** 重复确认对话框:用户选择"跳过" */
|
||||
const onDuplicateSkip = () => {
|
||||
const { pendingUrls, currentIndex } = duplicateDialogState.value
|
||||
duplicateDialogState.value.open = false
|
||||
const remaining = pendingUrls.slice(currentIndex + 1)
|
||||
if (remaining.length > 0) {
|
||||
// 继续处理剩余 URL(不添加当前这个)
|
||||
const dir = addDir.value.trim() || undefined
|
||||
processUrlsWithCheck(remaining, dir).then(() => {})
|
||||
} else {
|
||||
finishAdd(duplicateSuccessCount.value)
|
||||
duplicateSuccessCount.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
/** 重复确认对话框:用户取消整个添加流程 */
|
||||
const onDuplicateCancel = () => {
|
||||
duplicateDialogState.value.open = false
|
||||
finishAdd(duplicateSuccessCount.value)
|
||||
duplicateSuccessCount.value = 0
|
||||
}
|
||||
|
||||
/** 重复类型文本 */
|
||||
const duplicateKindText = (kind: string): string => {
|
||||
switch (kind) {
|
||||
case 'url': return 'URL 已存在下载任务'
|
||||
case 'filename': return '同名文件已在下载列表中'
|
||||
case 'fileExists': return '目标目录已存在同名文件'
|
||||
default: return '重复'
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectDir = async () => {
|
||||
try {
|
||||
const selected = await openDialog({ directory: true, multiple: false })
|
||||
@@ -367,6 +517,11 @@ const handleCopy = async (text: string, label: string) => {
|
||||
|
||||
onMounted(async () => {
|
||||
await store.init()
|
||||
// 消费托盘菜单"新建下载"标志位
|
||||
if (pendingNewDownload.value) {
|
||||
pendingNewDownload.value = false
|
||||
addDialogOpen.value = true
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -458,8 +613,8 @@ const toggleSortOrder = () => {
|
||||
<TabsContent value="tasks" class="flex-1 mt-4 min-h-0 tab-animate">
|
||||
<div class="h-full flex flex-col gap-4">
|
||||
<!-- 状态栏 -->
|
||||
<Card class="shrink-0">
|
||||
<CardContent class="pl-4 pr-4">
|
||||
<Card class="shrink-0 !py-0 !gap-0">
|
||||
<CardContent class="pl-4 pr-4 py-3">
|
||||
<div class="flex items-center justify-between flex-wrap gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -611,8 +766,8 @@ const toggleSortOrder = () => {
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else key="task-list" class="flex flex-col gap-3 pb-4">
|
||||
<Card v-for="task in pagedTasks" :key="task.id" class="overflow-hidden">
|
||||
<CardContent class="pl-4 pr-4">
|
||||
<Card v-for="task in pagedTasks" :key="task.id" class="overflow-hidden !py-0 !gap-0">
|
||||
<CardContent class="pl-4 pr-4 py-3">
|
||||
<div class="flex items-start justify-between gap-3 mb-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
@@ -893,6 +1048,17 @@ const toggleSortOrder = () => {
|
||||
@update:model-value="(v: boolean) => store.settings && (store.settings.deleteFilesOnRemove = v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<Label for="check-duplicate" class="cursor-pointer">重复下载检查</Label>
|
||||
<span class="text-xs text-muted-foreground">添加下载前检查 URL 和文件名重复,发现重复时询问</span>
|
||||
</div>
|
||||
<Switch
|
||||
id="check-duplicate"
|
||||
:model-value="store.settings?.checkDuplicate ?? true"
|
||||
@update:model-value="(v: boolean) => store.settings && (store.settings.checkDuplicate = v)"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1218,6 +1384,39 @@ const toggleSortOrder = () => {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- ===== 重复下载确认弹窗 ===== -->
|
||||
<AlertDialog :open="duplicateDialogState.open">
|
||||
<AlertDialogContent class="max-w-md">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle class="flex items-center gap-2">
|
||||
<AlertCircle class="h-4 w-4 text-amber-500" />
|
||||
下载重复提醒
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription class="text-xs">
|
||||
<span class="block mb-2">
|
||||
{{ duplicateKindText(duplicateDialogState.result?.duplicate ?? '') }}
|
||||
</span>
|
||||
<span class="block font-mono text-xs break-all">{{ duplicateDialogState.url }}</span>
|
||||
<span v-if="duplicateDialogState.result?.existing" class="block mt-2 text-xs">
|
||||
已存在:
|
||||
<span class="font-medium">{{ duplicateDialogState.result.existing.filename }}</span>
|
||||
</span>
|
||||
<span class="block mt-2 text-muted-foreground">
|
||||
选择"仍然下载"将自动重命名(追加序号),选择"跳过"将不下载此链接。
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel @click="onDuplicateCancel">取消全部</AlertDialogCancel>
|
||||
<Button variant="outline" @click="onDuplicateSkip">跳过此链接</Button>
|
||||
<AlertDialogAction @click="onDuplicateConfirm">
|
||||
<Download class="h-4 w-4" />
|
||||
仍然下载
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<!-- ===== 删除任务专用对话框(带"同时删除文件"开关) ===== -->
|
||||
<AlertDialog :open="removeDialogState.open" @update:open="onRemoveOpenChange">
|
||||
<AlertDialogContent>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { VueDraggable } from 'vue-draggable-plus'
|
||||
import { appDataDir } from '@tauri-apps/api/path'
|
||||
import { revealItemInDir } from '@tauri-apps/plugin-opener'
|
||||
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
|
||||
import { emit, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import { currentMonitor, LogicalPosition, LogicalSize } from '@tauri-apps/api/window'
|
||||
import { useMonitorStore, type SensorEntry, type SensorGroup, type ConnectionState } from '@/stores/monitorStore'
|
||||
import { useModuleTabs } from '@/lib/useModuleTabs'
|
||||
@@ -1493,20 +1493,12 @@ async function resetOverlayPosition() {
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
|
||||
/** 关闭所有 OSD 窗口(组件卸载时调用) */
|
||||
async function closeAllOsdWindows() {
|
||||
try {
|
||||
const w = await WebviewWindow.getByLabel(OSD_OVERLAY_LABEL)
|
||||
if (w) await w.close()
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
// ===== OSD 窗口事件监听 =====
|
||||
let osdEventUnlisteners: UnlistenFn[] = []
|
||||
|
||||
async function setupOsdEventListeners() {
|
||||
// 守卫:避免重复注册(MonitorModule 可能因预渲染多次挂载)
|
||||
if (osdEventUnlisteners.length) return
|
||||
const { listen: tauriListen } = await import('@tauri-apps/api/event')
|
||||
// 监听悬浮窗上报的实际内容尺寸,按内容调整窗口大小(替代不准确的估算)
|
||||
// 仅当尺寸变化超过 1px 时才 setSize,避免无意义的频繁调用
|
||||
@@ -1611,15 +1603,36 @@ onMounted(async () => {
|
||||
if (osdConfig.value.overlayEnabled) {
|
||||
ensureOverlayWindow().catch(e => console.error('[OSD] 初始化悬浮窗失败:', e))
|
||||
}
|
||||
|
||||
// 监听托盘菜单"切换 OSD"事件
|
||||
try {
|
||||
osdEventUnlisteners.push(
|
||||
await listen('tray:toggle-osd', () => {
|
||||
osdConfig.value.overlayEnabled = !osdConfig.value.overlayEnabled
|
||||
saveOsdConfig(osdConfig.value)
|
||||
if (osdConfig.value.overlayEnabled) {
|
||||
if (osdConfig.value.overlayItems.length === 0) {
|
||||
toast.warning('OSD 显示项为空,已开启但未创建窗口')
|
||||
} else {
|
||||
ensureOverlayWindow().catch(e => console.error('[OSD] 托盘开启悬浮窗失败:', e))
|
||||
}
|
||||
} else {
|
||||
hideOverlayWindow().catch(e => console.error('[OSD] 托盘关闭悬浮窗失败:', e))
|
||||
}
|
||||
})
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('[OSD] 注册 tray:toggle-osd 监听失败:', e)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
store.dispose()
|
||||
// 清理 OSD 事件监听
|
||||
// 不 dispose store:SSE 订阅保持,确保切走监控模块后 OSD 仍有数据
|
||||
// store.subscribe() 内部有守卫(if unlistenFns.length return),重复 init 不会重复订阅
|
||||
// 不关闭 OSD 窗口:切走监控模块时保持 OSD 显示,由模块禁用或应用退出时统一清理
|
||||
// 仅清理组件级 OSD 事件监听(下次挂载会重新注册,setupOsdEventListeners 有守卫)
|
||||
osdEventUnlisteners.forEach(fn => fn())
|
||||
osdEventUnlisteners = []
|
||||
// 关闭悬浮窗(切走监控模块时释放 OSD 窗口)
|
||||
closeAllOsdWindows().catch(e => console.error('[OSD] 关闭窗口失败:', e))
|
||||
})
|
||||
|
||||
// 当 Kernel 从未就绪变成就绪时,主动拉一次快照填充 UI
|
||||
|
||||
@@ -44,6 +44,14 @@ export const moduleConfig: ModuleConfig = {
|
||||
} catch {
|
||||
/* 忽略:可能 Kernel 未运行 */
|
||||
}
|
||||
// 关闭 OSD 窗口(MonitorModule onUnmounted 不再自动关闭,需在禁用时手动关闭)
|
||||
try {
|
||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
const osd = await WebviewWindow.getByLabel('osd-overlay')
|
||||
if (osd) await osd.close()
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
}
|
||||
},
|
||||
order: 40
|
||||
|
||||
@@ -391,6 +391,14 @@ export const useAppStore = defineStore('app', () => {
|
||||
} catch (e) {
|
||||
// 非 Tauri 环境下忽略
|
||||
}
|
||||
|
||||
// 切换效果后修复 snap-layout 子窗口背景(clearEffects 触发重绘会导致白色背景显现)
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
invoke('fix_snap_background').catch(() => {})
|
||||
} catch {
|
||||
/* 非 Tauri 环境忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
// 系统主题变化时,若当前为"跟随系统"模式,同步更新 DOM 和窗口效果。
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const logger = createLogger('clipboard')
|
||||
|
||||
// ===== 与 Rust 端对应的数据结构(camelCase) =====
|
||||
|
||||
export type ClipboardKind = 'text' | 'image' | 'files'
|
||||
|
||||
export interface ClipboardItem {
|
||||
id: number
|
||||
kind: ClipboardKind
|
||||
preview: string
|
||||
size: number
|
||||
pinned: boolean
|
||||
pinnedOrder: number | null
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
/** 历史分页结果(与 Rust 端 HistoryPage 对应) */
|
||||
export interface HistoryPage {
|
||||
items: ClipboardItem[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface ClipboardItemDetail extends ClipboardItem {
|
||||
content: string | null
|
||||
imageBase64: string | null
|
||||
}
|
||||
|
||||
export interface ClipboardSettings {
|
||||
enabled: boolean
|
||||
maxItems: number
|
||||
maxImageKb: number
|
||||
recordText: boolean
|
||||
recordImage: boolean
|
||||
recordFiles: boolean
|
||||
dedup: boolean
|
||||
shortcut: string
|
||||
}
|
||||
|
||||
export interface ClipboardStatus {
|
||||
running: boolean
|
||||
count: number
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: ClipboardSettings = {
|
||||
enabled: true,
|
||||
maxItems: 500,
|
||||
maxImageKb: 5120,
|
||||
recordText: true,
|
||||
recordImage: true,
|
||||
recordFiles: true,
|
||||
dedup: true,
|
||||
shortcut: 'Alt+V',
|
||||
}
|
||||
|
||||
export const useClipboardStore = defineStore('clipboard', () => {
|
||||
const history = ref<ClipboardItem[]>([])
|
||||
const historyTotal = ref(0)
|
||||
const pinned = ref<ClipboardItem[]>([])
|
||||
const settings = ref<ClipboardSettings>({ ...DEFAULT_SETTINGS })
|
||||
const status = ref<ClipboardStatus>({ running: false, count: 0 })
|
||||
const loading = ref(false)
|
||||
|
||||
// 事件监听(应用级单例,只注册一次)
|
||||
let changedUnlisten: UnlistenFn | null = null
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/** 初始化:加载状态/设置,注册事件监听 */
|
||||
const init = async () => {
|
||||
try {
|
||||
const [s, st] = await Promise.all([
|
||||
invoke<ClipboardSettings>('clipboard_get_settings'),
|
||||
invoke<ClipboardStatus>('clipboard_status'),
|
||||
])
|
||||
settings.value = { ...DEFAULT_SETTINGS, ...s }
|
||||
status.value = st
|
||||
} catch (e) {
|
||||
logger.error('初始化失败: ' + e)
|
||||
}
|
||||
if (!changedUnlisten) {
|
||||
changedUnlisten = await listen('clipboard-changed', () => {
|
||||
// 防抖:短时间内多次复制只刷新一次
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
refreshHistory()
|
||||
refreshStatus()
|
||||
}, 250)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const dispose = () => {
|
||||
if (changedUnlisten) {
|
||||
changedUnlisten()
|
||||
changedUnlisten = null
|
||||
}
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 查询 =====
|
||||
/** 拉取指定页的历史数据。pageSize 默认 50。 */
|
||||
const fetchHistoryPage = async (opts: {
|
||||
kind?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
} = {}) => {
|
||||
const kind = opts.kind ?? 'all'
|
||||
const pageSize = opts.pageSize ?? 50
|
||||
const page = Math.max(1, opts.page ?? 1)
|
||||
const offset = (page - 1) * pageSize
|
||||
try {
|
||||
const res = await invoke<HistoryPage>('clipboard_get_history', {
|
||||
limit: pageSize,
|
||||
offset,
|
||||
kind,
|
||||
})
|
||||
history.value = res.items
|
||||
historyTotal.value = res.total
|
||||
} catch (e) {
|
||||
logger.error('获取历史失败: ' + e)
|
||||
}
|
||||
return history.value
|
||||
}
|
||||
|
||||
/** 兼容旧调用:拉取第一页 */
|
||||
const refreshHistory = async (kind: string = 'all') => fetchHistoryPage({ kind, page: 1 })
|
||||
|
||||
const refreshPinned = async () => {
|
||||
try {
|
||||
pinned.value = await invoke<ClipboardItem[]>('clipboard_get_pinned')
|
||||
} catch (e) {
|
||||
logger.error('获取固定条目失败: ' + e)
|
||||
}
|
||||
return pinned.value
|
||||
}
|
||||
|
||||
/** 搜索(分页)。pageSize 默认 50。 */
|
||||
const searchPage = async (query: string, page: number = 1, pageSize: number = 50) => {
|
||||
if (!query.trim()) {
|
||||
return fetchHistoryPage({ page, pageSize })
|
||||
}
|
||||
try {
|
||||
const res = await invoke<HistoryPage>('clipboard_search', {
|
||||
query,
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
})
|
||||
history.value = res.items
|
||||
historyTotal.value = res.total
|
||||
} catch (e) {
|
||||
logger.error('搜索失败: ' + e)
|
||||
}
|
||||
return history.value
|
||||
}
|
||||
|
||||
/** 兼容旧调用:搜索第一页 */
|
||||
const search = async (query: string) => searchPage(query, 1)
|
||||
|
||||
const getItem = async (id: number) => {
|
||||
try {
|
||||
return await invoke<ClipboardItemDetail | null>('clipboard_get_item', { id })
|
||||
} catch (e) {
|
||||
logger.error('获取详情失败: ' + e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const refreshStatus = async () => {
|
||||
try {
|
||||
status.value = await invoke<ClipboardStatus>('clipboard_status')
|
||||
} catch (e) {
|
||||
logger.error('获取状态失败: ' + e)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 操作 =====
|
||||
const setPinned = async (id: number, pinned: boolean) => {
|
||||
try {
|
||||
await invoke('clipboard_set_pinned', { id, pinned })
|
||||
// 固定/取消后刷新两个列表
|
||||
await Promise.all([refreshHistory(), refreshPinned()])
|
||||
} catch (e) {
|
||||
logger.error('切换固定失败: ' + e)
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (id: number) => {
|
||||
try {
|
||||
await invoke('clipboard_delete', { id })
|
||||
history.value = history.value.filter((i) => i.id !== id)
|
||||
pinned.value = pinned.value.filter((i) => i.id !== id)
|
||||
status.value.count = Math.max(0, status.value.count - 1)
|
||||
} catch (e) {
|
||||
logger.error('删除失败: ' + e)
|
||||
}
|
||||
}
|
||||
|
||||
const clear = async () => {
|
||||
try {
|
||||
await invoke('clipboard_clear')
|
||||
history.value = []
|
||||
await refreshStatus()
|
||||
} catch (e) {
|
||||
logger.error('清空失败: ' + e)
|
||||
}
|
||||
}
|
||||
|
||||
const copyBack = async (id: number) => {
|
||||
await invoke('clipboard_copy_back', { id })
|
||||
// copy_back 会触发 suppress,不会产生 clipboard-changed 事件
|
||||
}
|
||||
|
||||
const saveSettings = async (s: ClipboardSettings) => {
|
||||
try {
|
||||
await invoke('clipboard_save_settings', { settings: s })
|
||||
settings.value = { ...s }
|
||||
await refreshStatus()
|
||||
} catch (e) {
|
||||
logger.error('保存设置失败: ' + e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
const start = async () => {
|
||||
await invoke('clipboard_start')
|
||||
await refreshStatus()
|
||||
}
|
||||
|
||||
const stop = async () => {
|
||||
await invoke('clipboard_stop')
|
||||
await refreshStatus()
|
||||
}
|
||||
|
||||
// ===== 快捷弹窗 =====
|
||||
const showPopup = async () => {
|
||||
await invoke('clipboard_show_popup')
|
||||
}
|
||||
|
||||
const hidePopup = async () => {
|
||||
await invoke('clipboard_hide_popup')
|
||||
}
|
||||
|
||||
/// 隐藏弹窗并模拟 Ctrl+V 粘贴到原窗口
|
||||
const pasteToTarget = async () => {
|
||||
await invoke('clipboard_paste_to_target')
|
||||
}
|
||||
|
||||
const registerShortcut = async (shortcut: string) => {
|
||||
await invoke('clipboard_register_shortcut', { shortcut })
|
||||
}
|
||||
|
||||
const unregisterShortcut = async () => {
|
||||
await invoke('clipboard_unregister_shortcut')
|
||||
}
|
||||
|
||||
return {
|
||||
history,
|
||||
historyTotal,
|
||||
pinned,
|
||||
settings,
|
||||
status,
|
||||
loading,
|
||||
init,
|
||||
dispose,
|
||||
fetchHistoryPage,
|
||||
refreshHistory,
|
||||
refreshPinned,
|
||||
searchPage,
|
||||
search,
|
||||
getItem,
|
||||
refreshStatus,
|
||||
setPinned,
|
||||
remove,
|
||||
clear,
|
||||
copyBack,
|
||||
saveSettings,
|
||||
start,
|
||||
stop,
|
||||
showPopup,
|
||||
hidePopup,
|
||||
pasteToTarget,
|
||||
registerShortcut,
|
||||
unregisterShortcut,
|
||||
}
|
||||
})
|
||||
@@ -42,6 +42,25 @@ export interface DownloaderSettings {
|
||||
extensionPort: number
|
||||
extensionSecret: string
|
||||
deleteFilesOnRemove: boolean
|
||||
checkDuplicate: boolean
|
||||
}
|
||||
|
||||
/** 重复类型 */
|
||||
export type DuplicateKind = 'none' | 'url' | 'filename' | 'fileExists'
|
||||
|
||||
/** check_url 返回的结果 */
|
||||
export interface CheckUrlResult {
|
||||
ok: boolean
|
||||
error: string | null
|
||||
filename: string | null
|
||||
totalSize: number | null
|
||||
supportsResume: boolean
|
||||
duplicate: DuplicateKind
|
||||
existing: {
|
||||
id: string
|
||||
filename: string
|
||||
status: TaskStatus
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface DownloaderStatus {
|
||||
@@ -115,18 +134,33 @@ export const useDownloaderStore = defineStore('downloader', () => {
|
||||
url: string,
|
||||
filename?: string,
|
||||
dir?: string,
|
||||
headers?: Record<string, string>
|
||||
headers?: Record<string, string>,
|
||||
autoRename = false
|
||||
): Promise<string> => {
|
||||
const id = await invoke<string>('downloader_add_task', {
|
||||
url,
|
||||
filename: filename || null,
|
||||
dir: dir || null,
|
||||
headers: headers || null
|
||||
headers: headers || null,
|
||||
autoRename
|
||||
})
|
||||
await refreshTasks()
|
||||
return id
|
||||
}
|
||||
|
||||
/** 检查 URL 重复性并探测文件信息 */
|
||||
const checkUrl = async (
|
||||
url: string,
|
||||
dir?: string,
|
||||
headers?: Record<string, string>
|
||||
): Promise<CheckUrlResult> => {
|
||||
return await invoke<CheckUrlResult>('downloader_check_url', {
|
||||
url,
|
||||
dir: dir || null,
|
||||
headers: headers || null
|
||||
})
|
||||
}
|
||||
|
||||
const pauseTask = async (id: string) => {
|
||||
await invoke('downloader_pause_task', { id })
|
||||
await refreshTasks()
|
||||
@@ -223,6 +257,7 @@ export const useDownloaderStore = defineStore('downloader', () => {
|
||||
// tasks
|
||||
refreshTasks,
|
||||
addTask,
|
||||
checkUrl,
|
||||
pauseTask,
|
||||
resumeTask,
|
||||
removeTask,
|
||||
|
||||
Reference in New Issue
Block a user