6 changed files with 645 additions and 59 deletions
@ -0,0 +1,7 @@ |
|||
import CustomDropdown from './src/index.vue' |
|||
|
|||
CustomDropdown.install = function(Vue) { |
|||
Vue.component(CustomDropdown.name || 'CustomDropdown', CustomDropdown) |
|||
} |
|||
|
|||
export default CustomDropdown |
|||
@ -0,0 +1,421 @@ |
|||
<template> |
|||
<div class="custom-select" v-clickaway="handleClickAway" ref="dropdown" :class="{ 'is-open': state.isOpen }" |
|||
:style="{ width: computedWidth }"> |
|||
<!-- 触发按钮 --> |
|||
<div class="select-trigger" @click.stop="toggleDropdown"> |
|||
<slot name="trigger"> |
|||
{{ displayText }} |
|||
</slot> |
|||
<img class="arrow-icon" :src="state.isOpen ? openIcon : expandIcon" alt="dropdown indicator"> |
|||
</div> |
|||
|
|||
<!-- 下拉内容 --> |
|||
<transition name="slide-fade"> |
|||
<div v-show="state.isOpen" class="select-dropdown"> |
|||
<slot v-if="state.isOpen" name="normal"></slot> |
|||
|
|||
<template v-if="filteredOptions.length"> |
|||
<div v-for="(item, index) in filteredOptions" :key="`option-${index}`" class="dropdown-item" |
|||
:class="{ 'is-selected': isSelected(item) }" @click.stop="selectItem(item)"> |
|||
<slot name="item" :item="item"> |
|||
<div class="flex-between"> |
|||
<div class="left"> |
|||
<p class="one">{{ item[displayKey] }}</p> |
|||
</div> |
|||
<div class="right"> |
|||
<img v-if="isSelected(item)" :src="selectedIcon" alt="selected"> |
|||
</div> |
|||
</div> |
|||
</slot> |
|||
</div> |
|||
</template> |
|||
|
|||
<div v-if="showNullOption" class="flex-between dropdown-item" @click.stop="selectNullItem"> |
|||
<div class="left"> |
|||
<p class="one">暂无收款账号</p> |
|||
<p>暂时没有收款账号,我想稍后配置</p> |
|||
</div> |
|||
<div class="right"> |
|||
<img :src="selectedIcon" alt="selected"> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</transition> |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
// 静态资源引入(Webpack编译时处理) |
|||
const STATIC_ICONS = Object.freeze({ |
|||
expand: require('../../assets/dropDown_expand.png'), |
|||
open: require('../../assets/dropDown_open.png'), |
|||
selected: require('../../assets/drop-selected.svg') |
|||
}) |
|||
|
|||
export default { |
|||
name: 'CustomDropdown', |
|||
props: { |
|||
width: { |
|||
type: String, |
|||
default: "200px", |
|||
validator: (val) => /^\d+(px|%|rem|em|vw)$/.test(val) |
|||
}, |
|||
options: { |
|||
type: Array, |
|||
default: () => [], |
|||
validator: (arr) => !arr.some(item => item === null || typeof item !== 'object') |
|||
}, |
|||
nullOption: { |
|||
type: Object, |
|||
default: null |
|||
}, |
|||
placeholder: { |
|||
type: String, |
|||
default: "请选择" |
|||
}, |
|||
value: { |
|||
type: [String, Number, Object], |
|||
default: null |
|||
}, |
|||
valueKey: { |
|||
type: String, |
|||
default: "value", |
|||
validator: (key) => typeof key === 'string' && key.trim().length > 0 |
|||
}, |
|||
displayKey: { |
|||
type: String, |
|||
default: "label", |
|||
validator: (key) => typeof key === 'string' && key.trim().length > 0 |
|||
}, |
|||
group: { |
|||
type: String, |
|||
default: null |
|||
}, |
|||
}, |
|||
|
|||
// 使用非响应式数据存储关键状态 |
|||
data: () => ({ |
|||
state: Object.seal({ |
|||
isOpen: false, |
|||
lastEmittedValue: null, |
|||
updateDepth: 0, |
|||
eventLock: false |
|||
}), |
|||
icons: STATIC_ICONS |
|||
}), |
|||
|
|||
computed: { |
|||
computedWidth() { |
|||
// console.log(this.width.endsWith('px'),this.width,'this.width.endsWith'); |
|||
// return this.width.endsWith('px') ? this.width : `${parseInt(this.width)}px` |
|||
return this.width |
|||
}, |
|||
filteredOptions() { |
|||
return Array.isArray(this.options) ? [...this.options] : [] |
|||
}, |
|||
showNullOption() { |
|||
return this.nullOption && !this.filteredOptions.length |
|||
}, |
|||
displayText() { |
|||
const current = this.findOptionByValue(this.value) |
|||
return current ? current[this.displayKey] : this.placeholder |
|||
}, |
|||
openIcon() { |
|||
return this.icons.open |
|||
}, |
|||
expandIcon() { |
|||
return this.icons.expand |
|||
}, |
|||
selectedIcon() { |
|||
return this.icons.selected |
|||
} |
|||
}, |
|||
created() { |
|||
// 监听同组下拉框的打开事件 |
|||
if (this.group) { |
|||
this.$busOn(`dropdown-group-${this.group}`, (openedId) => { |
|||
if (openedId !== this._uid && this.state.isOpen) { |
|||
this.closeDropdown(); |
|||
} |
|||
}); |
|||
} |
|||
// console.log(`Dropdown created with _uid:`, this._uid); |
|||
}, |
|||
watch: { |
|||
value: { |
|||
immediate: true, |
|||
handler(newVal) { |
|||
if (this.state.eventLock) return |
|||
this.state.lastEmittedValue = JSON.stringify(newVal) |
|||
} |
|||
} |
|||
}, |
|||
|
|||
methods: { |
|||
// 安全查找方法 |
|||
findOptionByValue(value) { |
|||
if (value === null || value === undefined) return null |
|||
return this.filteredOptions.find(item => { |
|||
return JSON.stringify(item[this.valueKey]) === JSON.stringify(value) |
|||
}) |
|||
}, |
|||
|
|||
isSelected(item) { |
|||
if (!this.value) return false |
|||
return JSON.stringify(item[this.valueKey]) === JSON.stringify(this.value) |
|||
}, |
|||
|
|||
// 防抖关闭方法 |
|||
// handleClickAway() { |
|||
// // console.log(this.state.isOpen,'1111----执行了'); |
|||
// // if (!this.state.isOpen) return |
|||
// // console.log(this.state.isOpen,'2222----执行了'); |
|||
|
|||
// // this.state.isOpen = false |
|||
// // console.log(this.state.isOpen,'333----执行了'); |
|||
// // this.$nextTick(() => { |
|||
// // this.$busEmit('closed') |
|||
// // }) |
|||
// // 确保点击的不是下拉框本身或其子元素 |
|||
// if (this.$refs.dropdown.contains(event.target)) return; |
|||
|
|||
// if (this.state.isOpen) { |
|||
// this.state.isOpen = false; |
|||
// this.$busEmit('closed'); |
|||
// } |
|||
// }, |
|||
toggleDropdown() { |
|||
console.log('Toggling dropdown, current state:', this.state.isOpen) |
|||
if (this.state.eventLock) return |
|||
// 强制更新状态 |
|||
// this.state.isOpen = !this.state.isOpen |
|||
|
|||
// 调试输出 |
|||
console.log('New state:', this.state.isOpen) |
|||
console.log('Dropdown element:', this.$refs.dropdown) |
|||
|
|||
// 触发相应事件 |
|||
// if (this.state.isOpen) { |
|||
// // this.$busEmit('opened') |
|||
// this.openDropdown(); |
|||
|
|||
// } else { |
|||
// this.closeDropdown(); |
|||
|
|||
// this.$busEmit('closed') |
|||
// } |
|||
if (this.state.isOpen) { |
|||
this.closeDropdown(); |
|||
} else { |
|||
this.openDropdown(); |
|||
} |
|||
|
|||
// 强制重新渲染(调试用) |
|||
this.$forceUpdate() |
|||
|
|||
}, |
|||
// 统一关闭方法 |
|||
closeDropdown() { |
|||
this.state.isOpen = false; |
|||
this.$busEmit('closed'); |
|||
this.$emit('closed'); |
|||
}, |
|||
|
|||
// 统一打开方法 |
|||
openDropdown() { |
|||
// 通知同组其他下拉框关闭 |
|||
if (this.group) { |
|||
this.$busEmit(`dropdown-group-${this.group}`, this._uid); |
|||
} |
|||
|
|||
this.state.isOpen = true; |
|||
this.$busEmit('opened'); |
|||
this.$emit('opened'); |
|||
}, |
|||
|
|||
// 优化后的切换方法 |
|||
// toggleDropdown() { |
|||
// if (this.state.eventLock) return; |
|||
|
|||
// if (this.state.isOpen) { |
|||
// this.closeDropdown(); |
|||
// } else { |
|||
// this.openDropdown(); |
|||
// } |
|||
// }, |
|||
|
|||
// 优化后的点击外部处理 |
|||
handleClickAway(event) { |
|||
// 排除点击的是触发元素的情况 |
|||
// debugger |
|||
// const trigger = this.$el.querySelector('.select-trigger'); |
|||
// if (trigger && trigger.contains(event.target)) return; |
|||
|
|||
// if (this.state.isOpen) { |
|||
// this.closeDropdown(); |
|||
// } |
|||
console.log('Before:', { |
|||
isOpen: this.state.isOpen, |
|||
display: this.$refs.dropdown.querySelector('.select-dropdown').style.display |
|||
}); |
|||
|
|||
// 确保下拉框元素实际可见 |
|||
const dropdownEl = this.$refs.dropdown.querySelector('.select-dropdown'); |
|||
const isActuallyVisible = dropdownEl && |
|||
(dropdownEl.style.display !== 'none'); |
|||
|
|||
if (isActuallyVisible && !this.state.isOpen) { |
|||
// 状态不同步时强制修正 |
|||
this.state.isOpen = true; |
|||
this.$nextTick(() => { |
|||
this.closeDropdown(); |
|||
}); |
|||
return; |
|||
} |
|||
|
|||
// 正常处理 |
|||
if (this.state.isOpen) { |
|||
this.closeDropdown(); |
|||
} |
|||
}, |
|||
|
|||
// 核心安全选择方法 |
|||
selectItem(item) { |
|||
if (this.state.updateDepth > 1) { |
|||
console.error('Recursion detected in dropdown selection') |
|||
this.state.isOpen = false |
|||
this.state.updateDepth = 0 |
|||
return |
|||
} |
|||
|
|||
const newValue = item[this.valueKey] |
|||
|
|||
// 值无变化时直接返回 |
|||
if (this.state.lastEmittedValue === JSON.stringify(newValue)) { |
|||
this.state.isOpen = false |
|||
return |
|||
} |
|||
|
|||
this.state.updateDepth++ |
|||
this.state.eventLock = true |
|||
this.state.lastEmittedValue = JSON.stringify(newValue) |
|||
|
|||
// 使用setTimeout确保调用栈清空 |
|||
setTimeout(() => { |
|||
try { |
|||
this.$emit('input', newValue) |
|||
this.$emit('change', Object.freeze({ ...item })) |
|||
} catch (e) { |
|||
console.error('Dropdown emit error:', e) |
|||
} finally { |
|||
this.state.isOpen = false |
|||
this.state.updateDepth = 0 |
|||
this.state.eventLock = false |
|||
} |
|||
}, 0) |
|||
}, |
|||
|
|||
selectNullItem() { |
|||
this.state.eventLock = true |
|||
this.state.lastEmittedValue = null |
|||
|
|||
setTimeout(() => { |
|||
this.$emit('input', null) |
|||
this.$emit('change', null) |
|||
this.state.isOpen = false |
|||
this.state.eventLock = false |
|||
}, 0) |
|||
} |
|||
}, |
|||
beforeDestroy() { |
|||
if (this.group) { |
|||
this.$busOff(`dropdown-group-${this.group}`); |
|||
} |
|||
// performance.measure('dropdown-lifetime', this.$_performanceMark) |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped lang="scss"> |
|||
/* Your existing styles remain the same */ |
|||
.custom-select { |
|||
display: inline-block; |
|||
vertical-align: middle; |
|||
height: 38px; |
|||
position: relative; |
|||
font-family: Arial, sans-serif; |
|||
} |
|||
|
|||
.select-trigger { |
|||
border-radius: 2px; |
|||
opacity: 1; |
|||
background: #FFFFFF; |
|||
border: 1px solid #DFE2E6; |
|||
width: 100%; |
|||
height: 40px; |
|||
box-sizing: border-box; |
|||
padding: 10px 12px; |
|||
cursor: pointer; |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
} |
|||
|
|||
.is-open .select-trigger { |
|||
border: 1px solid #006AFF; |
|||
transition: all .5s; |
|||
outline: 3px solid #D8E9FA; |
|||
} |
|||
|
|||
.select-trigger:hover { |
|||
border-color: #006AFF; |
|||
transition: all .5s; |
|||
} |
|||
|
|||
.arrow-icon { |
|||
width: 12px; |
|||
} |
|||
|
|||
.select-dropdown { |
|||
position: absolute; |
|||
top: 100%; |
|||
right: 0; |
|||
width: 100%; |
|||
border: 1px solid #ccc; |
|||
box-sizing: border-box; |
|||
border-radius: 4px; |
|||
background-color: #fff; |
|||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); |
|||
z-index: 1000; |
|||
margin-top: 4px; |
|||
max-height: 384px; |
|||
overflow-y: auto; |
|||
padding: 0 12px 12px 12px; |
|||
} |
|||
|
|||
.dropdown-item { |
|||
padding: 12px 10px; |
|||
cursor: pointer; |
|||
} |
|||
|
|||
.dropdown-item:hover { |
|||
background: #F6F7FA; |
|||
} |
|||
|
|||
.dropdown-item.is-selected { |
|||
background-color: #F6F7FA; |
|||
color: #006AFF; |
|||
} |
|||
|
|||
/* 展开收起动画 */ |
|||
.slide-fade-enter-active, |
|||
.slide-fade-leave-active { |
|||
transition: all 0.3s ease; |
|||
} |
|||
|
|||
.slide-fade-enter-from, |
|||
.slide-fade-leave-to { |
|||
opacity: 0; |
|||
transform: translateY(-10px); |
|||
} |
|||
</style> |
|||
Loading…
Reference in new issue