优化调整
This commit is contained in:
+34
-7
@@ -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 }" />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Skeleton } from "./Skeleton.vue"
|
||||
+82
-1
@@ -54,7 +54,11 @@ export const commands = {
|
||||
quickpanelShowWindow: () => __TAURI_INVOKE<null>("quickpanel_show_window"),
|
||||
/** 锁定屏幕(Windows: rundll32 user32.dll,LockWorkStation,CREATE_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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -10,3 +10,6 @@ import { ref } from 'vue'
|
||||
|
||||
/** 待打开新建下载对话框(由托盘"新建下载"触发) */
|
||||
export const pendingNewDownload = ref(false)
|
||||
|
||||
/** 待切换到下载任务列表页(由浏览器扩展新增下载触发,不弹对话框直接看任务) */
|
||||
export const pendingShowDownloadTasks = ref(false)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -1102,8 +1102,9 @@ onUnmounted(() => {
|
||||
// 不 dispose store:SSE 订阅保持,确保切走监控模块后 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
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user