带连接线树形结构的表格组件--数表组件

47 阅读10分钟

小小先说一下 好烦(。•ˇ‸ˇ•。),网上找了好多组件 ,就是关于我想解决树形组件和表格组件的嵌套 ,但是都没有我想要的, 找到的组件库都是那种没有连接线不能提现层级结构的 搞得我很不爽;所以自己动手写一个;

使用方法也很简单 ,给我这个复制了,现在 ai 都很发达,让它写一个数据格式化就行;然后扔到组件里;应该就可以了 ;下面给展示一下效果;

image.png

折叠后

image.png

使用说明书:

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树形列最小宽度Number300
nodeKey节点唯一标识字段名String'id'
childrenKey子节点字段名String'children'
labelKey节点标签字段名String'name'
defaultExpandAll是否默认展开所有节点Booleantrue
indentSize每级缩进大小(px)Number24
lineColor连接线颜色String'#409eff'
showControlButton是否显示控制按钮Booleanfalse
controlButtonText控制按钮文本Object{ fold: '折叠全部', expand: '展开全部' }
controlTargetType控制按钮目标节点类型Stringnull
controlTargetFilter控制按钮目标节点过滤函数Functionnull
rowTypeKey行类型字段名String'type'
rowTypeStyles行类型样式映射Object{}
getNodeLabel获取节点标签的函数Functionnull

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>

注意事项

  1. 数据格式:确保每个节点都有唯一的 id(或通过 nodeKey 指定的字段)
  2. 性能:对于大量数据的树形结构,建议使用虚拟滚动或分页
  3. 连接线:连接线使用伪元素绘制,确保表格容器有足够的空间显示
  4. 展开状态:组件内部维护展开状态,数据更新时会根据 defaultExpandAll 重置

常见问题

Q: 如何控制初始展开状态?

A: 设置 defaultExpandAllfalse,然后通过外部状态控制 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>