mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-25 01:59:35 +08:00
重构DefaultLayout.vue组件
This commit is contained in:
255
src/composables/usePullDownGesture.ts
Normal file
255
src/composables/usePullDownGesture.ts
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import { ref, computed, onMounted, onBeforeUnmount, inject, readonly } from 'vue'
|
||||||
|
import { useDisplay } from 'vuetify'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
// 下拉手势配置类型
|
||||||
|
export interface PullDownConfig {
|
||||||
|
START_THRESHOLD: number // 开始下拉的最小距离
|
||||||
|
SHOW_INDICATOR: number // 显示指示器的距离
|
||||||
|
TRIGGER_THRESHOLD: number // 触发回调的距离
|
||||||
|
MAX_PULL_DISTANCE: number // 最大下拉距离
|
||||||
|
PULL_RESISTANCE: number // 下拉阻力系数
|
||||||
|
CONTENT_FOLLOW_RATIO: number // 页面内容跟随比例
|
||||||
|
TOLERANCE: number // 手指抖动容忍度
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下拉手势选项
|
||||||
|
export interface PullDownOptions {
|
||||||
|
config?: Partial<PullDownConfig>
|
||||||
|
// 检查是否可以使用下拉手势的函数
|
||||||
|
canUsePullGesture?: () => boolean
|
||||||
|
// 触发回调
|
||||||
|
onTrigger?: () => void
|
||||||
|
// 是否启用(默认true)
|
||||||
|
enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认配置
|
||||||
|
const DEFAULT_CONFIG: PullDownConfig = {
|
||||||
|
START_THRESHOLD: 20,
|
||||||
|
SHOW_INDICATOR: 60,
|
||||||
|
TRIGGER_THRESHOLD: 100,
|
||||||
|
MAX_PULL_DISTANCE: 200,
|
||||||
|
PULL_RESISTANCE: 0.75,
|
||||||
|
CONTENT_FOLLOW_RATIO: 0.4,
|
||||||
|
TOLERANCE: 80,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePullDownGesture(options: PullDownOptions = {}) {
|
||||||
|
const display = useDisplay()
|
||||||
|
const route = useRoute()
|
||||||
|
const appMode = inject('pwaMode')
|
||||||
|
|
||||||
|
// 合并配置
|
||||||
|
const config = { ...DEFAULT_CONFIG, ...options.config }
|
||||||
|
|
||||||
|
// 状态管理
|
||||||
|
const isPulling = ref(false)
|
||||||
|
const startY = ref(0)
|
||||||
|
const pullDistance = ref(0)
|
||||||
|
const initialScrollTop = ref(0)
|
||||||
|
const hasDialogOpen = ref(false)
|
||||||
|
const lastDialogCheckTime = ref(0)
|
||||||
|
const DIALOG_CHECK_INTERVAL = 500
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const contentTransform = computed(() => {
|
||||||
|
if (!isPulling.value || pullDistance.value <= 0) return 'translateY(0)'
|
||||||
|
const moveDistance = pullDistance.value * config.CONTENT_FOLLOW_RATIO
|
||||||
|
return `translateY(${moveDistance}px)`
|
||||||
|
})
|
||||||
|
|
||||||
|
const contentTransition = computed(() => {
|
||||||
|
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 >= config.SHOW_INDICATOR
|
||||||
|
})
|
||||||
|
|
||||||
|
const indicatorRotation = computed(() => {
|
||||||
|
if (!isPulling.value) return 0
|
||||||
|
const progress = Math.min(
|
||||||
|
(pullDistance.value - config.SHOW_INDICATOR) / (config.TRIGGER_THRESHOLD - config.SHOW_INDICATOR),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
return progress * 180
|
||||||
|
})
|
||||||
|
|
||||||
|
const indicatorOpacity = computed(() => {
|
||||||
|
if (!isPulling.value) return 0
|
||||||
|
const progress = Math.min(
|
||||||
|
(pullDistance.value - config.SHOW_INDICATOR) / (config.TRIGGER_THRESHOLD - config.SHOW_INDICATOR),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
return 0.7 + progress * 0.3
|
||||||
|
})
|
||||||
|
|
||||||
|
const indicatorTransform = computed(() => {
|
||||||
|
return `translate(-50%, ${Math.min(20 + pullDistance.value - config.SHOW_INDICATOR, 50)}px)`
|
||||||
|
})
|
||||||
|
|
||||||
|
// 弹窗检测函数
|
||||||
|
const hasOpenDialog = (excludeSelector?: string) => {
|
||||||
|
try {
|
||||||
|
const dialogSelectors = [
|
||||||
|
'.v-overlay--active:not(.v-overlay--scroll-blocked)',
|
||||||
|
'.v-dialog--active',
|
||||||
|
'.v-menu--active',
|
||||||
|
'.v-bottom-sheet--active',
|
||||||
|
'.v-snackbar--active',
|
||||||
|
'[role="dialog"]:not([style*="display: none"])',
|
||||||
|
'.modal:not(.d-none):not([style*="display: none"])',
|
||||||
|
'[aria-modal="true"]:not([style*="display: none"])',
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const selector of dialogSelectors) {
|
||||||
|
const elements = document.querySelectorAll(selector)
|
||||||
|
if (elements.length > 0) {
|
||||||
|
// 如果需要排除特定元素(如QuickAccess面板)
|
||||||
|
if (excludeSelector && elements.length === 1) {
|
||||||
|
const element = elements[0]
|
||||||
|
if (element.closest(excludeSelector)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('检测弹窗状态时出错:', error)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 事件处理函数
|
||||||
|
const handleTouchStart = (event: TouchEvent) => {
|
||||||
|
if (!appMode || !display.mdAndDown.value || !options.enabled) return
|
||||||
|
|
||||||
|
// 检查是否可以使用下拉手势
|
||||||
|
if (options.canUsePullGesture && !options.canUsePullGesture()) return
|
||||||
|
|
||||||
|
// 检查是否有弹窗打开
|
||||||
|
hasDialogOpen.value = hasOpenDialog('.quick-access-panel')
|
||||||
|
lastDialogCheckTime.value = Date.now()
|
||||||
|
|
||||||
|
if (hasDialogOpen.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
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTouchMove = (event: TouchEvent) => {
|
||||||
|
if (!appMode || !display.mdAndDown.value || !options.enabled) return
|
||||||
|
|
||||||
|
// 检查是否可以使用下拉手势
|
||||||
|
if (options.canUsePullGesture && !options.canUsePullGesture()) return
|
||||||
|
|
||||||
|
// 只在必要时重新检测弹窗
|
||||||
|
const currentTime = Date.now()
|
||||||
|
if (currentTime - lastDialogCheckTime.value > DIALOG_CHECK_INTERVAL) {
|
||||||
|
hasDialogOpen.value = hasOpenDialog('.quick-access-panel')
|
||||||
|
lastDialogCheckTime.value = currentTime
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasDialogOpen.value) {
|
||||||
|
isPulling.value = false
|
||||||
|
pullDistance.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const touch = event.touches[0]
|
||||||
|
const deltaY = touch.clientY - startY.value
|
||||||
|
|
||||||
|
if (isPulling.value) {
|
||||||
|
if (deltaY > -config.TOLERANCE) {
|
||||||
|
pullDistance.value = Math.max(0, Math.min(deltaY * config.PULL_RESISTANCE, config.MAX_PULL_DISTANCE))
|
||||||
|
event.preventDefault()
|
||||||
|
} else {
|
||||||
|
isPulling.value = false
|
||||||
|
pullDistance.value = 0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (deltaY > config.START_THRESHOLD) {
|
||||||
|
const currentScrollTop = window.scrollY || document.documentElement.scrollTop || 0
|
||||||
|
|
||||||
|
if (currentScrollTop <= 100 && initialScrollTop.value <= 100) {
|
||||||
|
isPulling.value = true
|
||||||
|
pullDistance.value = Math.min(deltaY * config.PULL_RESISTANCE, config.MAX_PULL_DISTANCE)
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
if (!appMode || !display.mdAndDown.value || !options.enabled) return
|
||||||
|
|
||||||
|
// 检查是否可以使用下拉手势
|
||||||
|
if (options.canUsePullGesture && !options.canUsePullGesture()) return
|
||||||
|
|
||||||
|
// 重置弹窗检测标志
|
||||||
|
hasDialogOpen.value = false
|
||||||
|
lastDialogCheckTime.value = 0
|
||||||
|
|
||||||
|
if (isPulling.value && pullDistance.value >= config.TRIGGER_THRESHOLD) {
|
||||||
|
// 达到触发阈值,执行回调
|
||||||
|
options.onTrigger?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止拖拽状态
|
||||||
|
isPulling.value = false
|
||||||
|
|
||||||
|
// 延迟重置其他状态
|
||||||
|
setTimeout(() => {
|
||||||
|
pullDistance.value = 0
|
||||||
|
startY.value = 0
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生命周期管理
|
||||||
|
onMounted(() => {
|
||||||
|
if (appMode && display.mdAndDown.value) {
|
||||||
|
document.addEventListener('touchstart', handleTouchStart, { passive: false })
|
||||||
|
document.addEventListener('touchmove', handleTouchMove, { passive: false })
|
||||||
|
document.addEventListener('touchend', handleTouchEnd, { passive: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (appMode && display.mdAndDown.value) {
|
||||||
|
document.removeEventListener('touchstart', handleTouchStart)
|
||||||
|
document.removeEventListener('touchmove', handleTouchMove)
|
||||||
|
document.removeEventListener('touchend', handleTouchEnd)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
isPulling: readonly(isPulling),
|
||||||
|
pullDistance: readonly(pullDistance),
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
contentTransform,
|
||||||
|
contentTransition,
|
||||||
|
showPullIndicator,
|
||||||
|
indicatorRotation,
|
||||||
|
indicatorOpacity,
|
||||||
|
indicatorTransform,
|
||||||
|
|
||||||
|
// 配置
|
||||||
|
config,
|
||||||
|
|
||||||
|
// 工具函数
|
||||||
|
hasOpenDialog,
|
||||||
|
}
|
||||||
|
}
|
||||||
159
src/composables/useScrollLock.ts
Normal file
159
src/composables/useScrollLock.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { ref, watch, onBeforeUnmount, readonly } from 'vue'
|
||||||
|
|
||||||
|
// 滚动锁定配置选项
|
||||||
|
export interface ScrollLockOptions {
|
||||||
|
// 是否在组件卸载时自动恢复滚动(默认true)
|
||||||
|
autoRestore?: boolean
|
||||||
|
// 是否保存和恢复滚动位置(默认true)
|
||||||
|
preserveScrollPosition?: boolean
|
||||||
|
// 自定义锁定时的样式
|
||||||
|
lockStyles?: {
|
||||||
|
overflow?: string
|
||||||
|
position?: string
|
||||||
|
width?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认配置
|
||||||
|
const DEFAULT_OPTIONS: Required<ScrollLockOptions> = {
|
||||||
|
autoRestore: true,
|
||||||
|
preserveScrollPosition: true,
|
||||||
|
lockStyles: {
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'fixed',
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScrollLock(options: ScrollLockOptions = {}) {
|
||||||
|
const config = { ...DEFAULT_OPTIONS, ...options }
|
||||||
|
|
||||||
|
// 状态管理
|
||||||
|
const isLocked = ref(false)
|
||||||
|
const savedScrollPosition = ref(0)
|
||||||
|
const originalBodyStyles = ref<{ [key: string]: string }>({})
|
||||||
|
const originalDocumentStyles = ref<{ [key: string]: string }>({})
|
||||||
|
|
||||||
|
// 保存当前滚动位置
|
||||||
|
const saveScrollPosition = () => {
|
||||||
|
if (config.preserveScrollPosition) {
|
||||||
|
savedScrollPosition.value =
|
||||||
|
window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存原始样式
|
||||||
|
const saveOriginalStyles = () => {
|
||||||
|
// 保存 body 样式
|
||||||
|
originalBodyStyles.value = {
|
||||||
|
overflow: document.body.style.overflow,
|
||||||
|
position: document.body.style.position,
|
||||||
|
top: document.body.style.top,
|
||||||
|
width: document.body.style.width,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存 documentElement 样式
|
||||||
|
originalDocumentStyles.value = {
|
||||||
|
overflow: document.documentElement.style.overflow,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 锁定滚动
|
||||||
|
const lockScroll = () => {
|
||||||
|
if (isLocked.value) return
|
||||||
|
|
||||||
|
// 保存当前状态
|
||||||
|
saveScrollPosition()
|
||||||
|
saveOriginalStyles()
|
||||||
|
|
||||||
|
// 应用锁定样式
|
||||||
|
document.body.style.overflow = config.lockStyles.overflow || 'hidden'
|
||||||
|
document.body.style.position = config.lockStyles.position || 'fixed'
|
||||||
|
document.body.style.width = config.lockStyles.width || '100%'
|
||||||
|
document.documentElement.style.overflow = config.lockStyles.overflow || 'hidden'
|
||||||
|
|
||||||
|
// 如果需要保持滚动位置,设置top偏移
|
||||||
|
if (config.preserveScrollPosition) {
|
||||||
|
document.body.style.top = `-${savedScrollPosition.value}px`
|
||||||
|
}
|
||||||
|
|
||||||
|
isLocked.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复滚动
|
||||||
|
const restoreScroll = () => {
|
||||||
|
if (!isLocked.value) return
|
||||||
|
|
||||||
|
// 恢复原始样式
|
||||||
|
document.body.style.overflow = originalBodyStyles.value.overflow || ''
|
||||||
|
document.body.style.position = originalBodyStyles.value.position || ''
|
||||||
|
document.body.style.top = originalBodyStyles.value.top || ''
|
||||||
|
document.body.style.width = originalBodyStyles.value.width || ''
|
||||||
|
document.documentElement.style.overflow = originalDocumentStyles.value.overflow || ''
|
||||||
|
|
||||||
|
// 恢复滚动位置
|
||||||
|
if (config.preserveScrollPosition) {
|
||||||
|
window.scrollTo(0, savedScrollPosition.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
isLocked.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换滚动锁定状态
|
||||||
|
const toggleScrollLock = (lock?: boolean) => {
|
||||||
|
const shouldLock = lock !== undefined ? lock : !isLocked.value
|
||||||
|
|
||||||
|
if (shouldLock) {
|
||||||
|
lockScroll()
|
||||||
|
} else {
|
||||||
|
restoreScroll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听响应式值的变化
|
||||||
|
const watchTarget = (target: any) => {
|
||||||
|
return watch(
|
||||||
|
target,
|
||||||
|
newValue => {
|
||||||
|
toggleScrollLock(!!newValue)
|
||||||
|
},
|
||||||
|
{ immediate: false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生命周期清理
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (config.autoRestore && isLocked.value) {
|
||||||
|
restoreScroll()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
isLocked: readonly(isLocked),
|
||||||
|
savedScrollPosition: readonly(savedScrollPosition),
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
lockScroll,
|
||||||
|
restoreScroll,
|
||||||
|
toggleScrollLock,
|
||||||
|
watchTarget,
|
||||||
|
|
||||||
|
// 工具方法
|
||||||
|
saveScrollPosition,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 便捷的自动监听版本
|
||||||
|
export function useScrollLockWithWatch(target: any, options: ScrollLockOptions = {}) {
|
||||||
|
const scrollLock = useScrollLock(options)
|
||||||
|
|
||||||
|
// 自动监听目标值的变化
|
||||||
|
const stopWatcher = scrollLock.watchTarget(target)
|
||||||
|
|
||||||
|
// 返回所有功能 + 停止监听的方法
|
||||||
|
return {
|
||||||
|
...scrollLock,
|
||||||
|
stopWatcher,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { filterMenusByPermission } from '@/utils/permission'
|
import { filterMenusByPermission } from '@/utils/permission'
|
||||||
import { onUnreadMessage } from '@/utils/badge'
|
import { onUnreadMessage } from '@/utils/badge'
|
||||||
|
import { usePullDownGesture } from '@/composables/usePullDownGesture'
|
||||||
|
import { useScrollLockWithWatch } from '@/composables/useScrollLock'
|
||||||
|
|
||||||
const display = useDisplay()
|
const display = useDisplay()
|
||||||
const appMode = inject('pwaMode')
|
const appMode = inject('pwaMode')
|
||||||
@@ -55,114 +57,37 @@ const systemMenus = ref<NavMenu[]>([])
|
|||||||
// 插件快速访问相关状态
|
// 插件快速访问相关状态
|
||||||
const showPluginQuickAccess = ref(false)
|
const showPluginQuickAccess = ref(false)
|
||||||
|
|
||||||
// 下拉手势配置常量 (iOS风格)
|
// 使用滚动锁定 composable(自动监听showPluginQuickAccess的变化)
|
||||||
const PULL_CONFIG = {
|
useScrollLockWithWatch(showPluginQuickAccess)
|
||||||
START_THRESHOLD: 20, // 开始下拉的最小距离
|
|
||||||
SHOW_INDICATOR: 60, // 显示指示器的距离
|
|
||||||
TRIGGER_THRESHOLD: 100, // 触发快速访问的距离
|
|
||||||
MAX_PULL_DISTANCE: 200, // 最大下拉距离
|
|
||||||
PULL_RESISTANCE: 0.75, // 下拉阻力系数
|
|
||||||
CONTENT_FOLLOW_RATIO: 0.4, // 页面内容跟随比例
|
|
||||||
TOLERANCE: 80, // 手指抖动容忍度
|
|
||||||
}
|
|
||||||
|
|
||||||
// 下拉检测相关状态
|
|
||||||
const isPulling = ref(false)
|
|
||||||
const startY = ref(0)
|
|
||||||
const pullDistance = ref(0)
|
|
||||||
const initialScrollTop = ref(0)
|
|
||||||
|
|
||||||
// 检查是否有弹窗打开的函数
|
|
||||||
const hasOpenDialog = () => {
|
|
||||||
try {
|
|
||||||
// 检查 Vuetify 的各种弹窗组件
|
|
||||||
const vuetifyOverlays = document.querySelectorAll('.v-overlay--active:not(.v-overlay--scroll-blocked)')
|
|
||||||
const dialogs = document.querySelectorAll('.v-dialog--active')
|
|
||||||
const menus = document.querySelectorAll('.v-menu--active')
|
|
||||||
const bottomSheets = document.querySelectorAll('.v-bottom-sheet--active')
|
|
||||||
const snackbars = document.querySelectorAll('.v-snackbar--active')
|
|
||||||
|
|
||||||
// 检查自定义弹窗元素
|
|
||||||
const customDialogs = document.querySelectorAll('[role="dialog"]:not([style*="display: none"])')
|
|
||||||
const modalElements = document.querySelectorAll('.modal:not(.d-none):not([style*="display: none"])')
|
|
||||||
|
|
||||||
// 检查具有弹窗特征的元素
|
|
||||||
const dialogElements = document.querySelectorAll('[aria-modal="true"]:not([style*="display: none"])')
|
|
||||||
|
|
||||||
// 计算有效的弹窗数量
|
|
||||||
let totalDialogs =
|
|
||||||
vuetifyOverlays.length +
|
|
||||||
dialogs.length +
|
|
||||||
menus.length +
|
|
||||||
bottomSheets.length +
|
|
||||||
snackbars.length +
|
|
||||||
customDialogs.length +
|
|
||||||
modalElements.length +
|
|
||||||
dialogElements.length
|
|
||||||
|
|
||||||
// 如果 QuickAccess 面板打开,不算作阻止下拉的弹窗
|
|
||||||
if (showPluginQuickAccess.value) {
|
|
||||||
totalDialogs = Math.max(0, totalDialogs - 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return totalDialogs > 0
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('检测弹窗状态时出错:', error)
|
|
||||||
// 出错时保守处理,认为有弹窗打开
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否可以使用下拉手势
|
// 检查是否可以使用下拉手势
|
||||||
const canUsePullGesture = computed(() => {
|
const canUsePullGesture = () => {
|
||||||
// 检查是否在dashboard页面
|
// 检查是否在dashboard页面
|
||||||
const isDashboard = route.name === 'dashboard' || route.path === '/dashboard'
|
const isDashboard = route.name === 'dashboard' || route.path === '/dashboard'
|
||||||
|
|
||||||
// 检查是否是管理员
|
// 检查是否是管理员
|
||||||
const isAdmin = superUser.value
|
const isAdmin = superUser.value
|
||||||
|
// 检查插件快速访问面板是否已显示
|
||||||
|
const quickAccessOpen = showPluginQuickAccess.value
|
||||||
|
|
||||||
return isDashboard && isAdmin
|
return isDashboard && isAdmin && !quickAccessOpen
|
||||||
})
|
}
|
||||||
|
|
||||||
// 计算页面内容的transform
|
// 使用下拉手势 composable
|
||||||
const contentTransform = computed(() => {
|
const {
|
||||||
if (!isPulling.value || pullDistance.value <= 0) return 'translateY(0)'
|
pullDistance,
|
||||||
// 页面内容跟随下拉距离,使用配置的跟随比例
|
contentTransform,
|
||||||
const moveDistance = pullDistance.value * PULL_CONFIG.CONTENT_FOLLOW_RATIO
|
contentTransition,
|
||||||
return `translateY(${moveDistance}px)`
|
showPullIndicator,
|
||||||
})
|
indicatorRotation,
|
||||||
|
indicatorOpacity,
|
||||||
// 计算页面内容的transition
|
indicatorTransform,
|
||||||
const contentTransition = computed(() => {
|
config: PULL_CONFIG,
|
||||||
// 拖拽时不使用transition,松手后使用transition回弹
|
} = usePullDownGesture({
|
||||||
return isPulling.value ? 'none' : 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)'
|
enabled: true,
|
||||||
})
|
canUsePullGesture,
|
||||||
|
onTrigger: () => {
|
||||||
// 计算下拉指示器的显示状态
|
showPluginQuickAccess.value = true
|
||||||
const showPullIndicator = computed(() => {
|
},
|
||||||
return canUsePullGesture.value && isPulling.value && pullDistance.value >= PULL_CONFIG.SHOW_INDICATOR
|
|
||||||
})
|
|
||||||
|
|
||||||
// 计算下拉指示器的旋转角度
|
|
||||||
const indicatorRotation = computed(() => {
|
|
||||||
if (!isPulling.value) return 0
|
|
||||||
// 从显示指示器开始计算旋转,到触发阈值时旋转180度
|
|
||||||
const progress = Math.min(
|
|
||||||
(pullDistance.value - PULL_CONFIG.SHOW_INDICATOR) / (PULL_CONFIG.TRIGGER_THRESHOLD - PULL_CONFIG.SHOW_INDICATOR),
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
return progress * 180 // 0到180度的旋转
|
|
||||||
})
|
|
||||||
|
|
||||||
// 计算下拉指示器的透明度
|
|
||||||
const indicatorOpacity = computed(() => {
|
|
||||||
if (!isPulling.value) return 0
|
|
||||||
// 从显示指示器开始计算透明度
|
|
||||||
const progress = Math.min(
|
|
||||||
(pullDistance.value - PULL_CONFIG.SHOW_INDICATOR) / (PULL_CONFIG.TRIGGER_THRESHOLD - PULL_CONFIG.SHOW_INDICATOR),
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
return 0.7 + progress * 0.3 // 0.7到1.0的透明度
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 根据分类获取菜单列表
|
// 根据分类获取菜单列表
|
||||||
@@ -190,116 +115,6 @@ function handleUnreadMessage(count: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理触摸开始
|
|
||||||
function handleTouchStart(event: TouchEvent) {
|
|
||||||
if (!appMode || !display.mdAndDown.value) return
|
|
||||||
|
|
||||||
// 检查是否满足下拉手势的条件
|
|
||||||
if (!canUsePullGesture.value) return
|
|
||||||
|
|
||||||
// 实时检查是否有弹窗打开
|
|
||||||
if (hasOpenDialog()) 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 (!canUsePullGesture.value) return
|
|
||||||
|
|
||||||
// 实时检查是否有弹窗打开
|
|
||||||
if (hasOpenDialog()) {
|
|
||||||
// 如果检测到弹窗打开,立即停止下拉
|
|
||||||
isPulling.value = false
|
|
||||||
pullDistance.value = 0
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果插件快速访问面板已显示,不处理下拉手势
|
|
||||||
if (showPluginQuickAccess.value) return
|
|
||||||
|
|
||||||
const touch = event.touches[0]
|
|
||||||
const deltaY = touch.clientY - startY.value
|
|
||||||
|
|
||||||
// 如果已经开始下拉,继续保持下拉状态,避免中途中断
|
|
||||||
if (isPulling.value) {
|
|
||||||
// 继续下拉,但要确保是向下移动
|
|
||||||
if (deltaY > -PULL_CONFIG.TOLERANCE) {
|
|
||||||
// 允许轻微的向上偏移,避免手指抖动导致中断
|
|
||||||
pullDistance.value = Math.max(0, Math.min(deltaY * PULL_CONFIG.PULL_RESISTANCE, PULL_CONFIG.MAX_PULL_DISTANCE))
|
|
||||||
// 阻止默认滚动行为
|
|
||||||
event.preventDefault()
|
|
||||||
} else {
|
|
||||||
// 如果向上移动超过容忍度,停止下拉
|
|
||||||
isPulling.value = false
|
|
||||||
pullDistance.value = 0
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 还没开始下拉,检查是否应该开始
|
|
||||||
if (deltaY > PULL_CONFIG.START_THRESHOLD) {
|
|
||||||
// 检查当前的滚动位置
|
|
||||||
const currentScrollTop = window.scrollY || document.documentElement.scrollTop || 0
|
|
||||||
|
|
||||||
// 必须同时满足:1. 向下拖拽超过阈值 2. 当前在页面顶部 3. 从顶部开始拖拽
|
|
||||||
if (currentScrollTop <= 100 && initialScrollTop.value <= 100) {
|
|
||||||
// 向下拖拽且在页面顶部附近,开始下拉
|
|
||||||
isPulling.value = true
|
|
||||||
pullDistance.value = Math.min(deltaY * PULL_CONFIG.PULL_RESISTANCE, PULL_CONFIG.MAX_PULL_DISTANCE)
|
|
||||||
// 阻止默认滚动
|
|
||||||
event.preventDefault()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理触摸结束
|
|
||||||
function handleTouchEnd() {
|
|
||||||
if (!appMode || !display.mdAndDown.value) return
|
|
||||||
|
|
||||||
// 检查是否满足下拉手势的条件
|
|
||||||
if (!canUsePullGesture.value) return
|
|
||||||
|
|
||||||
// 实时检查是否有弹窗打开
|
|
||||||
if (hasOpenDialog()) {
|
|
||||||
// 如果检测到弹窗打开,立即停止下拉并重置状态
|
|
||||||
isPulling.value = false
|
|
||||||
pullDistance.value = 0
|
|
||||||
startY.value = 0
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果插件快速访问面板已显示,不处理下拉手势
|
|
||||||
if (showPluginQuickAccess.value) return
|
|
||||||
|
|
||||||
if (isPulling.value && pullDistance.value >= PULL_CONFIG.TRIGGER_THRESHOLD) {
|
|
||||||
// 达到触发阈值,触发插件快速访问
|
|
||||||
showPluginQuickAccess.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 先停止拖拽状态,触发回弹动画
|
|
||||||
isPulling.value = false
|
|
||||||
|
|
||||||
// 延迟重置其他状态,让动画完成
|
|
||||||
setTimeout(() => {
|
|
||||||
pullDistance.value = 0
|
|
||||||
startY.value = 0
|
|
||||||
}, 300) // 与transition时间匹配
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭插件快速访问
|
// 关闭插件快速访问
|
||||||
function handleClosePluginQuickAccess() {
|
function handleClosePluginQuickAccess() {
|
||||||
showPluginQuickAccess.value = false
|
showPluginQuickAccess.value = false
|
||||||
@@ -310,34 +125,6 @@ function handlePluginClick() {
|
|||||||
showPluginQuickAccess.value = false
|
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(() => {
|
onMounted(() => {
|
||||||
// 获取菜单列表
|
// 获取菜单列表
|
||||||
startMenus.value = getMenuList(t('menu.start'))
|
startMenus.value = getMenuList(t('menu.start'))
|
||||||
@@ -349,27 +136,9 @@ onMounted(() => {
|
|||||||
// 监听全局未读消息事件
|
// 监听全局未读消息事件
|
||||||
const unsubscribe = onUnreadMessage(handleUnreadMessage)
|
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(() => {
|
onBeforeUnmount(() => {
|
||||||
unsubscribe()
|
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>
|
</script>
|
||||||
@@ -381,7 +150,7 @@ onMounted(() => {
|
|||||||
class="pull-indicator"
|
class="pull-indicator"
|
||||||
:style="{
|
:style="{
|
||||||
opacity: indicatorOpacity,
|
opacity: indicatorOpacity,
|
||||||
transform: `translate(-50%, ${Math.min(20 + pullDistance - PULL_CONFIG.SHOW_INDICATOR, 50)}px)`,
|
transform: indicatorTransform,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ onBeforeUnmount(() => {
|
|||||||
:mode="!isLoaded ? 'intersect' : 'manual'"
|
:mode="!isLoaded ? 'intersect' : 'manual'"
|
||||||
side="start"
|
side="start"
|
||||||
:items="messages"
|
:items="messages"
|
||||||
class="overflow-visible message-scroll h-full"
|
class="overflow-auto h-full"
|
||||||
@load="loadMessages"
|
@load="loadMessages"
|
||||||
:load-more-text="t('message.loadMore') + ' ...'"
|
:load-more-text="t('message.loadMore') + ' ...'"
|
||||||
>
|
>
|
||||||
@@ -143,9 +143,3 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</VInfiniteScroll>
|
</VInfiniteScroll>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.message-scroll {
|
|
||||||
overflow-y: auto !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user