该篇文章的写法采用的是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>