Files
Thing/src/lib/use-module-tabs.ts
T
2026-08-11 17:19:36 +08:00

117 lines
3.5 KiB
TypeScript
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.
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 中对应的待跳转 tabpendingTab)。
*
* ```vue
* <!-- 模板中给 TabsList 包一层带 ref 的 div -->
* <div ref="tabsListRef">
* <TabsList>...</TabsList>
* </div>
* ```
*
* ## 工作原理
* 1. onMounted 时注册标签到 moduleTabsStoreTitleBar 据此渲染浮动切换器
* 2. 用 IntersectionObserver 监听 TabsList 可见性(rootMargin 裁剪 TitleBar 高度)
* 3. 双向 watch 同步本地 activeTab 与 store.activeTab
* 4. 消费搜索导航的待跳转 tab(模块尚未挂载的场景)
* 5. onUnmounted 时清理 observer 并注销标签
*
* ## 约束
* - TitleBar 高度固定为 40px (h-10)composable 内部已用 44px 裁剪(含缓冲)
* - 一个模块同一时间只能注册一组标签(store 是单例)
* - 模块卸载时务必让 composable 的 onUnmounted 执行(已自动处理,无需手动调用)
*/
export function useModuleTabs(
moduleId: string,
activeTab: Ref<string>,
tabs: ModuleTab[]
): Ref<HTMLElement | null> {
const tabsStore = useModuleTabsStore()
const tabsListRef = ref<HTMLElement | null>(null)
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 表示视口;顶部裁剪 44pxTitleBar 高度 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 () => {
tabsStore.registerTabs(tabs, activeTab.value)
await nextTick()
setupObserver()
// 搜索导航跳转:模块刚挂载,消费待跳转 tab
applyPendingTab()
})
onUnmounted(() => {
if (observer) {
observer.disconnect()
observer = null
}
tabsStore.unregisterTabs()
})
return tabsListRef
}