基于ElementPlus封装多级分页加载多选树组件

业务上需要实现一个多级分页加载,可混选不同级的组件。现将组件实现代码记录如下。


组件代码:

components/TreeClickMore/index.vue
<template>
    <div class="tree-more">
        <!-- 左侧内容 --> 
        <div class="left">
            <div class="tools">
                <!-- 搜索框 --> 
                <el-form @submit.prevent>
                    <el-form-item>
                        <el-input v-model.trim="keywords" @keyup.enter="searchList" class="tree-search"
                            :placeholder="searchPlaceholder" clearable />
                    </el-form-item>
                </el-form>
            </div>
            <div class="tree-box">
               <!-- 树组件 --> 
                <el-tree v-if="showTree" ref="treeMoreRef" :empty-text="emptyText" :props="treeProps" :load="getList"
                    :node-key="value" :default-expand-all="!!keywordsParam" lazy>
                    <template #default="{ node, data }">
                        <!-- 加载更多 --> 
                        <span v-if="data.type === 'more'" class="more-text" @click.stop="loadMore(node, data)">
                            <span class="icon">
                                <i-ep-pointer v-if="!data.loading" class="ani-scaling"></i-ep-pointer>
                                <i-ep-loading v-else class="ani-rotating"></i-ep-loading>
                            </span>
                            {{ data[label] }}
                        </span>
                        <!-- 普通节点 --> 
                        <span v-else class="tree-text">
                            <span>{{ treeText(data[value], data[label]) }}</span>
                            <span>
                                <el-link type="primary"
                                    @click.stop="checkTree(node)"><i-ep-d-arrow-right></i-ep-d-arrow-right></el-link>
                            </span>
                        </span>
                    </template>
                </el-tree>
            </div>
        </div>
       <!-- 右侧内容 --> 
        <div class="right">
            <div class="tools">
                <el-button type="primary" link class="clear" @click="clearAll">清空</el-button>
            </div>
            <div class="tree-box">
                <!-- 已选中数据形成的树 --> 
                <el-tree ref="selectedTree" :data="selTreeList" :node-key="value" :props="treeProps" default-expand-all>
                    <template #default="{ node, data }">
                        <span class="tree-text">
                            <span>{{ treeText(data[value], data[label]) }}</span>
                            <span>
                                <el-link type="primary" @click.stop="deleteSel(node, true)"
                                    v-if="data?.children?.length"><i-ep-circle-close></i-ep-circle-close></el-link>
                                <el-link type="primary"
                                    @click.stop="deleteSel(node)"><i-ep-close></i-ep-close></el-link>
                            </span>
                        </span>
                    </template>
                </el-tree>
            </div>
        </div>
    </div>
</template>

<script setup>
import { useSetEffect, useListEffect, useOperateEffect } from './index'
import { useFormItem } from 'element-plus'

const { formItem } = useFormItem() // 用来触发表单校验

const props = defineProps({
    url: { // 远程地址
        required: true,
        type: String,
        default: ''
    },
    editData: { // 编辑回填信息
        type: Array,
        default() {
            return []
        }
    },
    maxLevel: { // 最多层级
        type: Number,
        default: 1000
    },
    value: { // value配置项
        type: String,
        default: 'id'
    },
    label: { // label配置项
        type: String,
        default: 'text'
    },
    /*
     当额外传参是动态变化的,需要用响应式的方式传进来
    */
    otherParams: { // 接口传参
        type: Object,
        default() {
            return {}
        }
    },
    pageSize: { // 每次传参个数
        type: Number,
        default: 50,
    },
    keyName: { // 搜索条件的key名
        type: String,
        default: 'keywords'
    },
    pageNumName: { // 搜索条件的当前页名
        type: String,
        default: 'pageNum'
    },
    pageSizeName: { // 搜索条件的每页个数名
        type: String,
        default: 'pageSize'
    },
    showId: { // 是否显示id
        type: Boolean,
        default: false,
    },
    searchPldText: { // 搜索显示文字
        type: String,
        default: ''
    },
    modelValue: { // v-model的隐藏传参,无需手动传
        type: Array
    },
})

const emit = defineEmits(['change', 'update:modelValue'])

const treeMoreRef = ref()
const selectedTree = ref()

// 动态配置项
const { treeText, searchPlaceholder, treeProps } = useSetEffect(props)

// 搜索和列表
const { showTree, emptyText, keywords, keywordsParam, searchList, getList, loadMore } = useListEffect(props, treeMoreRef)

// 操作
const { selTreeList, checkTree, deleteSel, clearAll } = useOperateEffect(props, emit, selectedTree, searchList, formItem)

</script>
<style>

</style>
<style lang="scss" scoped>
/* 演示用,实际动画样式,封装后引入 */
@keyframes scaling {
    0% {
        transform: scale(1);
    }

    50% {
        transform: scale(1.2);
    }
}

@keyframes rotating {
    0% {
        transform: rotate(0deg);
    }

    100% {
        transform: rotate(360deg);
    }
}

.ani-scaling {
    animation: scaling 1.5s linear infinite;
}

.ani-rotating {
    animation: rotating 2s linear infinite;
}

.tree-more {
    overflow: hidden;
    margin-bottom: 5px;

    .left,
    .right {
        float: left;
    }

    .left {
        margin-right: 20px;
    }

    .tools {
        height: 32px;
        margin-bottom: 10px;
        overflow: hidden;

        .tree-search {
            width: 260px;
        }

        .clear {
            float: right;
            margin-top: 13px;
            margin-right: 15px;
        }
    }

    .tree-box {
        width: 400px;
        height: 300px;
        padding: 10px;
        background: #fff;
        border: 1px solid var(--el-border-color);
        border-radius: var(--el-input-border-radius, var(--el-border-radius-base));
        overflow-y: auto;

        .tree-text {
            flex: 1;
            display: flex;
            align-items: center;
            justify-content: space-between;
            font-size: 14px;
            padding-right: 8px;
        }
    }

    .sel-item {
        display: inline-block;
        margin-right: 5px;

        .sel-item-delete {
            color: red;
            cursor: pointer;
            vertical-align: text-bottom;
        }
    }
}

.el-form-item.is-error .tree-search :deep(.el-input__wrapper) {
    box-shadow: 0 0 0 1px var(--el-input-border-color, var(--el-border-color)) inset;
}

:deep(.el-tree-node).node-more {
    .el-checkbox {
        display: none;
    }

    .more-text {
        &:hover {
            color: var(--el-color-primary);
        }

        .icon {
            margin-right: 5px;
            vertical-align: middle;
        }
    }
}
</style>
components/TreeClickMore/index.js
// import request from '@/utils/request'
import axios from 'axios' // 演示用,直接引入了axios,实际业务有封装。

// 页面配置
export const useSetEffect = (props) => {
    const treeText = (id, name) => {
        return props.showId ? `【${id}】${name}` : name
    }

    const searchPlaceholder = computed(() => props.searchPldText ? props.searchPldText : (props.showId ? '回车搜索ID或名称' : '回车搜索名称'))

    const treeProps = {
        label: props.label,
        isLeaf: 'leaf',
        class: (data) => {
            if (data.type === 'more') {
                return 'node-more'
            }
            return ''
        }
    }

    return { treeText, searchPlaceholder, treeProps }
}

// 页面数据
export const useListEffect = (props, treeMoreRef) => {
    const showTree = ref(true) // 显示树
    const keywords = ref('') // 搜索关键字
    const keywordsParam = ref('') // 搜索关键字传参
    const emptyText = ref('正在搜索中...')

    // 请求接口获取数据
    const getList = async (node, resolve, isMoreNode) => {
        const nodeData = toRaw(node?.data) || {}
        const isRootNode = !node?.level

        let pageNum = isMoreNode ? nodeData?.pageNum : 1
        let parentId = isMoreNode ? nodeData?.parentId : (nodeData.id || 0)
        let level = isMoreNode ? nodeData?.level : (++nodeData.level || 1)
        const pageSize = props.pageSize

        const params = Object.assign({}, props.otherParams, {
            [props.pageNumName]: pageNum,
            [props.pageSizeName]: pageSize,
            [props.keyName]: keywordsParam.value,
            parentId: parentId,
            level: level
        })

        const result = await axios.post(props.url, params)

        let { page, data: list } = result
        list = list || []
        const hasMore = pageNum * pageSize < page?.total

        // 增加最大层级处理
        list.forEach((item) => {
            item.leaf = props.maxLevel <= level
        })

        if (hasMore) {
            pageNum++
            list.push({
                type: 'more',
                disabled: true,
                leaf: true,
                loading: false,
                pageNum: pageNum,
                parentId: parentId,
                level: level,
                [props.value]: `more_${parentId}_${pageNum}`,
                [props.label]: '点击加载更多...',
            })
        }

        if (isRootNode) {
            !list.length && (emptyText.value = '暂无数据')
        }

        if (!isMoreNode) {
            resolve(list)
        } else {
            list.forEach((item) => {
                treeMoreRef.value.insertBefore(item, node.key)
            })
            treeMoreRef.value.remove(node) // 删除上一次的加载更多节点
        }
    }

    // 加载更多
    const loadMore = (node, nodeData) => {
        if (nodeData.loading) {
            return false
        }

        nodeData.loading = true
        nodeData[props.label] = '正在加载更多...'

        getList(node, null, true)
    }

    // 搜索
    const searchList = async () => {
        showTree.value = false
        keywordsParam.value = keywords.value
        await nextTick()
        showTree.value = true
        emptyText.value = '正在搜索中...'
    }

    return { showTree, emptyText, keywords, keywordsParam, searchList, getList, loadMore }
}


// 树的操作
export const useOperateEffect = (props, emit, selectedTree, searchList, formItem) => {
    // 已选树的值
    const selTreeList = computed({
        get: function () {
            return props.modelValue
        },
        set: async function (val) {
            val = toRaw(val)
            emit('update:modelValue', val)
            emit('change', val)
            formItem?.validate('change') // 触发表单校验
        }
    })

    // 获取子节点
    const getChildData = (node) => {
        let arr = []
        node?.childNodes?.forEach(item => {
            let obj = Object.assign({}, item.data)
            if (obj.type !== 'more') {
                obj.children = getChildData(item)
                arr.push(obj)
            }
        })
        return arr
    }

    // 更新已选节点
    const updateSelected = (leftArr, rightData) => {
        leftArr.forEach(item => {
            let findIndex = rightData.findIndex(rightTtem => rightTtem[props.value] === item[props.value])
            if (findIndex === -1) {
                rightData.push(item)
            } else {
                updateSelected(item.children, rightData[findIndex].children)
            }
        })
        return rightData
    }

    // 选择树
    const checkTree = (node) => {
        node = toRaw(node)
        let childList = getChildData(node)
        let obj = {}
        while (node?.data?.level) {
            obj = Object.assign({}, node.data)
            obj.children = childList
            childList = [obj]
            node = node.parent
        }

        selTreeList.value = updateSelected([obj], selTreeList.value)
    }

    // 删除单个
    const deleteSel = (node, deleteChild) => {
        if (deleteChild) {
            selectedTree.value.updateKeyChildren(node.data.id, [])
        } else {
            selectedTree.value.remove(node)
        }

        // 触发selTreeList的set
        selTreeList.value = selTreeList.value
    }

    // 清空所有
    const clearAll = () => {
        ElMessageBox.confirm('确认清空?', '提示', {
            confirmButtonText: '确认',
            cancelButtonText: '取消',
            type: 'warning',
        }).then(() => {
            selTreeList.value = []
        }).catch(() => {

        })
    }

    // 监听传参改变,需要把已选值置空,并重新请求接口
    watch(() => props.otherParams, () => {
        selTreeList.value = []
        searchList()
    }, { deep: true })

    return { selTreeList, checkTree, deleteSel, clearAll }
}

基础用法:

引入组件,设置v-model和url即可。

<tree-click-more v-model="" url=""></tree-click-more>

完整示例见以下代码:

<template>
    <div v-loading="formData.editLoading">
        <el-form ref="formRef" :model="formData" :rules="rules" label-width="130px">
            <h4>基础用法</h4>
            <el-form-item label="地域:" prop="area">
                <tree-click-more v-model="formData.area" url="/mapi/v1/strategy/area-info" label="city" @change="change">
                </tree-click-more>
            </el-form-item>

            <h4>编辑回填</h4>
            <el-form-item label="广告位:" prop="adunit">
                <tree-click-more v-model="formData.adunit" url="/mapi/v1/strategy/area-info" label="city">
                </tree-click-more>
            </el-form-item>

            <h4>联动关系</h4>
            <el-form-item label="联动:" prop="platform">
                <el-radio-group v-model="formData.platform">
                    <el-radio :label="1">app</el-radio>
                    <el-radio :label="3">ott</el-radio>
                </el-radio-group>
            </el-form-item>

            <el-form-item label="地域:" prop="union">
                <tree-click-more v-model="formData.union" url="/mapi/v1/strategy/area-info" label="city"
                    :other-params="otherParams">
                </tree-click-more>
            </el-form-item>

        </el-form>
    </div>
</template>
    
<script setup>
import TreeClickMore from '@/components/TreeClickMore/index.vue'
import Desc from './desc.vue'

const formRef = ref()
const change = (val) => {
    console.log(val)
}

const formData = reactive({
    platform: 1,
    area: [],
    adunit: [],
    union: [],
    reset: [],
    editLoading: true
})

const rules = reactive({
    area: [
        { required: true, message: '请选择地域', trigger: 'change' }
    ],
    adunit: [
        { required: true, message: '请选择广告位', trigger: 'change' }
    ],
})

// 联动关系
const otherParams = computed(() => {
    return { platform: formData.platform }
})

// 模拟接口回填信息
setTimeout(() => {
    formData.adunit = [{ "level": 1, "id": 16, "parentId": 0, "areaId": "1156140000", "city": "山西省", "leaf": false, "children": [] }, { "level": 2, "id": 41, "parentId": 0, "areaId": "1156210000", "city": "辽宁省", "leaf": false, "children": [{ "level": 2, "id": 42, "parentId": 41, "areaId": "1156210100", "city": "沈阳市", "leaf": false, "children": [] }] }, { "level": 2, "id": 3, "parentId": 0, "areaId": "1156130000", "city": "河北省", "leaf": false, "children": [{ "level": 3, "id": 4, "parentId": 3, "areaId": "1156130100", "city": "石家庄市", "leaf": false, "children": [{ "level": 3, "id": 170705, "parentId": 4, "areaId": "1156130101", "city": "市区", "leaf": false, "children": [] }, { "level": 3, "id": 170706, "parentId": 4, "areaId": "1156130102", "city": "深泽", "leaf": false, "children": [] }] }] }]
    formData.editLoading = false
}, 500)
</script>
    
<style lang="scss" scoped>
.page-title {
    margin-top: 20px;
}

.warning-wrap {
    margin-bottom: 10px;

    .warning {
        color: var(--el-color-warning);
        font-size: 14px;
    }
}
</style>

参数说明:

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,132评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,802评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,566评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,858评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,867评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,695评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,064评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,705评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,915评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,677评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,796评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,432评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,041评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,992评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,223评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,185评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,535评论 2 343

推荐阅读更多精彩内容