<template> <view class="dropdown-wrapper"> <!-- 插槽:触发器 --> <slot name="reference" :open="open" :close="close" :toggle="toggle"> <!-- 默认触发器 --> <view class="dropdown-trigger-default" data-dropdown-trigger @tap="open"> 点击选择 ▼ </view> </slot> <!-- 浮动下拉菜单 --> <view v-if="visible" ref="dropdownRef" class="zy-popup-dropdown-menu" :style="{ top: `${top}px`, right: `${right}px`, transform: positionStyle?positionStyle:position, maxHeight: `${props.maxHeight}px` }" @touchmove.stop > <view class="dropdown-content"> <view v-for="item in props.data" :key="item[props.valueKey]" class="dropdown-item" :class="{ 'is-selected': props.modelValue === item[props.valueKey] }" @tap="handleSelect(item)" > <text class="item-label">{{ item[props.labelKey] }}</text> <uni-icons v-show="props.modelValue === item[props.valueKey]" class="icon-check" :type="checkIcon" color="#0f56d5" /> </view> </view> </view> </view> </template> <script setup> import { ref, nextTick, onUnmounted } from 'vue' // ---------------------- // Props 定义 // ---------------------- const props = defineProps({ // 数据源 [{ label: 'xxx', value: '1' }] data: { type: Array, required: true }, // 当前选中值(v-model) modelValue: { type: [String, Number, Boolean, null], default: null }, // 显示字段名 labelKey: { type: String, default: 'name' }, // 值字段名 valueKey: { type: String, default: 'value' }, // id名 trigger: { type: String, default: '', required: true }, checkIcon: { // 选中时候的icon type: String, default: 'checkmarkempty' }, scrollClose: { // 页面滚动是否关闭已打开组件 type: Boolean, default: true }, maxHeight: { // 展开组件的最大高度(仅传入数字即可,需要进行计算,单位是px) type: Number, default: 486 }, positionStyle: { // 展开组件的position样式 type: String, default: '' } }) // ---------------------- // Emits // ---------------------- const emit = defineEmits(['update:modelValue', 'change', 'open', 'close']) // ---------------------- // 内部状态 // ---------------------- const visible = ref(false) const top = ref(0) const left = ref(0) const right = ref(12) const position = ref('translateY(8px)') const dropdownRef = ref(null) let observer = null // ---------------------- // 获取触发器位置(核心方法) function getTriggerRect() { return new Promise(resolve => { // console.log('🚀 ~ getTriggerRect ~ props.trigger:', props.trigger) uni .createSelectorQuery() .select('#' + props.trigger) .boundingClientRect(rect => { if (rect) { // console.log('✅ 定位成功:', rect) resolve(rect) } else { // console.error('❌ 查询失败,请确认 ID 是否正确或已渲染') resolve(null) } }) .exec() }) } // ---------------------- // 打开下拉框 // ---------------------- const open = async () => { if (visible.value) { close() return } const rect = await getTriggerRect() if (!rect) return doOpen(rect) // 抽离打开逻辑 } const doOpen = rect => { // 获取设备系统信息(同步) const res = uni.getSystemInfoSync() const windowHeight = Number(res.windowHeight) // 可视窗口高度(单位 px) const windowWidth = Number(res.windowWidth) // 可视窗口宽度(单位 px) const maxHeight = props.maxHeight // 弹窗最大高度(CSS 中设定的最大高度) const menuWidth = 215 // 弹窗固定宽度,必须与 CSS 一致(避免计算偏差) const rightGap = 12 // 弹窗右侧留白距离(安全边距) console.log('【doOpen】触发打开菜单', { res, rect, windowHeight, windowWidth }) // 计算触发元素上下方可用空间 const spaceBelow = windowHeight - rect.bottom // 下方剩余空间 const spaceAbove = rect.top // 上方剩余空间 // 判断上下空间是否都不足以放下弹窗(maxHeight) const isBothDirectionsInsufficient = spaceAbove < maxHeight && spaceBelow < maxHeight console.log('【空间判断】', { spaceAbove, // 上方还有多少空间 spaceBelow, // 下方还有多少空间 maxHeight, // 需要的高度 isBothDirectionsInsufficient: isBothDirectionsInsufficient // 是否上下都不够 }) let finalTop, finalLeft, finalTransform if (isBothDirectionsInsufficient) { // ======================== // 🎯 情况1:上下空间都不够 → 居中显示 // ======================== // 🔹 Y 轴定位:将弹窗垂直居中于屏幕中央 finalTop = windowHeight / 2 // 🔹 使用 transform 实现真正的居中(X 和 Y 方向都偏移自身尺寸的 50%) finalTransform = 'translate(0, -50%)' // 🔹 X 轴定位:右对齐,距离右侧边界 12px finalLeft = windowWidth - menuWidth - rightGap // ⚠️ 安全检查:防止弹窗左侧溢出屏幕(比如小屏设备) if (finalLeft < 0) { console.warn('【警告】弹窗左溢,修正为 0') finalLeft = 0 } console.log('【居中模式】启用屏幕中心定位', { finalTop, finalLeft, finalTransform }) } else { // ======================== // 📐 情况2:至少有一侧空间足够 → 正常弹出(向上或向下) // ======================== // 根据下方空间是否足够决定方向 const needUpward = spaceBelow < maxHeight // 下方不够就往上弹 if (needUpward) { // 向上展开:弹窗底部对齐触发元素顶部,再留 8px 间隙 finalTop = rect.top - maxHeight - 8 finalTransform = 'translateY(8px)' // 微调:让动画有“出现”感(可选) } else { // 向下展开:弹窗顶部对齐触发元素底部,再留 8px 间隙 finalTop = rect.bottom + 8 finalTransform = 'translateY(-8px)' // 微调偏移 } // 🔹 水平方向统一右对齐(距离右边 12px) finalLeft = '12px' console.log('【常规模式】根据方向定位', { direction: needUpward ? '向上弹出' : '向下弹出', finalTop, finalLeft, finalTransform }) } // ✅ 更新响应式数据,驱动 UI 更新 top.value = finalTop left.value = finalLeft // right.value = finalLeft position.value = finalTransform console.log('✅ 【最终定位结果】已设置:', { top: top.value, left: left.value, // right: right.value, transform: position.value }) // 显示弹窗 visible.value = true // 触发 open 事件,携带当前 modelValue emit('open', props.modelValue, props.trigger) // 在下一次 DOM 更新后绑定外部点击和滚动监听 nextTick(() => { console.log('🔧 执行 nextTick,准备绑定事件监听...') bindOutsideClickListener() bindScrollListener() }) } // ---------------------- // 关闭 & 切换 & 选择 // ---------------------- const close = () => { if (!visible.value) return visible.value = false emit('close', props.trigger) removeListeners() } const toggle = () => { visible.value ? close() : open() } const handleSelect = item => { const value = item[props.valueKey] const label = item[props.labelKey] // 动态构造返回对象,key 来自 props const selected = { [props.valueKey]: value, [props.labelKey]: label } // 检查 modelValue 是否变化 if (props.modelValue !== value) { emit('update:modelValue', value, props.trigger) emit('change', selected, props.trigger) } close() } // ---------------------- // 外部点击关闭 // ---------------------- const bindOutsideClickListener = () => { const handler = e => { // 阻止事件冒泡时也能监听到页面点击 const path = e.path || [] const isInside = path.some(node => node?.dataset?.dropdownTrigger) if (!isInside) close() } uni.$on('onPageTap', handler) observer = { ...observer, cleanupTap: () => uni.$off('onPageTap', handler) } } // ---------------------- // 页面滚动关闭 // ---------------------- const bindScrollListener = () => { const scrollHandler = res => { if (!visible.value) return // 滚动时重新定位 // repositionDebounced() console.log('🚀 ~ bindScrollListener ~ props.scrollClose:', props.scrollClose) if (props.scrollClose) return close() reposition() } uni.$on('onPageScroll', scrollHandler) observer = { ...observer, cleanupScroll: () => uni.$off('onPageScroll', scrollHandler) } } // 新增:重新计算并设置位置 // const reposition = async () => { // const rect = await getTriggerRect() // if (!rect) return // const res = uni.getSystemInfoSync() // const windowHeight = res.windowHeight // const maxHeight = props.maxHeight // const needUpward = windowHeight - rect.bottom < maxHeight // top.value = needUpward ? rect.top - maxHeight - 8 : rect.bottom + 8 // position.value = needUpward ? 'translateY(-8px)' : 'translateY(8px)' // } const reposition = async () => { // 获取触发元素的边界信息 const rect = await getTriggerRect() if (!rect) { console.warn('【reposition】无法获取触发元素位置,跳过重定位') return } console.log('【reposition】开始重新定位', { rect }) const res = uni.getSystemInfoSync() const windowHeight = Number(res.windowHeight) const windowWidth = Number(res.windowWidth) const maxHeight = props.maxHeight // 弹窗最大高度 const menuWidth = 215 // 必须与 CSS 一致 const rightGap = 12 // 右侧边距 // 计算上下可用空间 const spaceBelow = windowHeight - rect.bottom const spaceAbove = rect.top const isBothDirectionsInsufficient = spaceAbove < maxHeight && spaceBelow < maxHeight console.log('【reposition - 空间判断】', { spaceAbove, spaceBelow, maxHeight, isBothDirectionsInsufficient }) let finalTop, finalLeft, finalTransform if (isBothDirectionsInsufficient) { // ======================== // 🎯 情况1:上下都不够 → 屏幕居中 + 右对齐 // ======================== finalTop = windowHeight / 2 finalTransform = 'translate(0, -50%)' // Y轴居中,X不偏移(left 已经精确设置) finalLeft = windowWidth - menuWidth - rightGap if (finalLeft < 0) { console.warn('【reposition】左溢修正', finalLeft) finalLeft = 0 } console.log('【reposition - 居中模式】', { finalTop, finalLeft, finalTransform }) } else { // ======================== // 📐 情况2:正常方向展开 // ======================== const needUpward = spaceBelow < maxHeight finalTop = needUpward ? rect.top - maxHeight - 8 : rect.bottom + 8 finalTransform = needUpward ? 'translateY(-8px)' : 'translateY(8px)' finalLeft = windowWidth - menuWidth - rightGap if (finalLeft < 0) { console.warn('【reposition】右对齐导致左溢,修正为 0') finalLeft = 0 } console.log('【reposition - 常规模式】', { direction: needUpward ? '向上' : '向下', finalTop, finalLeft, finalTransform }) } // ✅ 更新响应式变量 top.value = finalTop left.value = finalLeft position.value = finalTransform console.log('✅ 【reposition 成功】更新定位:', { top: top.value, left: left.value, transform: position.value }) } const debounce = (fn, time = 10) => { let timer = null return function (...args) { if (timer) clearTimeout(timer) timer = setTimeout(() => { fn.apply(this, args) }, time) } } const repositionDebounced = debounce(reposition, 50) // ---------------------- // 移除监听 // ---------------------- const removeListeners = () => { observer?.cleanupTap?.() observer?.cleanupScroll?.() observer = null } // ---------------------- // 暴露方法给父组件调用 // ---------------------- defineExpose({ open, close, toggle }) // ---------------------- // 卸载清理 // ---------------------- onUnmounted(() => { removeListeners() }) </script> <style scoped> /* 整体容器 */ .dropdown-wrapper { display: inline-block; } /* 默认触发器样式 */ .dropdown-trigger-default { display: inline-flex; align-items: center; justify-content: center; padding: 8px 16px; background-color: #fff; border: 1px solid #ddd; border-radius: 6px; font-size: 15px; color: #333; } /* 下拉菜单主体 */ .zy-popup-dropdown-menu { position: fixed; width: 215px; background-color: #ffffff; border-radius: 8px; z-index: 9999; display: flex; flex-direction: column; box-shadow: 0 4px 5px -3px rgba(0, 0, 0, 0.08), 0 8px 12px 1px rgba(0, 0, 0, 0.04), 0 3px 15px 3px rgba(0, 0, 0, 0.05); } /* 内容区可滚动 */ .dropdown-content { flex: 1; height: 0; /* 防止撑开 */ max-height: 486px; overflow-y: auto; -webkit-overflow-scrolling: touch; /* iOS 平滑滚动 */ } /* 每一项 */ .dropdown-item { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; font-size: 15px; color: #333333; border-bottom: 1px solid #f5f5f5; } .dropdown-item:last-child { border-bottom: none; } /* 选中项样式 */ .dropdown-item.is-selected { color: #0f56d5; font-weight: 500; } /* 文本自动换行 */ .item-label { flex: 1; word-break: break-word; line-height: 1.4; text-align: left; } /* 对号图标 */ .icon-check { font-family: 'erda' !important; /* 可替换为 iconfont 字体 */ font-size: 16px; margin-left: 8px; color: #0f56d5; } </style>
修改当前组件,让滚动区域使用,确保复用当前组件样式,返回我完整的代码