动态Tab组件

This commit is contained in:
jxxghp
2025-07-05 11:06:46 +08:00
parent 09942ec946
commit da0756adf0
5 changed files with 351 additions and 98 deletions

View File

@@ -0,0 +1,98 @@
// 动态标签页相关类型
interface DynamicHeaderTabButton {
icon: string
color?: string | ComputedRef<string>
variant?: 'flat' | 'text' | 'elevated' | 'tonal' | 'outlined' | 'plain'
size?: string
class?: string
action?: () => void
show?: boolean | ComputedRef<boolean>
dataAttr?: string // 用于VMenu定位的data属性
}
interface DynamicHeaderTabItem {
title: string
icon?: string
tab: string
}
interface DynamicHeaderTabConfig {
items: DynamicHeaderTabItem[]
modelValue: string
appendButtons?: DynamicHeaderTabButton[]
routePath?: string
onUpdateModelValue?: (value: string) => void
}
export function useDynamicHeaderTab() {
const route = useRoute()
// 尝试从inject获取
const registerDynamicHeaderTab = inject<(tab: DynamicHeaderTabConfig) => void>('registerDynamicHeaderTab')
const unregisterDynamicHeaderTab = inject<() => void>('unregisterDynamicHeaderTab')
// 注册动态标签页
const registerHeaderTab = (config: {
items: DynamicHeaderTabItem[]
modelValue: Ref<string>
appendButtons?: DynamicHeaderTabButton[]
}) => {
const tabConfig: DynamicHeaderTabConfig = {
items: config.items,
modelValue: config.modelValue.value,
appendButtons: config.appendButtons,
routePath: route.path,
onUpdateModelValue: (value: string) => {
config.modelValue.value = value
},
}
// 监听modelValue变化并更新配置
watch(config.modelValue, newValue => {
tabConfig.modelValue = newValue
// 重新注册以更新值
if (registerDynamicHeaderTab) {
registerDynamicHeaderTab(tabConfig)
} else if (typeof window !== 'undefined') {
// 使用全局方法作为备用
const globalRegister = (window as any).__VUE_INJECT_DYNAMIC_HEADER_TAB__
if (globalRegister) {
globalRegister(tabConfig)
}
}
})
// 在组件卸载时取消注册
onUnmounted(() => {
if (unregisterDynamicHeaderTab) {
unregisterDynamicHeaderTab()
}
})
// 初始注册
if (registerDynamicHeaderTab) {
registerDynamicHeaderTab(tabConfig)
} else if (typeof window !== 'undefined') {
// 使用全局方法作为备用
const globalRegister = (window as any).__VUE_INJECT_DYNAMIC_HEADER_TAB__
if (globalRegister) {
globalRegister(tabConfig)
}
}
}
// 取消注册
const unregisterHeaderTab = () => {
if (unregisterDynamicHeaderTab) {
unregisterDynamicHeaderTab()
}
}
return {
registerHeaderTab,
unregisterHeaderTab,
}
}
// 导出类型以供其他地方使用
export type { DynamicHeaderTabButton, DynamicHeaderTabItem, DynamicHeaderTabConfig }

View File

@@ -8,6 +8,7 @@ import SearchBar from '@/layouts/components/SearchBar.vue'
import ShortcutBar from '@/layouts/components/ShortcutBar.vue'
import UserProfile from '@/layouts/components/UserProfile.vue'
import QuickAccess from '@/layouts/components/QuickAccess.vue'
import HeaderTab from '@/layouts/components/HeaderTab.vue'
import { useUserStore } from '@/stores'
import { getNavMenus } from '@/router/i18n-menu'
import { NavMenu } from '@/@layouts/types'
@@ -64,6 +65,92 @@ const showPluginQuickAccess = ref(false)
// 离线状态管理
const { setAppOffline, isOffline } = useGlobalOfflineStatus()
// 动态标签页相关
// 定义动态标签页类型
interface DynamicHeaderTab {
items: Array<{ title: string; icon: string; tab: string }>
modelValue: string
appendButtons?: Array<{
icon: string
color?: string | ComputedRef<string>
variant?: 'flat' | 'text' | 'elevated' | 'tonal' | 'outlined' | 'plain'
size?: string
class?: string
action?: () => void
show?: boolean | ComputedRef<boolean>
dataAttr?: string
}>
routePath?: string // 用于标识哪个路由注册的
onUpdateModelValue?: (value: string) => void // 用于通知值更新
}
// 提供动态标签页注册和获取的方法
const dynamicHeaderTab = ref<DynamicHeaderTab | null>(null)
// 提供一个方法让其他组件注册动态标签页
const registerDynamicHeaderTab = (tab: DynamicHeaderTab) => {
// 保存注册标签页的路由路径
tab.routePath = route.path
dynamicHeaderTab.value = tab
}
// 提供一个方法让其他组件取消注册动态标签页
const unregisterDynamicHeaderTab = () => {
dynamicHeaderTab.value = null
}
// 标签页值更新处理
const handleTabChange = (newValue: string) => {
if (dynamicHeaderTab.value) {
dynamicHeaderTab.value.modelValue = newValue
// 通知注册的页面更新值
if (dynamicHeaderTab.value.onUpdateModelValue) {
dynamicHeaderTab.value.onUpdateModelValue(newValue)
}
}
}
// 添加全局注册方法,解决注入不可用的问题
if (typeof window !== 'undefined') {
// 确保在浏览器环境中
;(window as any).__VUE_INJECT_DYNAMIC_HEADER_TAB__ = registerDynamicHeaderTab
}
// 提供给其他组件使用
provide('registerDynamicHeaderTab', registerDynamicHeaderTab)
provide('unregisterDynamicHeaderTab', unregisterDynamicHeaderTab)
// 监听路由变化来清除动态标签页
watch(
() => route.path,
newPath => {
// 当路由变化时,清除动态标签页(如果不是同一个路由注册的)
if (dynamicHeaderTab.value && dynamicHeaderTab.value.routePath !== newPath) {
dynamicHeaderTab.value = null
}
},
{ immediate: false },
)
// 显示动态标签页
const showDynamicHeaderTab = computed(() => {
return (
dynamicHeaderTab.value &&
dynamicHeaderTab.value.items.length > 0 &&
// 确保只在注册的路由路径下显示标签页
(!dynamicHeaderTab.value.routePath || dynamicHeaderTab.value.routePath === route.path)
)
})
// 在组件销毁时清理
onUnmounted(() => {
dynamicHeaderTab.value = null
// 清理全局方法
if (typeof window !== 'undefined') {
delete (window as any).__VUE_INJECT_DYNAMIC_HEADER_TAB__
}
})
// 监听Service Worker消息
const handleServiceWorkerMessage = (event: MessageEvent) => {
if (event.data && event.data.type === 'OFFLINE_STATUS') {
@@ -270,6 +357,30 @@ onMounted(() => {
transition: contentTransition,
}"
>
<!-- 👉 Dynamic Header Tab -->
<div v-if="showDynamicHeaderTab" class="dynamic-header-tab-container">
<HeaderTab
:items="dynamicHeaderTab!.items"
:model-value="dynamicHeaderTab!.modelValue"
@update:model-value="handleTabChange"
>
<template #append>
<template v-for="button in dynamicHeaderTab!.appendButtons" :key="button.icon">
<VBtn
v-if="typeof button.show === 'boolean' ? button.show !== false : (button.show as any)?.value !== false"
:icon="button.icon"
:variant="button.variant || 'text'"
:color="typeof button.color === 'string' ? button.color : (button.color as any)?.value || 'gray'"
:size="button.size || 'default'"
:class="button.class || 'settings-icon-button'"
:data-menu-activator="button.dataAttr"
@click="button.action"
/>
</template>
</template>
</HeaderTab>
</div>
<slot />
</div>

View File

@@ -117,14 +117,11 @@ onUnmounted(() => {
</template>
<style scoped lang="scss">
.tab-header {
position: sticky;
position: relative;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
backdrop-filter: blur(10px);
border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.05);
inset-block-start: 0;
margin-block-end: 16px;
padding-block: 8px;
padding-inline: 16px;

View File

@@ -119,6 +119,9 @@ async function saveTabOrder() {
}
}
// 使用动态标签页
const { registerHeaderTab } = useDynamicHeaderTab()
onBeforeMount(async () => {
initDiscoverTabs()
await loadOrderConfig()
@@ -130,28 +133,38 @@ onBeforeMount(async () => {
}
})
onMounted(() => {
// 注册动态标签页
registerHeaderTab({
items: discoverTabItems.value,
modelValue: activeTab,
appendButtons: [
{
icon: 'mdi-order-alphabetical-ascending',
variant: 'text',
color: 'grey',
class: 'settings-icon-button',
action: () => {
orderConfigDialog.value = true
},
},
],
})
})
onActivated(async () => {
await loadExtraDiscoverSources()
sortSubscribeOrder()
})
function useDynamicHeaderTab(): { registerHeaderTab: any } {
throw new Error('Function not implemented.')
}
</script>
<template>
<div>
<VHeaderTab :items="discoverTabItems" v-model="activeTab">
<template #append>
<VBtn
icon="mdi-order-alphabetical-ascending"
variant="text"
color="grey"
size="default"
class="settings-icon-button"
@click="orderConfigDialog = true"
/>
</template>
</VHeaderTab>
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
<VWindow v-model="activeTab" class="disable-tab-transition" :touch="false">
<VWindowItem value="themoviedb">
<transition name="fade-slide" appear>
<div>

View File

@@ -4,6 +4,7 @@ import SubscribePopularView from '@/views/subscribe/SubscribePopularView.vue'
import SubscribeShareView from '@/views/subscribe/SubscribeShareView.vue'
import SubscribeEditDialog from '@/components/dialog/SubscribeEditDialog.vue'
import { useI18n } from 'vue-i18n'
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
import { getSubscribeMovieTabs, getSubscribeTvTabs } from '@/router/i18n-menu'
@@ -14,7 +15,7 @@ const route = useRoute()
const subType = route.meta.subType?.toString()
const subId = ref(route.query.id as string)
const activeTab = ref(route.query.tab)
const activeTab = ref((route.query.tab as string) || '')
const shareViewKey = ref(0)
// 获取标签页
@@ -46,89 +47,64 @@ const searchShares = () => {
searchShareDialog.value = false
shareViewKey.value++
}
// VMenu activator选择器
const filterActivator = computed(() => '[data-menu-activator="filter-btn"]')
const searchActivator = computed(() => '[data-menu-activator="search-btn"]')
// 使用动态标签页
const { registerHeaderTab } = useDynamicHeaderTab()
// 注册动态标签页
onMounted(() => {
// 设置初始activeTab值
if (!activeTab.value && subscribeTabs.value.length > 0) {
activeTab.value = subscribeTabs.value[0].tab
}
registerHeaderTab({
items: subscribeTabs.value,
modelValue: activeTab,
appendButtons: [
{
icon: 'mdi-filter-multiple-outline',
variant: 'text',
color: computed(() => (subscribeFilter.value ? 'primary' : 'gray')),
class: 'settings-icon-button',
dataAttr: 'filter-btn',
action: () => {
filterSubscribeDialog.value = true
},
show: computed(() => activeTab.value === 'mysub'),
},
{
icon: 'mdi-movie-search-outline',
variant: 'text',
color: computed(() => (shareKeyword.value ? 'primary' : 'gray')),
class: 'settings-icon-button',
dataAttr: 'search-btn',
action: () => {
searchShareDialog.value = true
},
show: computed(() => activeTab.value === 'share'),
},
{
icon: 'mdi-clipboard-edit-outline',
variant: 'text',
color: 'gray',
class: 'settings-icon-button',
action: () => {
subscribeEditDialog.value = true
},
show: computed(() => activeTab.value === 'mysub'),
},
],
})
})
</script>
<template>
<div>
<VHeaderTab :items="subscribeTabs" v-model="activeTab">
<template #append>
<VMenu
v-if="activeTab === 'mysub'"
v-model="filterSubscribeDialog"
width="20rem"
:close-on-content-click="false"
scrim
>
<template #activator="{ props }">
<VBtn
icon="mdi-filter-multiple-outline"
variant="text"
:color="subscribeFilter ? 'primary' : 'gray'"
size="default"
class="settings-icon-button"
v-bind="props"
/>
</template>
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-filter-multiple-outline" class="mr-2" />
{{ t('subscribe.filterSubscriptions') }}
</VCardTitle>
<VDialogCloseBtn @click="filterSubscribeDialog = false" />
</VCardItem>
<VCardText>
<VTextField v-model="subscribeFilter" :label="t('subscribe.name')" clearable density="comfortable" />
</VCardText>
</VCard>
</VMenu>
<VMenu
v-if="activeTab === 'share'"
v-model="searchShareDialog"
width="25rem"
:close-on-content-click="false"
scrim
>
<template #activator="{ props }">
<VBtn
icon="mdi-movie-search-outline"
variant="text"
:color="shareKeyword ? 'primary' : 'gray'"
size="default"
class="settings-icon-button"
v-bind="props"
/>
</template>
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-movie-search-outline" class="mr-2" />
{{ t('subscribe.searchShares') }}
</VCardTitle>
<VDialogCloseBtn @click="searchShareDialog = false" />
</VCardItem>
<VCardText>
<VTextField v-model="shareKeyword" :label="t('subscribe.keyword')" clearable density="comfortable">
<template #append>
<VBtn prepend-icon="mdi-magnify" color="primary" @click="searchShares">{{ t('common.search') }}</VBtn>
</template>
</VTextField>
</VCardText>
</VCard>
</VMenu>
<VBtn
v-if="activeTab === 'mysub'"
icon="mdi-clipboard-edit-outline"
variant="text"
color="gray"
size="default"
class="settings-icon-button"
@click="subscribeEditDialog = true"
/>
</template>
</VHeaderTab>
<VWindow v-model="activeTab" class="disable-tab-transition" :touch="false">
<VWindow v-model="activeTab" class="disable-tab-transition content-window" :touch="false">
<VWindowItem value="mysub">
<transition name="fade-slide" appear>
<div>
@@ -152,6 +128,58 @@ const searchShares = () => {
</VWindowItem>
</VWindow>
<!-- 订阅过滤弹窗 -->
<Teleport to="body" v-if="filterSubscribeDialog">
<VMenu
v-model="filterSubscribeDialog"
width="20rem"
:close-on-content-click="false"
:activator="filterActivator"
location="bottom end"
>
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-filter-multiple-outline" class="mr-2" />
{{ t('subscribe.filterSubscriptions') }}
</VCardTitle>
<VDialogCloseBtn @click="filterSubscribeDialog = false" />
</VCardItem>
<VCardText>
<VTextField v-model="subscribeFilter" :label="t('subscribe.name')" clearable density="comfortable" />
</VCardText>
</VCard>
</VMenu>
</Teleport>
<!-- 搜索订阅分享弹窗 -->
<Teleport to="body" v-if="searchShareDialog">
<VMenu
v-model="searchShareDialog"
width="25rem"
:close-on-content-click="false"
:activator="searchActivator"
location="bottom end"
>
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-movie-search-outline" class="mr-2" />
{{ t('subscribe.searchShares') }}
</VCardTitle>
<VDialogCloseBtn @click="searchShareDialog = false" />
</VCardItem>
<VCardText>
<VTextField v-model="shareKeyword" :label="t('subscribe.keyword')" clearable density="comfortable">
<template #append>
<VBtn prepend-icon="mdi-magnify" color="primary" @click="searchShares">{{ t('common.search') }}</VBtn>
</template>
</VTextField>
</VCardText>
</VCard>
</VMenu>
</Teleport>
<!-- 订阅编辑弹窗 -->
<SubscribeEditDialog
v-if="subscribeEditDialog"
@@ -163,3 +191,9 @@ const searchShares = () => {
/>
</div>
</template>
<style scoped>
.content-window {
margin-block-start: 0;
}
</style>