今天实现一个客户单位组织树的功能,不限层级,数据量巨大,采用vue-treeselect 实现懒加载 + 远程搜索
这是vue+ iview组件 element的需要改一下tooltip的写法
这个支持对选择的单位全部层级进行悬浮提示(也无法在下面下拉回显,下拉需要触发懒加载,只有远程搜索才能回显层级)
代码详细解释:
tooltip 设置了最大宽度 没有选择项 或者 下拉框弹开 会disabled禁用
limit是多选展示的最大数量,我这里设置1个
:limitText="count => '更多' + count + '个'" 是 xxx 更多x个 对提示文字修改
flat是多选时候不设置这个回显会把 给出id的所有父级也勾选
multiple是多选
:class="{ treeClass: isEdit }" 这个是开始想通过设置placeholder 来回显层级,后面作废
:options="product_map" 下拉框的数据源
:appendToBody="appendToBody" 相当于transfer 把组件渲染到最外层,不受父级样式影响
:value-consists-of="valueConsistsOf" 选择后@select 方法获取的数据类型(id还是node层级)
:disabled="disabled" 禁用
:placeholder="placeholder" 默认提示语
:async="isAsync" 是否开启远程搜索模式
:load-options="isAsync ? asyncOptions : loadOptions" 懒加载和远程搜索触发的方法
:defaultExpandLevel="expandLevel" 默认展开的层级
:cacheOptions="false" 是否缓存搜索的数据 这里缓存会有问题
v-model="checkRroduct" 绑定的数据id
一些为空的提示语
noChildrenText="没有数据"
noOptionsText="没有数据"
noResultsText="没有搜索结果"
控制打开搜索模式还是懒加载
@search-change="searchChange"
@keydown.native="treeKeydown($event)"
@keyup.native="treeKeyup($event)"
@open="itemopen"
@close="itemClose"
下拉选项的自定义展示处理
因为选项的每一项 配置 disabled在远程搜索模式有问题,所以自己重写这个了
disabled是因为项目搜索后 一些选项要禁用
(这个气死我了) 写的破大防
[
{id: 151, group_id : "150,151", group_name : "xxx,xxx" },
{id: 152, group_id : "150,151,152", group_name : "xxx,xxx,xxx" },
{id: 162, group_id : "160,161,162" , group_name : "xxx,xxx,xxx"},
]
如上数据 group_id 是父级到它本身 我要把这个聚合为一条树,并且 根据id去禁用 上面的150,160,161 就需要禁用,也就是没有返回id和group_id 最后一位相同的选项
<div slot="option-label" slot-scope="{ node }">
<div :title="node.raw.label" v-if="node.raw.disabled" @click.prevent @click.stop :class="['labelClass', 'is-disabled']">
{{ node.raw.label }}
</div>
<div :title="node.raw.label" v-else class="labelClass">
{{ node.raw.label }}
</div>
</div>
选中项的自定义展示处理
<div slot="value-label" slot-scope="{ node }">{{ node.raw.longTitle }}</div>
接口讲解
queryTenantByLevel 传空查询所有一级客户 传parent_id查询用户子级(懒加载使用)
queryTenantByLike 传 staff_id查询这个id的用户信息 传company_name远程模糊搜索
方法讲解
handleEditTreeDataT 修改回显时候使用这个方法 handleEditTreeData是客户中心的特殊处理
handleEditTreeDataMuti 是多选的的回显
上面三个都做了权限控制,queryTenantByLevel返回空证明这个客户权限低,使用登录客户的staff_id请求数据
clear() 这个是对页面重置时候要调用的 清空选择项 this.$refs.cusTree.clear()
initTreeData 是新增和页面条件查询时候用的
toSelectProduct 选中选项后触发这个方法,返回node层级
asyncOptions 远程搜索 这里面做了一个时间控制,用户输入停止后1秒才会调接口 而且这个有 bug,必须callback(null, []) 后才能正常搜索,我这里控制第一次搜索结果为空,存在问题,后续研究吧
loadOptions 懒加载方法
后端返回数据需要这些字段
company_name 客户单位的名称
group_id 顶级到当前级别的id 如果当前是一级这个取当前用户的id
group_name 顶级到当前级别的company_name拼接
parent_id 当前用户的父级id 这个结合handleEditTreeData 客户中心是新增修改父级客户
所以this.checkRroduct = formData.parent_id 其他地方是staff_id
staff_id 当前用户的id
如何调用
首先全局注册一下这个组件
新建js文件
import Vue from 'vue'
import CusTree from './vue-treeselect/index.vue'
Vue.component('CusTree', CusTree)
在查询条件里面使用
<FormItem>
<CusTree style="width: 200px;" ref="cusTree" placeholder="客户单位" type="A" @getCheckRroduct="getCheckRroduct"></CusTree>
</FormItem>
// 重置
resetData() {
this.$refs.cusTree.clear()
this.condition = JSON.parse(JSON.stringify(this.conditionInitCache))
this.queryData()
},
// 这个是获取 checkRroduct是id label是中文名(防止后端让你传中文搜索)
getCheckRroduct(formData) {
this.condition.customer_id = formData.checkRroduct // formData.label
},
在新增修改数据里面使用
type为A是新增 其他类型需要在下面触发handleEditTreeDataT修改回显
queryTenantByLike传staff_id是查这个用户的具体信息
<CusTree :type="params.type" ref="cusTree" placeholder="客户单位" @getCheckRroduct="getCheckRroduct"></CusTree>
if (this.params.type == 'M') {
// console.log('params', this.params)
this.formValidate.customerId = this.params.customer_id
this.$http.operation.queryTenantByLike({ staff_id: this.formValidate.customerId }).then(resp => {
if (resp && resp.result_code == '0') {
this.$refs.cusTree.handleEditTreeDataT(resp.data[0])
} else {
this.$Message.error(resp.result_msg)
}
})
}
在多选时候
<CusTree flat multiple :type="params.type" ref="cusTree" placeholder="请选择通知人员" @getCheckRroduct="getCheckRroduct"></CusTree>
if (this.params.type == 'M') {
// console.log('params', this.params)
this.formValidate.customerId = this.params.customer_id
this.$http.operation.queryTenantByLike({ staff_id: this.formValidate.customerId }).then(resp => {
if (resp && resp.result_code == '0') {
this.$refs.cusTree.handleEditTreeDataMuti(resp.data)
} else {
this.$Message.error(resp.result_msg)
}
})
}
完整代码
<template>
<div>
<Tooltip max-width="200" :disabled="tooltipDisabled" :content="formData.longTitle">
<treeselect
:limit="1"
:clearable="clearable"
:limitText="count => '更多' + count + '个'"
:flat="flat"
:multiple="multiple"
:class="{ treeClass: isEdit }"
:options="product_map"
:appendToBody="appendToBody"
:value-consists-of="valueConsistsOf"
:disabled="disabled"
:placeholder="placeholder"
@select="toSelectProduct"
:async="isAsync"
:defaultExpandLevel="expandLevel"
:cacheOptions="false"
:disableFuzzyMatching="false"
:load-options="isAsync ? asyncOptions : loadOptions"
v-model="checkRroduct"
noChildrenText="没有数据"
noOptionsText="没有数据"
noResultsText="没有搜索结果"
@search-change="searchChange"
@keydown.native="treeKeydown($event)"
@keyup.native="treeKeyup($event)"
@open="itemopen"
@close="itemClose"
>
<div slot="option-label" slot-scope="{ node }">
<div :title="node.raw.label" v-if="node.raw.disabled" :class="['labelClass', 'is-disabled']">
{{ node.raw.label }}
</div>
<div :title="node.raw.label" v-else class="labelClass">
{{ node.raw.label }}
</div>
</div>
<div slot="value-label" slot-scope="{ node }">{{ node.raw.longTitle }}</div>
</treeselect>
</Tooltip>
</div>
</template>
<script>
export default {
components: {},
props: {
placeholder: {
type: String,
default: '请选择'
},
indexTTT: {
type: Number,
default: 0
},
type: {
type: String,
default: 'A'
},
valueConsistsOf: {
type: String,
default: 'ALL_WITH_INDETERMINATE'
},
isEdit: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
},
appendToBody: {
type: Boolean,
default: false
},
multiple: {
type: Boolean,
default: false
},
flat: {
type: Boolean,
default: false
},
clearT: {
type: Boolean,
default: false
},
clearable: {
type: Boolean,
default: true
},
khzx: {
type: Boolean,
default: false
},
tooltipShow: {
type: Boolean,
default: false
}
},
data() {
return {
isAsync: false,
checkRroductT: null,
userInfo: JSON.parse(this.$libs.getSessionStorage('user-info')),
tooltipDisabled: true,
checkRroduct: null,
expandLevel: 0,
product_map: [],
product_mapTemp: [],
formData: {},
searchQuery: '',
timer: null,
dataT: [],
formDataID: ''
}
},
watch: {
checkRroduct(val) {
this.formData.checkRroduct = val || ''
this.tooltipDisabled = this.tooltipShow ? true : !val
if (!val) {
this.formData.label = ''
this.formData.longTitle = ''
}
if (!(this.clearT && this.type != 'A')) {
this.$emit('getCheckRroduct', this.formData, this.indexTTT)
}
}
},
mounted() {
// console.log(this.type)
if (this.type == 'A') {
this.initTreeData()
}
},
methods: {
clear() {
this.checkRroduct = null
this.tooltipDisabled = true
},
// 客户中心显示父级
handleEditTreeData(formData) {
this.formDataID = formData.staff_id
if (!this.isEdit) {
this.$http.operation.queryTenantByLevel().then(resp => {
if (resp && resp.result_code == '0') {
if (resp.data.length == 0) {
this.$http.operation.queryTenantByLike({ staff_id: this.userInfo.staff_id }).then(resp => {
if (resp && resp.result_code == '0') {
resp.data.map(item => {
item.id = item.staff_id
item.label = item.company_name
item.longTitle = item.company_name
item.children = null
item.disabled = this.khzx && this.type == 'M' ? (item.staff_id == formData.staff_id ? true : false) : false
})
if (!formData.group_id) {
this.product_map = resp.data
this.product_mapTemp = resp.data
} else {
const parentIds = formData.group_id.split('/')
this.arrInsert(parentIds, resp.data).then(finalData => {
// 在这里,finalData 就是 arrInsert 方法返回的最终 dataT
resp.data = this.addLongTitle(finalData, formData.staff_id)
this.formData.longTitle = formData.group_name
this.formData.label = formData.company_name
this.formData.group_id = formData.group_id
this.checkRroduct = formData.parent_id
this.product_map = resp.data
this.product_mapTemp = resp.data
})
}
this.product_mapTemp = resp.data
} else {
this.$Message.error(resp.result_msg)
}
})
} else {
resp.data.map(item => {
item.id = item.staff_id
item.label = item.company_name
item.longTitle = item.company_name
item.children = null
item.disabled = this.khzx && this.type == 'M' ? (item.staff_id == formData.staff_id ? true : false) : false
})
if (!formData.group_id) {
this.product_map = resp.data
this.product_mapTemp = resp.data
} else {
const parentIds = formData.group_id.split('/')
this.arrInsert(parentIds, resp.data).then(finalData => {
// 在这里,finalData 就是 arrInsert 方法返回的最终 dataT
resp.data = this.addLongTitle(finalData, formData.staff_id)
this.formData.longTitle = formData.group_name
this.formData.label = formData.company_name
this.formData.group_id = formData.group_id
this.checkRroduct = formData.parent_id
this.product_map = resp.data
this.product_mapTemp = resp.data
})
}
}
} else {
this.$Message.error(resp.result_msg)
}
})
} else {
this.formData.longTitle = formData
this.tooltipDisabled = this.tooltipShow ? true : false
}
},
// 其他页面修改
handleEditTreeDataT(formData) {
this.$http.operation.queryTenantByLevel().then(resp => {
if (resp && resp.result_code == '0') {
if (resp.data.length == 0) {
this.$http.operation.queryTenantByLike({ staff_id: this.userInfo.staff_id }).then(resp => {
if (resp && resp.result_code == '0') {
resp.data.map(item => {
item.id = item.staff_id
item.label = item.company_name
item.longTitle = item.company_name
item.children = null
})
const parentIds = formData.group_id.split('/')
this.arrInsert(parentIds, resp.data).then(finalData => {
// 在这里,finalData 就是 arrInsert 方法返回的最终 dataT
resp.data = this.addLongTitle(finalData, formData.staff_id)
this.formData.longTitle = formData.group_name
this.formData.label = formData.company_name
this.formData.group_id = formData.group_id
this.checkRroduct = formData.staff_id
this.checkRroductT = formData.staff_id
this.product_map = resp.data
this.product_mapTemp = resp.data
})
} else {
this.$Message.error(resp.result_msg)
}
})
} else {
resp.data.map(item => {
item.id = item.staff_id
item.label = item.company_name
item.longTitle = item.company_name
item.children = null
})
const parentIds = formData.group_id.split('/')
this.arrInsert(parentIds, resp.data).then(finalData => {
// 在这里,finalData 就是 arrInsert 方法返回的最终 dataT
resp.data = this.addLongTitle(finalData, formData.staff_id)
this.formData.longTitle = formData.group_name
this.formData.label = formData.company_name
this.formData.group_id = formData.group_id
this.checkRroduct = formData.staff_id
this.checkRroductT = formData.staff_id
this.product_map = resp.data
this.product_mapTemp = resp.data
})
}
} else {
this.$Message.error(resp.result_msg)
}
})
},
addLongTitle(treeData, staff_id) {
// 从根节点开始,不需要前缀
return treeData.map(node => addNodeLongTitle(node, ''))
function addNodeLongTitle(node, prefix) {
// 如果没有提供title,则使用company_name作为默认title
const title = node.company_name
// 拼接前缀和当前节点的title
node.longTitle = (prefix ? prefix + ' / ' : '') + title
node.isDefaultExpanded = staff_id == node.id ? true : false
// 如果存在子节点,递归地为它们添加longTitle,并传递更新后的前缀
if (node.children && node.children.length > 0) {
node.children = node.children.map(child => addNodeLongTitle(child, node.longTitle))
}
return node
}
},
async arrInsert(parentIds, data) {
let dataT = JSON.parse(JSON.stringify(data))
const findNodeById = (id, nodes, data) => {
let nodesT = nodes
for (let i = 0; i < nodesT.length; i++) {
if (nodesT[i].id == id) {
nodesT[i].children = data
}
if (nodesT[i].children && nodesT[i].children.length > 0) {
findNodeById(id, nodesT[i].children, data)
}
}
return nodesT
}
for (let i = 0; i < parentIds.length; i++) {
let respT = await this.$http.operation.queryTenantByLevel({ parent_id: parentIds[i] })
respT.data.map(item => {
item.id = item.staff_id
item.label = item.company_name
item.children = null
})
dataT = findNodeById(parentIds[i], dataT, respT.data.length == 0 ? null : respT.data)
}
return dataT
},
// 多选逻辑
handleEditTreeDataMuti(formData) {
this.$http.operation.queryTenantByLevel().then(resp => {
if (resp && resp.result_code == '0') {
if (resp.data.length == 0) {
this.$http.operation.queryTenantByLike({ staff_id: this.userInfo.staff_id }).then(resp => {
if (resp && resp.result_code == '0') {
resp.data.map(item => {
item.id = item.staff_id
item.label = item.company_name
item.longTitle = item.company_name
item.children = null
})
const dataT = JSON.parse(JSON.stringify(this.handleTreeById(formData, true)))
this.product_map = dataT
this.checkRroduct = formData.map(item => item.staff_id)
this.formData.longTitle = formData.map(item => item.group_name).join(',')
this.product_mapTemp = resp.data
} else {
this.$Message.error(resp.result_msg)
}
})
} else {
resp.data.map(item => {
item.id = item.staff_id
item.label = item.company_name
item.longTitle = item.company_name
item.children = null
})
const dataT = JSON.parse(JSON.stringify(this.handleTreeById(formData, true)))
this.product_map = dataT
this.checkRroduct = resp.data.map(item => item.staff_id)
this.formData.longTitle = resp.data.map(item => item.group_name)
this.product_mapTemp = resp.data
}
} else {
this.$Message.error(resp.result_msg)
}
})
},
initTreeData() {
this.$http.operation.queryTenantByLevel().then(resp => {
if (resp && resp.result_code == '0') {
if (resp.data.length == 0) {
this.$http.operation.queryTenantByLike({ staff_id: this.userInfo.staff_id }).then(resp => {
if (resp && resp.result_code == '0') {
resp.data.map(item => {
item.id = item.staff_id
item.label = item.company_name
item.longTitle = item.company_name
item.children = null
})
this.product_map = resp.data
this.product_mapTemp = resp.data
} else {
this.$Message.error(resp.result_msg)
}
})
} else {
resp.data.map(item => {
item.id = item.staff_id
item.label = item.company_name
item.longTitle = item.company_name
item.children = null
})
this.product_map = resp.data
this.product_mapTemp = resp.data
}
} else {
this.$Message.error(resp.result_msg)
}
})
},
toSelectProduct(val) {
if (this.clearT && this.type != 'A') {
this.$Modal.confirm({
title: '确认',
content: '是否要改变客户单位,后续数据将被清空?',
onOk: () => {
if (val.disabled) {
this.$nextTick(() => {
this.checkRroduct = null
})
return
}
if (val.group_id) {
this.formData.group_id = val.group_id + '/' + val.id
this.formData.parent_id = val.id
} else {
this.formData.group_id = val.id
this.formData.parent_id = val.id
}
this.formData.label = val.label
this.formData.longTitle = val.longTitle
this.$emit('getCheckRroduct', this.formData, this.indexTTT)
},
onCancel: () => {
this.checkRroduct = this.checkRroductT
this.$Message.info('取消 ')
}
})
} else {
if (val.disabled) {
this.$nextTick(() => {
this.checkRroduct = null
})
return
}
if (val.group_id) {
this.formData.group_id = val.group_id + '/' + val.id
this.formData.parent_id = val.id
} else {
this.formData.group_id = val.id
this.formData.parent_id = val.id
}
this.formData.label = val.label
this.formData.longTitle = val.longTitle
this.$emit('getCheckRroduct', this.formData, this.indexTTT)
}
},
asyncOptions({ action, parentNode, searchQuery, callback }) {
// console.log(action, 1)
// console.log('isAsync1', this.isAsync)
if (action == 'ASYNC_SEARCH') {
this.searchQuery = searchQuery
clearTimeout(this.timer)
this.timer = setTimeout(() => {
this.$http.operation.queryTenantByLike({ company_name: this.searchQuery }).then(resp => {
if (resp && resp.result_code == '0') {
resp.data.map(item => {
item.id = item.staff_id
item.label = item.company_name
})
const dataT = JSON.parse(JSON.stringify(this.handleTreeById(resp.data)))
this.dataT = dataT
callback(null, dataT)
} else {
this.$Message.error(resp.result_msg)
}
})
this.timer = null
}, 1000)
} else {
callback(null, this.dataT)
}
},
loadOptions({ action, parentNode, searchQuery, callback }) {
// console.log(action, 2)
// console.log('isAsync2', this.isAsync)
if (action == 'LOAD_CHILDREN_OPTIONS') {
this.$http.operation.queryTenantByLevel({ parent_id: parentNode.id }).then(resp => {
if (resp && resp.result_code == '0') {
resp.data.map(item => {
item.id = item.staff_id
item.label = item.company_name
item.longTitle = parentNode.longTitle + ' / ' + item.company_name
item.children = null
item.disabled = this.khzx && this.type == 'M' ? (item.staff_id == this.formDataID ? true : false) : false
})
parentNode.children = resp.data
callback()
} else {
this.$Message.error(resp.result_msg)
}
})
}
},
handleTreeById(data, flag) {
const tree = []
let dataT = data.map(item => String(item.staff_id))
if (this.khzx && this.type == 'M') {
dataT.push(this.formDataID)
}
// console.log('dataT: ', dataT)
data.forEach(item => {
const groupId = item.group_id.split('/')
const groupName = item.group_name.split('/')
let currentLevel = tree
groupId.forEach((id, index) => {
const existingNode = currentLevel.find(node => node.id === id)
if (existingNode) {
currentLevel = existingNode.children
} else {
const newNode = {
id,
label: groupName[index],
group_id: groupId.slice(0, index).join('/'),
longTitle: groupName.slice(0, index + 1).join('/'),
children: [],
disabled: flag ? false : !dataT.includes(id)
}
currentLevel.push(newNode)
currentLevel = newNode.children
}
})
})
// 遍历tree并删除children为空数组的项
function pruneEmptyChildren(nodes) {
return nodes.filter(node => {
if (node.children && node.children.length === 0) {
delete node.children // 删除children属性
return true // 保留这个节点,因为它可能还有其他有用的属性
}
if (node.children) {
pruneEmptyChildren(node.children) // 递归处理子节点
}
return true // 保留这个节点
})
}
// 在完成树的构建后,调用pruneEmptyChildren函数
pruneEmptyChildren(tree)
return tree
},
searchChange(val) {
if (!val) {
this.isAsync = false
this.expandLevel = 0
this.product_map = JSON.parse(JSON.stringify(this.product_mapTemp))
} else {
this.isAsync = true
this.expandLevel = Infinity
}
},
// 输入时打开异步,下拉open/close关闭异步
treeKeydown() {
// console.log('treeKeydown')
this.isAsync = true
this.expandLevel = Infinity
},
treeKeyup(e) {
// if (!e.target.value) {
// this.isAsync = false
// this.expandLevel = 0
// }
},
itemopen() {
// console.log('itemopen')
this.tooltipDisabled = true
this.isAsync = false
this.expandLevel = 0
this.product_map = JSON.parse(JSON.stringify(this.product_mapTemp))
},
itemClose() {
// console.log('itemclose')
this.isAsync = false
this.tooltipDisabled = this.tooltipShow ? true : !this.checkRroduct
this.expandLevel = 0
},
transformData(groupId, groupName, staff_id, name, flag) {
const groups = groupId.split('/')
const names = groupName.split('/')
// 检查groupId和groupName的分割是否匹配
if (groups.length !== names.length) {
throw new Error('groupId和groupName的分割不匹配')
}
// 递归函数来构建树形结构
function buildTree(index) {
if (flag && index >= groups.length) {
// 当到达叶子节点时,添加额外的staff信息
return {
id: staff_id,
label: name,
longTitle: `${names.slice(0, index).join(' / ')} / ${name}`,
children: []
}
} else if (index >= groups.length) {
return null
}
const currentGroupId = groups[index]
const currentGroupName = names[index]
const longTitle = index === 0 ? currentGroupName : `${names.slice(0, index + 1).join(' / ')}`
const child = buildTree(index + 1) // 递归构建子节点
return {
id: Number(currentGroupId),
label: currentGroupName,
longTitle: longTitle,
children: child ? [child] : flag ? null : [] // 如果有子节点,则放入数组中
}
}
// 从根节点开始构建树
const root = buildTree(0)
return root
}
}
}
</script>
<style lang="scss" scoped>
.vue-treeselect {
font-size: 14px;
}
.labelClass {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
color: #333;
}
.treeClass {
::v-deep .vue-treeselect__placeholder {
color: black;
font-size: 14px;
}
}
.is-disabled {
/* 设置禁用选项的样式 */
color: #ccc;
cursor: not-allowed;
/* 可以添加更多样式 */
}
</style>
后记: 附上vue-treeselect的组件样式修改
// 选择框的样式
::v-deep .vue-treeselect__control {
background-color: rgb(10, 19, 33) !important;
border: rgb(10, 19, 33);
}
// 下拉框的样式
// ::v-deep .vue-treeselect__option {
// color: #2d8cf0;
// }
// 选中项的样式
::v-deep .vue-treeselect__single-value {
color: #2d8cf0;
}
// 默认提示语的样式
::v-deep .vue-treeselect__placeholder {
color: #2d8cf0;
}