Files
sh-app/components/nbd-select.vue
T
2026-06-10 14:17:54 +08:00

326 lines
6.4 KiB
Vue

<template>
<u-popup v-model="localVisible" mode="bottom" :height="height">
<view class="nbd-select">
<!-- 标题栏 -->
<view class="nbd-select-header" v-if="title">
<text class="nbd-select-title">{{ title }}</text>
<text class="nbd-select-tip" v-if="tip">{{ tip }}</text>
</view>
<!-- 搜索栏 -->
<view class="nbd-select-filter" v-if="showSearch">
<u-search
:placeholder="searchPlaceholder"
:height="72"
v-model="searchKey"
:show-action="false"
@change="handleSearch"
@clear="handleSearch"
></u-search>
</view>
<!-- 列表内容 -->
<scroll-view scroll-y="true" class="nbd-select-inner">
<view class="nbd-select-inner-wrap">
<!-- 空状态 -->
<view class="nbd-select-empty" v-if="filteredList.length === 0">
<u-empty mode="list" text="暂无数据"></u-empty>
</view>
<!-- 列表项 -->
<view
v-for="(item, idx) in filteredList"
:key="idx"
class="nbd-select-item"
:class="{ active: isSelected(item) }"
@click="handleItemClick(item)"
>
<text class="nbd-select-item-text">{{ item[labelKey] }}</text>
<view v-if="multiple" class="nbd-select-checkbox">
<u-icon
:name="isSelected(item) ? 'checkbox-mark' : 'square'"
:size="36"
:color="isSelected(item) ? $u.type.primary : '#dcdee0'"
></u-icon>
</view>
</view>
</view>
</scroll-view>
<!-- 底部按钮 -->
<view class="nbd-select-footer">
<u-button class="nbd-select-btn-cancel" @click="handleCancel">
{{ cancelText }}
</u-button>
<u-button class="nbd-select-btn-confirm" type="primary" @click="handleConfirm">
{{ confirmText }}
</u-button>
</view>
</view>
</u-popup>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
const props = defineProps({
// 弹窗显示状态
modelValue: {
type: Boolean,
default: false
},
// 弹窗高度
height: {
type: String,
default: '70%'
},
// 标题
title: {
type: String,
default: ''
},
// 标题提示
tip: {
type: String,
default: ''
},
// 数据列表
list: {
type: Array,
default: () => []
},
// 选中项(支持数组,多选时使用)
model: {
type: [Object, Array, String, Number],
default: null
},
// 是否多选
multiple: {
type: Boolean,
default: false
},
// 显示搜索框
showSearch: {
type: Boolean,
default: false
},
// 搜索占位符
searchPlaceholder: {
type: String,
default: '请输入关键词搜索'
},
// 字段名配置
labelKey: {
type: String,
default: 'label'
},
valueKey: {
type: String,
default: 'value'
},
// 自定义搜索字段(默认搜索 labelKey)
searchKeyField: {
type: String,
default: ''
},
// 按钮文字
cancelText: {
type: String,
default: '取消'
},
confirmText: {
type: String,
default: '确定'
}
})
const emit = defineEmits([
'update:modelValue',
'update:model',
'cancel',
'confirm',
'searchChange',
'change'
])
const localVisible = ref(false)
const searchKey = ref('')
// 当前选中的项(多选时为数组)
const selectedItems = ref(props.multiple ? (props.model || []) : (props.model || null))
// 监听外部 model 变化
watch(() => props.model, (newVal) => {
selectedItems.value = props.multiple ? (newVal || []) : newVal
}, { deep: true })
// 弹窗状态同步
watch(() => props.modelValue, (val) => {
localVisible.value = val
if (val) {
// 打开时重置搜索
searchKey.value = ''
}
})
watch(localVisible, (val) => {
emit('update:modelValue', val)
})
// 过滤后的列表
const filteredList = computed(() => {
if (!searchKey.value) return props.list
const keyword = searchKey.value.toLowerCase()
const searchField = props.searchKeyField || props.labelKey
return props.list.filter(item => {
const value = item[searchField]
if (value === null || value === undefined) return false
return String(value).toLowerCase().includes(keyword)
})
})
// 判断是否选中
const isSelected = (item) => {
if (props.multiple) {
return selectedItems.value.some(
selected => selected[props.valueKey] === item[props.valueKey]
)
}
if (selectedItems.value && typeof selectedItems.value === 'object') {
return selectedItems.value[props.valueKey] === item[props.valueKey]
}
return selectedItems.value === item[props.valueKey]
}
// 点击列表项
const handleItemClick = (item) => {
if (props.multiple) {
const index = selectedItems.value.findIndex(
selected => selected[props.valueKey] === item[props.valueKey]
)
if (index > -1) {
selectedItems.value.splice(index, 1)
} else {
selectedItems.value.push(item)
}
} else {
selectedItems.value = item
}
emit('change', selectedItems.value)
}
// 搜索
const handleSearch = (val) => {
emit('searchChange', val)
}
// 取消
const handleCancel = () => {
emit('cancel')
localVisible.value = false
}
// 确认
const handleConfirm = () => {
emit('confirm', selectedItems.value)
emit('update:model', selectedItems.value)
localVisible.value = false
}
</script>
<style lang="scss" scoped>
.nbd-select {
display: flex;
flex-direction: column;
height: 100%;
background: #ffffff;
border-radius: 24rpx 24rpx 0 0;
overflow: hidden;
&-header {
display: flex;
flex-direction: column;
padding: 24rpx 32rpx 20rpx;
border-bottom: 1rpx solid #f0f0f0;
.nbd-select-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
margin-bottom: 8rpx;
}
.nbd-select-tip {
font-size: 24rpx;
color: #999;
}
}
&-filter {
padding: 16rpx 32rpx;
border-bottom: 1rpx solid #f0f0f0;
}
&-inner {
flex: 1;
overflow-y: auto;
padding: 0 32rpx;
&-wrap {
padding: 16rpx 0;
}
}
&-empty {
padding: 80rpx 0;
}
&-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 0;
border-bottom: 1rpx solid #f5f6f8;
transition: background 0.2s;
&:last-child {
border-bottom: none;
}
&.active .nbd-select-item-text {
color: $u-type-primary;
font-weight: 500;
}
.nbd-select-item-text {
flex: 1;
font-size: 28rpx;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nbd-select-checkbox {
margin-left: 16rpx;
}
}
&-footer {
display: flex;
gap: 24rpx;
padding: 20rpx 32rpx 40rpx;
.nbd-select-btn-cancel,
.nbd-select-btn-confirm {
flex: 1;
height: 80rpx;
font-size: 28rpx;
border-radius: 40rpx;
}
}
}
</style>