148 lines
5.4 KiB
TypeScript
148 lines
5.4 KiB
TypeScript
import { nextTick, onMounted, onUnmounted, ref, watch, type Ref } from 'vue'
|
||
import { useModuleTabsStore, type ModuleTab } from '@/stores/moduleTabsStore'
|
||
|
||
/**
|
||
* 模块标签栏通用 composable。
|
||
*
|
||
* 让任何模块的 TabsList 在滚动被 TitleBar 遮挡时,
|
||
* 自动在 TitleBar 中显示一组浮动切换按钮。
|
||
*
|
||
* ## 使用方式
|
||
*
|
||
* ```ts
|
||
* // 模块 <script setup> 顶部
|
||
* const activeTab = ref('overview')
|
||
* const tabsListRef = useModuleTabs('proxy', activeTab, [
|
||
* { value: 'overview', label: '概览' },
|
||
* { value: 'settings', label: '设置' }
|
||
* ])
|
||
* ```
|
||
*
|
||
* 第一个参数为模块 id:搜索导航跳转时,模块挂载后会自动
|
||
* 消费 moduleTabsStore 中对应的待跳转 tab(pendingTab)。
|
||
*
|
||
* ```vue
|
||
* <!-- 模板中给 TabsList 包一层带 ref 的 div -->
|
||
* <div ref="tabsListRef">
|
||
* <TabsList>...</TabsList>
|
||
* </div>
|
||
* ```
|
||
*
|
||
* ## 工作原理
|
||
* 1. **setup 阶段(首帧之前)** 定下要显示的 tab:搜索跳转 > 上次停留 > 声明时的初始值
|
||
* —— 模块切回来是重新挂载,等到 onMounted 再改就会先渲染一帧初始页(闪一下)
|
||
* 2. onMounted 时注册标签到 moduleTabsStore,TitleBar 据此渲染浮动切换器
|
||
* 3. 用 IntersectionObserver 监听 TabsList 可见性(rootMargin 裁剪 TitleBar 高度)
|
||
* 4. 双向 watch 同步本地 activeTab 与 store.activeTab
|
||
* 5. 消费搜索导航的待跳转 tab(模块尚未挂载的场景)
|
||
* 6. onUnmounted 时清理 observer 并把当前 tab 记到 store(按模块)后注销标签
|
||
*
|
||
* ## 约束
|
||
* - TitleBar 高度固定为 40px (h-10),composable 内部已用 44px 裁剪(含缓冲)
|
||
* - 一个模块同一时间只能注册一组标签(store 是单例)
|
||
* - 模块卸载时务必让 composable 的 onUnmounted 执行(已自动处理,无需手动调用)
|
||
* - tab 记忆只保内存、不落盘:tab 集合可能随版本变化,跨重启恢复旧值风险更大
|
||
*/
|
||
export function useModuleTabs(
|
||
moduleId: string,
|
||
activeTab: Ref<string>,
|
||
tabs: ModuleTab[],
|
||
/** alwaysVisible: 模块自身不渲染 TabsList,标题栏切换器常驻(内容最大化模式) */
|
||
options: { alwaysVisible?: boolean } = {}
|
||
): Ref<HTMLElement | null> {
|
||
const tabsStore = useModuleTabsStore()
|
||
const tabsListRef = ref<HTMLElement | null>(null)
|
||
|
||
/**
|
||
* 首次渲染前就定下要显示的 tab:搜索跳转 > 上次停留 > 声明时的初始值。
|
||
*
|
||
* 必须在 setup 里做。模块切走时组件被卸载、`activeTab` 随之丢失,切回来重新挂载
|
||
* 时它是声明时的初始值(如 proxy 的 `'overview'`);若等到 onMounted 才恢复,
|
||
* 首帧已经按初始值渲染过一次 —— 表现为「先闪一下第一页,再跳到上次停留页」,
|
||
* 而不是一次干净的进入动画。
|
||
*
|
||
* 这里只**读** store(rememberedTab / peekPendingTab 都不改状态):注册仍留在
|
||
* onMounted,避免从未挂载的组件污染 store 的当前模块。
|
||
*/
|
||
const pendingTab = tabsStore.peekPendingTab(moduleId)
|
||
if (pendingTab && tabs.some(t => t.value === pendingTab)) {
|
||
activeTab.value = pendingTab
|
||
} else {
|
||
const restored = tabsStore.rememberedTab(moduleId, tabs, activeTab.value)
|
||
if (restored !== activeTab.value) activeTab.value = restored
|
||
}
|
||
|
||
let observer: IntersectionObserver | null = null
|
||
|
||
/** 应用待跳转 tab(若属于当前模块的 tab 列表) */
|
||
const applyPendingTab = () => {
|
||
const pending = tabsStore.consumePendingTab(moduleId)
|
||
if (pending && tabs.some(t => t.value === pending)) {
|
||
activeTab.value = pending
|
||
tabsStore.setActiveTab(pending)
|
||
}
|
||
}
|
||
|
||
const setupObserver = () => {
|
||
const el = tabsListRef.value
|
||
if (!el || observer) return
|
||
observer = new IntersectionObserver(
|
||
(entries) => {
|
||
for (const entry of entries) {
|
||
tabsStore.setFloatingVisible(!entry.isIntersecting)
|
||
}
|
||
},
|
||
{
|
||
// root=null 表示视口;顶部裁剪 44px(TitleBar 高度 40px + 4px 缓冲)
|
||
rootMargin: '-44px 0px 0px 0px',
|
||
threshold: 0
|
||
}
|
||
)
|
||
observer.observe(el)
|
||
}
|
||
|
||
// 本地 activeTab → store(用户点击模块内 TabsTrigger 时同步)
|
||
watch(activeTab, (val) => {
|
||
tabsStore.setActiveTab(val)
|
||
})
|
||
|
||
// store activeTab → 本地(用户点击 TitleBar 浮动切换器时同步)
|
||
watch(() => tabsStore.activeTab, (val) => {
|
||
if (val && val !== activeTab.value) {
|
||
activeTab.value = val
|
||
}
|
||
})
|
||
|
||
// 模块已挂载时(搜索结果选中同一模块),pendingTab 变化 → 直接切换 tab
|
||
watch(() => tabsStore.pendingTab, (p) => {
|
||
if (p?.moduleId === moduleId) {
|
||
applyPendingTab()
|
||
}
|
||
})
|
||
|
||
onMounted(async () => {
|
||
// 注册时带上模块 id:store 据此复用「上次停留的 tab」(模块卸载时保存)
|
||
const restored = tabsStore.registerTabs(moduleId, tabs, activeTab.value)
|
||
if (restored !== activeTab.value) activeTab.value = restored
|
||
await nextTick()
|
||
// 常驻模式:模块不渲染 TabsList,直接让标题栏切换器显示
|
||
if (options.alwaysVisible) {
|
||
tabsStore.setFloatingVisible(true)
|
||
} else {
|
||
setupObserver()
|
||
}
|
||
// 搜索导航跳转:模块刚挂载,消费待跳转 tab(优先级高于上次停留)
|
||
applyPendingTab()
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
if (observer) {
|
||
observer.disconnect()
|
||
observer = null
|
||
}
|
||
tabsStore.unregisterTabs(moduleId)
|
||
})
|
||
|
||
return tabsListRef
|
||
}
|