mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-06-20 23:24:03 +08:00
897 lines
28 KiB
Vue
897 lines
28 KiB
Vue
<script lang="ts" setup>
|
||
import VerticalNavSectionTitle from '@/@layouts/components/VerticalNavSectionTitle.vue'
|
||
import VerticalNavLayout from '@layouts/components/VerticalNavLayout.vue'
|
||
import VerticalNavLink from '@layouts/components/VerticalNavLink.vue'
|
||
import Footer from '@/layouts/components/Footer.vue'
|
||
import UserNofification from '@/layouts/components/UserNotification.vue'
|
||
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 AgentAssistantWidget from '@/components/AgentAssistantWidget.vue'
|
||
import ThemeCustomizer from '@/components/ThemeCustomizer.vue'
|
||
import { useGlobalSettingsStore, usePluginSidebarNavStore, useUserStore } from '@/stores'
|
||
import { getNavMenus } from '@/router/i18n-menu'
|
||
import { filterPluginSidebarNavEntries } from '@/utils/pluginSidebarNav'
|
||
import { NavMenu } from '@/@layouts/types'
|
||
import { useDisplay } from 'vuetify'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import {
|
||
buildUserPermissionContext,
|
||
filterItemsByPermission,
|
||
filterMenusByPermission,
|
||
hasItemPermission,
|
||
hasPermission,
|
||
type UserPermissionKey,
|
||
} from '@/utils/permission'
|
||
import { usePullDownGesture } from '@/composables/usePullDownGesture'
|
||
import { usePWA } from '@/composables/usePWA'
|
||
import OfflinePage from '@/layouts/components/OfflinePage.vue'
|
||
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
|
||
import {
|
||
readThemeCustomizerSettings,
|
||
THEME_CUSTOMIZER_CHANGE_EVENT,
|
||
THEME_CUSTOMIZER_OPEN_EVENT,
|
||
type ThemeCustomizerSettings,
|
||
} from '@/composables/useThemeCustomizer'
|
||
import logo from '@images/logo.svg?raw'
|
||
|
||
const display = useDisplay()
|
||
// PWA模式检测
|
||
const { appMode } = usePWA()
|
||
const { t } = useI18n()
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const themeLayout = ref(readThemeCustomizerSettings().layout)
|
||
const showThemeCustomizer = ref(false)
|
||
|
||
// 用户 Store
|
||
const userStore = useUserStore()
|
||
const pluginSidebarNavStore = usePluginSidebarNavStore()
|
||
const globalSettingsStore = useGlobalSettingsStore()
|
||
|
||
// 获取用户权限信息
|
||
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||
const canAdmin = computed(() => hasPermission(userPermissions.value, 'admin'))
|
||
const showAgentAssistant = computed(() => globalSettingsStore.get('AI_AGENT_ENABLE') === true)
|
||
|
||
// 开始菜单项
|
||
const startMenus = ref<NavMenu[]>([])
|
||
|
||
// 发现菜单项
|
||
const discoveryMenus = ref<NavMenu[]>([])
|
||
|
||
// 订阅菜单项
|
||
const subscribeMenus = ref<NavMenu[]>([])
|
||
|
||
// 整理菜单项
|
||
const organizeMenus = ref<NavMenu[]>([])
|
||
|
||
// 系统菜单项
|
||
const systemMenus = ref<NavMenu[]>([])
|
||
|
||
// 主题定制器的水平布局只在桌面 UI 中启用,App 模式始终保留移动端导航。
|
||
const showHorizontalThemeNav = computed(() => {
|
||
return themeLayout.value === 'horizontal' && !appMode.value && !display.mdAndDown.value
|
||
})
|
||
|
||
const horizontalNavGroups = computed(() =>
|
||
[
|
||
{ title: t('menu.start'), icon: 'mdi-home-outline', items: startMenus.value },
|
||
{ title: t('menu.discovery'), icon: 'mdi-compass-outline', items: discoveryMenus.value },
|
||
{ title: t('menu.subscribe'), icon: 'mdi-rss', items: subscribeMenus.value },
|
||
{ title: t('menu.organize'), icon: 'mdi-folder-play-outline', items: organizeMenus.value },
|
||
{ title: t('menu.system'), icon: 'mdi-cog-outline', items: systemMenus.value },
|
||
].filter(group => group.items.length > 0),
|
||
)
|
||
|
||
const navbarExtraHeight = computed(() => {
|
||
const dynamicTabHeight = showDynamicHeaderTab.value ? 2.75 : 0
|
||
const horizontalNavHeight = showHorizontalThemeNav.value ? 3.25 : 0
|
||
|
||
return `${dynamicTabHeight + horizontalNavHeight}rem`
|
||
})
|
||
|
||
const mainContentPaddingTop = computed(() => {
|
||
const dynamicTabPadding = showDynamicHeaderTab.value ? 3.25 : 0
|
||
const horizontalNavPadding = showHorizontalThemeNav.value ? 3.5 : 0
|
||
|
||
return `${dynamicTabPadding + horizontalNavPadding}rem`
|
||
})
|
||
|
||
// 插件快速访问相关状态
|
||
const showPluginQuickAccess = ref(false)
|
||
|
||
// 离线状态管理
|
||
const { setAppOffline, isOffline } = useGlobalOfflineStatus()
|
||
|
||
// 动态标签页相关
|
||
// 定义动态标签页类型
|
||
interface DynamicHeaderTabButton {
|
||
icon: string
|
||
color?: string | ComputedRef<string>
|
||
variant?: 'flat' | 'text' | 'elevated' | 'tonal' | 'outlined' | 'plain'
|
||
size?: string
|
||
class?: string
|
||
action?: () => void
|
||
permission?: UserPermissionKey
|
||
show?: boolean | ComputedRef<boolean>
|
||
loading?: boolean | ComputedRef<boolean>
|
||
dataAttr?: string
|
||
}
|
||
|
||
interface DynamicHeaderTabItem {
|
||
title: string
|
||
icon?: string
|
||
tab: string
|
||
}
|
||
|
||
interface DynamicHeaderTab {
|
||
items: DynamicHeaderTabItem[]
|
||
modelValue: string
|
||
appendButtons?: DynamicHeaderTabButton[]
|
||
routePath?: string // 用于标识哪个路由注册的
|
||
onUpdateModelValue?: (value: string) => void // 用于通知值更新
|
||
}
|
||
|
||
// 提供动态标签页注册和获取的方法
|
||
const dynamicHeaderTab = ref<DynamicHeaderTab | null>(null)
|
||
const openHorizontalNavGroup = ref<string | null>(null)
|
||
const pendingHorizontalTab = ref<{ path: string; tab: string } | null>(null)
|
||
|
||
// 提供一个方法让其他组件注册动态标签页
|
||
const registerDynamicHeaderTab = (tab: DynamicHeaderTab) => {
|
||
// 保存注册标签页的路由路径
|
||
tab.routePath = route.path
|
||
// 强制更新,确保响应式系统能检测到变化
|
||
dynamicHeaderTab.value = { ...tab }
|
||
applyPendingHorizontalTab()
|
||
}
|
||
|
||
// 提供一个方法让其他组件取消注册动态标签页
|
||
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,
|
||
() => {
|
||
// 使用nextTick确保新页面的组件已经挂载完成
|
||
nextTick(() => {
|
||
// 如果当前标签页不属于新路由,则清除
|
||
if (dynamicHeaderTab.value && dynamicHeaderTab.value.routePath !== route.path) {
|
||
dynamicHeaderTab.value = null
|
||
}
|
||
})
|
||
},
|
||
{ immediate: false },
|
||
)
|
||
|
||
// 当前路由是否注册了动态标签页。
|
||
const hasDynamicHeaderTab = computed(() => {
|
||
return (
|
||
dynamicHeaderTab.value && dynamicHeaderTab.value.items.length > 0 && dynamicHeaderTab.value.routePath === route.path
|
||
)
|
||
})
|
||
|
||
// 水平布局下动态标签页会并入顶部导航三级菜单,不再额外显示标签页栏。
|
||
const showDynamicHeaderTab = computed(() => hasDynamicHeaderTab.value && !showHorizontalThemeNav.value)
|
||
|
||
const visibleDynamicHeaderButtons = computed(() => {
|
||
if (!hasDynamicHeaderTab.value) return []
|
||
|
||
const visibleButtons = (dynamicHeaderTab.value?.appendButtons ?? []).filter(
|
||
button => resolveMaybeRefValue(button.show, true) !== false,
|
||
)
|
||
return filterItemsByPermission(visibleButtons, userPermissions.value)
|
||
})
|
||
|
||
const visibleHorizontalHeaderButtons = computed(() => {
|
||
if (!showHorizontalThemeNav.value) return []
|
||
|
||
return visibleDynamicHeaderButtons.value
|
||
})
|
||
|
||
// 在组件销毁时清理
|
||
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') {
|
||
if (event.data.offline) {
|
||
setAppOffline(true, t('common.serverConnectionFailed'))
|
||
} else {
|
||
setAppOffline(false)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 检查是否可以使用下拉手势
|
||
const canUsePullGesture = () => {
|
||
// 检查是否在dashboard页面
|
||
const isDashboard = route.path === '/dashboard' || route.path === '/'
|
||
// 检查是否是管理员
|
||
const isAdmin = canAdmin.value
|
||
// 检查插件快速访问面板是否已显示
|
||
const quickAccessOpen = showPluginQuickAccess.value
|
||
// 检查是否离线
|
||
const offline = isOffline.value
|
||
|
||
return isDashboard && isAdmin && !quickAccessOpen && !offline
|
||
}
|
||
|
||
// 使用下拉手势 composable
|
||
const {
|
||
pullDistance,
|
||
contentTransform,
|
||
contentTransition,
|
||
showPullIndicator,
|
||
indicatorRotation,
|
||
indicatorOpacity,
|
||
indicatorTransform,
|
||
config: PULL_CONFIG,
|
||
} = usePullDownGesture({
|
||
enabled: true,
|
||
canUsePullGesture,
|
||
onTrigger: () => {
|
||
showPluginQuickAccess.value = true
|
||
},
|
||
})
|
||
|
||
// 根据分类获取菜单列表
|
||
const getMenuList = (header: string) => {
|
||
// 使用国际化菜单
|
||
const menus = getNavMenus(t)
|
||
const filteredMenus = filterMenusByPermission(menus, userPermissions.value)
|
||
return filteredMenus.filter((item: NavMenu) => item.header === header)
|
||
}
|
||
|
||
// 返回上一页
|
||
function goBack() {
|
||
history.back()
|
||
}
|
||
|
||
function handleThemeCustomizerChange(event: Event) {
|
||
themeLayout.value = (event as CustomEvent<ThemeCustomizerSettings>).detail.layout
|
||
}
|
||
|
||
function handleThemeCustomizerOpen() {
|
||
showThemeCustomizer.value = true
|
||
}
|
||
|
||
function isHorizontalNavActive(item: NavMenu) {
|
||
const targetPath = normalizeMenuPath(item.to)
|
||
if (!targetPath) return false
|
||
|
||
const currentPath = normalizeMenuPath(route.path)
|
||
|
||
return currentPath === targetPath || currentPath.startsWith(`${targetPath}/`)
|
||
}
|
||
|
||
function isHorizontalNavGroupActive(group: { items: NavMenu[] }) {
|
||
return group.items.some(isHorizontalNavActive)
|
||
}
|
||
|
||
function hasHorizontalDynamicTabs(item: NavMenu) {
|
||
return showHorizontalThemeNav.value && getHorizontalNavTabs(item).length > 0
|
||
}
|
||
|
||
function isHorizontalDynamicTabActive(tab: DynamicHeaderTabItem) {
|
||
return dynamicHeaderTab.value?.modelValue === tab.tab
|
||
}
|
||
|
||
async function handleHorizontalDynamicTabSelect(item: NavMenu, tab: DynamicHeaderTabItem) {
|
||
const targetPath = normalizeMenuPath(item.to)
|
||
const currentPath = normalizeMenuPath(route.path)
|
||
|
||
if (targetPath && currentPath !== targetPath) {
|
||
// 三级菜单可能在目标页面挂载前点击,先记录待切换 tab,页面注册动态 tab 后再应用。
|
||
pendingHorizontalTab.value = { path: targetPath, tab: tab.tab }
|
||
await router.push(targetPath)
|
||
} else {
|
||
handleTabChange(tab.tab)
|
||
}
|
||
|
||
openHorizontalNavGroup.value = null
|
||
}
|
||
|
||
function closeHorizontalNavGroup() {
|
||
openHorizontalNavGroup.value = null
|
||
}
|
||
|
||
function resolveMaybeRefValue<T>(value: T | ComputedRef<T> | undefined, fallback: T): T {
|
||
return isRef(value) ? value.value : (value ?? fallback)
|
||
}
|
||
|
||
function resolveHeaderButtonColor(button: DynamicHeaderTabButton) {
|
||
return resolveMaybeRefValue(button.color, 'gray')
|
||
}
|
||
|
||
function resolveHeaderButtonLoading(button: DynamicHeaderTabButton) {
|
||
return resolveMaybeRefValue(button.loading, false)
|
||
}
|
||
|
||
function handleHeaderButtonClick(button: DynamicHeaderTabButton) {
|
||
if (!hasItemPermission(button, userPermissions.value)) return
|
||
|
||
button.action?.()
|
||
}
|
||
|
||
function getHorizontalTabIcon(tab: DynamicHeaderTabItem) {
|
||
const icon = tab.icon?.trim()
|
||
|
||
// 部分页面会把业务来源标识(如 themoviedb/douban/bangumi)放进 icon 字段,
|
||
// 这些值不是菜单里的可渲染图标,三级菜单统一回退到默认图标。
|
||
if (!icon || (!icon.startsWith('mdi-') && !icon.startsWith('tabler-') && !icon.includes(':'))) {
|
||
return 'mdi-circle-medium'
|
||
}
|
||
|
||
return icon
|
||
}
|
||
|
||
function normalizeMenuPath(value: unknown) {
|
||
if (typeof value !== 'string') return ''
|
||
|
||
return value.replace(/\/$/, '') || '/'
|
||
}
|
||
|
||
function getHorizontalNavTabs(item: NavMenu): DynamicHeaderTabItem[] {
|
||
const targetPath = normalizeMenuPath(item.to)
|
||
|
||
if (targetPath && isHorizontalNavActive(item) && hasDynamicHeaderTab.value) {
|
||
return dynamicHeaderTab.value?.items ?? []
|
||
}
|
||
|
||
return item.tabs ?? []
|
||
}
|
||
|
||
function applyPendingHorizontalTab() {
|
||
if (!pendingHorizontalTab.value || !hasDynamicHeaderTab.value) return
|
||
|
||
const pending = pendingHorizontalTab.value
|
||
if (normalizeMenuPath(route.path) !== pending.path) return
|
||
|
||
const tabExists = dynamicHeaderTab.value?.items.some(item => item.tab === pending.tab)
|
||
if (!tabExists) return
|
||
|
||
handleTabChange(pending.tab)
|
||
pendingHorizontalTab.value = null
|
||
}
|
||
|
||
// 关闭插件快速访问
|
||
function handleClosePluginQuickAccess() {
|
||
showPluginQuickAccess.value = false
|
||
}
|
||
|
||
// 点击插件后关闭
|
||
function handlePluginClick() {
|
||
showPluginQuickAccess.value = false
|
||
}
|
||
|
||
function appendPluginSidebarMenus() {
|
||
for (const { navMenu, section } of filterPluginSidebarNavEntries(
|
||
pluginSidebarNavStore.items,
|
||
t,
|
||
userPermissions.value,
|
||
)) {
|
||
switch (section) {
|
||
case 'start':
|
||
startMenus.value.push(navMenu)
|
||
break
|
||
case 'discovery':
|
||
discoveryMenus.value.push(navMenu)
|
||
break
|
||
case 'subscribe':
|
||
subscribeMenus.value.push(navMenu)
|
||
break
|
||
case 'organize':
|
||
organizeMenus.value.push(navMenu)
|
||
break
|
||
case 'system':
|
||
default:
|
||
systemMenus.value.push(navMenu)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
onMounted(async () => {
|
||
// 主题定制器由布局统一承载,监听需要尽早注册,避免异步加载菜单期间丢失打开事件。
|
||
window.addEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
|
||
window.addEventListener(THEME_CUSTOMIZER_OPEN_EVENT, handleThemeCustomizerOpen)
|
||
|
||
// 获取菜单列表
|
||
startMenus.value = getMenuList(t('menu.start'))
|
||
discoveryMenus.value = getMenuList(t('menu.discovery'))
|
||
subscribeMenus.value = getMenuList(t('menu.subscribe'))
|
||
organizeMenus.value = getMenuList(t('menu.organize'))
|
||
systemMenus.value = getMenuList(t('menu.system'))
|
||
|
||
await pluginSidebarNavStore.ensureSidebarNav()
|
||
appendPluginSidebarMenus()
|
||
|
||
// 监听Service Worker消息
|
||
if ('serviceWorker' in navigator) {
|
||
navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage)
|
||
}
|
||
|
||
// 组件卸载时清理监听
|
||
onBeforeUnmount(() => {
|
||
window.removeEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
|
||
window.removeEventListener(THEME_CUSTOMIZER_OPEN_EVENT, handleThemeCustomizerOpen)
|
||
if ('serviceWorker' in navigator) {
|
||
navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage)
|
||
}
|
||
})
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<!-- 👉 Offline Page -->
|
||
<OfflinePage />
|
||
|
||
<!-- 👉 Pull Down Indicator -->
|
||
<div
|
||
v-if="appMode && showPullIndicator"
|
||
class="pull-indicator"
|
||
:style="{
|
||
'--pull-indicator-navbar-extra-height': navbarExtraHeight,
|
||
opacity: indicatorOpacity,
|
||
transform: indicatorTransform,
|
||
}"
|
||
>
|
||
<div
|
||
class="indicator-icon"
|
||
:style="{
|
||
transform: `scale(${
|
||
1 + Math.min((pullDistance - PULL_CONFIG.SHOW_INDICATOR) / PULL_CONFIG.MAX_PULL_DISTANCE, 0.5) * 0.3
|
||
}) rotate(${indicatorRotation}deg)`,
|
||
}"
|
||
>
|
||
<VIcon
|
||
icon="mdi-gesture-swipe-down"
|
||
size="24"
|
||
:color="pullDistance >= PULL_CONFIG.TRIGGER_THRESHOLD ? 'success' : 'primary'"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<VerticalNavLayout :style="{ '--navbar-tab-height': navbarExtraHeight }">
|
||
<!-- 👉 Navbar -->
|
||
<template #navbar="{ toggleVerticalOverlayNavActive }">
|
||
<div
|
||
class="theme-navbar-row d-flex h-16 align-center mx-1"
|
||
:class="{ 'theme-navbar-row--horizontal': showHorizontalThemeNav }"
|
||
>
|
||
<RouterLink v-if="showHorizontalThemeNav" :to="canAdmin ? '/dashboard' : '/apps'" class="theme-horizontal-logo">
|
||
<span class="theme-horizontal-logo__mark" v-html="logo" />
|
||
<span class="theme-horizontal-logo__text">MOVIEPILOT</span>
|
||
</RouterLink>
|
||
<!-- 👉 Vertical Nav Toggle -->
|
||
<IconBtn v-if="!appMode && display.mdAndDown.value" class="ms-n2" @click="toggleVerticalOverlayNavActive(true)">
|
||
<VIcon icon="mdi-menu" />
|
||
</IconBtn>
|
||
<!-- 👉 Back Button -->
|
||
<IconBtn v-if="appMode" class="ms-n2" @click="goBack">
|
||
<VIcon icon="mdi-arrow-left" size="32" />
|
||
</IconBtn>
|
||
<!-- 👉 Search Bar -->
|
||
<SearchBar v-if="!showHorizontalThemeNav" />
|
||
<!-- 👉 Spacer -->
|
||
<VSpacer />
|
||
<div
|
||
class="theme-navbar-actions d-flex align-center"
|
||
:class="{ 'theme-navbar-actions--horizontal': showHorizontalThemeNav }"
|
||
>
|
||
<!-- 👉 Horizontal Search Icon -->
|
||
<SearchBar v-if="showHorizontalThemeNav" icon-only />
|
||
<!-- 👉 Shortcuts -->
|
||
<ShortcutBar v-if="canAdmin" />
|
||
<!-- 👉 Notification -->
|
||
<UserNofification />
|
||
<!-- 👉 UserProfile -->
|
||
<UserProfile />
|
||
</div>
|
||
</div>
|
||
<div v-if="showHorizontalThemeNav" class="theme-horizontal-nav">
|
||
<VMenu
|
||
v-for="group in horizontalNavGroups"
|
||
:key="group.title"
|
||
:model-value="openHorizontalNavGroup === group.title"
|
||
location="bottom start"
|
||
offset="8"
|
||
:close-on-content-click="false"
|
||
@update:model-value="openHorizontalNavGroup = $event ? group.title : null"
|
||
>
|
||
<template #activator="{ props: menuProps }">
|
||
<VBtn
|
||
v-bind="menuProps"
|
||
:prepend-icon="group.icon"
|
||
append-icon="mdi-chevron-down"
|
||
:variant="isHorizontalNavGroupActive(group) ? 'tonal' : 'text'"
|
||
:color="isHorizontalNavGroupActive(group) ? 'primary' : 'default'"
|
||
rounded="pill"
|
||
class="theme-horizontal-nav__item"
|
||
>
|
||
{{ group.title }}
|
||
</VBtn>
|
||
</template>
|
||
|
||
<VList class="theme-horizontal-nav__menu" min-width="13rem" density="comfortable">
|
||
<template v-for="item in group.items" :key="`${group.title}-${item.title}-${item.to}`">
|
||
<VMenu
|
||
v-if="hasHorizontalDynamicTabs(item)"
|
||
location="end top"
|
||
offset="8"
|
||
open-on-hover
|
||
:open-delay="0"
|
||
:close-delay="120"
|
||
:close-on-content-click="true"
|
||
>
|
||
<template #activator="{ props: subMenuProps }">
|
||
<VListItem v-bind="subMenuProps" :active="isHorizontalNavActive(item)">
|
||
<template #prepend>
|
||
<VIcon :icon="String(item.icon || '')" />
|
||
</template>
|
||
<VListItemTitle>{{ item.full_title || item.title }}</VListItemTitle>
|
||
<template #append>
|
||
<VIcon icon="mdi-chevron-right" size="small" />
|
||
</template>
|
||
</VListItem>
|
||
</template>
|
||
|
||
<VList class="theme-horizontal-nav__submenu" min-width="12rem" density="comfortable">
|
||
<VListItem
|
||
v-for="tab in getHorizontalNavTabs(item)"
|
||
:key="`${item.to}-${tab.tab}`"
|
||
:active="isHorizontalDynamicTabActive(tab)"
|
||
@click="handleHorizontalDynamicTabSelect(item, tab)"
|
||
>
|
||
<template #prepend>
|
||
<VIcon :icon="getHorizontalTabIcon(tab)" />
|
||
</template>
|
||
<VListItemTitle>{{ tab.title }}</VListItemTitle>
|
||
</VListItem>
|
||
</VList>
|
||
</VMenu>
|
||
|
||
<VListItem
|
||
v-else
|
||
:to="item.to || undefined"
|
||
:active="isHorizontalNavActive(item)"
|
||
@click="closeHorizontalNavGroup"
|
||
>
|
||
<template #prepend>
|
||
<VIcon :icon="String(item.icon || '')" />
|
||
</template>
|
||
<VListItemTitle>{{ item.full_title || item.title }}</VListItemTitle>
|
||
</VListItem>
|
||
</template>
|
||
</VList>
|
||
</VMenu>
|
||
<div v-if="visibleHorizontalHeaderButtons.length" class="theme-horizontal-nav__actions">
|
||
<VBtn
|
||
v-for="button in visibleHorizontalHeaderButtons"
|
||
:key="button.icon"
|
||
:icon="button.icon"
|
||
:variant="button.variant || 'text'"
|
||
:color="resolveHeaderButtonColor(button)"
|
||
:size="button.size || 'default'"
|
||
:class="button.class || 'settings-icon-button'"
|
||
:loading="resolveHeaderButtonLoading(button)"
|
||
:data-menu-activator="button.dataAttr"
|
||
@click="handleHeaderButtonClick(button)"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template #vertical-nav-content>
|
||
<VerticalNavLink v-for="item in startMenus" :item="item" />
|
||
<!-- 👉 发现 -->
|
||
<VerticalNavSectionTitle
|
||
v-if="discoveryMenus.length > 0"
|
||
:item="{
|
||
heading: t('menu.discovery'),
|
||
}"
|
||
/>
|
||
<VerticalNavLink v-for="item in discoveryMenus" :item="item" />
|
||
<!-- 👉 订阅 -->
|
||
<VerticalNavSectionTitle
|
||
v-if="subscribeMenus.length > 0"
|
||
:item="{
|
||
heading: t('menu.subscribe'),
|
||
}"
|
||
/>
|
||
<VerticalNavLink v-for="item in subscribeMenus" :item="item" />
|
||
<!-- 👉 整理 -->
|
||
<VerticalNavSectionTitle
|
||
v-if="organizeMenus.length > 0"
|
||
:item="{
|
||
heading: t('menu.organize'),
|
||
}"
|
||
/>
|
||
<VerticalNavLink v-for="item in organizeMenus" :item="item" />
|
||
<!-- 👉 系统 -->
|
||
<VerticalNavSectionTitle
|
||
v-if="systemMenus.length > 0"
|
||
:item="{
|
||
heading: t('menu.system'),
|
||
}"
|
||
/>
|
||
<VerticalNavLink v-for="item in systemMenus" :item="item" />
|
||
</template>
|
||
|
||
<template #after-vertical-nav-items />
|
||
|
||
<!-- 👉 Dynamic Header Tab -->
|
||
<template #dynamic-header-tab>
|
||
<div v-if="showDynamicHeaderTab">
|
||
<HeaderTab
|
||
:items="dynamicHeaderTab!.items"
|
||
:model-value="dynamicHeaderTab!.modelValue"
|
||
@update:model-value="handleTabChange"
|
||
>
|
||
<template #append>
|
||
<template v-for="button in visibleDynamicHeaderButtons" :key="button.icon">
|
||
<VBtn
|
||
:icon="button.icon"
|
||
:variant="button.variant || 'text'"
|
||
:color="resolveHeaderButtonColor(button)"
|
||
:size="button.size || 'default'"
|
||
:class="button.class || 'settings-icon-button'"
|
||
:loading="resolveHeaderButtonLoading(button)"
|
||
:data-menu-activator="button.dataAttr"
|
||
@click="handleHeaderButtonClick(button)"
|
||
/>
|
||
</template>
|
||
</template>
|
||
</HeaderTab>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 👉 下拉跟随动画 -->
|
||
<div
|
||
class="main-content-wrapper"
|
||
:style="{
|
||
transform: contentTransform,
|
||
transition: contentTransition,
|
||
paddingTop: mainContentPaddingTop,
|
||
}"
|
||
>
|
||
<slot />
|
||
</div>
|
||
|
||
<!-- 👉 Footer -->
|
||
<template #footer>
|
||
<Footer :show-nav="!showPluginQuickAccess" />
|
||
</template>
|
||
</VerticalNavLayout>
|
||
|
||
<!-- 👉 Plugin Quick Access -->
|
||
<QuickAccess
|
||
v-if="appMode"
|
||
:visible="showPluginQuickAccess"
|
||
:pull-distance="pullDistance"
|
||
@close="handleClosePluginQuickAccess"
|
||
@plugin-click="handlePluginClick"
|
||
/>
|
||
|
||
<!-- 👉 Theme Customizer -->
|
||
<ThemeCustomizer v-if="showThemeCustomizer" @close="showThemeCustomizer = false" />
|
||
|
||
<!-- 👉 Agent Assistant -->
|
||
<AgentAssistantWidget v-if="showAgentAssistant" />
|
||
</template>
|
||
|
||
<style lang="scss" scoped>
|
||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||
|
||
.main-content-wrapper {
|
||
backface-visibility: hidden;
|
||
block-size: 100%;
|
||
inline-size: 100%;
|
||
transform: translateZ(0);
|
||
will-change: transform;
|
||
}
|
||
|
||
.theme-navbar-row--horizontal {
|
||
gap: 1rem;
|
||
margin-inline: 0 !important;
|
||
}
|
||
|
||
:deep(.layout-dynamic-header-tab) {
|
||
padding-block-end: 0.25rem;
|
||
}
|
||
|
||
.theme-horizontal-logo {
|
||
display: inline-flex;
|
||
flex: 0 0 auto;
|
||
align-items: center;
|
||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||
column-gap: 0.75rem;
|
||
font-weight: 700;
|
||
letter-spacing: 0;
|
||
line-height: 1;
|
||
text-decoration: none;
|
||
}
|
||
|
||
.theme-horizontal-logo__mark {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
block-size: 2rem;
|
||
inline-size: 2rem;
|
||
}
|
||
|
||
.theme-horizontal-logo__mark :deep(svg) {
|
||
display: block;
|
||
block-size: 1.8rem;
|
||
inline-size: 1.8rem;
|
||
}
|
||
|
||
.theme-horizontal-logo__text {
|
||
font-size: 1.125rem;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.theme-navbar-actions--horizontal {
|
||
gap: 0.85rem;
|
||
|
||
:deep(.ms-2),
|
||
:deep(.ms-3) {
|
||
margin-inline-start: 0 !important;
|
||
}
|
||
|
||
:deep(.v-btn.v-btn--icon) {
|
||
flex: 0 0 auto;
|
||
border-radius: 12px;
|
||
block-size: 2.75rem;
|
||
color: rgba(var(--v-theme-on-surface), 0.78);
|
||
inline-size: 2.75rem;
|
||
}
|
||
|
||
:deep(.v-btn.v-btn--icon .v-icon) {
|
||
font-size: 1.75rem;
|
||
line-height: 1;
|
||
}
|
||
|
||
:deep(.v-avatar.cursor-pointer) {
|
||
flex: 0 0 auto;
|
||
block-size: 2.75rem !important;
|
||
inline-size: 2.75rem !important;
|
||
margin-inline-start: 0 !important;
|
||
}
|
||
}
|
||
|
||
.theme-horizontal-nav {
|
||
display: flex;
|
||
align-items: center;
|
||
block-size: 3.25rem;
|
||
gap: 0.25rem;
|
||
overflow-x: auto;
|
||
padding-block: 0.25rem 0.5rem;
|
||
padding-inline: 0.5rem;
|
||
scrollbar-width: none;
|
||
|
||
&::-webkit-scrollbar {
|
||
display: none;
|
||
}
|
||
}
|
||
|
||
.theme-horizontal-nav__item {
|
||
flex: 0 0 auto;
|
||
letter-spacing: 0;
|
||
}
|
||
|
||
.theme-horizontal-nav__menu,
|
||
.theme-horizontal-nav__submenu {
|
||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||
}
|
||
|
||
.theme-horizontal-nav__actions {
|
||
display: flex;
|
||
flex: 0 0 auto;
|
||
align-items: center;
|
||
margin-inline-start: auto;
|
||
}
|
||
|
||
.pull-indicator {
|
||
position: fixed;
|
||
z-index: 20;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 6px;
|
||
border-radius: 50%;
|
||
backdrop-filter: blur(20px);
|
||
background: rgba(var(--v-theme-surface), 0.3);
|
||
box-shadow:
|
||
0 1px 2px rgba(0, 0, 0, 10%),
|
||
0 1px 3px rgba(0, 0, 0, 6%);
|
||
inset-block-start: calc(
|
||
env(safe-area-inset-top, 0px) + 4rem + var(--pull-indicator-navbar-extra-height, 0rem) + 0.75rem
|
||
);
|
||
inset-inline-start: 50%;
|
||
pointer-events: none;
|
||
transform: translate3d(-50%, 0, 0);
|
||
transition:
|
||
opacity 0.2s ease,
|
||
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||
will-change: opacity, transform;
|
||
}
|
||
|
||
.indicator-icon {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 50%;
|
||
background: rgba(var(--v-theme-primary), 0.08);
|
||
block-size: 40px;
|
||
inline-size: 40px;
|
||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
|
||
/* 透明主题适配 */
|
||
html[class*='transparent'] .pull-indicator,
|
||
html[class*='mica'] .pull-indicator,
|
||
html[class*='acrylic'] .pull-indicator {
|
||
border: 1px solid rgba(255, 255, 255, 20%);
|
||
background: rgba(255, 255, 255, 95%);
|
||
box-shadow:
|
||
0 8px 32px rgba(0, 0, 0, 12%),
|
||
0 4px 16px rgba(0, 0, 0, 8%);
|
||
}
|
||
|
||
html[class*='transparent'] .indicator-icon,
|
||
html[class*='mica'] .indicator-icon,
|
||
html[class*='acrylic'] .indicator-icon {
|
||
background: rgba(var(--v-theme-primary), 0.12);
|
||
}
|
||
|
||
html[data-theme='dark'][class*='transparent'] .pull-indicator,
|
||
html[data-theme='dark'][class*='mica'] .pull-indicator,
|
||
html[data-theme='dark'][class*='acrylic'] .pull-indicator {
|
||
border: 1px solid rgba(255, 255, 255, 10%);
|
||
background: rgba(18, 18, 18, 95%);
|
||
box-shadow:
|
||
0 8px 32px rgba(0, 0, 0, 30%),
|
||
0 4px 16px rgba(0, 0, 0, 20%);
|
||
}
|
||
|
||
html[data-theme='dark'][class*='transparent'] .indicator-icon,
|
||
html[data-theme='dark'][class*='mica'] .indicator-icon,
|
||
html[data-theme='dark'][class*='acrylic'] .indicator-icon {
|
||
background: rgba(var(--v-theme-primary), 0.15);
|
||
}
|
||
</style>
|