<template>
<view :class="{ 'next-tree': !toEject, 'next-tree-eject': toEject }" v-if="showTree">
<view v-if="toEject && needMask" :class="[{ show: showTree }, 'next-tree-mask']" @tap="_cancel(1)"></view>
<view :class="[{ show: showTree }, 'next-tree-cnt', { 'next-tree-view-sc-focused': componentType == 'customer' }]">
<view class="next-tree-bar" v-if="needBtn && componentType != 'customer'">
<view
class="next-tree-bar-cancel"
:style="$rpxStyle({ color: cancelColor })"
hover-class="hover-c"
@tap="_cancel"
>取消</view
>
<view class="next-tree-bar-title" :style="$rpxStyle({ color: titleColor })">{{ title }}</view>
<view
class="next-tree-bar-confirm"
:style="$rpxStyle({ color: confirmColor })"
hover-class="hover-c"
@tap="_confirmBtn"
>确定</view
>
</view>
<view class="next-tree-bar-box" v-if="componentType == 'customer'">
<view class="next-tree-bar-customer" :style="$rpxStyle({ color: titleColor })">{{
title || '客户所属行业'
}}</view>
<uni-icons class="next-tree-bar-customer-icon" type="close-outline" size="13" color="#999999" @tap="_cancel" />
</view>
<slot></slot>
<view class="search-input-box" v-if="needSearch">
<view :class="['search-input-wrapper', { focused: isFocused }]" @click="onFocusS">
<uni-icons :size="18" :type="'search'" :style="{ marginRight: '6px' }" :color="'#CCCCCC'" />
<!-- 输入框 -->
<input
:value="inputValue"
class="search-input"
:placeholder="placeholder"
@input="onInput"
@confirm="onConfirm"
@focus="onFocusS"
@blur="onBlurS"
/>
<!-- 右侧清空 Icon -->
<uni-icons
v-if="inputValue"
:color="'#999999'"
:style="{ marginLeft: '6px' }"
:size="18"
:type="'clear'"
@tap.stop.prevent="onClearS"
/>
</view>
</view>
<view
class="next-tree-view"
:class="{
customer: componentType == 'customer'
}"
:style="$rpxStyle(nextTreeStyleCp)"
>
<view class="search-input-box" v-if="componentType == 'customer'">
<view :class="['search-input-wrapper', { focused: isFocused }]" @click="onFocus">
<uni-icons :size="18" :type="'search'" :style="$rpxStyle({ marginRight: '6px' })" :color="'#CCCCCC'" />
<!-- 输入框 -->
<input
:value="inputValue"
class="search-input"
:placeholder="placeholder"
@input="onInput"
@confirm="onConfirm"
@focus="onFocus"
@blur="onBlur"
/>
<!-- 右侧清空 Icon -->
<uni-icons
v-if="inputValue"
:color="'#999999'"
:style="$rpxStyle({ marginLeft: '6px' })"
:size="18"
:type="'clear'"
@tap.stop.prevent="onClear"
/>
</view>
</view>
<scroll-view
:class="['next-tree-view-sc', { 'next-tree-view-sc-boy': componentType == 'customer' }]"
:scroll-y="true"
:style="$rpxStyle(nextTreeScrollStyleCp)"
>
<slot name="empty"></slot>
<template v-if="!showDropdown">
<block v-for="(item, index) in treeList" :key="index">
<view
class="next-tree-item"
:style="
$rpxStyle([
{
marginLeft: componentType != 'customer' ? item.rank * marginLeftNum + 'px' : '0px',
paddingLeft:
componentType != 'customer'
? '0px'
: item.rank == 0
? '8px'
: (item.rank - 1) * marginLeftNum + 20 + 'px',
zIndex: item.rank * -1 + 50
}
])
"
:class="{
border: showBorder(item.show, item.lastRank),
show: item.show,
last: item.lastRank,
showchild: item.showChild,
open: item.open,
first: !item.parentIdNub,
borderTop: componentType == 'customer' && !showDropdown && index == 0
}"
>
<view
class="next-tree-label"
:class="{
checked: item.checked && componentType != 'customer',
disabled: item.disabled
}"
>
<uni-icons
v-if="!item.lastRank && componentType != 'customer'"
class="next-tree-icon"
:size="iconSize"
:type="item.lastRank ? lastIcon : item.showChild ? currentIcon : defaultIcon"
:color="treeIconColor"
@tap.stop="_treeItemTap(item, index)"
/>
<uni-icons
v-if="item.lastRank && zipList"
class="next-tree-icon"
:size="zipListIconSize"
:type="imgTypeFn(item.type)"
@tap.stop="_treeItemTap(item, index)"
/>
<uni-icons
v-if="item.parentIdNub && componentType == 'customer'"
class="next-tree-icon-zhexian"
:size="8"
color="#CCCCCC"
:type="'zhexian'"
@tap.stop="_treeItemSelect(item, index)"
/>
<view
class="next-tree-icon next-tree-icon-not"
v-if="!item.checked && !item.indeterminate && componentType == 'customer'"
@tap.stop="_treeItemSelect(item, index)"
>
<view class="next-tree-icon-not-selected"> </view>
</view>
<!-- <uni-icons
v-if="!item.checked && !item.indeterminate && componentType == 'customer'"
class="next-tree-icon"
:size="iconSize"
:type="'not-selected'"
:color="'#333'"
@tap.stop="_treeItemSelect(item, index)"
/> -->
<uni-icons
v-if="componentType == 'customer' && item.checked"
class="next-tree-icon"
:size="iconSize"
:type="'all-selected'"
:color="'#0F56D5'"
@tap.stop="_treeItemSelect(item, index)"
/>
<uni-icons
v-if="componentType == 'customer' && item.indeterminate && !item.checked"
class="next-tree-icon"
:color="'#0F56D5'"
:size="iconSize"
:type="'indeterminate'"
@tap.stop="_treeItemSelect(item, index)"
/>
<!-- {{indeterminate}} -->
<!-- <view v-if="!item.lastRank" class="next-tree-icon">{{
item.lastRank ? '' : item.showChild ? 'X' : '>'
}}</view> -->
<view v-if="!zipList" @tap.stop="_treeItemSelect(item, index)"> {{ item.name }}</view>
<view v-if="zipList" class="zipList_box" @tap.stop="_treeItemSelect(item, index)">
<view class="zipList_box_one">{{ item.name }}</view>
<view v-if="item.lastRank" class="zipList_box_two">文件大小:{{ getSize(item.size) }}</view>
</view>
</view>
<view class="next-tree-check" @tap.stop="_treeItemTap(item, index)">
<view class="next-tree-check-yes" v-if="item.checked && !zipList" :class="{ radio: !multiple }">
<!-- <text class="icon-text">✔</text> -->
<uni-icons v-if="componentType != 'customer'" :type="checkIcon" color="#0f56d5" />
</view>
<view class="next-tree-check-yes" v-if="item.lastRank && zipList" :class="{ radio: !multiple }">
<uni-icons type="forward" color="#CCCCCC" size="12" />
</view>
<view
class="next-tree-check-no"
v-if="componentType == 'customer'"
:class="{ radio: !multiple }"
></view>
</view>
<uni-icons
v-if="(!item.lastRank && componentType == 'customer') || item.showDownIcom"
class="next-tree-icon"
:size="iconSize"
:type="item.open ? 'up' : 'down'"
:color="!item.parentIdNub ? '#333' : '#999'"
@tap.stop="_treeItemTap(item, index)"
/>
</view> </block
></template>
<template v-else>
<view v-if="showDropdown" class="dropdown-list">
<view
v-if="inputSelection && inputValue && filteredList.length === 0"
class="dropdown-item add-new"
@click="
onSelect({
[otherLabelKeys]: inputValue,
[otherValueKeys]: inputValue
})
"
>
<rich-text
:nodes="
renderItemText({
[otherLabelKeys]: inputValue,
[otherValueKeys]: inputValue
})
"
/>
</view>
<!-- 存在匹配项时显示 -->
<view v-else-if="filteredList.length > 0" class="dropdown-item-list">
<view v-for="(item, index) in filteredList" :key="index" class="dropdown-item" @click="onSelect(item)">
<rich-text :nodes="renderItemText(item)" />
</view>
</view>
<!-- 既无可添加、也无匹配项时 -->
<view v-else class="empty-tip">
<image
class="empty-image"
src="https://gateway-in.rosino.com/prod/biz-zy-gateway/admin-api/infra/file/22/get/xzzl/notFound.png"
mode="widthFix"
></image>
<text class="empty-text">未找到搜索结果</text>
</view>
</view>
</template>
</scroll-view>
<!-- <view class="next-tree-view-bottom" v-if="componentType == 'customer'">
<view class="next-tree-view-bottom-cancel" hover-class="hover-c" @tap="_cancel">取消</view>
<view class="next-tree-view-bottom-confirm" hover-class="hover-c" @tap="_confirmBtn">确定</view>
</view> -->
<zy-sticky-bottom class="next-tree-view-bottom" v-if="componentType == 'customer'" :buttons="buttons" />
</view>
</view>
<view v-if="!toEject && needMask" :class="[{ show: showTree }, 'next-tree-mask-eject']" @tap="_cancel(1)"></view>
</view>
</template>
<script>
// 防抖函数
function debounce(func, wait) {
let timeout
return function (...args) {
clearTimeout(timeout)
timeout = setTimeout(() => func.apply(this, args), wait)
}
}
/**
* zy-tree 树组件
* @description 树组件
* @property {Array} treeData 组件数据{needShowChild: true,当前项与父级展开,当前项子级不展开
needOpen: true,当前项子级与父级展开,checked: true默认选中的值,irrevocable: true不能取消选中,选中的值父级自动打开}
* @property {String} valueKey 选中值键名
* @property {String} labelKey 展示值键名
* @property {String} childrenKey 子类数据键名
* @property {String} title 标题
* @property {Boolean} multiple 是否可以多选
* @property {Boolean} selectParent = 是否可以选父级
* @property {Boolean} foldAll = 折叠时关闭所有已经打开的子集,再次打开时需要一级一级打开
* @property {String} confirmColor = 确定按钮颜色
* @property {String} cancelColor = 取消按钮颜色
* @property {String} titleColor = 标题颜色
* @property {String} currentIcon = 展开时候的icon
* @property {String} checkIcon = 选中时候的icon
* @property {String} defaultIcon = 折叠时候的icon
* @property {String} lastIcon = 没有子集的icon
* @property {Boolean} border = 是否有分割线
* @property {Boolean} needBtn = 是否有操作按钮
* @property {Boolean} toEject = 是否是从底部弹出
* @property {Boolean} needMask = 是否是从底部弹出
* @property {String} boxHeight = 是否是从底部弹出时组件可选区域的高度默认112px
* @property {Boolean} clickMask = 点击遮罩层关闭组件
* @property {String} treeIconColor = 父级icon颜色
* @property {String} componentType = 组件类型('customer')
* @event {Function} _cancel弹窗关闭触发的方法
* @event {Function} _confirmBtn数据选中或有按钮时点击‘确定’触发的方法
* @event {Function} 可通过ref调用组件内方法_show,进行组件的展示
* @event {Function} 可通过ref调用组件内方法_hide,进行组件的关闭但不触发cancel事件
* @event {Function} 可通过ref调用组件内_reTreeList()对数据进行重置
*/
export default {
name: 'ZyTree',
props: {
treeData: {
type: Array,
default: function () {
return []
}
},
valueKey: {
type: String,
default: 'id'
},
labelKey: {
type: String,
default: 'label'
},
childrenKey: {
type: String,
default: 'children'
},
treeIconColor: {
type: String,
default: '#0f56d5'
},
title: {
type: String,
default: ''
},
multiple: {
// 是否可以多选
type: Boolean,
default: true
},
selectParent: {
//是否可以选父级
type: Boolean,
default: true
},
foldAll: {
//折叠时关闭所有已经打开的子集,再次打开时需要一级一级打开
type: Boolean,
default: false
},
confirmColor: {
// 确定按钮颜色
type: String,
default: '#0f56d5'
},
cancelColor: {
// 取消按钮颜色
type: String,
default: '#0f56d5'
},
titleColor: {
// 标题颜色
type: String,
default: ''
},
currentIcon: {
// 展开时候的icon
type: String,
default: 'folder'
},
checkIcon: {
// 选中时候的icon
type: String,
default: 'checkmarkempty'
},
defaultIcon: {
// 折叠时候的icon
type: String,
default: 'folder'
},
lastIcon: {
// 没有子集的icon
type: String,
default: ''
},
border: {
// 是否有分割线
type: Boolean,
default: true
},
faNoBorder: {
// 是否有分割线
type: Boolean,
default: false
},
needBtn: {
// 是否有操作按钮
type: Boolean,
default: false
},
toEject: {
// 是否是从底部弹出
type: Boolean,
default: false
},
boxHeight: {
// 是否是从底部弹出时组件可选区域的高度默认100%
type: String,
default: '500px'
},
clickMask: {
// 点击遮罩层关闭组件
type: Boolean,
default: true
},
openAll: {
// 全部打开
type: Boolean,
default: false
},
irrevocableAll: {
// 全部不能取消选中
type: Boolean,
default: false
},
nextTreeStyle: {
// 样式
type: Object,
default: () => ({})
},
nextTreeScrollStyle: {
// 样式
type: Object,
default: () => ({})
},
defaultValue: {
// 默认选中的值
type: Array,
default: function () {
return []
}
},
nextTreeMinHeight: {
type: String,
default: '500px'
},
needClickFn: {
// 选中子节点是否需要触发confirm
type: Boolean,
default: false
},
otherValueKey: {
// 额外选中的值
type: Array,
default: function () {
return []
}
},
needMask: {
// 是否需要遮罩层关闭组件
type: Boolean,
default: true
},
allWayShow: {
// 是否一直展示
type: Boolean,
default: false
},
iconSize: {
// icon大小默认16
type: String,
default: '16'
},
zipList: {
// 是否是文件展示列表
type: Boolean,
default: false
},
zipListIconSize: {
// 文件列表icon大小默认34
type: String,
default: '34'
},
marginLeftNum: {
// 每行左边缩进的距离
type: Number,
default: 25
},
componentType: {
// 组件类型('customer')customer~是客户编辑时编辑的样式,正常为''
type: String,
default: ''
},
// 默认占位符
placeholder: {
type: String,
default: '搜索行业关键词'
},
// 列表数据(由父组件传入)
list: {
type: Array,
default: () => []
},
// 控制是否显示下拉(可选,也可内部控制)
show: {
type: Boolean,
default: null
},
// 展示字段名,默认 'name'
otherLabelKeys: {
type: String,
default: 'name'
},
// 值字段名,默认 'value'
otherValueKeys: {
type: String,
default: 'value'
},
// 默认选中的值(用于回显)
value: {
type: [String, Number],
default: ''
},
// 防抖时间 ms
debounceTime: {
type: Number,
default: 60
},
// 是否选中输入
inputSelection: {
type: Boolean,
default: true
},
// 是否选中输入
otherCloseFn: {
type: Boolean,
default: false
},
// 展示字段名,默认 'name'
showDownIcom: {
type: Boolean,
default: false
},
// 是否需要输入框
needSearch: {
type: Boolean,
default: false
},
// 在 props 中新增
loadChildren: {
type: Function,
default: null // 注意:必须是 null,不能是 () => {}
}
},
watch: {
defaultValue: {
handler(val) {
console.log('🚀 ~ handler ~ val:', val)
// console.count('🚀 ~ defaultValue ~ val:111111111111111111111watch', val)
// 类型保护
if (!Array.isArray(val)) return
// 去重比较:避免内容未变却触发
const currentIds = [...val].sort()
const lastIds = [...(this.lastDefaultValue || [])].sort()
const isSame = currentIds.length === lastIds.length && currentIds.every((id, i) => id === lastIds[i])
if (isSame) return // 内容没变,跳过
// 缓存当前值
this.lastDefaultValue = [...val]
// 防抖执行
if (this._defaultSelectDebounceTimer) {
clearTimeout(this._defaultSelectDebounceTimer)
}
this._defaultSelectDebounceTimer = setTimeout(() => {
this._defaultSelect()
}, 60) // 60ms 内只执行最后一次
},
immediate: false,
deep: false // ❌ 不要用 deep: true 对数组!无效且性能差
},
value: {
immediate: true,
handler(val) {
if (val !== undefined && val !== '') {
// 根据 value 查找对应的 name 回显
const matched = this.list.find(item => item[this.otherValueKeys] === val)
this.inputValue = matched ? matched[this.otherLabelKeys] : ''
} else {
this.inputValue = ''
this.showDropdown = false
}
}
},
show: {
handler(val) {
this.showDropdown = val
},
immediate: true
}
},
created() {
// 创建防抖函数
this.debouncedSearch = debounce(query => {
this.$emit('search', query) // 抛出给父组件调用接口
}, this.debounceTime)
},
data() {
return {
showTree: false,
treeList: [],
selectIndex: -1,
childNums: [],
rt: [],
rtName: [],
otherValue: [],
cklickType: {
type: '',
value: ''
},
inputValue: '',
isFocused: false,
showDropdown: false,
debouncedSearch: null,
buttons: [
{
text: '取消',
type: 'cancel',
click: () => this._cancel()
},
{
text: '提交',
type: 'confirm',
click: () => this._confirmBtn(),
loading: false // ✅ 提前定义
}
]
}
},
computed: {
nextTreeStyleCp() {
return {
minHeight: this.nextTreeStyle.minHeight ?? this.nextTreeMinHeight,
height: this.nextTreeStyle.height ?? this.boxHeight,
...this.nextTreeStyle
}
},
nextTreeScrollStyleCp() {
return {
minHeight: this.nextTreeStyle.minHeight ?? this.nextTreeMinHeight,
...this.nextTreeStyle
}
},
filteredList() {
if (!this.inputValue) return this.list
return this.list.filter(item =>
String(item[this.otherLabelKeys]).toLowerCase().includes(this.inputValue.toLowerCase())
)
}
},
methods: {
escapeHTML(str) {
if (typeof str !== 'string') return ''
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
},
onInput(e) {
const value = e.detail.value.trim()
this.inputValue = value
// 触发防抖搜索
this.debouncedSearch(value)
// 抛出输入事件
this.$emit('input-change', value)
},
onConfirm() {
this.$emit('otherConfirm', this.inputValue)
},
onFocus() {
this.isFocused = true
this.showDropdown = true
this.$emit('focus', this.inputValue)
},
onFocusS() {
this.isFocused = true
this.$emit('focus', this.inputValue)
},
onBlur() {
setTimeout(() => {
this.isFocused = false
}, 200) // 延迟隐藏避免点击选项时消失
this.$emit('blur', this.inputValue)
},
onBlurS() {
setTimeout(() => {
this.isFocused = false
}, 200) // 延迟隐藏避免点击选项时消失
this.$emit('blur', this.inputValue)
},
onClear() {
this.inputValue = ''
this.showDropdown = false
this.$emit('clear')
this.$emit('input-change', '')
},
onClearS() {
this.inputValue = ''
this.$emit('clear')
this.$emit('input-change', '')
},
onSelect(item) {
const selectedValue = item[this.otherValueKeys]
const selectedLabel = item[this.otherLabelKeys]
// 反显到输入框
this.inputValue = selectedLabel
// 关闭下拉
this.showDropdown = false
// 返回选中值(使用自定义 valueField)
this.$emit('select', { [this.otherValueKeys]: selectedValue, label: selectedLabel })
// 通知父组件更新 v-model 或状态
this.$emit('input', selectedValue)
},
/**
* 渲染带高亮的文本(匹配输入内容)
*/
renderItemText(item) {
const text = item[this.otherLabelKeys] || ''
const query = this.inputValue.trim()
if (!query || !text) {
return this.escapeHTML(text)
}
const escapedText = this.escapeHTML(text)
const escapedQuery = this.escapeHTML(query)
// 注意:正则需确保转义后的字符串仍能正确匹配
const regex = new RegExp(`(${escapedQuery})`, 'gi')
const highlighted = escapedText.replace(regex, '<span style="color: #007AFF; font-weight: bold;">$1</span>')
// ✅ 使用 <p> 替代 <div>
return `<p>${highlighted}</p>`
},
escapeHTML(str) {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
},
imgTypeFn(type) {
let imgType = 'oth'
const typeMap = {
img: ['png', 'jpg', 'jpeg', 'gif', 'bmp'],
exel: ['xls', 'xlsx', 'xlsm'],
word: ['doc', 'docx'],
pdf: ['pdf'],
ppt: ['ppt', 'pptx'],
txt: ['txt']
}
for (let key in typeMap) {
if (typeMap[key].includes(type)) {
imgType = key
}
}
return imgType
},
getSize(val) {
let sizeInKB = val / 1024
return sizeInKB.toFixed(2) + 'KB'
},
showBorder(show, item) {
if (this.faNoBorder && !item) {
return false
} else {
return show && this.border
}
},
_show() {
this.showTree = true
this.$emit('showTree', true)
this._confirm()
},
_hide() {
this.$emit('showTree', false)
this.showTree = false
this.showDropdown = false
this.inputValue = ''
},
_cancel(val) {
if (val == 1 && !this.clickMask) return
if (this.otherCloseFn) return this.$emit('cancel', '')
this._hide()
this.$emit('cancel', '')
},
_confirm(val) {
this.rt = []
this.rtName = []
this.otherValue = []
// 处理所选数据
this.treeList.forEach(item => {
// if (item.id == 100 && item.checked) {
if (item.checked || item.indeterminate) {
this.confirmEach(this.treeData)
}
// if (item.checked && item.parentId && item.parentId != '100') {
if ((item.checked || item.indeterminate) && item.parentId) {
this.rt.push(item.id)
this.rtName.push(item.name)
this.otherValueKey.forEach(key => {
if (!this.otherValue[key]) {
this.otherValue[key] = [] // 初始化数组
}
this.otherValue[key].push(item[key]) // 将对应字段值加入数组
})
}
})
if (val == 1 && !this.allWayShow) {
this._cancel()
}
this.$emit('confirm', { id: this.rt, name: this.rtName, otherValue: this.otherValue }, { ...this.cklickType })
},
_confirmBtn() {
this.rt = []
this.rtName = []
this.otherValue = []
// 处理所选数据
this.treeList.forEach(item => {
// if (item.id == 100 && item.checked) {
// if (item.checked) {
// return this.confirmEach(this.treeData)
// }
// if (item.checked && item.parentId && item.parentId != '100') {
if ((item.checked || item.indeterminate) && item.parentId) {
this.rt.push(item.id)
this.rtName.push(item.name)
this.otherValueKey.forEach(key => {
if (!this.otherValue[key]) {
this.otherValue[key] = [] // 初始化数组
}
this.otherValue[key].push(item[key]) // 将对应字段值加入数组
})
}
})
this.$emit('_confirmBtn', { id: this.rt, name: this.rtName, otherValue: this.otherValue })
},
confirmEach(list) {
list.forEach(item => {
if (item.isDy) {
this.rtName.push(item.name)
this.rt.push(item.id)
this.otherValueKey.forEach(key => {
if (!this.otherValue[key]) {
this.otherValue[key] = [] // 初始化数组
}
this.otherValue[key].push(item[key]) // 将对应字段值加入数组
})
}
if (item.children && item.children.length > 0) this.confirmEach(item.children)
})
},
//扁平化树结构
_renderTreeList(list = [], rank = 0, parentId = [], parents = []) {
// console.time('_renderTreeList')
// console.count('_renderTreeList 执行次数')
list.forEach(item => {
this.treeList.push({
...item,
id: item[this.valueKey],
name: item[this.labelKey],
source: item,
parentIdNub: item.parentId,
parentId,
parents,
rank,
showChild: false,
needShowChild: this.openAll ? this.openAll : (item.needShowChild ?? false),
open: false,
needOpen: this.openAll ? this.openAll : (item.needOpen ?? false),
show: rank === 0,
hideArr: [],
orChecked: this.defaultValue.some(obj => obj == item[this.valueKey]) ? true : item.checked ? true : false,
checked: this.defaultValue.some(obj => obj == item[this.valueKey]) ? true : item.checked ? true : false,
disabled: item.disabled ?? false,
isParent: Array.isArray(item[this.childrenKey]) && item[this.childrenKey].length > 0,
irrevocable: this.irrevocableAll ? this.irrevocableAll : (item.irrevocable ?? false),
indeterminate: false,
hasLoadedChildren: !!item.children?.length, // 已有子节点视为已加载
loading: false, // 加载状态
lastRank: !(item.showDownIcom || (Array.isArray(item[this.childrenKey]) && item[this.childrenKey].length > 0))
})
if ((Array.isArray(item[this.childrenKey]) && item[this.childrenKey].length > 0) || item.showDownIcom) {
const parentid = [...parentId]
const parentArr = [...parents]
parentid.push(item[this.valueKey])
parentArr.push({
[this.valueKey]: item[this.valueKey],
[this.labelKey]: item[this.labelKey]
})
this._renderTreeList(item[this.childrenKey], rank + 1, parentid, parentArr)
}
})
// console.time('_renderTreeList')
},
_defaultSelect() {
// console.time('_defaultSelect')
// console.count('_defaultSelect 执行次数')
if (this.componentType === 'customer') {
this.treeList.forEach(v => {
this.setAncestorsCustomerT(this.defaultValue, !v.checked, v.id)
if (v.isParent && (v.checked || v.indeterminate)) {
this.$set(v, 'open', true)
this.$set(v, 'showChild', true) // 如果 showChild 控制显示,也要同步
}
})
this._expandParentsOfCheckedNodes()
console.log('🚀 ~ _defaultSelect ~ this.treeList:11111111111111111', this.treeList)
} else {
this.treeList.forEach(v => {
const isChecked = this.defaultValue.some(obj => obj === v.id)
console.log('🚀 ~ _defaultSelect ~ isChecked:', isChecked)
if (isChecked !== v.checked) {
v.checked = isChecked
}
})
// 同步父子级选中状态
this.treeList.forEach(v => {
if (v.checked) {
this._handleCheckedNode(v)
}
if (v.needOpen) {
this._handleOpenNode(v)
}
if (v.needShowChild) {
this._handleShowChildNode(v)
}
})
}
// console.timeEnd('_defaultSelect')
},
_propagateExpansionUpward() {
// console.count('_propagateExpansionUpward 执行次数')
const shouldExpand = new Set() // 存储应展开的节点 ID
// 第一步:标记所有 checked/indeterminate 节点
for (const node of this.treeList) {
if (node.checked || node.indeterminate) {
shouldExpand.add(node.id)
}
}
// 第二步:向上传播展开状态(利用 parentId 链)
for (const node of this.treeList) {
if (shouldExpand.has(node.id)) {
let current = node
while (current.parentId && current.parentId.length > 0) {
const parentId = current.parentId[current.parentId.length - 1] // 直接父级
const parent = this.treeList.find(n => n.id === parentId)
if (!parent) break
shouldExpand.add(parent.id)
current = parent
}
}
}
// 第三步:批量更新状态(只对发生变化的节点使用 $set)
for (const node of this.treeList) {
if (shouldExpand.has(node.id)) {
if (!node.showChild) this.$set(node, 'showChild', true)
if (!node.open) this.$set(node, 'open', true)
if (!node.show) this.$set(node, 'show', true)
}
}
return shouldExpand
},
_expandParentsOfCheckedNodes() {
// console.count('_expandParentsOfCheckedNodes 执行次数')
const shouldExpand = this._propagateExpansionUpward()
// 只对根展开节点调用 _updateDescendantsVisibility(避免重复)
const rootExpandNodes = [...shouldExpand]
.map(id => this.treeList.find(n => n.id === id))
.filter(n => n && n.isParent)
// 使用 Set 去重后调用
const processedParents = new Set()
for (const node of rootExpandNodes) {
if (node && !processedParents.has(node.id)) {
processedParents.add(node.id)
this._updateDescendantsVisibility(node)
}
}
},
_handleCheckedNode(v) {
this.treeList.forEach(v2 => {
if (v.parentId.toString().indexOf(v2.parentId.toString()) >= 0) {
v2.show = true
if (v.parentId.includes(v2.id)) {
v2.showChild = true
v2.open = true
}
}
})
},
_handleOpenNode(v) {
v.showChild = true
v.open = true
v.show = true
this.treeList.forEach(v2 => {
if (v2.parentId.toString().indexOf(v.id.toString()) >= 0) {
v2.show = true
if (v2.parentId.includes(v.id)) {
if (this.componentType == 'customer') {
v2.showChild = false
v2.open = false
} else {
v2.showChild = true
v2.open = true
}
}
}
if (v.parentId.includes(v2.id)) {
v2.showChild = true
v2.open = true
}
})
},
_handleShowChildNode(v) {
this.treeList.forEach(v2 => {
if (v.parentId.toString().indexOf(v2.parentId.toString()) >= 0) {
v2.show = true
if (v.parentId.includes(v2.id)) {
v2.showChild = true
v.showChild = true
}
}
if (v.id === v2.id) {
v.showChild = false
v.open = false
}
})
},
_updateDescendantsVisibility(parentNode) {
const { id: parentId } = parentNode
const queue = [] // 使用队列模拟 BFS
// 找到所有直接子节点作为起点
for (const node of this.treeList) {
if (Array.isArray(node.parentId) && node.parentId.includes(parentId)) {
queue.push(node)
}
}
// BFS 遍历后代
while (queue.length > 0) {
const node = queue.shift()
const parentInPath = this.treeList.find(p => p.id === node.parentId[node.parentId.length - 1])
// 判断是否应该显示:父节点 showChild === true 且父节点已展开
const parentVisible = parentInPath ? parentInPath.showChild : false
const shouldBeShown =
parentVisible &&
node.parentId.every(pid => {
const p = this.treeList.find(n => n.id === pid)
return p && p.showChild
})
if (node.show !== shouldBeShown) {
this.$set(node, 'show', shouldBeShown)
}
// 继续向下遍历
for (const child of this.treeList) {
if (Array.isArray(child.parentId) && child.parentId.includes(node.id)) {
queue.push(child)
}
}
}
},
_expandParentsOfNodeById(itemId) {
const node = this.treeList.find(n => n.id === itemId)
if (!node || !node.parentId || node.parentId.length === 0) return
const visited = new Set() // 防止重复处理
let current = node
while (current.parentId && current.parentId.length > 0) {
const parentId = current.parentId[current.parentId.length - 1]
const parent = this.treeList.find(n => n.id === parentId)
if (!parent || visited.has(parent.id)) break
visited.add(parent.id)
// 强制展开父节点
if (!parent.open) this.$set(parent, 'open', true)
if (!parent.showChild) this.$set(parent, 'showChild', true)
if (!parent.show) this.$set(parent, 'show', true)
// 触发后代可见性更新
this._updateDescendantsVisibility(parent)
current = parent
}
},
// 点击
_treeItemTap(item, index) {
// 如果是客户类型且支持动态加载子节点
if (this.componentType === 'customer' && item.showDownIcom && !item.hasLoadedChildren) {
// console.time('_treeItemTap')
// console.count('_treeItemTap 执行次数')
if (!this.loadChildren) return
this.$set(this.treeList[index], 'loading', true)
this.loadChildren(item)
.then(rawChildren => {
console.log('🚀 ~ _treeItemTap ~ rawChildren111111111111:', rawChildren)
const children = Array.isArray(rawChildren) ? rawChildren : []
console.log('🚀 ~ _treeItemTap ~ children22222222222222:', children)
if (children.length === 0) {
this.$set(this.treeList[index], 'lastRank', true)
this.$set(this.treeList[index], 'hasLoadedChildren', true)
return
}
// 已经加载过则不再重复插入
if (this.treeList.some(node => node.parentId[node.parentId.length - 1] === item.id)) {
console.warn(`⚠️ 节点 ${item.id} 的子节点已存在,跳过重复加载`)
this.$set(this.treeList[index], 'hasLoadedChildren', true)
this.$set(this.treeList[index], 'showChild', true)
this.$set(this.treeList[index], 'open', true)
this._updateDescendantsVisibility(this.treeList[index]) // 触发展开
if (this.needClickFn) this._confirm()
return
}
// 构建子节点列表
const tempTreeList = []
const buildNodes = (list, rank, parentId, parents) => {
list.forEach(child => {
if (!child) return
const node = {
...child,
id: child[this.valueKey],
name: child[this.labelKey],
source: child,
parentIdNub: child.parentId,
parentId: [...parentId],
pathAncestors: [...parentId], // 关键:记录祖先路径用于高效查找
parents: [...parents],
rank,
showChild: false,
open: false,
show: false,
checked:
this.defaultValue.includes(child[this.valueKey]) &&
!(child.showDownIcom || (Array.isArray(child.children) && child.children.length > 0)),
indeterminate: this.defaultValue.includes(child[this.valueKey]),
isParent: !!child.showDownIcom || (Array.isArray(child.children) && child.children.length > 0),
hasLoadedChildren: !!child.children?.length,
lastRank: !(child.showDownIcom || (Array.isArray(child.children) && child.children.length > 0)),
loading: false,
disabled: child.disabled ?? false,
irrevocable: child.irrevocable ?? false,
hideArr: Array(rank + 1).fill(null) // 根据层级创建对应长度的 null 数组
}
console.log(
'111111111111111111111111111```````',
child,
child.name,
child.id,
child.showDownIcom || (Array.isArray(child.children) && child.children.length > 0)
)
if (node.isParent && (node.checked || node.indeterminate)) {
node.open = true
}
tempTreeList.push(node)
// 递归构建子节点
if (node.isParent && Array.isArray(child.children) && child.children.length > 0) {
const pids = [...parentId, child[this.valueKey]]
const pnames = [
...parents,
{ [this.valueKey]: child[this.valueKey], [this.labelKey]: child[this.labelKey] }
]
buildNodes(child.children, rank + 1, pids, pnames)
}
})
}
// 开始构建
buildNodes(
children,
item.rank + 1,
[...item.parentId, item.id],
[...item.parents, { [this.valueKey]: item.id, [this.labelKey]: item.name }]
)
// 插入到当前节点之后
const insertIndex = index + 1
this.treeList.splice(insertIndex, 0, ...tempTreeList)
// 🔥 强制修正当前节点状态
const currentNode = this.treeList[index]
if (currentNode) {
this.$set(currentNode, 'lastRank', children.length === 0 ? true : false)
this.$set(currentNode, 'hasLoadedChildren', true)
this.$set(currentNode, 'open', children.length > 0)
this.$set(currentNode, 'showChild', children.length > 0)
// 如果有子节点,触发展开逻辑
if (children.length > 0) {
this._updateDescendantsVisibility(currentNode)
}
}
// 显示直接子节点
tempTreeList.forEach(child => {
if (child.parentId[child.parentId.length - 1] === item.id && child.rank === item.rank + 1) {
this.$set(child, 'show', true)
}
})
// 触发确认回调(如需)
if (this.needClickFn) this._confirm()
})
.catch(err => {
console.error('加载失败:', err)
})
.finally(() => {
this.$set(this.treeList[index], 'loading', false)
console.log('🚀 ~ .finally ~ item.id:', item.id)
this._expandParentsOfNodeById(item.id)
// console.log('🚀 ~ .finally ~ this.treeList:11111111111111111', this.treeList)
})
// console.time('_treeItemTap')
return
}
if (this.zipList && item.lastRank == true) {
//点击最后一级时触发事件
this.treeList[index].checked = !this.treeList[index].checked
for (let i = 0; i < this.treeList.length; i++) {
if (this.treeList[i][this.valueKey] == this.treeList[index][this.valueKey]) {
this.treeList[i].checked = this.treeList[index].checked
}
}
this._fixMultiple(index)
if (this.needClickFn) {
this._confirm()
}
this.$emit('lastRank', item, this.treeList[index].checked)
return
}
this.cklickType = {
type: '',
value: ''
}
if (item.disabled) return
if (item.irrevocable && this.treeList[index].checked) return
if (item.lastRank == true) {
//点击最后一级时触发事件
this.treeList[index].checked = !this.treeList[index].checked
for (let i = 0; i < this.treeList.length; i++) {
if (this.treeList[i][this.valueKey] == this.treeList[index][this.valueKey]) {
this.treeList[i].checked = this.treeList[index].checked
}
}
this._fixMultiple(index)
if (this.needClickFn) {
this._confirm()
}
//子级选择/选满/取消选择, 父级往上同步状态
if (this.componentType == 'customer') {
console.log(1)
this.setAncestorsCustomer(item.parentId, this.treeList[index].checked, item.id)
}
this.$emit('lastRank', item, this.treeList[index].checked)
return
}
let list = this.treeList
let id = item.id
if (item.showChild && !item.open) {
item.showChild = false
item.open = false
} else {
item.showChild = !item.showChild
}
item.open = item.showChild ? true : !item.open
list.forEach(childItem => {
if (item.showChild === false) {
this.cklickType = {
type: 'click',
value: 'close'
}
//隐藏所有子级
if (!childItem.parentId.includes(id)) {
return
}
if (!this.foldAll) {
if (childItem.lastRank !== true && !childItem.open) {
childItem.showChild = false
}
// 为隐藏的内容添加一个标记
if (childItem.show) {
childItem.show = false
childItem.hideArr[item.rank] = id
}
} else {
if (childItem.lastRank !== true) {
childItem.showChild = false
}
}
childItem.show = false
} else {
this.cklickType = {
type: 'click',
value: 'open'
}
// 打开子集
if (childItem.parentId[childItem.parentId.length - 1] === id) {
childItem.show = true
}
// 打开被隐藏的子集
if (childItem.parentId.includes(id) && !this.foldAll) {
if (childItem.hideArr[item.rank] === id) {
childItem.show = true
if (childItem.open && childItem.showChild) {
childItem.showChild = true
} else {
childItem.showChild = false
}
childItem.hideArr[item.rank] = null
}
}
}
})
if (this.selectParent) {
this.$emit('faClick', item, this.cklickType.value)
}
if (!this.selectParent) return
if (!this.needBtn || this.needClickFn) {
this._confirm()
}
},
_treeItemSelect(item, index) {
this.cklickType = {
type: '',
value: ''
}
if (this.selectParent) {
this.$emit('faClick', item, !this.treeList[index].checked)
}
if (item.lastRank == true) {
this.$emit('lastRank', item, !this.treeList[index].checked)
}
if (item.disabled) return
if (!this.selectParent && !item.lastRank) return this._treeItemTap(item, index)
if (item.irrevocable && this.treeList[index].checked) return
this.treeList[index].checked = !this.treeList[index].checked
if (this.treeList[index].checked && this.treeList[index].indeterminate) {
this.treeList[index].indeterminate = false
}
for (let i = 0; i < this.treeList.length; i++) {
if (this.treeList[i][this.valueKey] == this.treeList[index][this.valueKey]) {
this.treeList[i].checked = this.treeList[index].checked
}
}
// 选父级, 子级自动全选
// this.syncChecked(this.treeList, item.id, this.treeList[index].checked)
this.syncChecked(item.id, this.treeList[index].checked)
if (item.rank > 0) {
item.parentId.forEach(pid => {
const parent = this.treeList.filter(i => i.id === pid)
const childNum = parent.length > 0 ? parent[0].childNum : 0
if (this.childNums[pid] === undefined) {
this.childNums[pid] = 1
} else if (this.childNums[pid] < childNum) {
this.childNums[pid]++
}
})
//子级选择/选满/取消选择, 父级往上同步状态
if (this.componentType == 'customer') {
console.log(1)
this.setAncestorsCustomerT(item.parentId, this.treeList[index].checked, item.id)
} else {
console.log(2)
this.setAncestors(item.parentId, this.treeList[index].checked)
}
} else {
if (this.componentType == 'customer') {
this.setAncestorsCustomerT(item.parentId, this.treeList[index].checked, item.id)
}
}
this._fixMultiple(index)
if (!this.needBtn) {
if (this.multiple) {
this._confirm()
} else {
if (this.componentType == 'customer') {
this._confirm()
} else {
this._confirm(1)
}
}
}
if (this.needClickFn) {
this._confirm()
}
},
syncChecked(parentId, checked) {
this.treeList.forEach(item => {
if (item.disabled || item.irrevocable) return
// 判断当前节点是否在 parent 路径下
if (Array.isArray(item.parentId) && item.parentId.includes(parentId)) {
this.$set(item, 'checked', checked)
}
})
},
setAncestors(pids, checked) {
this.treeList.forEach((item, index) => {
if (pids.includes(item.id)) {
if (checked && this.childNums[item.id] !== undefined && item.childNum === this.childNums[item.id]) {
// 子级全部选中, 父级才选中
this.treeList[index].checked = true
} else {
this.treeList[index].checked = false
}
this.setAncestors(item.parentId, checked)
this.cklickType = {
type: 'checked',
value: checked
}
}
})
},
setAncestorsCustomer(pids, checked, id) {
if (this.multiple) {
console.log('---multiple')
} else {
for (let index = 0; index < this.treeList.length; index++) {
if (!checked) {
this.treeList[index].checked = false
this.treeList[index].indeterminate = false
} else {
if (pids.includes(this.treeList[index].id) && !this.treeList[index].lastRank) {
this.treeList[index].checked = false
this.treeList[index].indeterminate = true
} else if (pids.includes(this.treeList[index].id) && this.treeList[index].lastRank) {
this.treeList[index].checked = true
this.treeList[index].indeterminate = false
} else {
this.treeList[index].indeterminate = false
}
}
}
}
// 记录操作类型
this.cklickType = {
type: 'checked',
value: checked
}
},
setAncestorsCustomerT(pids, checked, id) {
if (this.multiple) {
console.log('---multiple')
} else {
for (let index = 0; index < this.treeList.length; index++) {
if (!checked) {
this.treeList[index].checked = false
this.treeList[index].indeterminate = false
} else {
if (pids.includes(this.treeList[index].id) && !this.treeList[index].lastRank) {
this.treeList[index].checked = false
this.treeList[index].indeterminate = true
} else if (pids.includes(this.treeList[index].id) && this.treeList[index].lastRank) {
this.treeList[index].checked = true
this.treeList[index].indeterminate = false
} else {
this.treeList[index].indeterminate = false
}
}
}
}
// 记录操作类型
this.cklickType = {
type: 'checked',
value: checked
}
},
// _treeItemSelect(item, index) {
// this.treeList[index].checked = !this.treeList[index].checked
// this._fixMultiple(index)
// },
// 处理单选多选
_fixMultiple(index) {
if (!this.multiple) {
// 如果是单选
this.treeList.forEach((v, i) => {
if (i != index) {
this.treeList[i].checked = false
} else {
if (this.treeList[i].checked) {
this.treeList[i].checked = true
} else {
this.treeList[i].checked = false
}
}
})
if (this.componentType == 'customer') {
this._confirm()
} else {
this._confirm(1)
}
}
},
// 重置数据
_reTreeList() {
this.treeList.forEach((v, i) => {
this.treeList[i].checked = v.orChecked
})
},
_initTree(treeData = this.treeData) {
// console.count('_initTree执行次数')
this.treeList = []
this._renderTreeList(treeData)
this.$nextTick(() => {
// this._defaultSelect(treeData)
})
}
},
mounted() {
this._initTree()
this.$watch(
() => this.treeData,
list => {
// console.count('_initTree执行次数')
this._initTree(list)
},
{ deep: true, immediate: true }
)
this.$watch(
() => [this.multiple, this.selectParent],
() => {
if (this.treeData.length) {
this._reTreeList()
}
},
{ deep: true, immediate: true }
)
}
}
</script>
<style lang="scss">
@import './style.scss';
</style>
在传入defaultValue的时候,如果默认选中项是数组中的最后一个父级中的某一个选项,那么就会导致无法选中
最新发布