优化调整

This commit is contained in:
zhongluofeng
2026-08-14 17:47:06 +08:00
parent 6c7897bf47
commit 7d49a7395f
50 changed files with 2805 additions and 259 deletions
+76 -66
View File
@@ -16,7 +16,6 @@ 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 { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from '@/components/ui/dialog'
@@ -27,6 +26,10 @@ import {
import {
Pagination, PaginationContent, PaginationItem, PaginationEllipsis,
} from '@/components/ui/pagination'
import { Skeleton } from '@/components/ui/skeleton'
import {
Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle,
} from '@/components/ui/empty'
const store = useClipboardStore()
@@ -74,6 +77,12 @@ const detailOpen = ref(false)
const detailLoading = ref(false)
const detail = ref<ClipboardItemDetail | null>(null)
const openDetail = async (item: ClipboardItem) => {
// reka-ui Dialog 打开时会把当前活动元素记为 triggerElement,关闭时对其无 preventScroll 地 focus
// 导致历史列表的 ScrollAreaViewport(tabindex=0) 被聚焦并滚回顶部。打开前 blur,避免记录滚动容器。
const active = document.activeElement
if (active instanceof HTMLElement) {
active.blur()
}
detailOpen.value = true
detailLoading.value = true
detail.value = null
@@ -283,9 +292,17 @@ 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 }
// 首次进入:异步加载并显示骨架屏。再次进入时 store 已缓存历史/固定/设置,
// 直接即时渲染缓存数据,后台并行静默刷新,避免每次切换都出现骨架屏/空态闪烁。
const isFirst = !store.initialized
if (isFirst) store.loading = true
try {
await Promise.all([store.init(), loadPage(), store.refreshPinned()])
store.initialized = true
form.value = { ...store.settings }
} finally {
store.loading = false
}
})
onUnmounted(() => {
@@ -369,13 +386,33 @@ onUnmounted(() => {
<ScrollArea class="flex-1 min-h-0">
<div class="space-y-1.5 pr-2">
<div
v-if="!historyList.length"
class="flex flex-col items-center justify-center text-muted-foreground py-12"
>
<ClipboardList class="size-12 mb-3 opacity-40" />
<p class="text-sm">暂无历史记录复制内容后将自动收录</p>
<!-- 加载骨架数据异步返回前撑起画面避免误显示"暂无历史" -->
<div v-if="store.loading" class="space-y-1.5">
<div
v-for="n in 8" :key="n"
class="flex items-center gap-3 rounded-lg border p-3"
>
<Skeleton class="size-4 shrink-0" />
<div class="flex-1 space-y-2">
<Skeleton class="h-3.5 w-3/4" />
<Skeleton class="h-2.5 w-1/4" />
</div>
</div>
</div>
<Empty
v-else-if="!historyList.length"
class="py-12"
>
<EmptyHeader>
<EmptyMedia variant="icon">
<ClipboardList class="size-6" />
</EmptyMedia>
<EmptyTitle>暂无历史记录</EmptyTitle>
</EmptyHeader>
<EmptyContent>
<EmptyDescription>复制内容后将自动收录图片与文件也会被记录</EmptyDescription>
</EmptyContent>
</Empty>
<Card
v-for="item in historyList"
:key="item.id"
@@ -392,30 +429,15 @@ onUnmounted(() => {
</div>
</div>
<div class="flex items-center gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition">
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-7" @click.stop="handleCopy(item)">
<Copy class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>复制</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-7" @click.stop="handlePin(item)">
<component :is="item.pinned ? PinOff : Pin" class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ item.pinned ? '取消固定' : '固定' }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-7 hover:text-destructive" @click.stop="handleDelete(item)">
<Trash2 class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>删除</TooltipContent>
</Tooltip>
<Button variant="ghost" size="icon" class="size-7" title="复制" @click.stop="handleCopy(item)">
<Copy class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="size-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="size-7 hover:text-destructive" title="删除" @click.stop="handleDelete(item)">
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
</Card>
@@ -426,14 +448,17 @@ onUnmounted(() => {
<!-- 固定 -->
<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="size-12 mb-3 opacity-40" />
<p class="text-sm">暂无固定条目</p>
<p class="text-xs mt-1">鼠标悬停历史条目点击图钉按钮即可固定</p>
</div>
<Empty v-if="!pinnedList.length" class="h-full py-8">
<EmptyHeader>
<EmptyMedia variant="icon">
<Pin class="size-6" />
</EmptyMedia>
<EmptyTitle>暂无固定条目</EmptyTitle>
</EmptyHeader>
<EmptyContent>
<EmptyDescription>鼠标悬停历史条目点击图钉按钮即可固定</EmptyDescription>
</EmptyContent>
</Empty>
<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="size-4 text-primary shrink-0" />
@@ -446,30 +471,15 @@ onUnmounted(() => {
</div>
</div>
<div class="flex items-center gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition">
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-7" @click.stop="handleCopy(item)">
<Copy class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>复制</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-7" @click.stop="handlePin(item)">
<PinOff class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>取消固定</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-7 hover:text-destructive" @click.stop="handleDelete(item)">
<Trash2 class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>删除</TooltipContent>
</Tooltip>
<Button variant="ghost" size="icon" class="size-7" title="复制" @click.stop="handleCopy(item)">
<Copy class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="size-7" title="取消固定" @click.stop="handlePin(item)">
<PinOff class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="size-7 hover:text-destructive" title="删除" @click.stop="handleDelete(item)">
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
</Card>
+20 -22
View File
@@ -8,14 +8,14 @@ import { Effect, EffectState } from '@tauri-apps/api/window'
import { commands } from '@/lib/bindings'
import {
ClipboardList, Pin, PinOff, Trash2, Search, Image as ImageIcon,
FileText, Files, Loader2,
FileText, Files,
} from '@lucide/vue'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Input } from '@/components/ui/input'
import {
Pagination, PaginationContent, PaginationItem, PaginationEllipsis,
} from '@/components/ui/pagination'
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'
import { Skeleton } from '@/components/ui/skeleton'
// ===== 与 Rust 端对应的数据结构(bindings 提供,camelCase =====
// kind 为 bindings 生成的 string,前端按字符串比较即可
@@ -364,7 +364,6 @@ onUnmounted(() => {
<template>
<div class="popup-root flex flex-col h-screen w-screen" @keydown="onKeydown">
<TooltipProvider>
<!-- 搜索栏与剪切板主页统一样式 -->
<div class="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
<div class="relative flex-1 max-w-sm">
@@ -390,8 +389,18 @@ onUnmounted(() => {
<!-- 列表 -->
<ScrollArea class="popup-list flex-1 min-h-0">
<div class="space-y-1.5 p-2">
<div v-if="loading && !hasItems" class="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Loader2 class="size-6 animate-spin" />
<div v-if="loading && !hasItems" class="space-y-1.5">
<div
v-for="n in 8"
:key="n"
class="flex items-start gap-3 rounded-lg border px-3 py-2"
>
<Skeleton class="size-4 shrink-0 mt-0.5" />
<div class="flex-1 space-y-1.5">
<Skeleton class="h-3.5 w-3/4" />
<Skeleton class="h-2.5 w-1/4" />
</div>
</div>
</div>
<div v-else-if="!hasItems" class="flex flex-col items-center justify-center py-12 text-muted-foreground">
<ClipboardList class="size-10 mb-2 opacity-40" />
@@ -417,22 +426,12 @@ onUnmounted(() => {
</div>
</div>
<div class="popup-item-actions shrink-0">
<Tooltip>
<TooltipTrigger as-child>
<button class="popup-action-btn size-7" @click="togglePin(item, $event)">
<component :is="item.pinned ? PinOff : Pin" class="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>固定</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button class="popup-action-btn size-7 hover:text-destructive" @click="deleteItem(item, $event)">
<Trash2 class="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>删除</TooltipContent>
</Tooltip>
<button class="popup-action-btn size-7" :title="item.pinned ? '取消固定' : '固定'" @click="togglePin(item, $event)">
<component :is="item.pinned ? PinOff : Pin" class="h-3.5 w-3.5" />
</button>
<button class="popup-action-btn size-7 hover:text-destructive" title="删除" @click="deleteItem(item, $event)">
<Trash2 class="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
@@ -472,7 +471,6 @@ onUnmounted(() => {
<span><kbd>Enter</kbd> 粘贴</span>
<span><kbd>Esc</kbd> 关闭</span>
</div>
</TooltipProvider>
</div>
</template>
+6 -1
View File
@@ -16,7 +16,7 @@ import { useDownloaderStore, type DownloadTask, type TaskStatus, type CheckUrlRe
import { useModuleTabs } from '@/lib/use-module-tabs'
import { useModuleTabsStore } from '@/stores/moduleTabsStore'
import { createLogger } from '@/lib/logger'
import { pendingNewDownload } from '@/lib/trayEvents'
import { pendingNewDownload, pendingShowDownloadTasks } from '@/lib/trayEvents'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@@ -536,6 +536,11 @@ onMounted(async () => {
pendingNewDownload.value = false
addDialogOpen.value = true
}
// 消费浏览器扩展新增下载标志位:直接显示任务列表页
if (pendingShowDownloadTasks.value) {
pendingShowDownloadTasks.value = false
activeTab.value = 'tasks'
}
})
onUnmounted(() => {
+3 -2
View File
@@ -1102,8 +1102,9 @@ onUnmounted(() => {
// 不 dispose storeSSE 订阅保持,确保切走监控模块后 OSD 仍有数据
// store.subscribe() 内部有守卫(if unlistenFns.length return),重复 init 不会重复订阅
// 不关闭 OSD 窗口:切走监控模块时保持 OSD 显示,由模块禁用或应用退出时统一清理
// 释放 OSD 事件监听(App 启动或模块重新挂载时会重新注册)
store.disposeOsd()
// 不调用 store.disposeOsd()tray:toggle-osd 监听与 OSD 配置 watcher 由 App.vue 的
// initOsd() 注册,属应用级常驻(与模块生命周期解耦);若在此释放,切走监控模块后
// 托盘菜单的 OSD 开关会失效。OSD 事件监听仅在 App 卸载(应用退出)时统一释放。
})
// 当 Kernel 从未就绪变成就绪时,主动拉一次快照填充 UI
+15 -10
View File
@@ -328,9 +328,9 @@ const init = async () => {
await store.waitForApi()
store.refreshVersion()
loadProxiesWithError()
// 若自动切换已开启,恢复定时器
// 若自动切换已开启,恢复定时器(静默,不弹通知、不立即执行)
if (autoSwitchEnabled.value) {
startAutoSwitch()
startAutoSwitch(false, false)
}
}
} finally {
@@ -370,10 +370,10 @@ watch(running, async (val, old) => {
await store.waitForApi()
await store.refreshVersion()
await loadProxiesWithError()
// 自动切换若已开启,mihomo 启动/重启后恢复定时器
// 自动切换若已开启,mihomo 启动/重启后恢复定时器(静默,不弹通知)
// handleStop 会停掉旧定时器,此处统一接管启动路径,避免开关显示开但功能静默失效)
if (autoSwitchEnabled.value) {
startAutoSwitch()
startAutoSwitch(false)
}
}
})
@@ -495,16 +495,21 @@ const quickSwitchNode = async (name: string) => {
}
// ===== 自动切换节点 =====
const startAutoSwitch = () => {
const startAutoSwitch = (notify = true, immediate = true) => {
stopAutoSwitch()
if (!autoSwitchEnabled.value) return
const ms = autoSwitchInterval.value * 60 * 1000
autoSwitchTimer = setInterval(runAutoSwitch, ms)
toast.success('自动切换已开启', {
description: `${autoSwitchInterval.value} 分钟测试并切换至最优节点`
})
// 立即执行一次
runAutoSwitch()
// 仅用户主动开启时提示;模块挂载/内核重启恢复定时器时静默,避免每次切换都弹通知
if (notify) {
toast.success('自动切换已开启', {
description: `${autoSwitchInterval.value} 分钟测试并切换至最优节点`
})
}
// 立即执行一次(用户主动开启/调整时立即生效;进入模块恢复时跳过,避免每次进入都测速切换)
if (immediate) {
runAutoSwitch()
}
}
const stopAutoSwitch = () => {
+159
View File
@@ -0,0 +1,159 @@
<script setup lang="ts">
import { Pin } from '@lucide/vue'
defineProps<{
items: string[]
favs?: string[]
open?: boolean
}>()
const emit = defineEmits<{
pick: [value: string]
fav: [value: string]
}>()
function pick(value: string) {
emit('pick', value)
}
</script>
<template>
<!-- mousedown.prevent 阻止点击下拉项时输入框失焦使 focus 触发的历史面板保持展开 -->
<div
v-if="open && ((favs && favs.length) || items.length)"
class="qp-fa-dropdown"
@mousedown.prevent
>
<!-- 钉住/常用独立保存显示在历史之上 -->
<template v-if="favs && favs.length">
<p class="qp-fa-dropdown-label">常用</p>
<div
v-for="f in favs"
:key="'fav-' + f"
class="qp-fa-dropdown-item"
>
<button
type="button"
class="flex-1 min-w-0 text-left text-xs truncate"
:title="f"
@click="pick(f)"
>
{{ f || '(空)' }}
</button>
<button
type="button"
class="qp-fa-pin pinned"
title="取消钉住"
@click="emit('fav', f)"
>
<Pin class="size-3.5" />
</button>
</div>
</template>
<!-- 历史最多 10 排除已钉住的 -->
<p
v-if="items.filter(x => !(favs ?? []).includes(x)).length"
class="qp-fa-dropdown-label"
>
历史
</p>
<div
v-for="it in items.filter(x => !(favs ?? []).includes(x))"
:key="'his-' + it"
class="qp-fa-dropdown-item"
>
<button
type="button"
class="flex-1 min-w-0 text-left text-xs truncate"
:title="it"
@click="pick(it)"
>
{{ it || '(空)' }}
</button>
<button
v-if="favs"
type="button"
class="qp-fa-pin"
title="钉住为常用"
@click="emit('fav', it)"
>
<Pin class="size-3.5" />
</button>
</div>
</div>
</template>
<style scoped>
.qp-fa-dropdown {
position: absolute;
top: calc(100% + 2px);
left: 0;
right: 0;
z-index: 50;
max-height: 220px;
overflow-y: auto;
background: var(--popover, var(--card));
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 4px;
}
.qp-fa-dropdown-label {
padding: 3px 8px 1px;
font-size: 10px;
color: var(--muted-foreground);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.qp-fa-dropdown-item {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 6px;
border-radius: 5px;
}
.qp-fa-dropdown-item:hover {
background: var(--accent);
}
.qp-fa-pin {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border: none;
background: none;
padding: 2px;
color: var(--muted-foreground);
cursor: pointer;
opacity: 0.5;
transition: opacity 0.1s, color 0.1s;
}
.qp-fa-pin:hover {
opacity: 1;
color: var(--foreground);
}
.qp-fa-pin.pinned {
color: var(--primary);
opacity: 1;
}
.qp-fa-dropdown::-webkit-scrollbar {
width: 6px;
}
.qp-fa-dropdown::-webkit-scrollbar-thumb {
background: var(--muted-foreground);
opacity: 0.3;
border-radius: 3px;
}
.qp-fa-dropdown::-webkit-scrollbar-track {
background: transparent;
}
</style>
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -528,7 +528,15 @@ async function changeEngine(v: string) {
</div>
<div class="flex items-center gap-2 text-sm">
<Badge variant="secondary">文件</Badge>
<span class="text-muted-foreground">索引指定目录快速定位文件</span>
<span class="text-muted-foreground">索引指定目录快速定位文件支持打开/显示/复制路径/删除.lnk 按应用处理</span>
</div>
<div class="flex items-center gap-2 text-sm">
<Badge variant="secondary">目录操作</Badge>
<span class="text-muted-foreground">文件资源管理器批量处理批量解压正则批量重命名实时预览筛选批量删除</span>
</div>
<div class="flex items-center gap-2 text-sm">
<Badge variant="secondary">历史</Badge>
<span class="text-muted-foreground">记录最近交互空查询优先展示</span>
</div>
<div class="flex items-center gap-2 text-sm">
<Badge variant="secondary">剪贴板</Badge>
@@ -548,7 +556,7 @@ async function changeEngine(v: string) {
</div>
<div class="flex items-center gap-2 text-sm">
<Badge variant="secondary">系统</Badge>
<span class="text-muted-foreground">锁屏退出应用</span>
<span class="text-muted-foreground">系统命令注册表CMD/PowerShell任务管理器控制面板关机/重启/休眠锁屏退出应用</span>
</div>
<div class="flex items-center gap-2 text-sm">
<Badge variant="secondary">网页</Badge>
+20
View File
@@ -38,8 +38,17 @@ const state = reactive<TrayMenuState>({
const loadingAction = ref<string | null>(null)
const refreshing = ref(false)
const osdVisible = ref(false)
const nodeSelectOpen = ref(false)
let unlistenFns: UnlistenFn[] = []
/** 菜单关闭/失焦前重置状态:关闭节点下拉框并取消所有焦点,避免下次打开时残留 */
function resetMenuState() {
nodeSelectOpen.value = false
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
}
function readOsdVisible(): boolean {
try {
const raw = localStorage.getItem(STORAGE_KEYS.monitorOsdConfig)
@@ -260,6 +269,8 @@ const sortedNodes = computed(() => {
})
async function handleAction(action: string, payload?: Record<string, unknown>) {
// 点击菜单项即关闭下拉并取消焦点,避免下次打开残留
resetMenuState()
try { await invoke('tray_menu_hide') } catch { /* 忽略 */ }
if (action === 'proxy_refresh') refreshing.value = true
@@ -305,6 +316,7 @@ function delayClass(delay: number | null): string {
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault()
resetMenuState()
invoke('tray_menu_hide').catch(() => {})
}
}
@@ -410,9 +422,16 @@ onMounted(async () => {
await applyTheme()
Object.assign(state, event.payload)
osdVisible.value = readOsdVisible()
// 显示前重置上次残留的下拉/焦点状态(双保险)
resetMenuState()
await measureAndShow()
}))
// 菜单失焦(点击其他位置自动隐藏)时重置下拉框与焦点,避免下次打开时残留
unlistenFns.push(await getCurrentWindow().onFocusChanged(({ payload: focused }) => {
if (!focused) resetMenuState()
}))
// 仅更新状态数据,不重新显示窗口。
// 动作完成后的状态更新不应让已隐藏的菜单重新弹出(measureAndShow 会触发 win.show)。
// 菜单显示统一由右键托盘触发的 tray-menu-show 事件负责。
@@ -465,6 +484,7 @@ onUnmounted(() => {
<label class="tray-node-label">节点</label>
<Select
:model-value="state.proxyCurrent ?? ''"
v-model:open="nodeSelectOpen"
:disabled="proxyLoading"
@update:model-value="handleSelectNode"
>