优化调整

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
+34 -7
View File
@@ -14,8 +14,9 @@ import { useProcessStore } from '@/stores/processStore'
import { TooltipProvider } from '@/components/ui/tooltip'
import { moduleRegistry } from '@/modules/registry'
import type { ModuleMeta } from '@/types/module'
import { pendingNewDownload } from '@/lib/trayEvents'
import { pendingNewDownload, pendingShowDownloadTasks } from '@/lib/trayEvents'
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
import { commands } from '@/lib/bindings'
const appStore = useAppStore()
const screenshotStore = useScreenshotStore()
@@ -43,6 +44,9 @@ const activeModule = ref('')
const activeComponent = shallowRef<Component | null>(null)
/** 模块组件加载中(异步 import 未完成)标志,避免切换期间仍显示上一个模块内容 */
const moduleLoading = ref(false)
/** 当前可显示的模块(内置模块 + 已启用的用户模块),按用户排序显示 */
const availableModules = computed<NavModule[]>(() => {
const enabledIds = appStore.enabledModules.map(m => m.id)
@@ -67,10 +71,15 @@ const availableModules = computed<NavModule[]>(() => {
let moduleLoadSeq = 0
const loadModule = async (moduleId: string) => {
const seq = ++moduleLoadSeq
// 立即清空旧组件并进入加载态,避免异步 import 期间仍渲染上一个模块内容
// (否则 ModuleContainer 以 :key="activeModule" 重挂载旧组件,用户误以为切换失败)
activeComponent.value = null
moduleLoading.value = true
const component = await moduleRegistry.loadComponent(moduleId)
// 过期请求(期间用户又切换了模块)直接丢弃,不覆盖 activeComponent 也不触发钩子
if (seq !== moduleLoadSeq) return
activeComponent.value = component
moduleLoading.value = false
// 调用模块的 onActivate 生命周期钩子
const config = moduleRegistry.getConfig(moduleId)
@@ -151,16 +160,24 @@ onMounted(async () => {
// 快速面板:同步命令缓存与设置到 localStorage,供独立窗口读取
quickpanelStore.syncCommands()
quickpanelStore.syncSettings()
// 初始化文件索引 DB 并恢复增量监听(上次构建过索引时自动恢复,不重建)
commands.quickpanelInitFileIndex().catch(e => console.error('文件索引初始化失败:', e))
// 监听快速面板执行命令事件:显示主窗口 + 切换模块
trayUnlisteners.push(
await listen<{ moduleId: string }>('quickpanel-execute-command', async (e) => {
const win = getCurrentWindow()
// Rust 端强制置前(绕过 Windows 前台锁定,主窗口被遮挡时也能到前台)
try {
await win.show()
await win.unminimize()
await win.setFocus()
await commands.quickpanelFocusMainWindow()
} catch {
/* 忽略窗口操作失败 */
// 回退:前端 show + setFocus
const win = getCurrentWindow()
try {
await win.show()
await win.unminimize()
await win.setFocus()
} catch {
/* 忽略窗口操作失败 */
}
}
handleSearch(e.payload.moduleId)
})
@@ -179,6 +196,16 @@ onMounted(async () => {
}
})
)
// 浏览器扩展新增下载:直接切到下载模块的任务列表页(主窗口已由 Rust 端置前)
trayUnlisteners.push(
await listen(EVENTS.downloadExtensionAdded, () => {
const enabledIds = appStore.enabledModules.map(m => m.id)
if (enabledIds.includes('downloader') || moduleRegistry.getConfig('downloader')?.builtin) {
pendingShowDownloadTasks.value = true
handleModuleChange('downloader')
}
})
)
trayUnlisteners.push(
await listen(EVENTS.trayOpenSettings, () => {
handleModuleChange('settings')
@@ -210,7 +237,7 @@ onUnmounted(() => {
:active-module="activeModule"
@change="handleModuleChange"
/>
<ModuleContainer :active-component="activeComponent" :active-module="activeModule" />
<ModuleContainer :active-component="activeComponent" :active-module="activeModule" :loading="moduleLoading" />
</div>
</div>
<Toaster position="bottom-right" rich-colors close-button :style="{ zIndex: 99999 }" />
+42 -1
View File
@@ -1,15 +1,32 @@
<script setup lang="ts">
import type { Component } from 'vue'
import { ref, watch } from 'vue'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Skeleton } from '@/components/ui/skeleton'
defineProps<{
const props = defineProps<{
activeComponent: Component | null
activeModule: string
loading: boolean
}>()
const containerRef = ref<HTMLElement | null>(null)
// 切换模块时重置主滚动区位置,避免新模块沿用上一个模块的滚动距离
watch(
() => props.activeModule,
() => {
const viewport = containerRef.value?.querySelector<HTMLElement>(
'[data-slot="scroll-area-viewport"]'
)
if (viewport) viewport.scrollTop = 0
}
)
</script>
<template>
<main
ref="containerRef"
class="flex-1"
:style="{ backgroundColor: 'var(--effect-bg)', backdropFilter: 'var(--effect-blur)' }"
>
@@ -22,6 +39,30 @@ defineProps<{
>
<component :is="activeComponent" />
</div>
<div
v-else-if="loading"
key="loading"
class="h-full w-full p-6"
>
<!-- 模块加载骨架撑起画面避免空白闪屏 -->
<div class="h-full max-w-5xl mx-auto space-y-5">
<div class="flex items-center gap-3">
<Skeleton class="h-8 w-40" />
<Skeleton class="h-6 w-24 ml-auto" />
</div>
<div class="grid gap-4 md:grid-cols-2">
<div v-for="n in 4" :key="n" class="rounded-lg border p-5 space-y-4">
<div class="flex items-center justify-between">
<Skeleton class="h-5 w-32" />
<Skeleton class="h-5 w-16" />
</div>
<Skeleton class="h-4 w-full" />
<Skeleton class="h-4 w-5/6" />
<Skeleton class="h-4 w-2/3" />
</div>
</div>
</div>
</div>
<div
v-else
key="empty"
+2 -2
View File
@@ -36,7 +36,7 @@ const displayModules = computed(() => {
'bg-primary text-primary-foreground shadow-md': activeModule === module.id,
'hover:bg-secondary/50': activeModule !== module.id
}"
@click="emit('change', module.id)"
@click="activeModule !== module.id && emit('change', module.id)"
>
<component :is="getModuleIcon(module.icon)" class="size-4" />
</Button>
@@ -60,7 +60,7 @@ const displayModules = computed(() => {
'bg-primary text-primary-foreground shadow-md': activeModule === 'settings',
'hover:bg-secondary/50': activeModule !== 'settings'
}"
@click="emit('change', 'settings')"
@click="activeModule !== 'settings' && emit('change', 'settings')"
>
<Settings class="size-4" />
</Button>
+20
View File
@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="empty"
:class="cn(
'flex min-w-0 flex-1 flex-col items-center justify-center gap-6 text-balance rounded-lg border-dashed p-6 text-center md:p-12',
props.class,
)"
>
<slot />
</div>
</template>
+20
View File
@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="empty-content"
:class="cn(
'flex w-full min-w-0 max-w-sm flex-col items-center gap-4 text-balance text-sm',
props.class,
)"
>
<slot />
</div>
</template>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<p
data-slot="empty-description"
:class="cn(
'text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4',
$attrs.class ?? '',
)"
>
<slot />
</p>
</template>
+20
View File
@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="empty-header"
:class="cn(
'flex max-w-sm flex-col items-center gap-2 text-center',
props.class,
)"
>
<slot />
</div>
</template>
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import type { EmptyMediaVariants } from "."
import { cn } from "@/lib/utils"
import { emptyMediaVariants } from "."
const props = defineProps<{
class?: HTMLAttributes["class"]
variant?: EmptyMediaVariants["variant"]
}>()
</script>
<template>
<div
data-slot="empty-icon"
:data-variant="variant"
:class="cn(emptyMediaVariants({ variant }), props.class)"
>
<slot />
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="empty-title"
:class="cn('text-lg font-medium tracking-tight', props.class)"
>
<slot />
</div>
</template>
+26
View File
@@ -0,0 +1,26 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as Empty } from "./Empty.vue"
export { default as EmptyContent } from "./EmptyContent.vue"
export { default as EmptyDescription } from "./EmptyDescription.vue"
export { default as EmptyHeader } from "./EmptyHeader.vue"
export { default as EmptyMedia } from "./EmptyMedia.vue"
export { default as EmptyTitle } from "./EmptyTitle.vue"
export const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
},
},
defaultVariants: {
variant: "default",
},
},
)
export type EmptyMediaVariants = VariantProps<typeof emptyMediaVariants>
+15
View File
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="skeleton"
:class="cn('animate-pulse rounded-md bg-muted', props.class)"
/>
</template>
+1
View File
@@ -0,0 +1 @@
export { default as Skeleton } from "./Skeleton.vue"
+82 -1
View File
@@ -54,7 +54,11 @@ export const commands = {
quickpanelShowWindow: () => __TAURI_INVOKE<null>("quickpanel_show_window"),
/** 锁定屏幕(Windows: rundll32 user32.dll,LockWorkStationCREATE_NO_WINDOW 避免黑窗) */
quickpanelLockScreen: () => __TAURI_INVOKE<null>("quickpanel_lock_screen"),
/** 初始化文件索引数据库(应用启动时调用) */
/**
* 初始化文件索引数据库(应用启动时调用)。
* 若存在上次构建的索引(last_built_dirs 非空),自动恢复 notify 增量监听,
* 无需重建即可继续自动同步文件变更。
*/
quickpanelInitFileIndex: () => __TAURI_INVOKE<null>("quickpanel_init_file_index"),
/** 构建文件索引(全量重建,阻塞操作建议在 spawn_blocking 调用) */
quickpanelBuildFileIndex: () => __TAURI_INVOKE<number>("quickpanel_build_file_index"),
@@ -96,6 +100,35 @@ export const commands = {
* 适用于内置系统工具:regedit、shutdown、cmd、powershell、taskmgr 等。
*/
quickpanelRunSystemCommand: (command: string, args: string[]) => __TAURI_INVOKE<null>("quickpanel_run_system_command", { command, args }),
/** 列出目录下的压缩包文件(供批量解压面板使用)。 */
quickpanelListArchives: (dir: string) => __TAURI_INVOKE<ArchiveInfo[]>("quickpanel_list_archives", { dir }),
/** 列出目录下的全部条目(供批量重命名/删除面板使用,不含子目录递归)。 */
quickpanelListDir: (dir: string) => __TAURI_INVOKE<FileEntry[]>("quickpanel_list_dir", { dir }),
/**
* 批量解压。`files` 为压缩包路径列表,`dest_dir` 为目标目录,
* `password` 为统一解压密码(可空),`into_subfolder` 是否解压到同名子文件夹。
* 每完成一个文件通过 `quickpanel-extract-progress` 事件推送进度。
*/
quickpanelBatchExtract: (files: string[], destDir: string, password: string | null, intoSubfolder: boolean) => __TAURI_INVOKE<ExtractResult[]>("quickpanel_batch_extract", { files, destDir, password, intoSubfolder }),
/**
* 正则批量重命名预览:对每个文件名应用 `pattern → replacement`
* 仅返回有匹配的文件,`newName` 为替换结果。
*/
quickpanelPreviewRename: (files: string[], pattern: string, replacement: string) => __TAURI_INVOKE<RenamePreview[]>("quickpanel_preview_rename", { files, pattern, replacement }),
/** 执行重命名。同一目录下若目标已存在则跳过该项。 */
quickpanelApplyRename: (items: RenameItem[]) => __TAURI_INVOKE<RenameResult[]>("quickpanel_apply_rename", { items }),
/**
* 批量删除文件/目录。`force=false` 时移动至回收站;`force=true` 时先递归清除
* 只读属性再永久删除(可绕过只读/部分占用导致的删除失败,但被其他进程真正
* 锁定的文件仍会失败并返回原因)。
*/
quickpanelDeleteFiles: (paths: string[], force: boolean) => __TAURI_INVOKE<DeleteResult[]>("quickpanel_delete_files", { paths, force }),
/**
* 显示主窗口并强制置为前台。
* Tauri 的 set_focus 在 Windows 上受前台锁定限制,主窗口被其他应用遮挡时无法到前台;
* 改用原生 SetForegroundWindow + BringWindowToTop(模拟 Alt 键重置前台锁定)。
*/
quickpanelFocusMainWindow: () => __TAURI_INVOKE<null>("quickpanel_focus_main_window"),
clipboardGetHistory: (limit: number | null, offset: number | null, kind: string | null) => __TAURI_INVOKE<HistoryPage>("clipboard_get_history", { limit, offset, kind }),
clipboardGetPinned: () => __TAURI_INVOKE<ClipboardItem[]>("clipboard_get_pinned"),
clipboardSearch: (query: string, limit: number | null, offset: number | null) => __TAURI_INVOKE<HistoryPage>("clipboard_search", { query, limit, offset }),
@@ -206,6 +239,12 @@ export type AppRecord = {
path: string,
};
export type ArchiveInfo = {
name: string,
path: string,
size: number,
};
/** 前端可见的捕获数据 */
export type CaptureData = {
pngBase64: string,
@@ -280,6 +319,14 @@ export type CustomCommand = {
args?: string[],
};
/** 批量删除单个条目的结果。 */
export type DeleteResult = {
name: string,
path: string,
ok: boolean,
error: string,
};
/** 下载任务 */
export type DownloadTask = {
/** 任务 ID(自增 hex 字符串) */
@@ -350,6 +397,20 @@ export type ExistingTaskInfo = {
status: TaskStatus,
};
export type ExtractResult = {
name: string,
path: string,
ok: boolean,
error: string,
};
export type FileEntry = {
name: string,
path: string,
isDir: boolean,
size: number,
};
/** 单个文件记录(返回给前端) */
export type FileRecord = {
path: string,
@@ -449,6 +510,26 @@ export type QuickPanelSettings = {
customCommands?: CustomCommand[],
};
export type RenameItem = {
path: string,
oldName: string,
newName: string,
};
export type RenamePreview = {
path: string,
oldName: string,
newName: string,
error: string,
};
export type RenameResult = {
oldName: string,
newName: string,
ok: boolean,
error: string,
};
export type ScreenRect = {
x: number,
y: number,
+11
View File
@@ -28,6 +28,7 @@ export const EVENTS = {
quickpanelShow: 'quickpanel-show',
quickpanelHide: 'quickpanel-hide',
quickpanelExecuteCommand: 'quickpanel-execute-command',
quickpanelExtractProgress: 'quickpanel-extract-progress',
// 截图
screenshotBegin: 'screenshot-begin',
screenshotOverlayReady: 'screenshot-overlay-ready',
@@ -56,6 +57,8 @@ export const EVENTS = {
// 其他
processStatusChanged: 'process-status-changed',
downloadAdded: 'download-added',
/** 浏览器扩展通过 HTTP API 新增下载(置前主窗口并跳到下载画面) */
downloadExtensionAdded: 'download-extension-added',
} as const
/** localStorage 存储键 */
@@ -66,6 +69,14 @@ export const STORAGE_KEYS = {
quickpanelSettings: 'thing_quickpanel_settings',
quickpanelHistory: 'thing_quickpanel_history',
quickpanelHistoryItems: 'thing_quickpanel_history_items',
quickpanelPwdHistory: 'thing_quickpanel_pwd_history',
quickpanelPwdFavs: 'thing_quickpanel_pwd_favs',
quickpanelRenameMatchHistory: 'thing_quickpanel_rename_match_history',
quickpanelRenameMatchFavs: 'thing_quickpanel_rename_match_favs',
quickpanelRenameReplaceHistory: 'thing_quickpanel_rename_replace_history',
quickpanelRenameReplaceFavs: 'thing_quickpanel_rename_replace_favs',
quickpanelDeleteFilterHistory: 'thing_quickpanel_delete_filter_history',
quickpanelDeleteFilterFavs: 'thing_quickpanel_delete_filter_favs',
currencyRates: 'thing_quickpanel_currency_rates',
monitorOsdConfig: 'thing_monitor_osd_config',
screenshotHistory: 'thing_screenshot_history',
+3
View File
@@ -10,3 +10,6 @@ import { ref } from 'vue'
/** 待打开新建下载对话框(由托盘"新建下载"触发) */
export const pendingNewDownload = ref(false)
/** 待切换到下载任务列表页(由浏览器扩展新增下载触发,不弹对话框直接看任务) */
export const pendingShowDownloadTasks = ref(false)
+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"
>
+3
View File
@@ -43,6 +43,8 @@ export const useClipboardStore = defineStore('clipboard', () => {
const settings = ref<ClipboardSettings>({ ...DEFAULT_SETTINGS })
const status = ref<ClipboardStatus>({ running: false, count: 0 })
const loading = ref(false)
/** 是否已完成首次加载:再次进入模块时 store 已有缓存,可即时渲染而非再弹骨架屏 */
const initialized = ref(false)
// 事件监听(应用级单例,只注册一次)
let changedUnlisten: UnlistenFn | null = null
@@ -238,6 +240,7 @@ export const useClipboardStore = defineStore('clipboard', () => {
settings,
status,
loading,
initialized,
init,
dispose,
fetchHistoryPage,
+17 -3
View File
@@ -74,11 +74,21 @@ export const useDownloaderStore = defineStore('downloader', () => {
try {
// Rust 端序列化保证字段完整,断言为 Required 收窄后的类型
const fresh = (await commands.downloaderGetTasks()) as DownloadTask[]
// merge 化:保留本地仍在更新的任务对象(进度事件可能刚修改过它),
// 避免整体替换导致进行中任务的实时进度/速度被快照回退
// merge 化:进行中任务保留本地实时进度,避免快照回退;
// 暂停/终态(paused/complete/error)时 Rust 端已同步完整进度(完成时
// completed_size 已对齐 total_size),整体替换为服务端快照,
// 避免本地旧进度覆盖导致"已完成但停在 99%"的状态不一致
const merged = fresh.map(freshTask => {
if (
freshTask.status === 'paused' ||
freshTask.status === 'complete' ||
freshTask.status === 'error'
) {
return freshTask
}
const local = tasks.value.find(t => t.id === freshTask.id)
return local ?? freshTask
if (!local) return freshTask
return { ...local, status: freshTask.status, error: freshTask.error }
})
tasks.value = merged
} catch (e) {
@@ -91,6 +101,10 @@ export const useDownloaderStore = defineStore('downloader', () => {
const updateTaskProgress = (payload: ProgressPayload) => {
const task = tasks.value.find((t) => t.id === payload.id)
if (task) {
// 终态任务忽略迟到的进度事件(下载完成后 in-flight 事件可能把状态/进度回退)
if (task.status === 'complete' || task.status === 'error') return
// 已暂停任务忽略仍携带 active 的迟到事件(暂停瞬间发出的旧事件)
if (task.status === 'paused' && payload.status === 'active') return
task.completedSize = payload.completedSize
task.totalSize = payload.totalSize
task.speed = payload.speed
+5 -9
View File
@@ -980,18 +980,14 @@ export const useMonitorStore = defineStore('monitor', () => {
// 注册 OSD 窗口事件监听
setupOsdEventListeners().catch(e => logger.error('[OSD] 事件监听注册失败: ' + e))
// 监听托盘菜单"切换 OSD"事件(应用级常驻,不随模块挂载/卸载变化)
// 监听托盘菜单"切换 OSD"事件(应用级常驻,不随模块挂载/卸载变化)
// 只修改 overlayEnabled,窗口显示/隐藏统一由下方 store watch 处理,
// 与主界面 OSD 开关(updateOsdConfig)走完全相同的路径,避免双 hide 竞态。
listen(EVENTS.trayToggleOsd, () => {
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 => logger.error('[OSD] 托盘开启悬浮窗失败: ' + e))
}
} else {
hideOverlayWindow().catch(e => logger.error('[OSD] 托盘关闭悬浮窗失败: ' + e))
if (osdConfig.value.overlayEnabled && osdConfig.value.overlayItems.length === 0) {
toast.warning('OSD 显示项为空,已开启但未创建窗口')
}
}).then(unlisten => { osdEventUnlisteners.push(unlisten) })
.catch(e => logger.error('[OSD] 注册 tray:toggle-osd 监听失败: ' + e))