移动端tree组件父子组件联动。
TreeList组件
<!--
* @Author: yeminglong
* @Date: 2024-09-27 10:14:30
* @LastEditors: yeminglong
* @LastEditTime: 2024-10-17 15:56:43
* @Description:
-->
<script>
import TreeItem from '@/components/treeList/TreeItem.vue'
export default {
name: 'TreeList',
components: { TreeItem },
props: {
// 默认勾选的节点的 key 的数组
defaultCheckedKeys: Array,
// 默认展开的节点的 key 的数组
defaultExpandedKeys: Array,
// 是否每次只打开一个同级树节点展开
accordion: Boolean,
// 数据源
data: Array,
// 配置属性
props: {
label: 'name',
children: 'children'
},
// 节点标识
nodeKey: String
},
// provide: {
// // 当前选中的节点
// currentNode: {
// data: null
// }
// },
watch: {
data: {
handler(data) {
this.initData()
},
deep: true,
immediate: true
},
defaultCheckedKeys: {
handler(keys) {
this.setCheckedKeys(keys)
},
deep: true,
immediate: true
},
defaultExpandedKeys: {
handler(keys) {
this.setExpandedKeys(keys)
},
deep: true,
immediate: true
},
config: {
handler() {
this.initData()
},
deep: true
},
props: {
handler(props) {
Object.assign(this.config, props)
},
deep: true,
immediate: true
}
},
data() {
return {
config: {
label: 'name',
children: 'children',
...this.props
},
treeList: []
}
},
methods: {
initData() {
const { config } = this
const treeList = []
const deep = (list = [], parent = null) => {
for (const item of list) {
const children = item?.[config.children] ?? []
const node = {
// 勾选状态
checked: false,
// 是否是半选
isHalf: false,
// 是否是叶子节点
isLeaf: children.length === 0,
// 层级默认是从0开始
level: !parent ? 0 : parent.level + 1,
label: item[config.label],
disabled: item?.disabled ?? false,
open: item?.open ?? true,
// 数据
data: item,
// 子节点
children: []
}
if (!parent) {
treeList.push(node)
} else {
parent.children.push(node)
}
if (children && children.length) {
node.isLeaf = false
deep(item.children, node)
}
}
}
deep(this.data, null)
this.treeList = treeList
this.setCheckedKeys(this.defaultCheckedKeys)
},
/**
* 获取数据集合
* @returns {*[]}
*/
getData() {
return this.treeList
},
/**
* 则返回目前被选中的节点的 key 所组成的数组
* @param leafOnly {Boolean} 是否只是叶子节点
* @returns {*[]}
*/
getCheckedKeys(leafOnly = false) {
const { treeList, config, nodeKey } = this
const checkedKeys = []
const deep = (list = []) => {
for (const item of list) {
if (!leafOnly && item.checked) {
// console.log(item.data[config.label])
checkedKeys.push(item.data[nodeKey])
}
if (item.children && item.children.length) {
deep(item.children)
} else {
if (leafOnly && item.checked) {
// console.log(item.data[config.label])
checkedKeys.push(item.data[nodeKey])
}
}
}
}
deep(treeList)
return checkedKeys
},
/**
* 返回目前被选中的节点所组成的数组
* @param leafOnly {Boolean} 是否只是叶子节点
* @param includeHalfChecked {Boolean} 是否包含半选节点
* @returns {*[]}
*/
getCheckedNodes(leafOnly = false, includeHalfChecked = false) {
const { treeList, config } = this
const checkedNodes = []
const deep = (list = []) => {
for (const item of list) {
if (!leafOnly) {
if (
item.checked &&
!(includeHalfChecked && item.isHalf)
) {
const node = { ...item.data }
delete node[config.children]
checkedNodes.push(node)
}
}
if (item.children && item.children.length) {
deep(item.children)
} else {
if (leafOnly) {
if (
item.checked &&
!includeHalfChecked &&
!item.isHalf
) {
const node = { ...item.data }
delete node[config.children]
checkedNodes.push(node)
}
}
}
}
}
deep(treeList)
// console.log(JSON.stringify(checkedNodes, null, 4))
return checkedNodes
},
/**
* 通过 keys 设置目前勾选的节点,使用此方法必须设置 node-key 属性
* @param keys
* @param leafOnly
*/
setCheckedKeys(keys = [], leafOnly = false) {
const { config, nodeKey } = this
// const isHalf = item => {
// const children = item.children
// let totalNodes = 0
// let checkedNodes = 0
// function isHalfSelected(nodes = []) {
// for (const node of nodes) {
// totalNodes++
// if (node.checked) {
// checkedNodes++
// }
// if (node.children.length) {
// isHalfSelected(node.children)
// }
// }
// }
// isHalfSelected(children)
// return checkedNodes > 0 && totalNodes !== checkedNodes
// }
const isHalfSelected = (nodes = []) => {
let totalNodes = 0
let checkedNodes = 0
function traverse(node) {
totalNodes++
if (node.checked) {
checkedNodes++
}
if (node.children.length) {
node.children.forEach(traverse)
}
}
nodes.forEach(traverse)
return checkedNodes > 0 && totalNodes !== checkedNodes
}
const isHalf = item => {
const children = item.children
return isHalfSelected(children)
}
const deep = (list = [], parent = null) => {
for (const item of list) {
const children = item.children
if (children?.length) {
deep(children, item)
}
item.checked = keys.includes(item.data[nodeKey])
const checkCount = children.filter(v => v.checked).length
// 非叶子节点 判断选中和半选状态
if (!item.isLeaf) {
// 半选状态
item.isHalf = isHalf(item)
item.checked = children.length === checkCount
}
}
}
if (keys) {
deep(this.treeList, null)
}
},
/**
* 默认展开的节点的 key 的数组
* @param keys
*/
setExpandedKeys(keys = []) {
const { nodeKey } = this
const deep = (list = [], parent = null) => {
for (const item of list) {
const children = item.children
if (children?.length) {
deep(children, item)
}
item.open = keys.includes(item.data[nodeKey])
}
}
if (keys.length) {
deep(this.treeList, null)
}
}
}
}
</script>
<template>
<div class="tree-list">
<div
class="root-item"
v-for="(item, index) in treeList"
:key="`children-${(nodeKey && item.data[nodeKey]) || index}`"
>
<tree-item
:accordion="accordion"
:half-value="item.isHalf"
@half-change="
val => {
treeList[index].isHalf = val
$set(treeList[index], 'isHalf', val)
}
"
:disabled="item.disabled"
:open="item.open"
@open-change="
open => {
if (accordion) {
treeList.forEach((child, i) => {
child.open = false
$set(treeList[i], 'open', false)
})
treeList[index].open = true
$set(treeList[index], 'open', true)
} else {
treeList[index].open = open
$set(treeList[index], 'open', open)
}
}
"
:is-leaf="treeList[index].isLeaf"
v-model="treeList[index].checked"
:node-key="nodeKey"
:path="[item.data?.[nodeKey] ?? item.label]"
:props="props"
:data="item.data"
:label="item.label"
:children="item.children"
@check="$emit('check', $event)"
>
<template
#label="{ data, label, value, checked, path, isHalf }"
>
<slot
name="label"
v-bind="{
data,
label,
value,
checked,
path,
isHalf
}"
>
{{ label }}
</slot>
</template>
</tree-item>
</div>
</div>
</template>
<style scoped lang="scss">
.root-item {
//background: #f5f8fd;
//border-radius: 12px;
//padding: 38px 50px;
//margin: 20px 0 20px 0;
}
</style>
TreeItem组件
<!--
* @Author: yeminglong
* @Date: 2024-09-25 09:57:50
* @LastEditors: yeminglong
* @LastEditTime: 2024-10-17 15:57:34
* @Description:
-->
<script>
import CheckBox from '@/components/treeList/CheckBox.vue'
export default {
name: 'TreeItem',
components: { CheckBox },
// inject: ['currentNode'],
props: {
// 是否每次只打开一个同级树节点展开
accordion: Boolean,
level: {
type: Number,
default: 0
},
value: Boolean,
halfValue: Boolean,
isLeaf: Boolean,
disabled: Boolean,
open: { type: Boolean, default: true },
nodeKey: String,
path: Array,
props: {
label: 'name',
children: 'children'
},
data: Object,
label: String,
children: Array
},
computed: {
isHalf() {
const children = this.children
let totalNodes = 0
let checkedNodes = 0
function isHalfSelected(nodes = []) {
for (const node of nodes) {
totalNodes++
if (node.checked) {
checkedNodes++
}
if (node.children.length) {
isHalfSelected(node.children)
}
}
}
isHalfSelected(children)
return checkedNodes > 0 && totalNodes !== checkedNodes
},
nodeData() {
// 派发节点check事件
const node = { ...this.data }
delete node[this.props.children]
return node
}
},
watch: {
value: {
handler(checked) {
this.checked = checked
},
immediate: true
},
// checked(checked) {
// if (this.level >= this.currentNode.data?.level) {
// this.checkedChildren(checked)
// }
// },
isHalf(halfValue) {
this.$emit('half-change', halfValue)
},
open: {
handler(open) {
this.expanded = open
},
immediate: true
},
expanded(open) {}
},
data() {
return {
checked: false,
expanded: true
}
},
methods: {
/**
* 点击节点
*/
handleClick(checked) {
this.checkedChildren(this.children, checked)
// if (this.disabled) {
// return false
// }
// const checked = !this.checked
// this.currentNode.data = { checked, ...this.$props }
this.checked = checked
this.$emit('input', checked)
this.$emit('node-change', { checked, data: this.nodeData })
this.$nextTick(() => {
this.$emit('check', {
checked,
data: this.nodeData,
label: this.label,
path: this.path,
isHalf: this.isHalf
})
})
},
/**
* children 全选 反选
* @param children
* @param checked
*/
checkedChildren(children = [], checked = false) {
// const children = this?.children
if (children && children.length) {
for (const child of children) {
if (child.children.length) {
this.checkedChildren(child.children, checked)
}
child.checked = checked
}
}
},
/**
* 父级节点处理node-change事件
*/
nodeChange() {
const children = this.children
const checkCount = children.filter(v => v.checked).length
// this.checked = count > 0
this.checked = children.length === checkCount
this.$emit('input', this.checked)
this.$emit('node-change', {
checked: this.checked,
data: this.nodeData
})
},
openChange({ open, index }) {
if (this.accordion) {
this.children.forEach((child, i) => {
child.open = false
this.$set(this.children[i], 'open', false)
})
this.children[index].open = true
this.$set(this.children[index], 'open', true)
} else {
this.children[index].open = open
this.$set(this.children[index], 'open', open)
}
}
}
}
</script>
<template>
<div class="tree-item" :class="`tree-item-${level}`">
<div class="tree-item-header">
<div class="checkbox-box">
<div
class="checkbox-box-toggle"
:class="{
'is-expanded': expanded,
'is-leaf': !children.length
}"
>
<div
v-if="children.length"
@click="
() => {
expanded = !expanded
$emit('open-change', expanded)
}
"
>
<svg-icon
icon-class="common-arrow"
class="toggle-arrow"
/>
</div>
</div>
<check-box
v-model="checked"
@change="handleClick"
:disabled="disabled"
:half="isHalf"
>
<!--<div-->
<!-- class="checkbox-box-input"-->
<!-- :class="{-->
<!-- 'is-checked': checked,-->
<!-- 'is-half': isHalf,-->
<!-- 'is-disabled': disabled-->
<!-- }"-->
<!-->-->
<!-- <template v-if="checked">-->
<!-- <svg-icon-->
<!-- class="checkbox-box-input-icon"-->
<!-- icon-class="common-checked"-->
<!-- />-->
<!-- </template>-->
<!-- <template v-else>-->
<!-- <span class="half-line" v-if="isHalf"> </span>-->
<!-- </template>-->
<!--</div>-->
<div class="checkbox-box-label">
<slot
name="label"
v-bind="{
data,
label,
value,
checked,
path,
isHalf
}"
>
{{ label }}
</slot>
</div>
</check-box>
</div>
</div>
<div
class="tree-item-sub"
:class="[`tree-item-sub-${level}`, !expanded && `sub-is-expanded`]"
v-if="children && children.length"
>
<!--:value="item.checked"-->
<!--@input="value => (children[index].checked = value)"-->
<tree-item
v-for="(item, index) in children"
:key="`children-${(nodeKey && item.data[nodeKey]) || index}`"
:level="level + 1"
:accordion="accordion"
:half-value="item.isHalf"
@half-change="
val => {
children[index].isHalf = val
$set(children[index], 'isHalf', val)
}
"
:is-leaf="children[index].isLeaf"
:disabled="item.disabled"
:open="item.open"
@open-change="
open => {
openChange({ open, index })
}
"
v-model="children[index].checked"
@node-change="nodeChange"
:node-key="nodeKey"
:path="[...path, item.data?.[nodeKey] ?? item.label]"
:props="props"
:data="item.data"
:label="item.label"
:children="item.children"
@check="$emit('check', $event)"
>
<template
#label="{ data, label, value, checked, path, isHalf }"
>
<slot
name="label"
v-bind="{
data,
label,
value,
checked,
path,
isHalf
}"
>
{{ label }}
</slot>
</template>
</tree-item>
</div>
</div>
</template>
<style scoped lang="scss">
.tree-item {
//margin: 5px 0 5px 0;
}
.tree-item-header {
display: flex;
align-items: center;
padding: 15px 0;
//margin-bottom: 20px;
}
.checkbox-box {
display: flex;
//align-items: center;
align-items: flex-start;
font-size: 38px;
color: #323334;
> div {
display: flex;
align-items: center;
&:last-of-type {
flex: 1;
display: flex;
align-items: flex-start;
//border: 1px solid red;
}
}
.checkbox-box-toggle {
width: 50px;
height: 50px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 45px;
transform: rotate(0deg);
transition: transform 0.3s ease-in-out;
transform-origin: 50% 50%;
margin-right: 15px;
overflow: hidden;
color: #c0c4cc;
&.is-expanded {
transform: rotate(90deg);
}
&.is-leaf {
//visibility: hidden;
}
//:deep(.toggle-arrow) {
// color: #c0c4cc;
//}
}
.checkbox-box-input {
display: inline-flex;
align-items: center;
justify-content: center;
width: 50px;
height: 50px;
border-radius: 10px;
border: 2px solid rgb(200, 201, 204);
background: #fff;
color: #fff;
margin-right: 10px;
cursor: pointer;
overflow: hidden;
:deep(.checkbox-box-input-icon) {
width: 45px;
height: 45px;
overflow: hidden;
}
&:hover {
border: 2px solid #0262f1;
}
&.is-half,
&.is-checked {
border: 2px solid #0262f1;
background: #0262f1;
overflow: hidden;
}
&.is-disabled {
background-color: #ebf0fa;
border-color: rgb(220, 223, 230);
cursor: not-allowed;
overflow: hidden;
color: rgb(190, 194, 202);
&:hover {
border: 2px solid #dcdfe6;
}
}
.half-line {
border-radius: 4px;
height: 4px;
width: 60%;
background: #fff;
}
}
.checkbox-box-label {
flex: 1;
}
}
.tree-item-sub {
padding-left: 50px;
height: auto;
opacity: 1;
overflow: hidden;
&.sub-is-expanded {
opacity: 0;
height: 0;
transition: opacity 0.5s ease, height 0.5s ease;
}
}
</style>
checkbox组件
<!--
* @Author: yeminglong
* @Date: 2024-09-25 09:57:50
* @LastEditors: yeminglong
* @LastEditTime: 2024-10-17 15:57:34
* @Description:
-->
<script>
export default {
name: 'CheckBox',
props: {
disabled: Boolean,
value: Boolean,
half: Boolean
},
watch: {
value: {
handler(checked) {
this.checked = checked
},
immediate: true
},
checked: {
handler(checked) {
this.$emit('input', checked)
},
immediate: true
}
},
data() {
return {
checked: this.value
}
},
methods: {
handleClick() {
if (this.disabled) {
return
}
this.checked = !this.checked
this.$emit('input', this.checked)
this.$emit('change', this.checked)
}
}
}
</script>
<template>
<div @click="handleClick">
<div
class="checkbox-box-input"
:class="{
'is-checked': checked,
'is-half': half,
'is-disabled': disabled
}"
>
<template v-if="checked">
<svg-icon
class="checkbox-box-input-icon"
icon-class="common-checked"
/>
</template>
<template v-else>
<span class="half-line" v-if="half"> </span>
</template>
</div>
<slot> </slot>
</div>
</template>
<style scoped lang="scss">
.checkbox-box-input {
display: inline-flex;
align-items: center;
justify-content: center;
width: 50px;
height: 50px;
border-radius: 10px;
border: 2px solid rgb(200, 201, 204);
background: #fff;
color: #fff;
margin-right: 10px;
cursor: pointer;
overflow: hidden;
:deep(.checkbox-box-input-icon) {
width: 45px;
height: 45px;
overflow: hidden;
}
&:hover {
border: 2px solid #0262f1;
}
&.is-half,
&.is-checked {
border: 2px solid #0262f1;
background: #0262f1;
overflow: hidden;
}
&.is-disabled {
background-color: #ebf0fa;
border-color: rgb(220, 223, 230);
cursor: not-allowed;
overflow: hidden;
color: rgb(190, 194, 202);
&:hover {
border: 2px solid #dcdfe6;
}
}
.half-line {
border-radius: 4px;
height: 4px;
width: 60%;
background: #fff;
}
}
</style>
调用TreeList组件示例:
<!--
* @Author: yeminglong
* @Date: 2024-09-25 09:46:34
* @LastEditors: yeminglong
* @LastEditTime: 2024-10-15 13:48:28
* @Description:
-->
<script>
import TreeList from '@/components/treeList/TreeList.vue'
export default {
name: 'index',
components: { TreeList },
computed: {},
data() {
const treeData = [
{
id: 1,
label: '一级 1',
children: [
{
id: 4,
label: '二级 1-1',
children: [
{
id: 9,
label: '三级 1-1-1'
},
{
id: 10,
label: '三级 1-1-2'
}
]
}
]
},
{
id: 2,
label: '一级 2',
children: [
{
id: 5,
label: '二级 2-1'
},
{
id: 6,
label: '二级 2-2'
}
]
},
{
id: 3,
label: '一级 3',
children: [
{
id: 7,
label: '二级 3-1'
},
{
id: 8,
label: '二级 3-2'
}
]
}
]
return {
treeData,
defaultProps: {
children: 'children',
label: 'label'
},
defaultCheckedKeys: []
}
},
methods: {
check({ checked, data, path }) {
console.log(path)
// alert(JSON.stringify({ checked, data }))
},
getCheckedKeys() {
console.log(this.$refs.treeListRef.getCheckedKeys(true))
},
getCheckedNodes() {
console.log(this.$refs.treeListRef.getCheckedNodes(false, false))
}
}
}
</script>
<template>
<div class="root-flex">
<div>
<button @click="getCheckedKeys">getCheckedKeys()</button>
<button @click="getCheckedNodes">getCheckedNodes()</button>
<tree-list
ref="treeListRef"
:data="treeData"
node-key="id"
:props="defaultProps"
@check="check"
:default-checked-keys="defaultCheckedKeys"
>
<template
#label="{ data, value, checked, path, isHalf, label }"
>
{{ label }}
<!--({{ data.count }}) --checked:{{-->
<!-- checked-->
<!--}}-->
<!----halfValue:{{ halfValue }}--isHalf:{{ isHalf }}-->
</template>
</tree-list>
</div>
<!--<div>-->
<!-- <textarea-->
<!-- style="width: 100%"-->
<!-- :rows="6"-->
<!-- readonly-->
<!-- :value="JSON.stringify(newTreeData, null, 4)"-->
<!-- />-->
<!--</div>-->
</div>
</template>
<style scoped lang="scss">
.root-flex {
width: 100%;
height: 100%;
display: flex;
overflow-y: auto;
background: #fff;
font-size: 20px;
> div {
height: 100%;
width: 100%;
}
//:deep(.tree-item-1) {
// background: #f5f8fd;
// border-radius: 12px;
// overflow: hidden;
// margin-top: 10px;
// padding: 20px;
//}
}
</style>
用到的图标资源