重构DefaultLayout.vue组件

This commit is contained in:
jxxghp
2025-07-03 08:48:44 +08:00
parent 8718816fce
commit eb70ca233b
4 changed files with 441 additions and 264 deletions

View 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,
}
}

View 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,
}
}

View File

@@ -16,6 +16,8 @@ import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { filterMenusByPermission } from '@/utils/permission'
import { onUnreadMessage } from '@/utils/badge'
import { usePullDownGesture } from '@/composables/usePullDownGesture'
import { useScrollLockWithWatch } from '@/composables/useScrollLock'
const display = useDisplay()
const appMode = inject('pwaMode')
@@ -55,114 +57,37 @@ const systemMenus = ref<NavMenu[]>([])
// 插件快速访问相关状态
const showPluginQuickAccess = ref(false)
// 下拉手势配置常量 (iOS风格)
const PULL_CONFIG = {
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
}
}
// 使用滚动锁定 composable自动监听showPluginQuickAccess的变化
useScrollLockWithWatch(showPluginQuickAccess)
// 检查是否可以使用下拉手势
const canUsePullGesture = computed(() => {
const canUsePullGesture = () => {
// 检查是否在dashboard页面
const isDashboard = route.name === 'dashboard' || route.path === '/dashboard'
// 检查是否是管理员
const isAdmin = superUser.value
// 检查插件快速访问面板是否已显示
const quickAccessOpen = showPluginQuickAccess.value
return isDashboard && isAdmin
})
return isDashboard && isAdmin && !quickAccessOpen
}
// 计算页面内容的transform
const contentTransform = computed(() => {
if (!isPulling.value || pullDistance.value <= 0) return 'translateY(0)'
// 页面内容跟随下拉距离,使用配置的跟随比例
const moveDistance = pullDistance.value * PULL_CONFIG.CONTENT_FOLLOW_RATIO
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 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的透明度
// 使用下拉手势 composable
const {
pullDistance,
contentTransform,
contentTransition,
showPullIndicator,
indicatorRotation,
indicatorOpacity,
indicatorTransform,
config: PULL_CONFIG,
} = usePullDownGesture({
enabled: true,
canUsePullGesture,
onTrigger: () => {
showPluginQuickAccess.value = true
},
})
// 根据分类获取菜单列表
@@ -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() {
showPluginQuickAccess.value = false
@@ -310,34 +125,6 @@ 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'))
@@ -349,27 +136,9 @@ onMounted(() => {
// 监听全局未读消息事件
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>
@@ -381,7 +150,7 @@ onMounted(() => {
class="pull-indicator"
:style="{
opacity: indicatorOpacity,
transform: `translate(-50%, ${Math.min(20 + pullDistance - PULL_CONFIG.SHOW_INDICATOR, 50)}px)`,
transform: indicatorTransform,
}"
>
<div

View File

@@ -121,7 +121,7 @@ onBeforeUnmount(() => {
:mode="!isLoaded ? 'intersect' : 'manual'"
side="start"
:items="messages"
class="overflow-visible message-scroll h-full"
class="overflow-auto h-full"
@load="loadMessages"
:load-more-text="t('message.loadMore') + ' ...'"
>
@@ -143,9 +143,3 @@ onBeforeUnmount(() => {
</div>
</VInfiniteScroll>
</template>
<style scoped>
.message-scroll {
overflow-y: auto !important;
}
</style>