296 lines
12 KiB
Vue
296 lines
12 KiB
Vue
<script setup lang="ts">
|
||
import { ref, onMounted, onUnmounted, shallowRef, computed, watch, type Component } from 'vue'
|
||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||
import TitleBar from '@/components/layout/TitleBar.vue'
|
||
import Sidebar from '@/components/layout/Sidebar.vue'
|
||
import ModuleContainer from '@/components/layout/ModuleContainer.vue'
|
||
import { Toaster } from '@/components/ui/sonner'
|
||
import { useAppStore } from '@/stores/appStore'
|
||
import { useScreenshotStore } from '@/stores/screenshotStore'
|
||
import { useQuickPanelStore } from '@/stores/quickpanelStore'
|
||
import { useMonitorStore } from '@/stores/monitorStore'
|
||
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 { EVENTS, STORAGE_KEYS, WINDOWS } from '@/lib/constants'
|
||
import { commands } from '@/lib/bindings'
|
||
|
||
const appStore = useAppStore()
|
||
const screenshotStore = useScreenshotStore()
|
||
const quickpanelStore = useQuickPanelStore()
|
||
const monitorStore = useMonitorStore()
|
||
const processStore = useProcessStore()
|
||
|
||
/** 侧边栏 / 标题栏需要的模块信息(id + name + icon) */
|
||
interface NavModule {
|
||
id: string
|
||
name: string
|
||
icon: string
|
||
}
|
||
|
||
/** 从注册表元信息转换为导航用的精简结构 */
|
||
const toNavModule = (meta: ModuleMeta): NavModule => ({
|
||
id: meta.id,
|
||
name: meta.name,
|
||
icon: meta.icon
|
||
})
|
||
|
||
/** 上次激活的模块 ID(localStorage 持久化) */
|
||
const LAST_MODULE_KEY = STORAGE_KEYS.lastModule
|
||
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)
|
||
const allModules = moduleRegistry
|
||
.getAllMetas()
|
||
.filter(m => m.builtin || enabledIds.includes(m.id))
|
||
.map(toNavModule)
|
||
|
||
// 按 moduleOrder 排序,settings 始终在末尾
|
||
return allModules.sort((a, b) => {
|
||
if (a.id === 'settings') return 1
|
||
if (b.id === 'settings') return -1
|
||
const aIdx = appStore.moduleOrder.indexOf(a.id)
|
||
const bIdx = appStore.moduleOrder.indexOf(b.id)
|
||
if (aIdx === -1) return 1
|
||
if (bIdx === -1) return -1
|
||
return aIdx - bIdx
|
||
})
|
||
})
|
||
|
||
/** 模块加载请求序号:快速切换模块时丢弃过期加载结果,避免旧组件覆盖新组件 */
|
||
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)
|
||
config?.lifecycle?.onActivate?.()
|
||
}
|
||
|
||
const handleModuleChange = (moduleId: string) => {
|
||
// 同模块不重新加载(保留组件状态);搜索/托盘跳转到当前模块时仅触发 tab 导航
|
||
if (activeModule.value === moduleId) return
|
||
|
||
// 调用上一个模块的 onDeactivate 钩子
|
||
const prevConfig = moduleRegistry.getConfig(activeModule.value)
|
||
prevConfig?.lifecycle?.onDeactivate?.()
|
||
|
||
activeModule.value = moduleId
|
||
localStorage.setItem(LAST_MODULE_KEY, moduleId)
|
||
loadModule(moduleId)
|
||
}
|
||
|
||
// 搜索跳转与普通切换同路径:补齐 onDeactivate 钩子,避免旧模块资源泄漏
|
||
const handleSearch = (moduleId: string) => {
|
||
handleModuleChange(moduleId)
|
||
}
|
||
|
||
// 为单个下载任务创建专属的一次性下载窗口(浏览器扩展发起)。
|
||
// label 带 task id 保证同时多个下载时各占一个窗口;对应 capabilities/download-window.json 的 glob "download-window-*"
|
||
async function openDownloadWindow(taskId: string) {
|
||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||
const { currentMonitor } = await import('@tauri-apps/api/window')
|
||
const label = `${WINDOWS.downloadWindow}-${taskId}`
|
||
try {
|
||
const existing = await WebviewWindow.getByLabel(label)
|
||
if (existing) {
|
||
// 已存在:用 Rust 端强制置前(绕过前台锁定,双屏/后台创建也能到前台)
|
||
await commands.downloaderFocusWindow(label)
|
||
return
|
||
}
|
||
// 定位到主窗口当前所在显示器的中央偏上
|
||
const monitor = await currentMonitor()
|
||
const scale = monitor?.scaleFactor ?? 1
|
||
const w = 420
|
||
const h = 176
|
||
const x = Math.round(((monitor?.size.width ?? 1920) / scale - w) / 2)
|
||
const y = Math.round(((monitor?.size.height ?? 1080) / scale - h) / 2 * 0.8)
|
||
const win = new WebviewWindow(label, {
|
||
url: `index.html#download-window?task=${encodeURIComponent(taskId)}`,
|
||
title: '下载',
|
||
width: w,
|
||
height: h,
|
||
x,
|
||
y,
|
||
decorations: false,
|
||
transparent: true,
|
||
resizable: false,
|
||
maximizable: false,
|
||
minimizable: true,
|
||
shadow: true,
|
||
visible: false,
|
||
focus: false,
|
||
// 默认不置顶、放入任务栏(可最小化,任务栏图标唤出);下载完成时窗口置前提醒。
|
||
// 隐藏创建:由 DownloadWindow 贴合内容高度后一次性 show,避免显示后再 resize 闪烁
|
||
})
|
||
win.once('tauri://error', (e) => console.error('创建下载窗口失败:', e))
|
||
// 窗口改为隐藏创建:由 DownloadWindow 在 onMounted 贴合内容高度后一次性 show,
|
||
// 避免"先以 176 高度显示、再 resize 到内容高度"造成的闪烁。
|
||
// 此处仅保留异常兜底:WebView 加载异常导致 DownloadWindow 未 reveal 时,强制显示。
|
||
win.once('tauri://created', () => {
|
||
window.setTimeout(() => { void commands.downloaderFocusWindow(label) }, 2500)
|
||
})
|
||
} catch (e) {
|
||
console.error('创建下载窗口失败:', e)
|
||
}
|
||
}
|
||
|
||
const getFallbackModule = () => {
|
||
const enabledIds = appStore.enabledModules.map(m => m.id)
|
||
const fallback = moduleRegistry.getAllMetas().find(
|
||
m => !m.builtin && enabledIds.includes(m.id)
|
||
)
|
||
return fallback?.id || 'settings'
|
||
}
|
||
|
||
// 按 id 列表监听(而非 length):同时禁用一个 + 启用另一个时 length 不变,会漏检回退
|
||
watch(() => appStore.enabledModules.map(m => m.id).join(','), () => {
|
||
// 启动期间 activeModule 尚未确定,跳过
|
||
if (!activeModule.value) return
|
||
const enabledIds = appStore.enabledModules.map(m => m.id)
|
||
if (activeModule.value !== 'settings' && !enabledIds.includes(activeModule.value)) {
|
||
const fallback = getFallbackModule()
|
||
handleModuleChange(fallback)
|
||
}
|
||
// 模块启用/禁用变化时重新同步快速面板命令缓存
|
||
quickpanelStore.syncCommands()
|
||
})
|
||
|
||
/** 计算启动时应打开的默认模块:优先上次记忆,其次排序第一个 */
|
||
const resolveDefaultModule = (): string => {
|
||
const enabledIds = appStore.enabledModules.map(m => m.id)
|
||
const allMetas = moduleRegistry.getAllMetas()
|
||
|
||
// 尝试上次记忆的模块
|
||
const lastModule = localStorage.getItem(LAST_MODULE_KEY)
|
||
if (lastModule) {
|
||
const meta = allMetas.find(m => m.id === lastModule)
|
||
if (meta && (meta.builtin || enabledIds.includes(lastModule))) {
|
||
return lastModule
|
||
}
|
||
}
|
||
|
||
// 回退到排序第一个可用模块(settings 除外)
|
||
const first = availableModules.value.find(m => m.id !== 'settings')
|
||
return first?.id || 'settings'
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await appStore.init().catch(e => console.error('App init error:', e))
|
||
|
||
// 初始化监控 store:订阅后端 monitor-data / monitor-network 等事件,
|
||
// 使 OSD 窗口在应用启动后即可接收数据流,不依赖用户手动打开监控模块。
|
||
// init() 幂等:MonitorModule 挂载时再次调用不会重复订阅。
|
||
monitorStore.init()
|
||
// 显式初始化 OSD
|
||
// 使 OSD 窗口在应用启动时创建(若配置已开启),且不依赖监控模块挂载/卸载
|
||
monitorStore.initOsd()
|
||
|
||
const defaultModule = resolveDefaultModule()
|
||
activeModule.value = defaultModule
|
||
loadModule(defaultModule)
|
||
|
||
// 快速面板:同步命令缓存与设置到 localStorage,供独立窗口读取
|
||
quickpanelStore.syncCommands()
|
||
quickpanelStore.syncSettings()
|
||
// 初始化文件索引 DB 并恢复增量监听(上次构建过索引时自动恢复,不重建)
|
||
commands.quickpanelInitFileIndex().catch(e => console.error('文件索引初始化失败:', e))
|
||
// 监听快速面板执行命令事件:显示主窗口 + 切换模块
|
||
trayUnlisteners.push(
|
||
await listen<{ moduleId: string }>('quickpanel-execute-command', async (e) => {
|
||
// Rust 端强制置前(绕过 Windows 前台锁定,主窗口被遮挡时也能到前台)
|
||
try {
|
||
await commands.quickpanelFocusMainWindow()
|
||
} catch {
|
||
// 回退:前端 show + setFocus
|
||
const win = getCurrentWindow()
|
||
try {
|
||
await win.show()
|
||
await win.unminimize()
|
||
await win.setFocus()
|
||
} catch {
|
||
/* 忽略窗口操作失败 */
|
||
}
|
||
}
|
||
handleSearch(e.payload.moduleId)
|
||
})
|
||
)
|
||
|
||
// 监听托盘菜单事件
|
||
// tray:toggle-osd 由 monitorStore.initOsd() 注册的监听处理(与监控模块生命周期解耦)
|
||
trayUnlisteners.push(
|
||
await listen(EVENTS.trayNewDownload, () => {
|
||
// 设置标志位,DownloaderModule 挂载后消费
|
||
pendingNewDownload.value = true
|
||
// 切换到下载模块(如果未启用则切换到设置)
|
||
const enabledIds = appStore.enabledModules.map(m => m.id)
|
||
if (enabledIds.includes('downloader') || moduleRegistry.getConfig('downloader')?.builtin) {
|
||
handleModuleChange('downloader')
|
||
}
|
||
})
|
||
)
|
||
// 浏览器扩展新增下载:为该任务创建一个专属的一次性下载窗口(不打断主界面)。
|
||
// 主窗口本身无需置前,下载进度/完成事件由独立窗口自行监听。
|
||
trayUnlisteners.push(
|
||
await listen<{ id: string }>(EVENTS.downloadExtensionAdded, (e) => {
|
||
if (!e.payload?.id) return
|
||
void openDownloadWindow(e.payload.id)
|
||
})
|
||
)
|
||
trayUnlisteners.push(
|
||
await listen(EVENTS.trayOpenSettings, () => {
|
||
handleModuleChange('settings')
|
||
})
|
||
)
|
||
// 截图导出监听(应用级常驻,确保任何来源的截图都记录到历史)
|
||
screenshotStore.initExportListener().catch(e => console.error('[screenshot] 导出监听初始化失败:', e))
|
||
})
|
||
|
||
const trayUnlisteners: UnlistenFn[] = []
|
||
|
||
onUnmounted(() => {
|
||
trayUnlisteners.forEach(fn => fn())
|
||
screenshotStore.destroyExportListener()
|
||
// 释放 OSD 事件监听与配置 watcher
|
||
monitorStore.disposeOsd()
|
||
// 释放进程状态事件监听
|
||
processStore.destroyListener()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<TooltipProvider>
|
||
<div class="flex flex-col h-screen w-screen overflow-hidden">
|
||
<TitleBar :modules="availableModules" @search="handleSearch" />
|
||
<div class="flex-1 flex overflow-hidden">
|
||
<Sidebar
|
||
:modules="availableModules"
|
||
:active-module="activeModule"
|
||
@change="handleModuleChange"
|
||
/>
|
||
<ModuleContainer :active-component="activeComponent" :active-module="activeModule" :loading="moduleLoading" />
|
||
</div>
|
||
</div>
|
||
<Toaster position="bottom-right" rich-colors close-button :style="{ zIndex: 99999 }" />
|
||
</TooltipProvider>
|
||
</template>
|