业务上需要实现一个多级分页加载,可混选不同级的组件。现将组件实现代码记录如下。
组件代码:
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>