mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-05-27 19:29:52 +08:00
450 lines
14 KiB
Vue
450 lines
14 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 { useUserStore } from '@/stores'
|
||
import { getNavMenus } from '@/router/i18n-menu'
|
||
import { NavMenu } from '@/@layouts/types'
|
||
import { useDisplay } from 'vuetify'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { filterMenusByPermission } from '@/utils/permission'
|
||
import { onUnreadMessage } from '@/utils/badge'
|
||
|
||
const display = useDisplay()
|
||
const appMode = inject('pwaMode')
|
||
const { t } = useI18n()
|
||
|
||
// 用户 Store
|
||
const userStore = useUserStore()
|
||
|
||
// 响应式的超级用户状态
|
||
const superUser = computed(() => userStore.superUser)
|
||
|
||
// ShortcutBar 引用
|
||
const shortcutBarRef = ref<InstanceType<typeof ShortcutBar> | null>(null)
|
||
|
||
// 获取用户权限信息
|
||
const userPermissions = computed(() => ({
|
||
is_superuser: userStore.superUser,
|
||
...userStore.permissions,
|
||
}))
|
||
|
||
// 开始菜单项
|
||
const startMenus = ref<NavMenu[]>([])
|
||
|
||
// 发现菜单项
|
||
const discoveryMenus = ref<NavMenu[]>([])
|
||
|
||
// 订阅菜单项
|
||
const subscribeMenus = ref<NavMenu[]>([])
|
||
|
||
// 整理菜单项
|
||
const organizeMenus = ref<NavMenu[]>([])
|
||
|
||
// 系统菜单项
|
||
const systemMenus = ref<NavMenu[]>([])
|
||
|
||
// 插件快速访问相关状态
|
||
const showPluginQuickAccess = ref(false)
|
||
|
||
// 下拉检测相关状态
|
||
const isPulling = ref(false)
|
||
const startY = ref(0)
|
||
const pullDistance = ref(0)
|
||
const initialScrollTop = ref(0)
|
||
|
||
// 计算页面内容的transform
|
||
const contentTransform = computed(() => {
|
||
if (!isPulling.value || pullDistance.value <= 0) return 'translateY(0)'
|
||
// 页面内容的移动距离是下拉距离的35%,与新的下拉距离相匹配
|
||
const moveDistance = pullDistance.value * 0.35
|
||
return `translateY(${moveDistance}px)`
|
||
})
|
||
|
||
// 计算页面内容的transition
|
||
const contentTransition = computed(() => {
|
||
// 拖拽时不使用transition,松手后使用transition回弹
|
||
return isPulling.value ? 'none' : 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)'
|
||
})
|
||
|
||
// 计算下拉指示器的显示状态
|
||
const showPullIndicator = computed(() => {
|
||
return isPulling.value && pullDistance.value > 30
|
||
})
|
||
|
||
// 计算下拉指示器的旋转角度
|
||
const indicatorRotation = computed(() => {
|
||
if (!isPulling.value) return 0
|
||
const progress = Math.min(pullDistance.value / 120, 1)
|
||
return progress * 180 // 0到180度的旋转
|
||
})
|
||
|
||
// 计算下拉指示器的透明度
|
||
const indicatorOpacity = computed(() => {
|
||
if (!isPulling.value) return 0
|
||
return Math.min(pullDistance.value / 80, 1)
|
||
})
|
||
|
||
// 根据分类获取菜单列表
|
||
const getMenuList = (header: string) => {
|
||
// 使用国际化菜单
|
||
const menus = getNavMenus()
|
||
const filteredMenus = filterMenusByPermission(menus, userPermissions.value)
|
||
return filteredMenus.filter((item: NavMenu) => item.header === header)
|
||
}
|
||
|
||
// 返回上一页
|
||
function goBack() {
|
||
history.back()
|
||
}
|
||
|
||
// 处理未读消息事件
|
||
function handleUnreadMessage(count: number) {
|
||
if (superUser.value && count > 0) {
|
||
// 延迟一点时间确保组件已渲染
|
||
setTimeout(() => {
|
||
if (shortcutBarRef.value && typeof shortcutBarRef.value.openMessageDialog === 'function') {
|
||
shortcutBarRef.value.openMessageDialog()
|
||
}
|
||
}, 500)
|
||
}
|
||
}
|
||
|
||
// 处理触摸开始
|
||
function handleTouchStart(event: TouchEvent) {
|
||
if (!appMode || !display.mdAndDown.value) return
|
||
|
||
// 如果插件快速访问面板已显示,不处理下拉手势
|
||
if (showPluginQuickAccess.value) return
|
||
|
||
const touch = event.touches[0]
|
||
startY.value = touch.clientY
|
||
|
||
// 重置下拉状态,但不立即阻止滚动
|
||
isPulling.value = false
|
||
pullDistance.value = 0
|
||
|
||
// 记录开始时的滚动位置,用于更准确的判断
|
||
initialScrollTop.value = window.scrollY || document.documentElement.scrollTop || 0
|
||
}
|
||
|
||
// 处理触摸移动
|
||
function handleTouchMove(event: TouchEvent) {
|
||
if (!appMode || !display.mdAndDown.value) return
|
||
|
||
// 如果插件快速访问面板已显示,不处理下拉手势
|
||
if (showPluginQuickAccess.value) return
|
||
|
||
const touch = event.touches[0]
|
||
const deltaY = touch.clientY - startY.value
|
||
|
||
// 如果已经开始下拉,继续保持下拉状态,避免中途中断
|
||
if (isPulling.value) {
|
||
// 继续下拉,但要确保是向下移动
|
||
if (deltaY > -5) {
|
||
// 允许轻微的向上偏移(-5px),避免手指抖动导致中断
|
||
pullDistance.value = Math.max(0, Math.min(deltaY * 0.7, 250))
|
||
// 阻止默认滚动行为
|
||
event.preventDefault()
|
||
} else {
|
||
// 如果向上移动超过阈值,停止下拉
|
||
isPulling.value = false
|
||
pullDistance.value = 0
|
||
}
|
||
} else {
|
||
// 还没开始下拉,检查是否应该开始
|
||
if (deltaY > 5) {
|
||
// 检查当前的滚动位置
|
||
const currentScrollTop = window.scrollY || document.documentElement.scrollTop || 0
|
||
|
||
// 必须同时满足:1. 向下拖拽超过5px 2. 当前在页面顶部 3. 从顶部开始拖拽
|
||
if (currentScrollTop <= 100 && initialScrollTop.value <= 100) {
|
||
// 向下拖拽且在页面顶部附近,开始下拉
|
||
isPulling.value = true
|
||
pullDistance.value = Math.min(deltaY * 0.7, 250)
|
||
// 阻止默认滚动
|
||
event.preventDefault()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 处理触摸结束
|
||
function handleTouchEnd() {
|
||
if (!appMode || !display.mdAndDown.value) return
|
||
|
||
// 如果插件快速访问面板已显示,不处理下拉手势
|
||
if (showPluginQuickAccess.value) return
|
||
|
||
if (isPulling.value && pullDistance.value > 120) {
|
||
// 增加触发阈值到120px
|
||
// 触发插件快速访问
|
||
showPluginQuickAccess.value = true
|
||
}
|
||
|
||
// 先停止拖拽状态,触发回弹动画
|
||
isPulling.value = false
|
||
|
||
// 延迟重置其他状态,让动画完成
|
||
setTimeout(() => {
|
||
pullDistance.value = 0
|
||
startY.value = 0
|
||
}, 300) // 与transition时间匹配
|
||
}
|
||
|
||
// 关闭插件快速访问
|
||
function handleClosePluginQuickAccess() {
|
||
showPluginQuickAccess.value = false
|
||
}
|
||
|
||
// 点击插件后关闭
|
||
function handlePluginClick() {
|
||
showPluginQuickAccess.value = false
|
||
}
|
||
|
||
// 保存页面滚动位置
|
||
let scrollPosition = 0
|
||
|
||
// 监听插件快速访问的显示状态,控制背景滚动
|
||
watch(showPluginQuickAccess, visible => {
|
||
if (visible) {
|
||
// 保存当前滚动位置
|
||
scrollPosition = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0
|
||
|
||
// 显示时锁定背景滚动
|
||
document.body.style.overflow = 'hidden'
|
||
document.body.style.position = 'fixed'
|
||
document.body.style.top = `-${scrollPosition}px`
|
||
document.body.style.width = '100%'
|
||
document.documentElement.style.overflow = 'hidden'
|
||
} else {
|
||
// 隐藏时恢复滚动
|
||
document.body.style.overflow = ''
|
||
document.body.style.position = ''
|
||
document.body.style.top = ''
|
||
document.body.style.width = ''
|
||
document.documentElement.style.overflow = ''
|
||
|
||
// 恢复滚动位置
|
||
window.scrollTo(0, scrollPosition)
|
||
}
|
||
})
|
||
|
||
onMounted(() => {
|
||
// 获取菜单列表
|
||
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'))
|
||
|
||
// 监听全局未读消息事件
|
||
const unsubscribe = onUnreadMessage(handleUnreadMessage)
|
||
|
||
// 只在appMode下添加触摸事件监听
|
||
if (appMode && display.mdAndDown.value) {
|
||
document.addEventListener('touchstart', handleTouchStart, { passive: false })
|
||
document.addEventListener('touchmove', handleTouchMove, { passive: false })
|
||
document.addEventListener('touchend', handleTouchEnd, { passive: true })
|
||
}
|
||
|
||
// 组件卸载时清理监听
|
||
onBeforeUnmount(() => {
|
||
unsubscribe()
|
||
// 恢复body滚动样式
|
||
document.body.style.overflow = ''
|
||
document.body.style.position = ''
|
||
document.body.style.top = ''
|
||
document.body.style.width = ''
|
||
document.documentElement.style.overflow = ''
|
||
if (appMode && display.mdAndDown.value) {
|
||
document.removeEventListener('touchstart', handleTouchStart)
|
||
document.removeEventListener('touchmove', handleTouchMove)
|
||
document.removeEventListener('touchend', handleTouchEnd)
|
||
}
|
||
})
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<!-- 👉 Pull Down Indicator -->
|
||
<div
|
||
v-if="appMode && showPullIndicator"
|
||
class="pull-indicator"
|
||
:style="{
|
||
opacity: indicatorOpacity,
|
||
transform: `translate(-50%, ${Math.min(pullDistance * 0.3, 30)}px)`,
|
||
}"
|
||
>
|
||
<div
|
||
class="indicator-icon"
|
||
:style="{
|
||
transform: `scale(${Math.min(1 + pullDistance / 400, 1.15)}) rotate(${indicatorRotation}deg)`,
|
||
}"
|
||
>
|
||
<VIcon icon="mdi-gesture-swipe-down" size="24" :color="pullDistance > 120 ? 'success' : 'primary'" />
|
||
</div>
|
||
</div>
|
||
<VerticalNavLayout>
|
||
<!-- 👉 Navbar -->
|
||
<template #navbar="{ toggleVerticalOverlayNavActive }">
|
||
<div class="d-flex h-100 align-center mx-1">
|
||
<!-- 👉 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 />
|
||
<!-- 👉 Spacer -->
|
||
<VSpacer />
|
||
<!-- 👉 Shortcuts -->
|
||
<ShortcutBar v-if="superUser" ref="shortcutBarRef" />
|
||
<!-- 👉 Notification -->
|
||
<UserNofification />
|
||
<!-- 👉 UserProfile -->
|
||
<UserProfile />
|
||
</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 />
|
||
|
||
<!-- 👉 下拉跟随动画 -->
|
||
<div
|
||
class="main-content-wrapper"
|
||
:style="{
|
||
transform: contentTransform,
|
||
transition: contentTransition,
|
||
}"
|
||
>
|
||
<slot />
|
||
</div>
|
||
|
||
<!-- 👉 Footer -->
|
||
<template #footer>
|
||
<Footer />
|
||
</template>
|
||
</VerticalNavLayout>
|
||
|
||
<!-- 👉 Plugin Quick Access -->
|
||
<QuickAccess
|
||
v-if="appMode"
|
||
:visible="showPluginQuickAccess"
|
||
:pull-distance="pullDistance"
|
||
@close="handleClosePluginQuickAccess"
|
||
@plugin-click="handlePluginClick"
|
||
/>
|
||
</template>
|
||
|
||
<style lang="scss" scoped>
|
||
.main-content-wrapper {
|
||
backface-visibility: hidden;
|
||
block-size: 100%;
|
||
inline-size: 100%;
|
||
transform: translateZ(0);
|
||
will-change: transform;
|
||
}
|
||
|
||
.pull-indicator {
|
||
position: fixed;
|
||
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: 80px;
|
||
inset-inline-start: 50%;
|
||
pointer-events: none;
|
||
transform: translateX(-50%);
|
||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
|
||
.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>
|