Vue3 Vite H5 手写一个横向展开的多级树列表

发布时间 2023-04-14 15:08:51作者: 宇宙野牛

最近写h5要做那种稍微复杂一点的树,没找到现成的UI组件库可用,vant的树只有两级不满足,只能自己写
ps. 选框的选择/反选/半选对父子选项的影响还有bug,留到脑子好的时候再优化

效果

代码

框架是Vue3+Vite+Vant4。复选框用的vant的checkbox,应该也可以换别的或者原生。

模板

<template>
    <div class="top-content">
        <div class="top-tree">
            <div class="top-tree-panel" v-for="(active, level) in treeLevelIndex" :key="level">
                <ul>
                    <li :class="['top-tree-item', index === active ? 'active' : '', item.checked ? 'checked' : '', item.indeterminate ? 'indeterminate' : '']" 
                        v-for="(item,index) in treePanel[level]" :key="index" 
                        @click="($event) => expandChildren(item, index, level, $event)">
                        <span>{{ item.dataName }}</span>
                        <van-checkbox v-model="item.checked" shape="square" @change="(checked) => itemCheckChange(checked, item, index, level)"></van-checkbox>
                    </li>
                </ul>
            </div>
        </div>
        <div class="top-selected">
            <div class="top-selected-total">已选({{ checkedItems.length }})</div>
            <div class="top-selected-list">
                <div class="top-selected-item" v-for="(item,index) in checkedItems" :key="index">
                    <span>{{ item.dataName }}</span>
                    <van-icon name="cross" @click="removeItem(item)"  />
                </div>
            </div>
        </div>
        <div class="top-button">
            <div class="btn btn-reset" @click="reset">重置</div>
            <div class="btn btn-submit" @click="submit">查看</div>
        </div>
    </div>
</template>

script(setup)

<script setup>
// props.data 是示例数据
const props = defineProps({
    data: {
        type: Array,
        required: true,
        default: function(){
            return [
                {
                    id: "1",
                    dataName: "数据1",
                    children: [
                        {
                            id: "1-1",
                            dataName: "二级1",
                            parentId: "1",
                            root: {
                                id: "1",
                                dataName: "数据1",
                            }
                        },
                        {
                            id: "1-2",
                            dataName: "二级2",
                            parentId: "1",
                            root: {
                                id: "1",
                                dataName: "数据1",
                            }
                        },
                    ]
                },
                {
                    id: "2",
                    dataName: "数据2",
                },
                {
                    id: "3",
                    dataName: "数据3",
                    children: [
                        {
                            id: "3-1",
                            dataName: "数据3二级1",
                            parentId: "3",
                            root: {
                                id: "3",
                                dataName: "数据3",
                            },
                            children: [
                                {
                                    id: "3-1-1",
                                    dataName: "数据3三级1",
                                    parentId: "3-1",
                                    root: {
                                        id: "3",
                                        dataName: "数据3",
                                    },
                                },
                                {
                                    id: "3-1-2",
                                    dataName: "数据3三级2",
                                    parentId: "3-1",
                                    root: {
                                        id: "3",
                                        dataName: "数据3",
                                    },
                                },
                                {
                                    id: "3-1-3",
                                    dataName: "数据3三级3",
                                    parentId: "3-1",
                                    root: {
                                        id: "3",
                                        dataName: "数据3",
                                    },
                                },
                                {
                                    id: "3-1-4",
                                    dataName: "数据3三级4",
                                    parentId: "3-1",
                                    root: {
                                        id: "3",
                                        dataName: "数据3",
                                    },
                                },
                            ]
                        },
                        {
                            id: "3-2",
                            dataName: "数据3二级2",
                            parentId: "3",
                            root: {
                                id: "3",
                                dataName: "数据3",
                            },
                        },
                    ]
                }
            ];
        }
    }
})

// 选中的叶子数据项
const checkedItems = ref([]);

const treePanel = ref([]); // 当前显示的选项列表面板
const treeLevelIndex = ref([]); // 记录当前显示的列表面板在树中的索引
const handleTreePanel = () => {
    treePanel.value = [];
    if(props.data.length > 0) {
        let tree = JSON.parse(JSON.stringify(props.data));
        treePanel.value.push(tree);
        // 1级以下递归初始化treePanel和treeLevelIndex
        pushTreeIndex(tree);
    }
}
const pushTreeIndex = (tree) => {
    let nextTree = tree[0].children;
    if(nextTree && nextTree.length > 0) {
        treePanel.value.push(nextTree);
        treeLevelIndex.value.push(0);
        pushTreeIndex(nextTree);
    }else{
        treeLevelIndex.value.push(-1);
    }
}
// 如果是固定的树数据,mounted执行一次即可
onMounted(()=>{
    handleTreePanel();
})
// 如果是异步延迟获取的数据,可能需要用watch监听获取
watch(
    () => {
        return {...props.data};
    }, 
    (newVal, oldVal) => {
        handleTreePanel();
    }, 
    {deep: true}
)

// 点击该项(而非复选框)展开子列表
const expandChildren = (item, index, level, event) => {
    // 点击到图标(checkbox)阻止事件
    let tar = event.target;
    if(tar.tagName === "I" || tar.className.indexOf("van-icon") > -1) {
        return; 
    }

    let children = item.children || [];
    treeLevelIndex.value[level] = index; // 当前level下当前点击的item变为active
    if(treePanel.value[level + 1] !== undefined) { // 下一级已经有了
        if(children.length > 0){ // 从有到有
            treePanel.value[level + 1] = children;
            treeLevelIndex.value[level + 1] = -1;
        } else { // 从有到无,删到当前级别
            treeLevelIndex.value.length = level + 1;
            treePanel.value.length = level + 1;
        }
    }else { // 当前没有下一级
        if(children.length > 0){ // 从无到有
            treePanel.value.push(children);
            treeLevelIndex.value.push(-1);
        }else { // 从无到无
            treePanel.value.length = level + 1;
            treeLevelIndex.value.length = level + 1;
        }
    }
}

// 复选框点击事件
const itemCheckChange = (checked, item, index, level, auto = true) => {
    if(checked === undefined) {
        return; // 禁止子级面板切换时触发
    }

    if(item.children) {
        // 不在treePanel里显示的子项,不会自动继续触发itemCheckChange事件 ;_;
        const childrenVisible = treeLevelIndex.value[level] === index;
        item.children.forEach((c, j) => {
            c.checked = checked;
            // 所以在这里做处理,如果当前没有显示,需要手动触发子项的itemCheckChange事件
            if(!childrenVisible){
                itemCheckChange(checked, c, j, level + 1, false)
            }
        })
    }else{
        onClickItem(item, checked); // 有可能真正要做业务逻辑的地方其实只在这里
        // 向checkedItems中增加或删除当前项
        let i = checkedItems.value.findIndex(child => child.id === item.id);
        if(checked){
            if(i === -1){
                checkedItems.value.push(item);
            }
        }else {
            if(i > -1){
                checkedItems.value.splice(i,1);
            }
        }
    }

    // 子项全选/全不选时修改父项,这里发现有bug
    if(level > 0 && auto){
        let parentItem = treePanel.value[level - 1][treeLevelIndex.value[level - 1]];
        let parentChildren = parentItem.children;
        if(parentChildren.every(i => i.checked)) {
            parentItem.checked = true;
            parentItem.indeterminate = false;
        } else if (parentChildren.every(i => !i.checked)) {
            parentItem.checked = false;
            parentItem.indeterminate = false;
        } else if (parentChildren.some(i => !i.checked)) {
            parentItem.indeterminate = true; // 半选
        }
    }
}

const onClickItem = (nodeData, checked) => {
    // ...具体业务逻辑
}

// 已选列表中单个取消选中
const removeItem = (item) => {
    item.checked = false;
    let i = checkedItems.value.findIndex(child => child.id === item.id);
    checkedItems.value.splice(i,1);
}

// 重置按钮,全部取消选中
const reset = () => {
    checkedItems.value.forEach(item => {
        item.checked = false;
    })
    checkedItems.value.length = 0;
}
</script>

样式(参考)

<style lang="scss" scoped>
    .top-content {
        .top-tree {
            display: flex;
            flex-direction: row;
            width: 100%;
            .top-tree-panel {
                flex: 1;
                padding: 30px 0;
                border-right: 1px solid #314051;
                &:last-child {
                    border-right: none;
                }
                .top-tree-item {
                    display: flex;
                    justify-content: space-between;
                    height: 80px;
                    line-height: 80px;
                    padding: 0 30px;
                    color: #fff;
                    font-size: 28px;
                    overflow: hidden;
		    text-overflow: ellipsis;
		    white-space: nowrap;
                    // 点击当前行
                    &.active {
                        background: #3A4B60;
                    }
                    // 选中
                    &.checked {
                        color: #37B1FF;
                    }
                    // 父级半选
                    &.indeterminate {
                        :deep(.van-badge__wrapper) {
                            color: var(--van-white);
                            background-color: var(--van-checkbox-checked-icon-color);
                            border-color: var(--van-checkbox-checked-icon-color);
                            &::before{
                                content: "\2013" !important; // 短横线
                            }
                        }
                    }
                    // 以下两项,解决选框从半选变为不选时,由于样式设置而短暂出现对勾的问题。(不确定使用其他checkbox组件是否会出现此问题)
                    :deep(.van-badge__wrapper)::before {
                        content: "";
                    }
                    &.checked {
                        :deep(.van-badge__wrapper)::before {
                            content: "\e728"; // 对勾
                        }
                    }
                }
            }
        }
        .top-selected {
            display: flex;
            font-size: 28px;
            font-weight: 400;
            padding: 30px;
            align-items: center;
            .top-selected-total {
                flex-shrink: 0;
            }
            .top-selected-list {
                display: flex;
                flex-flow: row wrap;
                .top-selected-item {
                    border-radius: 38px;
                    padding: 10px 20px;
                    margin-left: 10px;
                    margin-bottom: 10px;
                }
            }
        }
    }
</style>