支持通配符SSL证书、多域名证书、IP证书。适配ACME接口, 支持Zerossl、Let's Encrypt和Google等渠道。登录已有账号
2026-09-07 13:16:03 UniApp 下拉框组件 组件封装
在 UniApp 项目里做下拉选择,自带的 picker 在多选和搜索场景下基本没法直接用,很多页面要重复写弹出层,干脆抽了个组件。这个组件支持单选/多选切换、关键词过滤、全选清空、最大数量限制、禁用选项,字段名也能自定义,因为接口返回的不一定叫 label 和 value。
代码直接放出来,后面再说配置和几个容易踩的点。
<template>
<view class="custom-select">
<view class="select-trigger" @click="togglePopup">
<text class="placeholder" v-if="selectedLabels.length === 0">{{ placeholder }}</text>
<text class="selected-value" v-else>{{ displayText }}</text>
<uni-icons :type="showPopup ? 'arrowup' : 'arrowdown'" size="16" color="#999"></uni-icons>
</view>
<uni-popup ref="popup" type="bottom" :mask-click="false">
<view class="popup-content">
<view class="popup-header">
<text class="title">{{ title }}</text>
<view class="actions">
<text class="action-btn" @click="selectAll" v-if="multiple">全选</text>
<text class="action-btn" @click="clearAll" v-if="multiple">清空</text>
<text class="action-btn confirm" @click="confirmSelection">确定</text>
</view>
</view>
<view class="search-box" v-if="searchable">
<uni-icons type="search" size="16" color="#999"></uni-icons>
<input
type="text"
v-model="searchText"
:placeholder="searchPlaceholder"
@input="filterOptions"
class="search-input"
/>
</view>
<scroll-view scroll-y class="option-list">
<view
class="option-item"
v-for="(item, index) in filteredOptions"
:key="item[valueKey]"
@click="toggleOption(item)"
>
<checkbox
:checked="isSelected(item)"
:disabled="item.disabled"
v-if="multiple"
class="checkbox"
/>
<radio
:checked="isSelected(item)"
:disabled="item.disabled"
v-else
class="radio"
/>
<text class="option-label">{{ item[labelKey] }}</text>
<text class="option-desc" v-if="item.desc">{{ item.desc }}</text>
</view>
<view class="empty-tip" v-if="filteredOptions.length === 0">
<text>暂无数据</text>
</view>
</scroll-view>
</view>
</uni-popup>
</view>
</template>
<script>
export default {
name: 'CustomSelect',
props: {
options: { type: Array, default: () => [] },
value: { type: [Array, String, Number], default: () => ([]) },
multiple: { type: Boolean, default: false },
searchable: { type: Boolean, default: false },
placeholder: { type: String, default: '请选择' },
searchPlaceholder: { type: String, default: '请输入关键词搜索' },
title: { type: String, default: '请选择' },
labelKey: { type: String, default: 'label' },
valueKey: { type: String, default: 'value' },
max: { type: Number, default: Infinity }
},
data() {
return {
showPopup: false,
searchText: '',
filteredOptions: [],
selectedValues: [],
selectedLabels: [],
selectedItems: []
}
},
computed: {
displayText() {
if (this.multiple) {
return this.selectedLabels.join('、')
} else {
return this.selectedLabels[0] || ''
}
}
},
watch: {
options: {
immediate: true,
handler(newVal) {
this.filteredOptions = [...newVal]
this.initSelection()
}
},
value: {
immediate: true,
handler(newVal) {
this.initSelection()
}
}
},
methods: {
initSelection() {
if (this.multiple) {
this.selectedValues = Array.isArray(this.value) ? [...this.value] : []
} else {
this.selectedValues = this.value !== undefined && this.value !== null ? [this.value] : []
}
this.selectedItems = this.options.filter(item =>
this.selectedValues.includes(item[this.valueKey])
)
this.selectedLabels = this.selectedItems.map(item => item[this.labelKey])
},
togglePopup() {
if (this.showPopup) {
this.$refs.popup.close()
} else {
this.$refs.popup.open()
this.searchText = ''
this.filterOptions()
}
this.showPopup = !this.showPopup
},
filterOptions() {
if (!this.searchText) {
this.filteredOptions = [...this.options]
return
}
const searchText = this.searchText.toLowerCase()
this.filteredOptions = this.options.filter(item =>
String(item[this.labelKey]).toLowerCase().includes(searchText) ||
(item.desc && String(item.desc).toLowerCase().includes(searchText))
)
},
toggleOption(item) {
if (item.disabled) return
const value = item[this.valueKey]
if (this.multiple) {
const index = this.selectedValues.indexOf(value)
if (index > -1) {
this.selectedValues.splice(index, 1)
} else {
if (this.selectedValues.length >= this.max) {
uni.showToast({
title: `最多选择${this.max}项`,
icon: 'none'
})
return
}
this.selectedValues.push(value)
}
} else {
this.selectedValues = [value]
this.confirmSelection()
}
},
isSelected(item) {
return this.selectedValues.includes(item[this.valueKey])
},
selectAll() {
if (!this.multiple) return
const availableOptions = this.filteredOptions.filter(item => !item.disabled)
if (availableOptions.length > this.max) {
uni.showToast({
title: `最多选择${this.max}项`,
icon: 'none'
})
return
}
this.selectedValues = availableOptions.map(item => item[this.valueKey])
},
clearAll() {
if (!this.multiple) return
this.selectedValues = []
},
confirmSelection() {
this.selectedItems = this.options.filter(item =>
this.selectedValues.includes(item[this.valueKey])
)
this.selectedLabels = this.selectedItems.map(item => item[this.labelKey])
if (this.multiple) {
this.$emit('input', [...this.selectedValues])
this.$emit('change', [...this.selectedValues], [...this.selectedItems])
} else {
this.$emit('input', this.selectedValues[0])
this.$emit('change', this.selectedValues[0], this.selectedItems[0])
}
this.$refs.popup.close()
this.showPopup = false
}
}
}
</script>
<style scoped>
.custom-select {
position: relative;
width: 100%;
}
.select-trigger {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 15px;
border: 1px solid #e5e5e5;
border-radius: 4px;
background-color: #fff;
}
.placeholder {
color: #999;
}
.popup-content {
background-color: #fff;
border-radius: 12px 12px 0 0;
padding-bottom: env(safe-area-inset-bottom);
}
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
border-bottom: 1px solid #f5f5f5;
}
.title {
font-size: 16px;
font-weight: bold;
}
.actions {
display: flex;
gap: 15px;
}
.action-btn {
color: #666;
font-size: 14px;
}
.action-btn.confirm {
color: #2979ff;
}
.search-box {
display: flex;
align-items: center;
padding: 10px 15px;
border-bottom: 1px solid #f5f5f5;
}
.search-input {
flex: 1;
margin-left: 8px;
height: 30px;
font-size: 14px;
}
.option-list {
max-height: 50vh;
padding: 5px 0;
}
.option-item {
display: flex;
align-items: center;
padding: 12px 15px;
}
.checkbox, .radio {
margin-right: 10px;
}
.option-label {
flex: 1;
font-size: 15px;
}
.option-desc {
font-size: 12px;
color: #999;
margin-left: 10px;
}
.empty-tip {
padding: 20px;
text-align: center;
color: #999;
}
</style>
使用示例也贴一下,v-model 直接绑,multiple 控制多选,searchable 打开搜索框。
<template>
<view class="container">
<view class="demo-section">
<text class="section-title">单选下拉框</text>
<custom-select
:options="singleOptions"
v-model="singleValue"
placeholder="请选择一个水果"
@change="handleSingleChange"
/>
<text class="selected-result">当前选中: {{ singleValue }}</text>
</view>
<view class="demo-section">
<text class="section-title">多选下拉框</text>
<custom-select
:options="multiOptions"
v-model="multiValue"
:multiple="true"
placeholder="请选择多个水果(最多3个)"
:max="3"
title="水果多选"
searchable
@change="handleMultiChange"
/>
<text class="selected-result">当前选中: {{ multiValue.join(', ') }}</text>
</view>
</view>
</template>
<script>
import CustomSelect from '@/components/custom-select.vue'
export default {
components: { CustomSelect },
data() {
return {
singleValue: '',
multiValue: [],
singleOptions: [
{ label: '苹果', value: 'apple' },
{ label: '香蕉', value: 'banana' },
{ label: '橙子', value: 'orange', disabled: true },
{ label: '西瓜', value: 'watermelon' },
{ label: '菠萝', value: 'pineapple' }
],
multiOptions: [
{ label: '苹果', value: 'apple', desc: '脆甜' },
{ label: '香蕉', value: 'banana', desc: '软糯' },
{ label: '橙子', value: 'orange', desc: '多汁' }
]
}
},
methods: {
handleSingleChange(value, item) {
console.log('单选变化:', value, item)
},
handleMultiChange(values, items) {
console.log('多选变化:', values, items)
}
}
}
</script>
<style>
.container {
padding: 20px;
}
.demo-section {
margin-bottom: 30px;
}
.section-title {
display: block;
margin-bottom: 10px;
font-size: 16px;
font-weight: bold;
}
.selected-result {
display: block;
margin-top: 10px;
color: #666;
}
</style>
props 这块,options 默认空数组,value 支持数组和单值,具体行为看 multiple。
multiple 默认 false,单选时点选项直接确认并关闭弹层;多选需要手动点确定,可以全选和清空。searchable 控制搜索框显示,搜索匹配 label 和 desc 字段,过滤是本地数据。placeholder 和 title 不用多说。labelKey 和 valueKey 默认分别是 label 和 value,如果接口返回字段叫 name 和 id,就传 labelKey="name" valueKey="id"。max 默认 Infinity,多选时超过会弹 toast 提示。
事件方面,组件 emit 了 input 和 change。
input 是为了兼容 v-model,在确认选择时触发。change 也同时触发,参数是选中的值,多选是数组,单选是单个值,第二个参数是对应的 item 或 items。
实现上有个地方要注意,initSelection 的时机。options 和 value 都可能异步回来,我用 watch immediate 处理,但 options 没到的时候 selectedItems 是空,选中标签不会显示,等 options 到了会自动重新初始化。另一个坑是 max 判断,全选时如果过滤后的可用选项数量超过 max,直接 toast 并 return,不会部分选中。
搜索过滤里用 toLowerCase 之前用 String() 包了一层,不然字段值不是字符串会直接报错,这个在真机上很容易碰到。弹出层用的 uni-popup,mask-click 设为 false,防止点遮罩关闭后 showPopup 状态不同步,下次点不开。
顺带说一句,真机调试或者上线时如果组件配合接口请求,域名要走 HTTPS。证书这块我一般用 lcjmSSL 自动申请,支持多域名和泛域名,配置一次后面自动续期,不用手动去服务器上折腾。它也能走 ACME 协议,自动验证部署,API 直接调,比宝塔里手动申请省事不少。
整体代码可以直接复制到项目里,确认 uni-icons 和 uni-popup 已引入。本地过滤在数据量大的时候会卡,后面如果做远程搜索再单独扩展。
深入解析UniApp跨平台开发核心策略!本文系统讲解如何通过条件编译实现JS/CSS/HTML多维度平台隔离,结合基础组件统一与扩展组件按需集成,构建高度适配各端的移动应用。掌握关键技术点,让你的代码复用率突破80%,轻松驾驭多端开发挑战。
还在为UniApp中设备信息异步获取的延迟而烦恼吗?`uni.getSystemInfoSync` 为你带来同步解决方案!本文将从实用的角度出发,剖析这个API如何帮助开发者在应用启动初期快速获取屏幕尺寸、系统版本等关键信息,优化用户体验,并讲解在使用过程中需注意的平台差异与性能考量。
还在为UniApp中复杂的下拉选择器交互烦恼吗?本文深入剖析如何构建一个功能强大的Select插件,完美支持模糊搜索、智能筛选和快速重置,告别冗余代码,大幅提升开发效率和用户体验,让数据选择前所未有的便捷!
在UniApp Vue3开发中,是否被多设备适配问题困扰?本文揭示如何利用rem单位,结合动态计算屏幕宽度,轻松实现一套代码完美适配各种尺寸屏幕。无需复杂插件,高效解决自适应难题,大幅提升开发效率和用户体验,让你的应用无缝呈现。