Files
Thing/src/App.vue
T

296 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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
})
/** 上次激活的模块 IDlocalStorage 持久化) */
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>