小小先说一下 好烦(。•ˇ‸ˇ•。),网上找了好多组件 ,就是关于我想解决树形组件和表格组件的嵌套 ,但是都没有我想要的, 找到的组件库都是那种没有连接线不能提现层级结构的 搞得我很不爽;所以自己动手写一个;
使用方法也很简单 ,给我这个复制了,现在 ai 都很发达,让它写一个数据格式化就行;然后扔到组件里;应该就可以了 ;下面给展示一下效果;
折叠后
使用说明书:
TreeTable 树形表格组件
一个功能强大的树形表格组件,支持多层级数据展示、展开/折叠、连接线绘制等功能。
特性
- ✅ 支持多层级树形数据展示
- ✅ 自动绘制树形连接线(支持深层嵌套)
- ✅ 支持展开/折叠功能
- ✅ 支持默认展开所有节点
- ✅ 支持自定义列配置(通过插槽)
- ✅ 支持自定义树形列内容(通过插槽)
- ✅ 支持批量展开/折叠控制按钮(可选)
- ✅ 支持行类型样式配置
- ✅ 完全可配置的数据字段映射
安装
将组件文件 TreeTable.vue 放到 src/components/me/ 目录下。
基础用法
<template>
<TreeTable :data="treeData" />
</template>
<script setup>
import TreeTable from '@/components/me/TreeTable.vue'
const treeData = [
{
id: 1,
name: '节点1',
children: [
{
id: 11,
name: '节点1-1',
children: [
{ id: 111, name: '节点1-1-1' }
]
},
{ id: 12, name: '节点1-2' }
]
},
{
id: 2,
name: '节点2'
}
]
</script>
Props
| 参数 | 说明 | 类型 | 默认值 |
|---|---|---|---|
| data | 树形数据 | Array | [] |
| treeColumnLabel | 树形列标题 | String | '名称' |
| treeColumnMinWidth | 树形列最小宽度 | Number | 300 |
| nodeKey | 节点唯一标识字段名 | String | 'id' |
| childrenKey | 子节点字段名 | String | 'children' |
| labelKey | 节点标签字段名 | String | 'name' |
| defaultExpandAll | 是否默认展开所有节点 | Boolean | true |
| indentSize | 每级缩进大小(px) | Number | 24 |
| lineColor | 连接线颜色 | String | '#409eff' |
| showControlButton | 是否显示控制按钮 | Boolean | false |
| controlButtonText | 控制按钮文本 | Object | { fold: '折叠全部', expand: '展开全部' } |
| controlTargetType | 控制按钮目标节点类型 | String | null |
| controlTargetFilter | 控制按钮目标节点过滤函数 | Function | null |
| rowTypeKey | 行类型字段名 | String | 'type' |
| rowTypeStyles | 行类型样式映射 | Object | {} |
| getNodeLabel | 获取节点标签的函数 | Function | null |
Slots
tree-cell
自定义树形列内容。
参数:
row: 当前行数据对象
<TreeTable :data="treeData">
<template #tree-cell="{ row }">
<el-tag v-if="row.type === 'component'" type="primary">组件</el-tag>
<span>{{ row.name }}</span>
</template>
</TreeTable>
columns
自定义表格列。
参数:
flattenedData: 扁平化后的数据数组
<TreeTable :data="treeData">
<template #columns="{ flattenedData }">
<el-table-column label="类型" width="150">
<template #default="{ row }">
<span>{{ row.type }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="200">
<template #default="{ row }">
<el-tag>{{ row.status }}</el-tag>
</template>
</el-table-column>
</template>
</TreeTable>
完整示例
示例1:基础用法
<template>
<TreeTable :data="treeData" />
</template>
<script setup>
import TreeTable from '@/components/me/TreeTable.vue'
const treeData = [
{
id: 1,
name: '根节点1',
children: [
{ id: 11, name: '子节点1-1' },
{ id: 12, name: '子节点1-2' }
]
}
]
</script>
示例2:自定义字段映射
<template>
<TreeTable
:data="treeData"
node-key="nodeId"
children-key="subNodes"
label-key="nodeName"
/>
</template>
<script setup>
import TreeTable from '@/components/me/TreeTable.vue'
const treeData = [
{
nodeId: 1,
nodeName: '根节点',
subNodes: [
{ nodeId: 11, nodeName: '子节点' }
]
}
]
</script>
示例3:带控制按钮
<template>
<TreeTable
:data="treeData"
:show-control-button="true"
control-target-type="process"
:control-button-text="{ fold: '折叠全部工艺', expand: '展开全部工艺' }"
/>
</template>
<script setup>
import TreeTable from '@/components/me/TreeTable.vue'
const treeData = [
{
id: 1,
name: '组件1',
type: 'component',
children: [
{
id: 11,
name: '工艺1',
type: 'process',
children: [
{ id: 111, name: 'ITP1', type: 'itp' }
]
}
]
}
]
</script>
示例4:自定义列和样式
<template>
<TreeTable :data="treeData">
<template #tree-cell="{ row }">
<el-tag v-if="row.type === 'component'" type="primary" size="small">
组件
</el-tag>
<el-tag v-else-if="row.type === 'process'" type="success" size="small">
工艺
</el-tag>
<span style="margin-left: 8px">{{ row.name }}</span>
</template>
<template #columns>
<el-table-column label="类型" width="150">
<template #default="{ row }">
<span>{{ row.type }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="200">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">
{{ row.status }}
</el-tag>
</template>
</el-table-column>
</template>
</TreeTable>
</template>
<script setup>
import TreeTable from '@/components/me/TreeTable.vue'
const getStatusType = (status) => {
const map = {
'已完成': 'success',
'进行中': 'primary',
'待开始': 'info'
}
return map[status] || 'info'
}
</script>
示例5:使用过滤函数控制按钮
<template>
<TreeTable
:data="treeData"
:show-control-button="true"
:control-target-filter="filterProcessNodes"
:control-button-text="{ fold: '折叠全部质检点', expand: '展开全部质检点' }"
/>
</template>
<script setup>
import TreeTable from '@/components/me/TreeTable.vue'
const filterProcessNodes = (node) => {
// 返回包含 ITP 子节点的工艺节点
return node.type === 'process' &&
node.children &&
node.children.some(child => child.type === 'itp')
}
</script>
示例6:自定义节点标签
<template>
<TreeTable
:data="treeData"
:get-node-label="getNodeLabel"
/>
</template>
<script setup>
import TreeTable from '@/components/me/TreeTable.vue'
const getNodeLabel = (row) => {
return `${row.name} (${row.code})`
}
</script>
数据格式
组件期望的数据格式为标准的树形结构:
[
{
id: 1, // 节点唯一标识(可通过 nodeKey 配置)
name: '节点名称', // 节点显示名称(可通过 labelKey 配置)
children: [ // 子节点数组(可通过 childrenKey 配置)
{
id: 11,
name: '子节点',
children: []
}
]
}
]
样式定制
组件支持通过 CSS 变量和深度选择器进行样式定制:
<style lang="scss" scoped>
// 自定义行类型样式
:deep(.row-type-component) {
background-color: #e6f4ff !important;
}
:deep(.row-type-process) {
background-color: #f0f9ff !important;
}
:deep(.row-type-itp) {
background-color: #f5f7fa !important;
}
</style>
注意事项
- 数据格式:确保每个节点都有唯一的
id(或通过nodeKey指定的字段) - 性能:对于大量数据的树形结构,建议使用虚拟滚动或分页
- 连接线:连接线使用伪元素绘制,确保表格容器有足够的空间显示
- 展开状态:组件内部维护展开状态,数据更新时会根据
defaultExpandAll重置
常见问题
Q: 如何控制初始展开状态?
A: 设置 defaultExpandAll 为 false,然后通过外部状态控制 expandedKeys。
Q: 如何自定义连接线颜色?
A: 通过 lineColor prop 设置,例如 :line-color="'#ff0000'"。
Q: 如何禁用默认展开所有节点?
A: 设置 :default-expand-all="false"。
Q: 控制按钮如何判断目标节点?
A: 可以通过 controlTargetType 指定节点类型,或通过 controlTargetFilter 函数自定义判断逻辑。
License
MIT
原始代码
``
<template>
<div
class="tree-table-container"
:style="{
'--line-color': lineColor,
'--indent-size': indentSize + 'px'
}"
>
<!-- 控制按钮 -->
<div v-if="showControlButton" class="tree-control-bar">
<el-button
:icon="allTargetNodesExpanded ? Fold : Expand"
@click="toggleAllTargetNodes"
size="small"
type="primary"
>
{{ allTargetNodesExpanded ? controlButtonText.fold : controlButtonText.expand }}
</el-button>
</div>
<el-table
:data="flattenedData"
border
style="width: 100%"
:row-class-name="getRowClassName"
>
<!-- 树形列 -->
<el-table-column
:label="treeColumnLabel"
:min-width="treeColumnMinWidth"
:class-name="getTreeColumnClassName"
>
<template #default="{ row }">
<div
class="tree-cell"
:class="{
'tree-cell-child': row.level > 0,
'tree-cell-first': row.isFirstChild,
'tree-cell-last': row.isLastChild,
'tree-cell-only': row.isOnlyChild,
'tree-cell-parent-expanded': row.parentExpanded,
'tree-cell-has-next': row.hasNextSibling,
'tree-cell-has-children-expanded': row.children && row.children.length > 0 && row.expanded,
'tree-cell-first-after-last': row.isFirstChildAfterLast
}"
:style="{
paddingLeft: (row.level * indentSize) + 'px',
'--parent-icon-left': row.level > 0 ? ((row.level - 1) * indentSize + 8) + 'px' : '0px'
}"
>
<!-- 连接线容器:用于绘制多层级连接线 -->
<template v-if="row.level > 0 && row.parentPath && row.parentExpanded">
<!-- 更上层父级的连接线(只有当父级展开时才需要) -->
<template v-for="(path, pathIndex) in row.parentPath.slice(0, -1)" :key="pathIndex">
<div
class="tree-line-ancestor"
:class="{
'line-ancestor-last': path.isLast && !row.hasNextSibling && (!hasChildren(row) || !row.expanded) && !(path.hasChildren && path.expanded)
}"
:style="{ left: (path.level * indentSize + 8) + 'px' }"
></div>
</template>
</template>
<!-- 展开/折叠图标或占位符 -->
<span
v-if="hasChildren(row)"
class="tree-expand-icon"
@click.stop="toggleExpand(row)"
>
<el-icon>
<ArrowRight v-if="!row.expanded" />
<ArrowDown v-else />
</el-icon>
</span>
<span v-else class="tree-placeholder"></span>
<!-- 树形列内容插槽 -->
<slot name="tree-cell" :row="row">
<span class="tree-label">{{ getNodeLabel(row) }}</span>
</slot>
</div>
</template>
</el-table-column>
<!-- 自定义列 -->
<slot name="columns" :flattenedData="flattenedData"></slot>
</el-table>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { ArrowRight, ArrowDown, Fold, Expand } from '@element-plus/icons-vue'
const props = defineProps({
// 树形数据
data: {
type: Array,
required: true,
default: () => []
},
// 树形列配置
treeColumnLabel: {
type: String,
default: '名称'
},
treeColumnMinWidth: {
type: Number,
default: 300
},
// 节点标识字段
nodeKey: {
type: String,
default: 'id'
},
// 子节点字段名
childrenKey: {
type: String,
default: 'children'
},
// 节点标签字段名
labelKey: {
type: String,
default: 'name'
},
// 是否默认展开所有节点
defaultExpandAll: {
type: Boolean,
default: true
},
// 每级缩进大小(px)
indentSize: {
type: Number,
default: 24
},
// 连接线颜色
lineColor: {
type: String,
default: '#409eff'
},
// 是否显示控制按钮
showControlButton: {
type: Boolean,
default: false
},
// 控制按钮文本
controlButtonText: {
type: Object,
default: () => ({
fold: '折叠全部',
expand: '展开全部'
})
},
// 控制按钮目标节点类型(用于判断哪些节点需要控制)
controlTargetType: {
type: String,
default: null
},
// 控制按钮目标节点判断函数
controlTargetFilter: {
type: Function,
default: null
},
// 行类型字段(用于设置不同的背景色)
rowTypeKey: {
type: String,
default: 'type'
},
// 行类型样式映射
rowTypeStyles: {
type: Object,
default: () => ({})
},
// 获取节点标签的函数
getNodeLabel: {
type: Function,
default: null
}
})
// 默认展开所有节点
const expandedKeys = ref(new Set())
// 递归收集所有有子节点的节点ID
const collectAllNodeIds = (items) => {
const ids = []
items.forEach(item => {
const children = item[props.childrenKey]
if (children && children.length > 0) {
ids.push(item[props.nodeKey])
ids.push(...collectAllNodeIds(children))
}
})
return ids
}
// 监听数据变化,自动展开所有节点
watch(() => props.data, (newData) => {
if (props.defaultExpandAll && newData && newData.length > 0) {
const allIds = collectAllNodeIds(newData)
expandedKeys.value = new Set(allIds)
}
}, { immediate: true, deep: true })
// 判断是否有子节点
const hasChildren = (row) => {
return row[props.childrenKey] && row[props.childrenKey].length > 0
}
// 切换展开/折叠
const toggleExpand = (row) => {
const key = row[props.nodeKey]
if (expandedKeys.value.has(key)) {
expandedKeys.value.delete(key)
} else {
expandedKeys.value.add(key)
}
}
// 递归收集目标节点 ID(用于控制按钮)
const collectTargetNodeIds = (items) => {
const ids = []
items.forEach(item => {
const children = item[props.childrenKey]
// 如果指定了目标类型
if (props.controlTargetType && item[props.rowTypeKey] === props.controlTargetType) {
if (children && children.length > 0) {
ids.push(item[props.nodeKey])
}
}
// 如果指定了过滤函数
else if (props.controlTargetFilter && props.controlTargetFilter(item)) {
if (children && children.length > 0) {
ids.push(item[props.nodeKey])
}
}
if (children && children.length > 0) {
ids.push(...collectTargetNodeIds(children))
}
})
return ids
}
// 判断所有目标节点是否展开
const allTargetNodesExpanded = computed(() => {
if (!props.showControlButton || (!props.controlTargetType && !props.controlTargetFilter)) {
return true
}
if (!props.data || props.data.length === 0) return true
const targetIds = collectTargetNodeIds(props.data)
if (targetIds.length === 0) return true
return targetIds.every(id => expandedKeys.value.has(id))
})
// 切换所有目标节点的展开/折叠状态
const toggleAllTargetNodes = () => {
const targetIds = collectTargetNodeIds(props.data)
if (allTargetNodesExpanded.value) {
targetIds.forEach(id => {
expandedKeys.value.delete(id)
})
} else {
targetIds.forEach(id => {
expandedKeys.value.add(id)
})
}
}
// 获取节点标签
const getNodeLabel = (row) => {
if (props.getNodeLabel) {
return props.getNodeLabel(row)
}
return row[props.labelKey] || '-'
}
// 检查节点是否是另一个节点的子孙
const isDescendant = (childRow, parentId, result) => {
let currentParentId = childRow.parentId
while (currentParentId) {
if (currentParentId === parentId) {
return true
}
const parentRow = result.find(r => r[props.nodeKey] === currentParentId)
if (!parentRow) break
currentParentId = parentRow.parentId
}
return false
}
// 检查是否是收尾节点
const isTerminatingNode = (row) => {
return row.isLastChild &&
!row.hasNextSibling &&
(!hasChildren(row) || !row.expanded)
}
// 扁平化树形数据
const flattenedData = computed(() => {
const result = []
const flatten = (items, level = 0, parentId = null, parentPath = []) => {
items.forEach((item, index) => {
const isLast = index === items.length - 1
const isFirst = index === 0
const isOnly = items.length === 1
const key = item[props.nodeKey]
const expanded = expandedKeys.value.has(key)
const itemHasChildren = hasChildren({ [props.childrenKey]: item[props.childrenKey] })
const row = {
...item,
level,
expanded,
parentId,
parentPath: [...parentPath],
isFirstChild: isFirst,
isLastChild: isLast,
isOnlyChild: isOnly,
hasNextSibling: !isLast,
parentExpanded: level === 0 || (parentPath.length > 0 && parentPath[parentPath.length - 1].expanded),
isFirstChildAfterLast: false,
children: itemHasChildren ? item[props.childrenKey] : []
}
result.push(row)
if (itemHasChildren && expanded) {
const newParentPath = [...parentPath, { level, isLast, expanded, hasChildren: true }]
flatten(item[props.childrenKey], level + 1, key, newParentPath)
}
})
}
flatten(props.data)
// 后处理:修正 isLastChild 和 hasNextSibling
result.forEach((row, index) => {
if (row.level === 0) return
// 检查前一个节点是否是同一父级下的收尾节点
if (index > 0 && row.isFirstChild) {
const prevRow = result[index - 1]
if (prevRow.parentId === row.parentId &&
prevRow.level === row.level &&
isTerminatingNode(prevRow)) {
row.isFirstChildAfterLast = true
}
}
// 判断是否是同一父级下的最后一行
let isLastRowInParent = true
for (let i = index + 1; i < result.length; i++) {
const nextRow = result[i]
if (nextRow.level === row.level && nextRow.parentId === row.parentId) {
isLastRowInParent = false
break
}
if (nextRow.level < row.level) {
break
}
if (nextRow.level > row.level) {
if (!isDescendant(nextRow, row[props.nodeKey], result)) {
break
}
}
}
if (hasChildren(row) && row.expanded) {
isLastRowInParent = false
}
if (isLastRowInParent) {
if (hasChildren(row) && row.expanded) {
row.isLastChild = false
row.hasNextSibling = true
} else {
row.isLastChild = true
row.hasNextSibling = false
}
}
})
return result
})
// 获取行类名
const getRowClassName = ({ row }) => {
const classes = []
// 根据行类型添加类名
const rowType = row[props.rowTypeKey]
if (rowType) {
classes.push(`row-type-${rowType}`)
}
if (row.level > 0) {
classes.push('tree-row-child')
if (row.isFirstChild) classes.push('tree-row-first-child')
if (row.isLastChild) classes.push('tree-row-last-child')
if (row.isOnlyChild) classes.push('tree-row-only-child')
}
return classes.join(' ')
}
// 获取树形列类名
const getTreeColumnClassName = ({ row }) => {
return row.level > 0 ? 'tree-column-child' : ''
}
</script>
<style lang="scss" scoped>
.tree-table-container {
.tree-control-bar {
margin-bottom: 16px;
display: flex;
justify-content: flex-end;
}
:deep(.el-table__body-wrapper) {
overflow: visible !important;
}
:deep(.el-table__body) {
overflow: visible !important;
}
:deep(.el-table__cell) {
padding-top: 0 !important;
padding-bottom: 0 !important;
}
:deep(.cell) {
padding-top: 0 !important;
padding-bottom: 0 !important;
}
.tree-cell {
display: flex;
align-items: center;
position: relative;
min-height: 32px;
&.tree-cell-child {
position: relative;
&.tree-cell-parent-expanded {
&::before {
content: '';
position: absolute;
left: var(--parent-icon-left, 0);
width: 0;
border-left: 2px dashed var(--line-color, #409eff);
pointer-events: none;
z-index: 1;
}
&::after {
content: '';
position: absolute;
left: var(--parent-icon-left, 0);
top: 50%;
width: 12px;
height: 0;
border-top: 2px dashed var(--line-color, #409eff);
pointer-events: none;
z-index: 1;
}
&.tree-cell-has-children-expanded::before {
top: -200px !important;
bottom: -200px !important;
height: auto !important;
}
&.tree-cell-first::before {
top: calc(50% - 1px);
bottom: -200px;
height: auto;
}
&.tree-cell-first-after-last::before {
top: calc(-100% - 0px) !important;
bottom: auto !important;
height: calc(150% + 0px) !important;
}
&.tree-cell-has-next::before,
&:not(.tree-cell-first):not(.tree-cell-last):not(.tree-cell-only)::before {
top: -200px;
bottom: -200px;
height: auto;
}
&.tree-cell-last.tree-cell-has-next::before {
top: -200px;
bottom: -200px;
height: auto;
}
&.tree-cell-last:not(.tree-cell-has-next):not(.tree-cell-has-children-expanded)::before {
top: calc(-100% - 0px) !important;
bottom: auto !important;
height: calc(100% + 50%) !important;
}
&.tree-cell-only:not(.tree-cell-has-children-expanded)::before {
top: calc(-100% - 0px) !important;
bottom: auto !important;
height: calc(100% + 50%) !important;
}
}
&:not(.tree-cell-parent-expanded) {
&::after {
content: '';
position: absolute;
left: var(--parent-icon-left, 0);
top: 50%;
width: calc((var(--parent-icon-left, 0) * -1) + var(--indent-size, 24px));
height: 0;
border-top: 2px dashed var(--line-color, #409eff);
pointer-events: none;
z-index: 1;
}
}
}
.tree-expand-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
cursor: pointer;
color: #606266;
flex-shrink: 0;
margin-right: 4px;
&:hover {
color: #409eff;
}
}
.tree-placeholder {
display: inline-block;
width: 16px;
flex-shrink: 0;
}
.tree-label {
margin-left: 8px;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.tree-line-ancestor {
position: absolute;
left: 0;
top: -200px;
bottom: -200px;
width: 0;
border-left: 2px dashed var(--line-color, #409eff);
pointer-events: none;
z-index: 1;
&.line-ancestor-last {
bottom: auto;
height: calc(50% + 200px);
}
}
:deep(.tree-row-child) {
background-color: #fafafa;
td {
border-top: none !important;
border-bottom: none !important;
background-color: #fafafa !important;
}
}
:deep(.tree-row-first-child) {
td {
border-top: 1px solid #ebeef5 !important;
}
}
:deep(.tree-row-last-child) {
td {
border-bottom: 1px solid #ebeef5 !important;
}
}
:deep(.tree-row-only-child) {
td {
border-top: 1px solid #ebeef5 !important;
border-bottom: 1px solid #ebeef5 !important;
}
}
:deep(.el-table__row:hover) {
.tree-row-child {
background-color: #f5f7fa;
td {
background-color: #f5f7fa !important;
}
}
}
:deep(.el-table__cell) {
vertical-align: middle;
}
}
</style>