mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-05-21 08:10:16 +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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user