Element-Plus 文件实现文件夹树

1,036 阅读3分钟

前言

为了满足项目的需要,我开发了一个文件夹树结构。使用了基于 Element Plus 的 el-tree 来设计这个文件夹树,使其支持节点的增删改操作。

效果图

iShot_2024-04-04_10.51.16.gif

目录结构

Project
|-- src
|-  |-- components
|-  |-  |-- FolderTree
|-  |-  |-   |-- src
|-  |-  |-   |-  |-- FolderTree.vue
|-  |-  |-   |-- index.tx

上代码

index.ts

import FolderTree from './src/FolderTree.vue'

interface Tree {
  id?: string
  label: string
  name: string
  isAddNode?: boolean
  children?: Tree[]
  parentId: string
}

export { FolderTree, Tree }

FolderTree.vue

<script setup lang="ts">
import { defineComponent, nextTick, PropType, ref, useAttrs, watch } from 'vue'
import {
  ElDropdown,
  ElDropdownItem,
  ElDropdownMenu,
  ElInput,
  ElMessage,
  ElMessageBox,
  ElTree
} from 'element-plus'
import { Icon } from '@/components/Icon'
import type Node from 'element-plus/es/components/tree/src/model/node'
import { set } from 'lodash-es'
import { Tree } from '@/components/FolderTree'
import { useI18n } from '@/hooks/web/useI18n'
import { useIcon } from '@/hooks/web/useIcon'
import { isEmptyVal } from '@/utils/is'

const { t } = useI18n()
const attrs = useAttrs()

defineComponent({
  name: 'FolderTree',
  inheritAttrs: false
})

const props = defineProps({
  dataList: {
    type: Array as PropType<any[]>,
    default: () => []
  }
})

const nodeAdd = ref()
const dropdown = ref()
const treeEl = ref<ComponentRef<typeof ElTree>>()

const emit = defineEmits(['submit', 'remove'])

const treeData = ref<any[]>([
  {
    id: '0',
    name: '全部',
    children: props.dataList || [],
    isAddNode: false,
    parentId: ''
  }
])

// 添加子节点
const addChildNode = (data: Tree) => {
  const newNode: Tree = {
    label: '',
    children: [],
    name: '',
    isAddNode: true,
    parentId: data.id?.toString() as string
  }
  if (!data.children) {
    data.children = []
  }
  data.children.push(newNode)
  nextTick(() => {
    nodeAdd.value.focus()
  })
  set(treeData.value, 'value', treeData.value)
}

const addNode = (node: Node, data: Tree) => {
  if (data.isAddNode) return // 如果已经是添加状态,则不再执行
  addChildNode(data) // 添加子节点
  // 自动展开当前节点
  if (node) {
    node.expanded = true
  }
}

const editNode = (data: Tree) => {
  if (data.id === '0') return
  data.isAddNode = true
  nextTick(() => {
    nodeAdd.value.focus()
  })
  set(treeData.value, 'value', treeData.value)
}

const handleInputEnter = (node: Node, data: Tree) => {
  data.isAddNode = false // 取消添加节点状态
  if (data.label.trim() !== '') {
    // 确保节点名称不为空
    data.label = data.label.trim() // 将节点标签替换为输入的内容
    emit('submit', data)
  } else {
    cleanNode(node, data)
  }
  set(treeData.value, 'value', treeData.value)
}

const handleInputEsc = (node: Node, data: Tree) => {
  data.isAddNode = false
  if (isEmptyVal(node.label)) {
    cleanNode(node, data)
  }
}

const cleanNode = (node: Node, data: Tree) => {
  const parent = node.parent
  if (parent) {
    const children = parent.data.children || parent.data
    const index = children.findIndex((d: Tree) => d.id === data.id)
    children.splice(index, 1)
  }
}

const delNode = (node: Node, data: Tree) => {
  if (data.id === '0') return
  ElMessageBox.confirm('确定删除当前节点吗?', '提示', {
    confirmButtonText: t('common.delOk'),
    cancelButtonText: t('common.delCancel'),
    type: 'warning'
  }).then(() => {
    if (node.childNodes && node.childNodes.length > 0) {
      ElMessage.warning(`【${node.label}】下存在子节点,无法删除`)
      return
    }
    cleanNode(node, data)
    set(treeData.value, 'value', treeData.value)
    emit('remove', data)
  })
}

watch(
  () => props.dataList,
  (newValue) => {
    treeData.value[0].children = newValue
  },
  {
    deep: true,
    immediate: true
  }
)

defineExpose({
  treeEl
})
</script>

<template>
  <div>
    <ElTree
      v-bind="attrs"
      ref="treeEl"
      :data="treeData"
      :highlight-current="true"
      :default-expand-all="true"
      :expand-on-click-node="false"
      node-key="id"
    >
      <template #default="{ node, data }">
        <span class="custom-tree-node">
          <span>
            <span>
              <Icon icon="twemoji:file-folder" class="mr-5px" :size="15" />
            </span>
            <span v-if="!data.isAddNode">{{ node.label }}</span>
            <span>
              <ElInput
                ref="nodeAdd"
                @blur="
                  () => {
                    handleInputEsc(node, data)
                  }
                "
                v-model="data.label"
                v-if="data.isAddNode"
                class="add-new-child-node"
                clearable
                @keyup.esc="
                  () => {
                    handleInputEsc(node, data)
                  }
                "
                @keyup.enter="() => handleInputEnter(node, data)"
              />
            </span>
          </span>
          <span class="button-group">
            <ElDropdown ref="dropdown" trigger="contextmenu">
              <template #default>
                <Icon :size="16" class="cursor-pointer" icon="basil:other-1-outline" />
              </template>
              <template #dropdown>
                <ElDropdownMenu>
                  <ElDropdownItem
                    @click="addNode(node, data)"
                    :icon="useIcon({ icon: 'subway:add', size: 14, color: '#67C23A' })"
                  >
                    添加
                  </ElDropdownItem>
                  <ElDropdownItem
                    v-if="data.id !== '0'"
                    @click="editNode(data)"
                    :icon="useIcon({ icon: 'system-uicons:write', size: 14 })"
                  >
                    编辑
                  </ElDropdownItem>
                  <ElDropdownItem
                    v-if="data.id !== '0'"
                    @click="delNode(node, data)"
                    :icon="useIcon({ icon: 'subway:subtraction', size: 14, color: 'red' })"
                  >
                    删除
                  </ElDropdownItem>
                </ElDropdownMenu>
              </template>
            </ElDropdown>
          </span>
        </span>
      </template>
    </ElTree>
  </div>
</template>

<style scoped lang="less">
.add-new-child-node {
  z-index: 20;
  padding-left: 0;
  width: calc(100% - 30px);

  :deep(.el-input__wrapper) {
    background: #fff;
    height: 18px;
  }

  :deep(.el-input__inner) {
    background: #fff;
    height: 18px;
  }
}

.custom-tree-node {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: space-between;
  font-size: 14px;
  padding-right: 1px;
}

.button-group {
  display: flex;
  align-items: center;
  gap: 5px; /* 间距大小 */
}
</style>

使用方式

<script setup lang="ts">
import { FolderTree, Tree } from '@/components/FolderTree'
import { ref } from 'vue'

const treeData = ref([{ id: '1', name: 'Test', children: [], isAddNode: false }])
const currentNodeKey = ref('')

/**
 * 删除节点操作
 * @param data
 */
const removeNode = async (data: Tree) => {
  if (!data.id) return
  console.log(data)
}

/**
 * 删除新增修改操作
 * @param data
 */
const submitNode = async (data: Tree) => {
  console.log(data)
}

/**
 * 节点点击
 * @param data
 */
const currentChange = (data: Tree) => {
  console.log(data)
}
</script>
<template>
  <div class="w-300px">
    <ElCard>
      <FolderTree
        ref="folderRef"
        :props="{
          label: 'name'
        }"
        :dataList="treeData"
        :current-node-key="currentNodeKey"
        @remove="removeNode"
        @submit="submitNode"
        @current-change="currentChange"
      />
    </ElCard>
  </div>
</template>

<style lang="less"></style>

注意

如果需要用到原生的方法,调用方式

// 例如
folderRef.value?.treeEl?.setCurrentKey()
folderRef.value?.treeEl?.filter

总结

在这里做一个记录,我对 el-tree 进行了二次封装,完全暴露了原生 el-tree 的所有属性和方法。我自定义了两个方法:submit 和 remove,用于处理节点的增加、删除和修改操作。如果大家有其他的建议或想法,也欢迎大家提出并指点一下!