|
@@ -0,0 +1,482 @@
|
|
|
+<template>
|
|
|
+ <view>
|
|
|
+ <!-- 表单输入框 -->
|
|
|
+ <view v-if="formInput">
|
|
|
+ <uni-easyinput
|
|
|
+ v-model="inputVal"
|
|
|
+ type="text"
|
|
|
+ :placeholder="placeholder"
|
|
|
+ :clearable="clearable"
|
|
|
+ :readonly="readonly"
|
|
|
+ @focus.stop="handleOpen('focus')"
|
|
|
+ ></uni-easyinput>
|
|
|
+ </view>
|
|
|
+ <!-- 插槽 -->
|
|
|
+ <view v-else @tap="handleOpen('slot')">
|
|
|
+ <slot></slot>
|
|
|
+ </view>
|
|
|
+ <!-- 弹窗 -->
|
|
|
+ <uni-popup ref="popup">
|
|
|
+ <view class="popup-content" :style="popupStyle">
|
|
|
+ <view class="popup-header">
|
|
|
+ <view class="popup-title">{{ label }}</view>
|
|
|
+ <uni-icons
|
|
|
+ type="closeempty"
|
|
|
+ size="24"
|
|
|
+ color="#999"
|
|
|
+ @tap="closePopup"
|
|
|
+ />
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="popup-search" v-if="filter">
|
|
|
+ <uni-easyinput
|
|
|
+ v-model="keyword"
|
|
|
+ type="text"
|
|
|
+ :placeholder="searchPlaceholder"
|
|
|
+ @input="inputChange"
|
|
|
+ suffixIcon="search"
|
|
|
+ ></uni-easyinput>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <scroll-view
|
|
|
+ class="popup-body"
|
|
|
+ scroll-y
|
|
|
+ :style="{ height: bodyHeight + 'px' }"
|
|
|
+ >
|
|
|
+ <view
|
|
|
+ v-for="(level, levelIndex) in showList"
|
|
|
+ :key="levelIndex"
|
|
|
+ class="level-container"
|
|
|
+ >
|
|
|
+ <view v-if="!level.data?.length">
|
|
|
+ {{ keyword ? '没有匹配的数据' : '暂无数据' }}
|
|
|
+ </view>
|
|
|
+ <template v-else>
|
|
|
+ <view
|
|
|
+ v-for="item in level.data"
|
|
|
+ :key="item[itemValue]"
|
|
|
+ class="item-container"
|
|
|
+ :class="calcClass(item, levelIndex)"
|
|
|
+ @tap="handleItemClick(item, levelIndex)"
|
|
|
+ >
|
|
|
+ <text class="item-label">{{ item[itemLabel] }}</text>
|
|
|
+ <view class="item-icons">
|
|
|
+ <uni-icons
|
|
|
+ v-if="isItemActive(item, levelIndex) && !hasChildren(item)"
|
|
|
+ type="checkmarkempty"
|
|
|
+ color="#00B760"
|
|
|
+ size="16"
|
|
|
+ />
|
|
|
+ <uni-icons
|
|
|
+ v-if="hasChildren(item)"
|
|
|
+ type="arrowright"
|
|
|
+ color="#999"
|
|
|
+ size="16"
|
|
|
+ />
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </template>
|
|
|
+ </view>
|
|
|
+ </scroll-view>
|
|
|
+
|
|
|
+ <view class="popup-footer" v-if="showFooter || multiple || isTreeData">
|
|
|
+ <button
|
|
|
+ v-if="clearable"
|
|
|
+ class="btn cancel"
|
|
|
+ @tap="handleClear"
|
|
|
+ >
|
|
|
+ {{ clearText }}
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ class="btn confirm"
|
|
|
+ @tap="handleConfirm"
|
|
|
+ :disabled="!hasSelection"
|
|
|
+ >
|
|
|
+ {{ submitText }}
|
|
|
+ </button>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </uni-popup>
|
|
|
+ </view>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+import { ref, computed, watch, nextTick } from 'vue'
|
|
|
+import { debounce } from 'lodash-es'
|
|
|
+
|
|
|
+const emit = defineEmits(['update:value', 'update:text', 'change', 'search'])
|
|
|
+
|
|
|
+const props = defineProps({
|
|
|
+ // 基础属性
|
|
|
+ value: [String, Number, Array, Object],
|
|
|
+ text: String,
|
|
|
+ items: {
|
|
|
+ type: Array,
|
|
|
+ default: () => []
|
|
|
+ },
|
|
|
+ label: {
|
|
|
+ type: String,
|
|
|
+ default: '请选择'
|
|
|
+ },
|
|
|
+ placeholder: {
|
|
|
+ type: String,
|
|
|
+ default: '请选择'
|
|
|
+ },
|
|
|
+
|
|
|
+ // 字段映射
|
|
|
+ itemLabel: {
|
|
|
+ type: String,
|
|
|
+ default: 'label'
|
|
|
+ },
|
|
|
+ itemValue: {
|
|
|
+ type: String,
|
|
|
+ default: 'value'
|
|
|
+ },
|
|
|
+ children: {
|
|
|
+ type: String,
|
|
|
+ default: 'children'
|
|
|
+ },
|
|
|
+
|
|
|
+ // 功能开关
|
|
|
+ multiple: Boolean,
|
|
|
+ formInput: Boolean, // 表单右侧输入框
|
|
|
+ filter: Boolean, // 可检索
|
|
|
+ clearable: Boolean,
|
|
|
+ readonly: Boolean,
|
|
|
+ oneLevel: Boolean, // 不展示子级
|
|
|
+ showFooter: {
|
|
|
+ type: Boolean,
|
|
|
+ default: false
|
|
|
+ },
|
|
|
+
|
|
|
+ // 搜索相关
|
|
|
+ searchPlaceholder: {
|
|
|
+ type: String,
|
|
|
+ default: '请输入'
|
|
|
+ },
|
|
|
+ searchDebounceTime: {
|
|
|
+ type: Number,
|
|
|
+ default: 500
|
|
|
+ },
|
|
|
+
|
|
|
+ // 样式
|
|
|
+ popupStyle: [String, Object],
|
|
|
+
|
|
|
+ // 文本自定义
|
|
|
+ clearText: {
|
|
|
+ type: String,
|
|
|
+ default: '清除'
|
|
|
+ },
|
|
|
+ submitText: {
|
|
|
+ type: String,
|
|
|
+ default: '确定'
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+ // 使用 lodash 的防抖
|
|
|
+ const debouncedSearch = debounce((value) => {
|
|
|
+ if (value = '') {
|
|
|
+ showList.value = [{
|
|
|
+ choose: props.multiple ? [] : -1,
|
|
|
+ data: props.items
|
|
|
+ }]
|
|
|
+ } else {
|
|
|
+ showList.value = [{
|
|
|
+ choose: showList.value?.choose || -1,
|
|
|
+ data: props.items?.length && props.items.map(j => j[props.itemLabel] && j[props.itemLabel].includes(value))
|
|
|
+ }]
|
|
|
+ debugger
|
|
|
+ }
|
|
|
+ emit('search', value)
|
|
|
+ }, props.searchDebounceTime)
|
|
|
+
|
|
|
+const isTreeData = ref(false)
|
|
|
+
|
|
|
+// 组件引用
|
|
|
+const popup = ref()
|
|
|
+const popupContent = ref()
|
|
|
+
|
|
|
+// 数据状态
|
|
|
+const inputVal = ref('')
|
|
|
+const keyword = ref('')
|
|
|
+const showList = ref([])
|
|
|
+const bodyHeight = ref(400)
|
|
|
+
|
|
|
+// 计算属性
|
|
|
+const hasSelection = computed(() => {
|
|
|
+ if (props.multiple) {
|
|
|
+ return showList.value.some(level => Array.isArray(level.choose) && level.choose.length > 0)
|
|
|
+ }
|
|
|
+ return showList.value.some(level => level.choose !== -1)
|
|
|
+})
|
|
|
+
|
|
|
+// 初始化
|
|
|
+const initShowList = () => {
|
|
|
+ showList.value = [{
|
|
|
+ choose: props.multiple ? [] : -1,
|
|
|
+ data: props.items
|
|
|
+ }]
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+const isItemActive = (item, levelIndex) => {
|
|
|
+ const level = showList.value[levelIndex]
|
|
|
+ if (!level) return false
|
|
|
+
|
|
|
+ if (Array.isArray(level.choose)) {
|
|
|
+ return level.choose.includes(item[props.itemValue])
|
|
|
+ }
|
|
|
+ return level.choose === item[props.itemValue]
|
|
|
+}
|
|
|
+
|
|
|
+const hasChildren = (item) => {
|
|
|
+ return props.oneLevel ? false : Boolean(item[props.children] && item[props.children].length > 0)
|
|
|
+}
|
|
|
+
|
|
|
+// 方法
|
|
|
+const calcClass = (item, levelIndex) => {
|
|
|
+ return `${isItemActive(item, levelIndex) ? 'active' : ''}${hasChildren(item) ? 'has-children' : ''}`
|
|
|
+}
|
|
|
+
|
|
|
+const handleOpen = async () => {
|
|
|
+ popup.value.open('bottom')
|
|
|
+ await nextTick()
|
|
|
+ calculateBodyHeight()
|
|
|
+}
|
|
|
+
|
|
|
+const closePopup = () => {
|
|
|
+ popup.value.close()
|
|
|
+}
|
|
|
+
|
|
|
+const calculateBodyHeight = () => {
|
|
|
+ if (!popupContent.value) return
|
|
|
+ const query = uni.createSelectorQuery().in(popupContent.value)
|
|
|
+ query.select('.popup-content').boundingClientRect(data => {
|
|
|
+ if (data) {
|
|
|
+ bodyHeight.value = data.height - 120 // 减去头部和底部高度
|
|
|
+ }
|
|
|
+ }).exec()
|
|
|
+}
|
|
|
+
|
|
|
+const handleItemClick = (item, levelIndex) => {
|
|
|
+ // if ((!props.multiple && !item?.children?.length) || props.oneLevel) {
|
|
|
+ // inputVal.value = item[props.itemLabel]
|
|
|
+ // emit('update:value', item[props.itemValue])
|
|
|
+ // emit('change', item[props.itemValue], item)
|
|
|
+ // closePopup()
|
|
|
+ // return
|
|
|
+ // }
|
|
|
+
|
|
|
+ // 处理多选
|
|
|
+ if (props.multiple) {
|
|
|
+ handleMultiSelect(item, levelIndex)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // 处理单选
|
|
|
+ handleSingleSelect(item, levelIndex)
|
|
|
+}
|
|
|
+
|
|
|
+const handleSingleSelect = (item, levelIndex) => {
|
|
|
+ const newShowList = [...showList.value]
|
|
|
+
|
|
|
+ // 更新当前级别的选择
|
|
|
+ newShowList[levelIndex].choose = item[props.itemValue]
|
|
|
+
|
|
|
+ // 如果有子级,添加下一级
|
|
|
+ if (!props.oneLevel && hasChildren(item)) {
|
|
|
+ newShowList.splice(levelIndex + 1)
|
|
|
+ newShowList.push({
|
|
|
+ choose: -1,
|
|
|
+ data: item[props.children]
|
|
|
+ })
|
|
|
+ } else {
|
|
|
+ // 没有子级,截断后面的级别
|
|
|
+ newShowList.splice(levelIndex + 1)
|
|
|
+ // 最后一级,更新输出文本
|
|
|
+ inputVal.value = item[props.itemLabel]
|
|
|
+ emit('update:value', item[props.itemValue])
|
|
|
+ emit('change', item[props.itemValue], item)
|
|
|
+ closePopup()
|
|
|
+ }
|
|
|
+
|
|
|
+ showList.value = newShowList
|
|
|
+}
|
|
|
+
|
|
|
+const handleMultiSelect = (item, levelIndex) => {
|
|
|
+ const newShowList = [...showList.value]
|
|
|
+ const currentLevel = newShowList[levelIndex]
|
|
|
+
|
|
|
+ // 初始化选择数组
|
|
|
+ if (!Array.isArray(currentLevel.choose)) {
|
|
|
+ currentLevel.choose = []
|
|
|
+ }
|
|
|
+
|
|
|
+ // 切换选择状态
|
|
|
+ const index = currentLevel.choose.indexOf(item[props.itemValue])
|
|
|
+ if (index === -1) {
|
|
|
+ currentLevel.choose.push(item[props.itemValue])
|
|
|
+ } else {
|
|
|
+ currentLevel.choose.splice(index, 1)
|
|
|
+ }
|
|
|
+
|
|
|
+ showList.value = newShowList
|
|
|
+}
|
|
|
+
|
|
|
+const inputChange = () => {
|
|
|
+ debouncedSearch(keyword.value)
|
|
|
+}
|
|
|
+
|
|
|
+const handleClear = () => {
|
|
|
+ initShowList()
|
|
|
+ inputVal.value = ''
|
|
|
+ emit('update:value', props.multiple ? [] : null)
|
|
|
+ emit('update:text', '')
|
|
|
+ emit('change', props.multiple ? [] : null)
|
|
|
+ closePopup()
|
|
|
+}
|
|
|
+
|
|
|
+const handleConfirm = () => {
|
|
|
+ if (props.multiple) {
|
|
|
+ const selectedValues = []
|
|
|
+ const selectedLabels = []
|
|
|
+
|
|
|
+ showList.value.forEach(level => {
|
|
|
+ if (Array.isArray(level.choose)) {
|
|
|
+ level.data.forEach(item => {
|
|
|
+ if (level.choose.includes(item[props.itemValue])) {
|
|
|
+ selectedValues.push(item[props.itemValue])
|
|
|
+ selectedLabels.push(item[props.itemLabel])
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ emit('update:value', selectedValues)
|
|
|
+ emit('update:text', selectedLabels.join(','))
|
|
|
+ emit('change', selectedValues)
|
|
|
+ } else {
|
|
|
+ const lastLevel = showList.value[showList.value.length - 1]
|
|
|
+ const selectedItem = lastLevel.data.find(item => item[props.itemValue] === lastLevel.choose)
|
|
|
+
|
|
|
+ if (selectedItem) {
|
|
|
+ emit('update:value', selectedItem[props.itemValue])
|
|
|
+ emit('update:text', selectedItem[props.itemLabel])
|
|
|
+ emit('change', selectedItem[props.itemValue])
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ closePopup()
|
|
|
+}
|
|
|
+
|
|
|
+// 监听props变化
|
|
|
+watch(() => props.items, initShowList, { immediate: true })
|
|
|
+
|
|
|
+watch(() => props.value, (newVal) => {
|
|
|
+ // if (!newVal) {
|
|
|
+ // initShowList()
|
|
|
+ // inputVal.value = ''
|
|
|
+ // return
|
|
|
+ // }
|
|
|
+ // TODO: 实现回显逻辑
|
|
|
+}, { immediate: true })
|
|
|
+
|
|
|
+watch(() => props.text, (newVal) => {
|
|
|
+ inputVal.value = newVal || ''
|
|
|
+}, { immediate: true })
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss" scoped>
|
|
|
+.popup-content {
|
|
|
+ background: #fff;
|
|
|
+ border-radius: 16rpx 16rpx 0 0;
|
|
|
+ padding: 24rpx;
|
|
|
+ box-sizing: border-box;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+}
|
|
|
+
|
|
|
+.popup-header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ padding-bottom: 16rpx;
|
|
|
+ border-bottom: 1rpx solid #eee;
|
|
|
+}
|
|
|
+
|
|
|
+.popup-title {
|
|
|
+ font-size: 32rpx;
|
|
|
+ font-weight: bold;
|
|
|
+ color: #333;
|
|
|
+}
|
|
|
+
|
|
|
+.popup-search {
|
|
|
+ padding: 24rpx 0;
|
|
|
+}
|
|
|
+
|
|
|
+.popup-body {
|
|
|
+ flex: 1;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.level-container {
|
|
|
+ margin-bottom: 16rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.item-container {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ padding: 20rpx 16rpx;
|
|
|
+ border-radius: 8rpx;
|
|
|
+ margin-bottom: 8rpx;
|
|
|
+
|
|
|
+ &.active {
|
|
|
+ background-color: #f5fff9;
|
|
|
+ color: #00B760;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.has-children {
|
|
|
+ background-color: #f9f9f9;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.item-label {
|
|
|
+ flex: 1;
|
|
|
+ font-size: 28rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.item-icons {
|
|
|
+ margin-left: 16rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.popup-footer {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ padding-top: 24rpx;
|
|
|
+ border-top: 1rpx solid #eee;
|
|
|
+
|
|
|
+ .btn {
|
|
|
+ flex: 1;
|
|
|
+ height: 80rpx;
|
|
|
+ line-height: 80rpx;
|
|
|
+ font-size: 28rpx;
|
|
|
+ border-radius: 8rpx;
|
|
|
+
|
|
|
+ &.cancel {
|
|
|
+ background: #f5f5f5;
|
|
|
+ color: #666;
|
|
|
+ margin-right: 16rpx;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.confirm {
|
|
|
+ background: #00B760;
|
|
|
+ color: #fff;
|
|
|
+
|
|
|
+ &[disabled] {
|
|
|
+ opacity: 0.6;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|