【uni-app】树形结构数据选择框

该篇文章的写法采用的是v3写法,如果需要v2写法的 可以把代码复制进去让AI处理一下

效果预览:

组件图片分享:

主页面示例:

javascript 复制代码
<template>
  <view>
    <view>{{ selectedTypeName || '请选择' }}</view>

    <button @click="showDeviceTypeModal = true">选择类型</button>
    <!-- 自定义模态选择器 -->
    <view class="type-modal" @click="closeModal" v-if="showDeviceTypeModal">
       <view class="type-content" @click.stop>
          <view class="type-header">
             <text class="type-title">选择类型</text>
             <text class="type-close" @click="closeModal">×</text>
          </view>
          <scroll-view class="type-scroll" scroll-y="true" show-scrollbar="true">
             <view class="type-list">
                <view v-for="item in dataSource" :key="item.value">
                   <neo-tree-list-item :paramData="item" title="label" @tapText="handleItem" />
                </view>
             </view>
          </scroll-view>
       </view>
    </view>

  </view>
</template>

<script setup>
import { ref } from "vue";
import NeoTreeListItem from '@/components/neo-tree-list-item/neo-tree-list-item.vue';

// 假数据示例
const dataSource = [
   {
      text: '设备类型1',
      value: '1',
      children: [
         {
            text: '设备类型1-1',
            value: '1-1',
            children: [
                {
                   text: '设备类型1-1-1',
                   value: '1-1-1',
                   children: [
                          {
                            text: '设备类型1-1-1-1',
                            value: '1-1-1-1',
                          },
                          {
                            text: '设备类型1-1-1-2',
                            value: '1-1-1-2',
                          },
                          {
                            text: '设备类型1-1-1-3',
                          }
                   ]
                },
                {
                   text: '设备类型1-1-2',
                   value: '1-1-2',
                },
                {
                   text: '设备类型1-1-3',
                   value: '1-1-3',
                }
            ]
         },
         {
            text: '设备类型1-2',
            value: '1-2',
         },
         {
            text: '设备类型1-3',
            value: '1-3',
         },
         {
            text: '设备类型1-4',
            value: '1-4',
         }
      ]
   },
   {
      text: '设备类型2',
      value: '2',
   },
   {
      text: '设备类型3',
      value: '3',
   },
   {
      text: '设备类型4',
      value: '4',
   },
]

const showDeviceTypeModal = ref(false);
const closeModal = () => {
  showDeviceTypeModal.value = false;
};

const selectedTypeName = ref('');
const handleItem = (item) => {
  console.log(item);
  selectedTypeName.value =item.text
  closeModal();
};

</script>

<style lang="scss" scoped>

.type-modal {
   position: fixed;
   top: 0;
   left: 0;
   right: 0;
   bottom: 0;
   background-color: rgba(0, 0, 0, 0.6);
   z-index: 999;
   display: flex;
   justify-content: center;
   align-items: center;

   .type-content {
      width: 90%;
      max-height: 80%;
      background-color: #fff;
      border-radius: 16rpx;
      overflow: hidden;

      .type-header {
         display: flex;
         justify-content: space-between;
         align-items: center;
         padding: 20rpx 30rpx;
         border-bottom: 2rpx solid #f0f0f0;

         .type-title {
            font-size: 32rpx;
            font-weight: bold;
         }

         .type-close {
            font-size: 40rpx;
            color: #999;
         }
      }

      .type-scroll {
         max-height: 50vh;

         .type-list {
            padding-bottom: 20rpx;

            .type-item {
               display: flex;
               justify-content: space-between;
               align-items: center;
               padding: 20rpx 30rpx;
               border-bottom: 2rpx solid #f0f0f0;

               &.disabled {
                  color: #ccc;
               }

               &.selected {
                  color: #007fff;
               }

               .item-text {
                  font-size: 28rpx;
               }

               .check-icon {
                  margin-left: 20rpx;
               }
            }
         }
      }

      .type-footer {
         padding: 20rpx 30rpx;
         display: flex;
         justify-content: center;

         .confirm-btn {
            width: 100%;
            background-color: #007fff;
            color: #fff;
            border-radius: 10rpx;
         }
      }
   }
}

</style>

组件页面:

javascript 复制代码
<template>
  <view class="col-item" :class="{ 'col-item-bot': localShow }">
    <block v-if="paramData">
      <view class="col-item-title">
        <view 
          class="item-box" 
          :class="{ 'ch-item': currentLayer === 1 }"
          @click="handleItem(paramData)"
        >
          <image
            v-if="hasChildren"
            @click.stop="tapItemOne(paramData)"
            :class="localShow ? 'arrow-down-css' : 'arrow-right-css'"
            src="./image/arrow.png"
            class="arrow-icon"
          />
          <view class="item-box-left">
            <view class="left-images" v-show="currentLayer === 1">
            </view>
            <view>{{ paramData[title] || paramData.text }}</view>
          </view>
        </view>
      </view>

      <view 
        v-if="hasChildren && shouldRenderChildren" 
        v-show="localShow"
        class="children-container"
      >
        <view 
          v-for="item in paramData[children]" 
          :key="getItemKey(item)"
        >
          <neo-tree-list-item
            @parentEmit="parentEmit"
            :parentData="paramData"
            :title="title"
            :layer="currentLayer + 1"
            :paramData="item"
            @tapText="onTapText"
            @tapTitle="onTapTitle"
          />
        </view>
      </view>
    </block>
  </view>
</template>

<script setup>
import { ref, computed, watch } from 'vue';

const props = defineProps({
  paramData: {
    type: Object,
    default: undefined
  },
  parentData: {
    type: Object,
    default: undefined
  },
  title: {
    type: String,
    default: 'text'
  },
  children: {
    type: String,
    default: 'children'
  }
});

const emit = defineEmits(['tapText', 'tapTitle', 'parentEmit', 'update:show']);

const currentLayer = ref(0);
const hasRenderedChildren = ref(false);
const localShow = ref(props.paramData?.show || false);

const hasChildren = computed(() => {
  return props.paramData?.[props.children]?.length > 0;
});

const shouldRenderChildren = computed(() => {
  if (hasRenderedChildren.value) return true;
  if (localShow.value) {
    hasRenderedChildren.value = true;
    return true;
  }
  return false;
});

watch(
  () => props.paramData?.show,
  (newVal) => {
    localShow.value = newVal || false;
    if (newVal && !hasRenderedChildren.value) {
      hasRenderedChildren.value = true;
    }
  },
  { immediate: true }
);

const getItemKey = (item) => {
  return item.value || item.id || item.text || JSON.stringify(item);
};

const tapItemOne = (item) => {
  if (!hasChildren.value) {
    emit('tapTitle', item);
    return;
  }

  // 基于当前 localShow 状态来切换,而不是 item.show
  const newShowValue = !localShow.value;
  localShow.value = newShowValue;
  
  // 通知父组件更新状态
  emit('update:show', {
    item: item,
    show: newShowValue
  });
  
  if (newShowValue && item.created === undefined) {
    item.created = true;
    hasRenderedChildren.value = true;
  }
};

// 其他方法保持不变
const handleItem = (item) => {
  emit('tapText', item);
};

const onTapText = (item) => {
  emit('tapText', item);
};

const onTapTitle = (item) => {
  emit('tapTitle', item);
};

const parentEmit = () => {
  if (props.parentData) {
    emit('parentEmit');
  }
};

const recursionChecked = (item, checked) => {
  if (!item[props.children]) return;
  
  item[props.children].forEach(child => {
    child.checked = checked;
    recursionChecked(child, checked);
  });
};

watch(
  () => props.paramData,
  () => {
  },
  { deep: true }
);
</script>

<style scoped lang="scss">
.col-item {
  background: #ffffff;

  .col-item-title {
    display: flex;
    justify-content: flex-start;
  }
  
  .left-image {
    margin: 16rpx 0rpx 16rpx 32rpx;
    padding: 12rpx;
    display: flex;
    align-items: center;
    justify-content: center;
    background: rgba(0, 127, 255, 0.12);
    border-radius: 12rpx;
    
    .img {
      width: 60rpx;
    }
  }
  
  .item-box {
    height: 80rpx;
    display: flex;
    align-items: center;
    justify-content: flex-start;
    width: 100%;
    padding: 0 32rpx;
    border-bottom: 2rpx solid rgba(126, 134, 142, 0.16);
    
    .item-box-left {
      display: flex;
      align-items: center;
      justify-content: flex-start;
      line-height: 40rpx;
    }
    
    .left-images {
      margin: 16rpx 0rpx;
      width: 40rpx;
      height: 40rpx;
      display: flex;
      align-items: center;
      justify-content: center;
      border-radius: 12rpx;
      margin-right: 24rpx;
      
      .img {
        width: 60rpx;
      }
    }
  }
  
  .ch-item {
    border-bottom: 0;
    box-shadow: 124rpx 2rpx 0rpx rgba(126, 134, 142, 0.16);
  }
}

.col-item-bot {
  margin-bottom: 24rpx;
}

.arrow-down-css,
.arrow-right-css {
  width: 30rpx;
  height: 30rpx;
  margin-right: 1rpx;
  transition: transform 0.2s ease;
}

.arrow-down-css {
  transform: rotate(90deg);
}

.arrow-right-css {
  transform: rotate(0deg);
}

.children-container {
  padding-left: 60rpx; // 图标宽度+间距,确保所有子级对齐
}
</style>
相关推荐
赵庆明老师44 分钟前
Uniapp微信小程序开发:EF Core 中级联删除
uni-app
lijun_xiao20091 小时前
前端最新Vue2+Vue3基础入门到实战项目全套教程
前端
90后的晨仔1 小时前
Pinia 状态管理原理与实战全解析
前端·vue.js
杰克尼1 小时前
JavaWeb_p165部门管理
java·开发语言·前端
EndingCoder1 小时前
WebSocket实时通信:Socket.io
服务器·javascript·网络·websocket·网络协议·node.js
90后的晨仔1 小时前
Vue3 状态管理完全指南:从响应式 API 到 Pinia
前端·vue.js
90后的晨仔1 小时前
Vue 内置组件全解析:提升开发效率的五大神器
前端·vue.js
我胡为喜呀1 小时前
Vue3 中的 watch 和 watchEffect:如何优雅地监听数据变化
前端·javascript·vue.js
Javashop_jjj2 小时前
三勾软件| 用SpringBoot+Element-UI+UniApp+Redis+MySQL打造的点餐连锁系统
spring boot·ui·uni-app
我登哥MVP2 小时前
Ajax 详解
java·前端·ajax·javaweb