mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-05-11 18:10:49 +08:00
动态Tab组件
This commit is contained in:
98
src/composables/useDynamicHeaderTab.ts
Normal file
98
src/composables/useDynamicHeaderTab.ts
Normal 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 }
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user