代码改变世界

树形组件,承受搜索展示,自定义展示,支持vue2,vue3,小应用等等

2025-09-14 09:48  tlnshuju  阅读(25)  评论(0)    收藏  举报

效果图

平台兼容性

Vue2Vue3ChromeSafariapp-vueapp-nvueAndroidiOS鸿蒙
---
微信小程序支付宝小程序抖音小程序百度小程序快手小程序京东小程序鸿蒙元服务QQ小程序飞书小程序快应用-华为快应用-联盟
-
多语言暗黑模式宽屏模式
××

属性

属性名类型默认值说明
sourceListArray[]源数据,目前支持tree结构,
valueKeyStringid指定 Object 中 key 的值作为节点数据id
textKeyStringname指定 Object 中 key 的值作为节点显示内容
childrenKeyStringchildren指定 Object 中 key 的值作为节点子集
multipleBooleanfalse是否多选,默认单选
selectParentBooleantrue是否可以选父级,默认可以
placeholderString标题
titleColorString标题颜色
confirmColorString#0055ff确定按钮颜色
cancelColorString#757575取消按钮颜色
switchColorString#666节点切换图标颜色
borderBooleanfalse是否有分割线,默认无
separatorString/选中项之间的分隔符

modelValue

String

 父组件传入的选中值(字符串,单选为单个ID,多选为分隔符连接的ID字符串)

returnParentWhenChildrenAllSelectedBooleanfalse子节点全选时是否返回父节点ID

方法

方法名参数默认值说明
show()显示选择器
hide()隐藏选择器

组件引用:

import treeSearch from "@/components/tree-search/tree-search.vue"
export default {
components: {
treeSearch
},
data() {
return {
listData: [{
id: "1",
name: "眼睑疾病",
children: [
{
id: "1-1",
name: "睑腺炎"
},
{
id: "1-2",
name: "睑板腺囊肿"
},
{
id: "1-3",
name: "睑缘炎",
children: [
{
id: "1-3-1",
name: "鳞屑性睑缘炎"
},
{
id: "1-3-2",
name: "溃疡性睑缘炎"
},
{
id: "1-3-3",
name: "眦部睑缘炎"
}
]
},
{
id: "1-4",
name: "眼睑湿疹"
}
]
},
{
id: "2",
name: "结膜疾病",
children: [
{
id: "2-1",
name: "结膜炎",
children: [
{
id: "2-1-1",
name: "细菌性结膜炎"
},
{
id: "2-1-2",
name: "细菌性结膜炎"
},
{
id: "2-1-3",
name: "衣原体性结膜炎"
}
]
},
{
id: "2-2",
name: "结膜结石"
},
{
id: "2-3",
name: "翼状胬肉"
},
{
id: "2-4",
name: "结膜下出血"
},
]
},
{
id: "3",
name: "角膜疾病",
children: [{
id: "3-1",
name: "角膜炎",
children: [{
id: "3-1-1",
name: "细菌性角膜炎"
},
{
id: "3-1-2",
name: "病毒性角膜炎"
},
{
id: "3-1-3",
name: "真菌性角膜炎"
},
{
id: "3-1-4",
name: "真菌性角膜炎"
}
]
},
{
id: "3-2",
name: "干眼综合征"
},
{
id: "3-3",
name: "暴露性角膜病变"
}
]
}
]
}
}
}

定义组件:

取消
{{placeholder}}
{{multiple?'确定':''}}
{{item[this.textKey]}}
export default {
name: "ba-tree-picker",
props: {
// 父组件传入的选中值(字符串,单选为单个ID,多选为分隔符连接的ID字符串)
modelValue: {
type: String,
required: true,
default: ''
},
// 节点唯一标识的字段名
valueKey: {
type: String,
default: 'id'
},
// 节点显示名称的字段名
textKey: {
type: String,
default: 'name'
},
// 子节点数组的字段名
children: {
type: String,
default: 'children'
},
// 树形数据源
sourceList: {
type: Array,
required: true,
default: () => []
},
// 输入框占位符
placeholder: {
type: String,
default: '请选择'
},
// 是否支持多选
multiple: {
type: Boolean,
default: true
},
// 是否允许选择父节点
selectParent: {
type: Boolean,
default: true
},
// 确认按钮颜色
confirmColor: {
type: String,
default: '' // 默认 #0055ff
},
// 取消按钮颜色
cancelColor: {
type: String,
default: '' // 默认 #757575
},
// 标题颜色
titleColor: {
type: String,
default: ''
},
// 节点展开/折叠图标颜色
switchColor: {
type: String,
default: '' // 默认 #666
},
// 选中项之间的分隔符
separator: {
type: String,
default: '/'
},
// 是否显示节点分割线
border: {
type: Boolean,
default: false
},
// 子节点全选时是否返回父节点ID
returnParentWhenChildrenAllSelected: {
type: Boolean,
default: false
},
// 是否不可输入
disabled: {
type: Boolean,
default: false
}
},
data() {
return {
showDialog: false, // 控制对话框显示/隐藏
treeList: [], // 扁平化的树形数据列表
searchValue: '', // 搜索输入值
selectedIds: [], // 选中的ID列表
selectedNames: '', // 选中的名称字符串
expandedNodes: new Set(), // 存储展开的节点ID
selectedNodes: new Set(), // 存储选中的节点ID
}
},
methods: {
// 显示对话框并重置树显示状态
show() {
this.showDialog = true
this._resetTreeDisplay()
this._updateSelectedState() // 确保打开时选中状态正确
},
// 隐藏对话框并清空搜索
_hide() {
this.showDialog = false
this.searchValue = ''
this._resetTreeDisplay()
},
// 取消操作,触发取消事件并隐藏对话框
_cancel() {
this._hide()
this.$emit("cancel")
},
// 确认选择,更新选中列表并触发父组件更新
_confirm() {
this._updateSelectedState(true)
this._hide()
},
// 处理搜索逻辑,过滤显示匹配的节点
handleSearch() {
if (this.searchValue) {
this.treeList.forEach(item => {
const matchesSelf = item[this.textKey].toLowerCase().includes(this.searchValue.toLowerCase())
const matchesChild = this._checkChildMatch(item, this.searchValue.toLowerCase())
item.isShow = matchesSelf || matchesChild
if (matchesSelf || matchesChild) {
this._showParents(item) // 确保父节点显示
}
})
} else {
this._resetTreeDisplay() // 清空搜索时恢复默认显示
}
this._updateSelectedState() // 搜索后重新计算选中状态
},
// 检查子节点是否匹配搜索关键词(递归检查所有子孙节点)
_checkChildMatch(item, keyword) {
let hasMatch = false
const itemIndex = this.treeList.findIndex(i => i[this.valueKey] === item[this.valueKey])
if (itemIndex !== -1) {
for (let i = itemIndex + 1; i  p[this.valueKey] === current.parentId)
if (parent) {
parent.isShow = true
parent.isShowChild = true
this.expandedNodes.add(parent[this.valueKey])
current = parent
} else {
break
}
}
},
// 重置树显示状态,恢复展开节点状态
_resetTreeDisplay() {
// 先重置所有节点状态
this.treeList.forEach(item => {
item.isShowChild = this.expandedNodes.has(item[this.valueKey]) && !item.isLastLevel
})
// 根节点默认显示
this.treeList.forEach(item => {
if (item.level === 0) {
item.isShow = true
} else {
item.isShow = false
}
})
// 确保展开节点的子节点正确显示
this.treeList.forEach(item => {
if (item.isShowChild) {
const index = this.treeList.findIndex(i => i[this.valueKey] === item[this.valueKey])
if (index !== -1) {
this._showChildNodes(item, index)
}
}
})
},
// 格式化树形数据,预加载所有子节点
_formatTreeData(list = [], level = 0, parentItem = null, isShowChild = true) {
if (!list || !Array.isArray(list)) return
// 使用更简单的插入方式:在数组末尾直接添加
let startIndex = this.treeList.length
const parentId = parentItem ? parentItem[this.valueKey] : -1
const modelValueArray = this._parseModelValue()
list.forEach(item => {
const isLastLevel = !item[this.children] || !Array.isArray(item[this.children]) || item[this.children].length === 0
const isExpanded = this.expandedNodes.has(item[this.valueKey])
let itemT = {
[this.valueKey]: item[this.valueKey],
[this.textKey]: item[this.textKey],
level,
isLastLevel,
isShow: isShowChild,
isShowChild: isExpanded && !isLastLevel,
checkStatus: 0, // 默认未选中
orCheckStatus: 0, // 初始选中状态
parentId,
children: item[this.children], // 保留原始children引用
childCount: item[this.children] ? item[this.children].length : 0,
childCheckCount: 0, // 子节点全选计数
childCheckPCount: 0 // 子节点部分选中计数
}
// 根据modelValue设置选中状态
if (modelValueArray.includes(String(itemT[this.valueKey]))) {
itemT.checkStatus = 2
itemT.orCheckStatus = 2
itemT.childCheckCount = itemT.childCount
this.selectedNodes.add(itemT[this.valueKey])
}
// 直接添加到数组末尾
this.treeList.push(itemT)
// 递归处理所有子节点(预加载)
if (itemT.children && itemT.children.length > 0) {
// 子节点是否显示应该基于当前节点是否显示,而不是展开状态
this._formatTreeData(itemT.children, level + 1, itemT, itemT.isShow)
}
})
// 递归完成后更新所有父节点的选中状态
if (level === 0) {
this.treeList.forEach(item => {
if (!item.isLastLevel) {
this._updateParentCheckStatus(item)
}
})
}
},
// 解析modelValue为数组格式
_parseModelValue() {
let modelValueArray = []
if (Array.isArray(this.modelValue)) {
modelValueArray = this.modelValue.map(String)
} else if (typeof this.modelValue === 'string' && this.modelValue.trim()) {
modelValueArray = this.modelValue.split(this.separator).map(id => id.trim()).filter(id => id)
} else if (typeof this.modelValue === 'number') {
modelValueArray = [String(this.modelValue)]
}
return modelValueArray
},
// 更新父节点的选中状态
_updateParentCheckStatus(item) {
if (item.isLastLevel) return
let childCheckCount = 0
let childCheckPCount = 0
for (let i = 0; i  0 || childCheckPCount > 0) {
item.checkStatus = 1
item.orCheckStatus = 1
} else {
item.checkStatus = 0
item.orCheckStatus = 0
this.selectedNodes.delete(item[this.valueKey])
}
},
// 处理节点展开/折叠
_onItemSwitch(item, index) {
if (item.isLastLevel) return
item.isShowChild = !item.isShowChild
if (item.isShowChild) {
this.expandedNodes.add(item[this.valueKey])
// 展开时确保子节点可见
this._showChildNodes(item, index)
} else {
this.expandedNodes.delete(item[this.valueKey])
// 折叠时重置子节点显示状态,确保在搜索模式下也能正确折叠
this._onItemChildSwitch(item, index)
}
this._updateSelectedState()
},
// 显示节点的所有子节点
_showChildNodes(item, index) {
const firstChildIndex = index + 1
for (let i = firstChildIndex; i  {
v.checkStatus = i === index ? 2 : 0
v.orCheckStatus = v.checkStatus
v.childCheckCount = v.checkStatus === 2 ? v.childCount : 0
v.childCheckPCount = 0
if (i === index) {
this.selectedNodes.add(v[this.valueKey])
} else {
this.selectedNodes.delete(v[this.valueKey])
}
})
this.selectedIds = [item[this.valueKey]]
this.selectedNames = item[this.textKey]
this.$emit('update:modelValue', item[this.valueKey])
this._hide()
return
}
// 多选模式:切换选中状态
item.checkStatus = item.checkStatus === 0 ? 2 : 0
item.orCheckStatus = item.checkStatus
item.childCheckCount = item.checkStatus === 2 ? item.childCount : 0
item.childCheckPCount = 0
if (item.checkStatus === 2) {
this.selectedNodes.add(item[this.valueKey])
} else {
this.selectedNodes.delete(item[this.valueKey])
}
// 更新子节点
if (item.children && !item.isLastLevel) {
this._onItemChildSelect(item, index)
}
// 更新父节点
this._onItemParentSelect(item, index)
// 更新选中状态和显示名称,不跳过发出事件,确保父节点选中状态能实时更新
this._updateSelectedState()
},
// 更新子节点选中状态
_onItemChildSelect(item, index) {
for (let i = 0; i  itemP[this.valueKey] === item.parentId)
if (parentIndex >= 0) {
let parent = this.treeList[parentIndex]
this._updateParentCheckStatus(parent)
this._onItemParentSelect(parent, parentIndex)
}
},
// 更新选中状态和显示名称
_updateSelectedState(skipEmit = false) {
// 发出更新事件
if (skipEmit) {
this.selectedIds = []
this.selectedNames = ''
if (this.returnParentWhenChildrenAllSelected) {
// 当子节点全选时返回父节点
let currentLevel = -1
this.treeList.forEach(item => {
if (currentLevel >= 0 && item.level > currentLevel) return
if (item.checkStatus === 2) {
currentLevel = item.level
this.selectedIds.push(item[this.valueKey])
this.selectedNames = this.selectedNames
? `${this.selectedNames} ${this.separator} ${item[this.textKey]}`
: item[this.textKey]
} else {
currentLevel = -1
}
})
} else {
// 返回所有选中的子节点
this.treeList.forEach(item => {
if (item.checkStatus === 2 && (!item.children || item.isLastLevel)) {
this.selectedIds.push(item[this.valueKey])
this.selectedNames = this.selectedNames
? `${this.selectedNames} ${this.separator} ${item[this.textKey]}`
: item[this.textKey]
}
})
}
this.$emit('update:modelValue', this.multiple ? this.selectedIds.join(this.separator) : (this.selectedIds[0] || ''))
}
},
// 初始化树形结构
_initTree() {
// 重置状态
this.treeList = []
this.expandedNodes.clear()
this.selectedNodes.clear()
// 格式化树数据
this._formatTreeData(this.sourceList)
// 根据modelValue设置选中状态并展开相关节点
const modelValueArray = this._parseModelValue()
if (modelValueArray.length > 0) {
const validIds = new Set(this.treeList.map(item => String(item[this.valueKey])))
const foundNames = []
modelValueArray.forEach(id => {
if (validIds.has(id)) {
const foundItem = this.treeList.find(item => String(item[this.valueKey]) === id)
if (foundItem) {
// 设置选中状态
foundItem.checkStatus = 2
foundItem.orCheckStatus = 2
this.selectedNodes.add(id)
foundNames.push(foundItem[this.textKey])
// 确保选中节点的所有父节点都展开
let current = foundItem
while (current.parentId !== -1) {
const parent = this.treeList.find(p => p[this.valueKey] === current.parentId)
if (parent) {
this.expandedNodes.add(parent[this.valueKey])
current = parent
} else {
break
}
}
}
}
})
if (foundNames.length > 0) {
this.selectedNames = foundNames.join(` ${this.separator} `)
}
}
// 更新所有父节点的选中状态
this.treeList.forEach(item => {
if (!item.isLastLevel) {
this._updateParentCheckStatus(item)
}
})
this._resetTreeDisplay()
}
},
watch: {
// 监听sourceList变化,重新初始化树
sourceList: {
handler() {
this._initTree()
},
deep: true
},
// 监听modelValue变化,更新树状态
modelValue: {
immediate: true,
handler() {
this._initTree()
}
}
},
// 组件挂载时初始化树
mounted() {
this._initTree()
}
}
/* 样式保持完全不变 */
:deep(.uni-easyinput) {
width: 100%;
position: relative;
text-align: left;
color: #333;
font-size: 14px;
}
.tree-cover {
position: fixed;
top: 0rpx;
right: 0rpx;
bottom: 0rpx;
left: 0rpx;
z-index: 100;
background-color: rgba(0, 0, 0, .4);
opacity: 0;
transition: all 0.3s ease;
visibility: hidden;
}
.tree-cover.show {
visibility: visible;
opacity: 1;
}
.tree-dialog {
position: fixed;
top: 0rpx;
right: 0rpx;
bottom: 0rpx;
left: 0rpx;
background-color: #fff;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
display: flex;
flex-direction: column;
z-index: 102;
top: 20%;
transition: all 0.3s ease;
transform: translateY(100%);
}
.tree-dialog.show {
transform: translateY(0);
}
.tree-bar {
height: 90rpx;
padding-left: 25rpx;
padding-right: 25rpx;
display: flex;
justify-content: space-between;
align-items: center;
box-sizing: border-box;
border-bottom-width: 1rpx !important;
border-bottom-style: solid;
border-bottom-color: #f5f5f5;
font-size: 32rpx;
color: #757575;
line-height: 1;
}
.tree-bar-confirm {
color: #0055ff;
padding: 15rpx;
}
.tree-bar-title {
font-weight: bold;
color: #333;
}
.tree-bar-cancel {
color: #757575;
padding: 15rpx;
}
.tree-view {
flex: 1;
padding: 20rpx;
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
}
.item-flex {
display: flex;
justify-content: space-between;
align-items: center;
}
.tree-list {
flex: 1;
height: 100%;
overflow: hidden;
}
.tree-item {
display: flex;
justify-content: space-between;
align-items: center;
line-height: 1;
height: 0;
opacity: 0;
transition: 0.2s;
overflow: hidden;
}
.tree-item.show {
height: 90rpx;
opacity: 1;
}
.tree-item.showchild:before {
transform: rotate(90deg);
}
.tree-item.last:before {
opacity: 0;
}
.switch-on {
width: 0;
height: 0;
border-left: 10rpx solid transparent;
border-right: 10rpx solid transparent;
border-top: 15rpx solid #666;
}
.switch-off {
width: 0;
height: 0;
border-bottom: 10rpx solid transparent;
border-top: 10rpx solid transparent;
border-left: 15rpx solid #666;
}
.item-last-dot {
position: absolute;
width: 10rpx;
height: 10rpx;
border-radius: 100%;
background: #666;
}
.item-last-space {
width: 10rpx;
height: 10rpx;
}
.item-icon {
width: 26rpx;
height: 26rpx;
margin-right: 8rpx;
padding-right: 20rpx;
padding-left: 20rpx;
}
.item-label {
flex: 1;
display: flex;
align-items: center;
height: 100%;
line-height: 1.2;
}
.item-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 450rpx;
}
.item-check {
width: 40px;
height: 40px;
display: flex;
justify-content: center;
align-items: center;
}
.item-check-yes,
.item-check-no {
width: 20px;
height: 20px;
border-top-left-radius: 20%;
border-top-right-radius: 20%;
border-bottom-right-radius: 20%;
border-bottom-left-radius: 20%;
border-top-width: 1rpx;
border-left-width: 1rpx;
border-bottom-width: 1rpx;
border-right-width: 1rpx;
border-style: solid;
border-color: #0055ff;
display: flex;
justify-content: center;
align-items: center;
box-sizing: border-box;
}
.item-check-yes-part {
width: 12px;
height: 12px;
border-top-left-radius: 20%;
border-top-right-radius: 20%;
border-bottom-right-radius: 20%;
border-bottom-left-radius: 20%;
background-color: #0055ff;
}
.item-check-yes-all {
margin-bottom: 5px;
border: 2px solid #007aff;
border-left: 0;
border-top: 0;
height: 12px;
width: 6px;
transform-origin: center;
transition: all 0.3s;
transform: rotate(45deg);
}
.item-check .radio {
border-top-left-radius: 50%;
border-top-right-radius: 50%;
border-bottom-right-radius: 50%;
border-bottom-left-radius: 50%;
}
.item-check .radio .item-check-yes-b {
border-top-left-radius: 50%;
border-top-right-radius: 50%;
border-bottom-right-radius: 50%;
border-bottom-left-radius: 50%;
}
.hover-c {
opacity: 0.6;
}
.itemBorder {
border-bottom: 1px solid #e5e5e5;
}