| <template> |
| <Teleport to="body"> |
| <Transition name="bottom-sheet" appear> |
| <div v-if="visible" class="bottom-sheet-overlay" @click="handleOverlayClick"> |
| <div |
| class="bottom-sheet" |
| :class="{ fullscreen: isFullscreen }" |
| @click.stop |
| ref="sheetRef" |
| > |
| |
| <div class="drag-indicator" v-if="draggable" @touchstart="handleTouchStart" @mousedown="handleMouseDown"> |
| <div class="drag-handle"></div> |
| </div> |
| |
| |
| <div class="bottom-sheet-header" v-if="title || closable"> |
| <h3 class="sheet-title" v-if="title">{{ title }}</h3> |
| <button |
| v-if="closable" |
| class="sheet-close-btn" |
| @click="handleClose" |
| > |
| <i class="fas fa-times"></i> |
| </button> |
| </div> |
| |
| |
| <div class="bottom-sheet-body" ref="bodyRef"> |
| <slot></slot> |
| </div> |
| </div> |
| </div> |
| </Transition> |
| </Teleport> |
| </template> |
| |
| <script setup> |
| import { ref, onMounted, onUnmounted, nextTick } from 'vue' |
| |
| const props = defineProps({ |
| // 标题 |
| title: { |
| type: String, |
| default: '' |
| }, |
| |
| |
| closable: { |
| type: Boolean, |
| default: true |
| }, |
| |
| |
| maskClosable: { |
| type: Boolean, |
| default: true |
| }, |
| |
| |
| draggable: { |
| type: Boolean, |
| default: true |
| }, |
| |
| |
| maxHeight: { |
| type: Number, |
| default: 90 |
| } |
| }) |
| |
| const emit = defineEmits(['close', 'open']) |
| |
| const visible = ref(false) |
| const isFullscreen = ref(false) |
| const sheetRef = ref(null) |
| const bodyRef = ref(null) |
| |
| |
| const isDragging = ref(false) |
| const startY = ref(0) |
| const startHeight = ref(0) |
| const currentTranslateY = ref(0) |
| |
| |
| const handleOverlayClick = () => { |
| if (props.maskClosable) { |
| handleClose() |
| } |
| } |
| |
| |
| const handleClose = () => { |
| close() |
| } |
| |
| |
| const handleTouchStart = (event) => { |
| if (!props.draggable) return |
| |
| isDragging.value = true |
| startY.value = event.touches[0].clientY |
| startHeight.value = sheetRef.value.offsetHeight |
| currentTranslateY.value = 0 |
| |
| document.addEventListener('touchmove', handleTouchMove, { passive: false }) |
| document.addEventListener('touchend', handleTouchEnd) |
| } |
| |
| |
| const handleMouseDown = (event) => { |
| if (!props.draggable) return |
| |
| isDragging.value = true |
| startY.value = event.clientY |
| startHeight.value = sheetRef.value.offsetHeight |
| currentTranslateY.value = 0 |
| |
| document.addEventListener('mousemove', handleMouseMove) |
| document.addEventListener('mouseup', handleMouseUp) |
| event.preventDefault() |
| } |
| |
| |
| const handleTouchMove = (event) => { |
| if (!isDragging.value) return |
| |
| const deltaY = event.touches[0].clientY - startY.value |
| |
| // 只允许向下拖拽 |
| if (deltaY > 0) { |
| currentTranslateY.value = deltaY |
| sheetRef.value.style.transform = `translateY(${deltaY}px)` |
| } |
| |
| event.preventDefault() |
| } |
| |
| // 处理鼠标移动 |
| const handleMouseMove = (event) => { |
| if (!isDragging.value) return |
| |
| const deltaY = event.clientY - startY.value |
| |
| if (deltaY > 0) { |
| currentTranslateY.value = deltaY |
| sheetRef.value.style.transform = `translateY(${deltaY}px)` |
| } |
| } |
| |
| // 处理触摸结束 |
| const handleTouchEnd = () => { |
| if (!isDragging.value) return |
| |
| isDragging.value = false |
| |
| // 如果拖拽距离超过阈值,关闭弹窗 |
| if (currentTranslateY.value > startHeight.value * 0.3) { |
| close() |
| } else { |
| // 回弹到原位置 |
| sheetRef.value.style.transform = '' |
| sheetRef.value.style.transition = 'transform 0.3s ease-out' |
| |
| setTimeout(() => { |
| if (sheetRef.value) { |
| sheetRef.value.style.transition = '' |
| } |
| }, 300) |
| } |
| |
| currentTranslateY.value = 0 |
| document.removeEventListener('touchmove', handleTouchMove) |
| document.removeEventListener('touchend', handleTouchEnd) |
| } |
| |
| |
| const handleMouseUp = () => { |
| if (!isDragging.value) return |
| |
| isDragging.value = false |
| |
| if (currentTranslateY.value > startHeight.value * 0.3) { |
| close() |
| } else { |
| sheetRef.value.style.transform = '' |
| sheetRef.value.style.transition = 'transform 0.3s ease-out' |
| |
| setTimeout(() => { |
| if (sheetRef.value) { |
| sheetRef.value.style.transition = '' |
| } |
| }, 300) |
| } |
| |
| currentTranslateY.value = 0 |
| document.removeEventListener('mousemove', handleMouseMove) |
| document.removeEventListener('mouseup', handleMouseUp) |
| } |
| |
| |
| const handleKeyDown = (event) => { |
| if (event.key === 'Escape' && props.closable) { |
| handleClose() |
| } |
| } |
| |
| |
| const open = () => { |
| visible.value = true |
| document.body.style.overflow = 'hidden' |
| document.addEventListener('keydown', handleKeyDown) |
| |
| nextTick(() => { |
| // 检查内容是否需要全屏显示 |
| if (bodyRef.value) { |
| const contentHeight = bodyRef.value.scrollHeight |
| const maxAllowedHeight = window.innerHeight * (props.maxHeight / 100) |
| |
| if (contentHeight > maxAllowedHeight * 0.8) { |
| isFullscreen.value = true |
| } |
| } |
| }) |
| |
| emit('open') |
| } |
| |
| |
| const close = () => { |
| visible.value = false |
| document.body.style.overflow = '' |
| document.removeEventListener('keydown', handleKeyDown) |
| emit('close') |
| } |
| |
| |
| onMounted(() => { |
| open() |
| }) |
| |
| onUnmounted(() => { |
| document.body.style.overflow = '' |
| document.removeEventListener('keydown', handleKeyDown) |
| document.removeEventListener('touchmove', handleTouchMove) |
| document.removeEventListener('touchend', handleTouchEnd) |
| document.removeEventListener('mousemove', handleMouseMove) |
| document.removeEventListener('mouseup', handleMouseUp) |
| }) |
| |
| |
| defineExpose({ |
| open, |
| close |
| }) |
| </script> |
| |
| <style scoped> |
| .bottom-sheet-overlay { |
| position: fixed; |
| top: 0; |
| left: 0; |
| right: 0; |
| bottom: 0; |
| background: rgba(0, 0, 0, 0.6); |
| backdrop-filter: blur(4px); |
| z-index: 2000; |
| display: flex; |
| align-items: flex-end; |
| justify-content: center; |
| } |
| |
| .bottom-sheet { |
| width: 100%; |
| max-width: 500px; |
| max-height: v-bind(maxHeight + '%'); |
| background: var(--bg-secondary); |
| border-radius: 16px 16px 0 0; |
| border: 1px solid rgba(255, 255, 255, 0.1); |
| box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.3); |
| overflow: hidden; |
| display: flex; |
| flex-direction: column; |
| margin-bottom: 0; |
| user-select: none; |
| } |
| |
| .bottom-sheet.fullscreen { |
| height: 90%; |
| border-radius: 16px; |
| margin: 20px; |
| } |
| |
| .drag-indicator { |
| padding: 8px 0 4px; |
| display: flex; |
| justify-content: center; |
| cursor: grab; |
| } |
| |
| .drag-indicator:active { |
| cursor: grabbing; |
| } |
| |
| .drag-handle { |
| width: 32px; |
| height: 4px; |
| background: rgba(255, 255, 255, 0.3); |
| border-radius: 2px; |
| } |
| |
| .bottom-sheet-header { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| padding: 16px 20px; |
| border-bottom: 1px solid rgba(255, 255, 255, 0.1); |
| flex-shrink: 0; |
| } |
| |
| .sheet-title { |
| font-size: 18px; |
| font-weight: 600; |
| color: var(--text-primary); |
| margin: 0; |
| } |
| |
| .sheet-close-btn { |
| width: 32px; |
| height: 32px; |
| border: none; |
| background: rgba(255, 255, 255, 0.1); |
| color: var(--text-secondary); |
| border-radius: 50%; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| cursor: pointer; |
| transition: var(--transition-fast); |
| } |
| |
| .sheet-close-btn:hover { |
| background: rgba(255, 255, 255, 0.2); |
| color: var(--text-primary); |
| } |
| |
| .bottom-sheet-body { |
| flex: 1; |
| overflow-y: auto; |
| min-height: 0; |
| } |
| |
| |
| .bottom-sheet-enter-active, |
| .bottom-sheet-leave-active { |
| transition: all 0.3s ease-out; |
| } |
| |
| .bottom-sheet-enter-from, |
| .bottom-sheet-leave-to { |
| opacity: 0; |
| } |
| |
| .bottom-sheet-enter-from .bottom-sheet, |
| .bottom-sheet-leave-to .bottom-sheet { |
| transform: translateY(100%); |
| } |
| |
| |
| @media (max-width: 500px) { |
| .bottom-sheet { |
| max-width: none; |
| margin: 0; |
| } |
| |
| .bottom-sheet.fullscreen { |
| margin: 0; |
| height: 95%; |
| border-radius: 16px 16px 0 0; |
| } |
| } |
| |
| @media (max-width: 375px) { |
| .bottom-sheet-header { |
| padding: 14px 16px; |
| } |
| |
| .sheet-title { |
| font-size: 16px; |
| } |
| |
| .sheet-close-btn { |
| width: 28px; |
| height: 28px; |
| } |
| } |
| |
| |
| .bottom-sheet-body::-webkit-scrollbar { |
| width: 4px; |
| } |
| |
| .bottom-sheet-body::-webkit-scrollbar-track { |
| background: transparent; |
| } |
| |
| .bottom-sheet-body::-webkit-scrollbar-thumb { |
| background: rgba(255, 255, 255, 0.2); |
| border-radius: 2px; |
| } |
| |
| .bottom-sheet-body::-webkit-scrollbar-thumb:hover { |
| background: rgba(255, 255, 255, 0.3); |
| } |
| </style> |