This commit is contained in:
zhongluofeng
2026-07-30 09:09:29 +08:00
parent 74902c4cec
commit f452322aad
32 changed files with 4586 additions and 115 deletions
+50 -1
View File
@@ -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>
+5
View File
@@ -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)
}
})
+15
View File
@@ -0,0 +1,15 @@
import { ref } from 'vue'
/**
* 托盘菜单触发的待处理动作
*
* Rust 后端通过 Tauri 事件通知前端执行模块切换 / 对话框打开等操作。
* 由于目标模块可能尚未挂载(延迟加载),使用 ref 作为共享标志位,
* 模块在 onMounted 中检查并消费。
*/
/** 待打开新建下载对话框(由托盘"新建下载"触发) */
export const pendingNewDownload = ref(false)
/** 待切换到设置模块(由托盘"常规设置"触发) */
export const pendingOpenSettings = ref(false)
+11 -5
View File
@@ -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 }) => {
+473 -18
View File
@@ -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+CAlt+VShift+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>
+633
View File
@@ -0,0 +1,633 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { Effect, EffectState } from '@tauri-apps/api/window'
import {
ClipboardList, Pin, PinOff, Trash2, Search, Image as ImageIcon,
FileText, Files, Loader2, ChevronLeft, ChevronRight,
} from '@lucide/vue'
import { ScrollArea } from '@/components/ui/scroll-area'
// ===== 与 Rust 端对应的数据结构(camelCase =====
type ClipboardKind = 'text' | 'image' | 'files'
interface ClipboardItem {
id: number
kind: ClipboardKind
preview: string
size: number
pinned: boolean
pinnedOrder: number | null
createdAt: number
}
interface HistoryPage {
items: ClipboardItem[]
total: number
}
// ===== 状态 =====
const items = ref<ClipboardItem[]>([])
const total = ref(0)
const currentPage = ref(1)
const PAGE_SIZE = 50
const searchQuery = ref('')
const selectedIndex = ref(0)
const loading = ref(false)
const searchInputRef = ref<HTMLInputElement | null>(null)
let unlistenFns: UnlistenFn[] = []
let searchTimer: ReturnType<typeof setTimeout> | null = null
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
// ===== 数据加载 =====
async function loadData() {
loading.value = true
try {
const q = searchQuery.value.trim()
const offset = (currentPage.value - 1) * PAGE_SIZE
let res: HistoryPage
if (q) {
res = await invoke<HistoryPage>('clipboard_search', { query: q, limit: PAGE_SIZE, offset })
} else {
res = await invoke<HistoryPage>('clipboard_get_history', { limit: PAGE_SIZE, offset, kind: 'all' })
}
items.value = res.items
total.value = res.total
selectedIndex.value = 0
} catch (e) {
console.error('[clipboard-popup] 加载失败:', e)
} finally {
loading.value = false
}
await nextTick()
scrollSelectedIntoView()
}
async function gotoPage(p: number) {
currentPage.value = Math.min(Math.max(1, p), totalPages.value)
await loadData()
}
// 防抖搜索
watch(searchQuery, () => {
currentPage.value = 1
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(loadData, 200)
})
// ===== 选择与粘贴 =====
/// 选中条目 → 写回剪贴板 → 隐藏窗口 → 模拟 Ctrl+V 粘贴到原窗口
async function selectAndPaste(item: ClipboardItem) {
try {
await invoke('clipboard_copy_back', { id: item.id })
// paste_to_target 会先隐藏窗口,再延迟模拟 Ctrl+V
await invoke('clipboard_paste_to_target')
} catch (e) {
console.error('[clipboard-popup] 粘贴失败:', e)
// 失败时至少隐藏窗口
await hideWindow()
}
}
async function togglePin(item: ClipboardItem, ev: Event) {
ev.stopPropagation()
try {
await invoke('clipboard_set_pinned', { id: item.id, pinned: !item.pinned })
await loadData()
} catch (e) {
console.error('[clipboard-popup] 固定失败:', e)
}
}
async function deleteItem(item: ClipboardItem, ev: Event) {
ev.stopPropagation()
try {
await invoke('clipboard_delete', { id: item.id })
items.value = items.value.filter((i) => i.id !== item.id)
} catch (e) {
console.error('[clipboard-popup] 删除失败:', e)
}
}
async function hideWindow() {
try {
await invoke('clipboard_hide_popup')
} catch {
/* 忽略 */
}
}
// ===== 键盘导航 =====
function onKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault()
selectedIndex.value = Math.min(selectedIndex.value + 1, items.value.length - 1)
scrollSelectedIntoView()
} else if (e.key === 'ArrowUp') {
e.preventDefault()
selectedIndex.value = Math.max(selectedIndex.value - 1, 0)
scrollSelectedIntoView()
} else if (e.key === 'Enter') {
e.preventDefault()
const item = items.value[selectedIndex.value]
if (item) selectAndPaste(item)
} else if (e.key === 'Escape') {
e.preventDefault()
hideWindow()
}
}
function scrollSelectedIntoView() {
nextTick(() => {
const el = document.querySelector('.popup-item-selected') as HTMLElement | null
el?.scrollIntoView({ block: 'nearest' })
})
}
// ===== 显示辅助 =====
const kindIcon = (k: ClipboardKind) => {
if (k === 'text') return FileText
if (k === 'image') return ImageIcon
return Files
}
const kindLabel = (k: ClipboardKind) => (k === 'text' ? '文本' : k === 'image' ? '图片' : '文件')
const kindBadgeClass = (k: ClipboardKind) =>
k === 'text'
? 'badge-text'
: k === 'image'
? 'badge-image'
: 'badge-files'
const formatTime = (ms: number) => {
const diff = Date.now() - ms
if (diff < 60_000) return '刚刚'
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}分钟前`
if (diff < 86_400_000) return `${Math.floor(diff / 3600_000)}小时前`
const d = new Date(ms)
return `${d.getMonth() + 1}/${d.getDate()}`
}
const hasItems = computed(() => items.value.length > 0)
// ===== 主题应用(与主应用同步) =====
/** 从 localStorage 读取主应用的主题设置 */
function readMainTheme(): { theme: string; effect: string } {
try {
const raw = localStorage.getItem('thing_app_settings')
if (raw) {
const s = JSON.parse(raw)
return {
theme: s.theme ?? 'system',
effect: s.effect ?? 'mica',
}
}
} catch {
/* 忽略 */
}
return { theme: 'system', effect: 'mica' }
}
/** 判断当前是否应为深色主题。
* 弹窗是独立窗口,主应用的 setTheme 不影响弹窗的 matchMedia
* 因此 system 模式下用 matchMedia 是可靠的。 */
function resolveIsDark(theme: string): boolean {
if (theme === 'dark') return true
if (theme === 'light') return false
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
/** 应用主题和窗口效果(与主应用同步)。
* 关键:先 setTheme 让窗口主题正确,再用通用 Effect.Mica(自动跟随窗口主题深浅)。 */
async function applyTheme() {
const root = document.documentElement
const { theme, effect } = readMainTheme()
// 1. 先设置窗口原生主题(system → null 跟随系统)
try {
const tauriWin = getCurrentWindow()
if (theme === 'system') {
await tauriWin.setTheme(null)
} else {
await tauriWin.setTheme(theme as 'dark' | 'light')
}
} catch {
/* 非 Tauri 环境忽略 */
}
// 2. 窗口主题已正确,用 matchMedia 判断深浅(弹窗自身不受主应用污染)
const isDark = resolveIsDark(theme)
// 3. 设置 DOM class
root.classList.remove('dark', 'effect-mica', 'effect-acrylic', 'effect-normal')
root.classList.add(`effect-${effect}`)
if (isDark) root.classList.add('dark')
// 4. 设置窗口效果与背景色
// 弹窗窗口创建时 transparent=true,透明窗口下原生背景色不显示,
// 需在实际可见的 DOM 元素(.popup-root)上设置背景色。
// 用 CSS 变量 --popup-bg 控制,mica/acrylic 模式下保持透明。
try {
const tauriWin = getCurrentWindow()
await tauriWin.clearEffects()
if (effect === 'mica') {
await tauriWin.setEffects({
effects: [Effect.Mica],
state: EffectState.FollowsWindowActiveState,
color: isDark ? [30, 41, 59, 0] : [248, 250, 252, 0],
})
await tauriWin.setBackgroundColor('#00000000')
root.style.setProperty('--popup-bg', 'transparent')
} else if (effect === 'acrylic') {
await tauriWin.setEffects({
effects: [Effect.Acrylic],
state: EffectState.FollowsWindowActiveState,
color: isDark ? [30, 41, 59, 0] : [248, 250, 252, 0],
})
await tauriWin.setBackgroundColor('#00000000')
root.style.setProperty('--popup-bg', 'transparent')
} else {
// 普通模式:透明窗口下原生背景不显示,由 DOM 提供背景色
await tauriWin.setBackgroundColor(isDark ? '#0f172a' : '#ffffff')
root.style.setProperty('--popup-bg', isDark ? '#0f172a' : '#ffffff')
}
} catch {
/* 非 Tauri 环境忽略 */
}
}
onMounted(async () => {
// 先应用主题(含窗口效果)
await applyTheme()
// 监听系统主题变化(仅在 system 模式下有意义)
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const onThemeChange = () => applyTheme()
mq.addEventListener('change', onThemeChange)
unlistenFns.push(() => mq.removeEventListener('change', onThemeChange))
// 监听弹窗显示事件:每次显示时重新同步主题 + 刷新数据
unlistenFns.push(await listen('clipboard-popup-show', async () => {
// 主应用可能切换了主题,每次显示前重新应用
await applyTheme()
searchQuery.value = ''
currentPage.value = 1
await loadData()
await nextTick()
searchInputRef.value?.focus()
}))
// 加载初始数据
await loadData()
await nextTick()
searchInputRef.value?.focus()
// 主题和数据都就绪后,调用 Rust 端显示窗口
try {
await invoke('clipboard_show_window')
} catch {
/* 忽略 */
}
})
onUnmounted(() => {
unlistenFns.forEach((fn) => fn())
})
</script>
<template>
<div class="popup-root flex flex-col h-screen w-screen" @keydown="onKeydown">
<!-- 搜索栏 -->
<div class="popup-header shrink-0">
<div class="search-wrap">
<Search class="h-4 w-4 text-muted-foreground" />
<input
ref="searchInputRef"
v-model="searchQuery"
placeholder="搜索剪贴板历史..."
class="search-input"
spellcheck="false"
/>
</div>
<span class="popup-count">{{ total }} </span>
</div>
<!-- 列表 -->
<ScrollArea class="popup-list flex-1 min-h-0">
<div v-if="loading && !hasItems" class="popup-empty">
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
</div>
<div v-else-if="!hasItems" class="popup-empty">
<ClipboardList class="h-10 w-10 mb-2 opacity-40" />
<p class="text-sm text-muted-foreground">
{{ searchQuery ? '无匹配结果' : '暂无历史记录' }}
</p>
</div>
<div
v-for="(item, idx) in items"
:key="item.id"
class="popup-item"
:class="{ 'popup-item-selected': idx === selectedIndex }"
@click="selectAndPaste(item)"
@mouseenter="selectedIndex = idx"
>
<component :is="kindIcon(item.kind)" class="popup-item-icon" />
<div class="popup-item-body">
<p class="popup-item-preview">{{ item.preview }}</p>
<div class="popup-item-meta">
<span class="popup-item-kind" :class="kindBadgeClass(item.kind)">{{ kindLabel(item.kind) }}</span>
<span>{{ formatTime(item.createdAt) }}</span>
</div>
</div>
<div class="popup-item-actions">
<button class="popup-action-btn" title="固定" @click="togglePin(item, $event)">
<component :is="item.pinned ? PinOff : Pin" class="h-3.5 w-3.5" />
</button>
<button class="popup-action-btn hover:text-destructive" title="删除" @click="deleteItem(item, $event)">
<Trash2 class="h-3.5 w-3.5" />
</button>
</div>
</div>
</ScrollArea>
<!-- 分页 -->
<div v-if="totalPages > 1" class="popup-pagination">
<button class="popup-page-btn" :disabled="currentPage <= 1" @click="gotoPage(currentPage - 1)">
<ChevronLeft class="h-3.5 w-3.5" />
</button>
<span class="popup-page-info">{{ currentPage }} / {{ totalPages }}</span>
<button class="popup-page-btn" :disabled="currentPage >= totalPages" @click="gotoPage(currentPage + 1)">
<ChevronRight class="h-3.5 w-3.5" />
</button>
</div>
<!-- 底部提示 -->
<div class="popup-footer shrink-0">
<span><kbd></kbd><kbd></kbd> 导航</span>
<span><kbd>Enter</kbd> 粘贴</span>
<span><kbd>Esc</kbd> 关闭</span>
</div>
</div>
</template>
<style scoped>
/* 弹窗根容器:背景色由 --popup-bg 控制(mica/acrylic 透明,普通模式不透明) */
.popup-root {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', 'PingFang SC', sans-serif;
background: var(--popup-bg, transparent);
color: var(--foreground);
border-radius: 8px;
overflow: hidden;
}
/* 搜索栏 */
.popup-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
}
.search-wrap {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
background: var(--input);
border-radius: 6px;
padding: 6px 10px;
}
.search-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--foreground);
font-size: 13px;
}
.search-input::placeholder {
color: var(--muted-foreground);
}
.popup-count {
font-size: 11px;
color: var(--muted-foreground);
white-space: nowrap;
}
/* 列表 */
.popup-list {
padding: 4px;
}
/* reka-ui ScrollAreaViewport 内部会出现一个 div,需保证高度撑满 */
.popup-list :deep([data-slot="scroll-area-viewport"] > div) {
min-height: 100%;
}
.popup-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
/* 预留边框空间,避免 hover/选中时布局抖动 */
border: 1px solid transparent;
/* 统一 hover 和 selected 为同一高亮效果,避免错乱 */
transition: background-color 0.1s, border-color 0.1s;
}
/* 鼠标悬停和键盘选中统一使用 accent 高亮 + 反色边框增强提醒 */
.popup-item:hover,
.popup-item-selected {
background: var(--accent);
border-color: var(--foreground);
}
.popup-item-icon {
width: 18px;
height: 18px;
margin-top: 1px;
color: var(--muted-foreground);
flex-shrink: 0;
}
.popup-item-body {
flex: 1;
min-width: 0;
}
.popup-item-preview {
font-size: 13px;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
word-break: break-all;
}
.popup-item-meta {
display: flex;
align-items: center;
gap: 8px;
margin-top: 3px;
font-size: 11px;
color: var(--muted-foreground);
}
.popup-item-kind {
background: var(--secondary);
color: var(--secondary-foreground);
padding: 0 5px;
border-radius: 3px;
font-size: 10px;
border: 1px solid transparent;
}
/* 类型 badge 配色(与主界面一致) */
.popup-item-kind.badge-text {
background: rgba(59, 130, 246, 0.12);
color: rgb(37, 99, 235);
border-color: rgba(59, 130, 246, 0.4);
}
.popup-item-kind.badge-image {
background: rgba(16, 185, 129, 0.12);
color: rgb(5, 150, 105);
border-color: rgba(16, 185, 129, 0.4);
}
.popup-item-kind.badge-files {
background: rgba(245, 158, 11, 0.12);
color: rgb(217, 119, 6);
border-color: rgba(245, 158, 11, 0.4);
}
/* 深色主题下调整 badge 文字色 */
.dark .popup-item-kind.badge-text {
color: rgb(96, 165, 250);
}
.dark .popup-item-kind.badge-image {
color: rgb(52, 211, 153);
}
.dark .popup-item-kind.badge-files {
color: rgb(251, 191, 36);
}
.popup-item-actions {
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 0.1s;
flex-shrink: 0;
}
/* 选中项和悬停项都显示操作按钮 */
.popup-item:hover .popup-item-actions,
.popup-item-selected .popup-item-actions {
opacity: 1;
}
.popup-action-btn {
background: transparent;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 4px;
color: var(--muted-foreground);
display: flex;
align-items: center;
}
.popup-action-btn:hover {
background: var(--muted);
}
/* 空状态 */
.popup-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 200px;
color: var(--muted-foreground);
}
/* 底部 */
.popup-footer {
display: flex;
justify-content: center;
gap: 16px;
padding: 6px 12px;
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--muted-foreground);
}
/* 分页 */
.popup-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 4px 12px;
border-top: 1px solid var(--border);
}
.popup-page-btn {
background: transparent;
border: 1px solid var(--border);
cursor: pointer;
padding: 3px;
border-radius: 4px;
color: var(--foreground);
display: flex;
align-items: center;
}
.popup-page-btn:hover:not(:disabled) {
background: var(--accent);
}
.popup-page-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.popup-page-info {
font-size: 11px;
color: var(--muted-foreground);
font-variant-numeric: tabular-nums;
min-width: 50px;
text-align: center;
}
.popup-footer kbd {
background: var(--muted);
color: var(--foreground);
padding: 1px 5px;
border-radius: 3px;
font-size: 10px;
margin-right: 2px;
font-family: inherit;
}
/* 滚动条 */
.popup-list::-webkit-scrollbar {
width: 6px;
}
.popup-list::-webkit-scrollbar-thumb {
background: var(--muted-foreground);
opacity: 0.3;
border-radius: 3px;
}
.popup-list::-webkit-scrollbar-track {
background: transparent;
}
</style>
+220 -21
View File
@@ -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>
+28 -15
View File
@@ -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 storeSSE 订阅保持,确保切走监控模块后 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
+8
View File
@@ -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
+8
View File
@@ -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 和窗口效果。
+293
View File
@@ -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,
}
})
+37 -2
View File
@@ -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,