Vue3 + ts + Ant Design Vue 的tree组件,实现自定义新增、编辑、删除

发布于:2025-08-31 ⋅ 阅读:(24) ⋅ 点赞:(0)

使用 Ant Design Vue 的tree结构实现目录管理

  1. 取消了展开和折叠图标,使得有些操作比较诡异,如果需要展开和折叠图标,删除css中的最后一段代码,有注释
  2. 通过用户选中和不选中两个状态决定图标的使用
  3. 在有子节点,并且未展开状态时,给父节点增加子节点时聚焦到新增节点会失败,原因未知,希望有大神解决后告知一下
  4. 代码可以直接使用,当组件引入即可
  5. 视频中宽度发生变化,是因为我宽度设置的不够,宽度足够的情况是不会有变化的
  6. @blur="handleEditConfirm(data)" @keyup.enter="handleEditConfirm(data)"点击回车后,会执行两次handleEditConfirm方法,留一个就行
    在这里插入图片描述
<template>
  <a-card title="目录" style="max-width: 600px; height: 100%" :bodyStyle="{ padding: '10px' }">
    <template #extra>
      <a-button size="small" type="link" :icon="h(FolderAddOutlined)" @click="addRootNode">
        <span style="font-size: 12px; margin-left: 2px">新建</span>
      </a-button>
    </template>
    <a-tree :showIcon="true" :fieldNames="fieldNames" :tree-data="treeData" v-model:selectedKeys="selectedKeys" v-model:expandedKeys="expandedKeys">
      <template #icon="{ selected }">
        <FolderOpenOutlined v-if="selected" />
        <FolderOutlined v-else />
      </template>
      <template #title="{ data }">
        <div class="node-content">
          <!-- 节点标题显示或编辑状态 -->
          <template v-if="data.isEditing">
            <a-input
              :class="data.id"
              v-model:value="data.name"
              size="small"
              style="width: 100px"
              @blur="handleEditConfirm(data)"
              @keyup.enter="handleEditConfirm(data)"
              :autoFocus="true"
            />
          </template>
          <template v-else>
            <span class="node-title-text">{{ data.name }}</span>
          </template>

          <!-- 节点操作按钮组 -->
          <div class="node-actions">
            <a-button class="action-icon" type="link" :icon="h(PlusOutlined)" @click.stop="handleAddChild(data)" style="width: 14px" />
            <a-button class="action-icon" type="link" :icon="h(EditOutlined)" @click.stop="handleEdit(data)" style="width: 14px" />
            <a-button class="action-icon" type="link" :icon="h(DeleteOutlined)" @click.stop="handleDelete(data)" style="width: 14px" />
          </div>
        </div>
      </template>
    </a-tree>

    <!-- 删除确认弹窗 -->
    <a-modal v-model:open="showDeleteConfirm" ok-text="确认" cancel-text="取消" @ok="handleDeleteConfirm">
      <template #title>
        <div style="display: flex; align-items: center; gap: 8px">
          <ExclamationCircleOutlined style="color: orange; font-size: 20px" />
          <span>提示</span>
        </div>
      </template>
      <h2 style="margin: 30px">确认删除该文件夹吗?</h2>
    </a-modal>

    <!-- 有子节点提示弹窗 -->
    <a-modal v-model:open="showHasChildrenAlert" :cancelButtonProps="{ style: { display: 'none' } }" @ok="showHasChildrenAlert = false">
      <template #title>
        <div style="display: flex; align-items: center; gap: 8px">
          <ExclamationCircleOutlined style="color: red; font-size: 20px" />
          <span>提示</span>
        </div>
      </template>
      <h2 style="margin: 30px">该目录下有子目录或者文件,不能删除 !</h2>
    </a-modal>
  </a-card>
</template>

<script lang="ts" setup>
  // 扩展树节点类型,增加操作所需的属性
  interface TreeNode {
    name: string;
    id: string;
    children?: TreeNode[];
    isEditing?: boolean; // 当前节点的编辑状态
    parentId: string; // 父节点id
    projectId: string; // 项目id,该节点输入哪个项目下
  }

  import { h, nextTick, reactive, ref, watch } from 'vue';
  import { useAppStore } from '@/store/modules/app';
  import { message } from 'ant-design-vue';
  import {
    ExclamationCircleOutlined,
    FolderAddOutlined,
    PlusOutlined,
    FolderOpenOutlined,
    FolderOutlined,
    EditOutlined,
    DeleteOutlined,
  } from '@ant-design/icons-vue';

  const appStore = useAppStore();
  // 映射treeData与tree组件节点字段名
  const fieldNames = ref({
    title: 'name',
    key: 'id',
    children: 'children',
  });
  // 初始数据,用于测试
  const treeData: TreeNode[] = reactive([
    {
      name: 'parent1',
      id: '0-0',
      parentId: '0',
      projectId: '123456',
      children: [
        { name: 'parent1-son1', id: '0-0-0', parentId: '0-0', projectId: '123456' },
        { name: 'parent1-son2', id: '0-0-1', parentId: '0-0', projectId: '123456' },
      ],
    },
    {
      name: 'parent2',
      id: '0-1',
      parentId: '0',
      projectId: '654321',
      children: [
        { name: 'parent2-son1', id: '0-1-0', parentId: '0-1', projectId: '654321' },
        { name: 'parent2-son2', id: '0-1-1', parentId: '0-1', projectId: '654321' },
      ],
    },
  ]);

  const selectedKeys = ref<string[]>([]); // 当前选中的节点
  const expandedKeys = ref<string[]>([]); // 展开的节点

  const showDeleteConfirm = ref(false); // 删除确认弹窗
  const currentDeleteNode = ref<TreeNode | null>(); // 当前删除的节点
  const showHasChildrenAlert = ref(false); // 有子节点提示弹窗

  const isEdit = ref<boolean>(false); // 是否处于编辑状态,由此状态选择api是新增还是修改
  // 生成唯一key
  const generateKey = () => {
    return Date.now().toString().slice(-4);
  };

  // 新增根节点
  const addRootNode = () => {
    resetEditState();
    // 添加一个临时的新节点
    const newKey = generateKey();
    const node = {
      name: '未命名文件夹',
      id: newKey,
      isEditing: true,
      children: [],
      parentId: '0',
      projectId: appStore.getProjectId,
    };
    treeData.push(node);
    focusInput(node);
  };

  // 处理新增子节点
  const handleAddChild = (node: TreeNode) => {
    resetEditState();
    // 添加一个临时的新节点
    const newKey = generateKey();
    const newNode = {
      name: '未命名文件夹',
      id: newKey,
      isEditing: true,
      parentId: node.id,
      projectId: node.projectId,
    };

    if (!node.children) {
      node.children = [];
    }
    node.children.push(newNode);
    // 如果父节点已展开,则直接聚焦到输入框
    if (expandedKeys.value.includes(node.id)) {
      focusInput(newNode);
    } else {
      // 展开父节点,展示新添加的子节点
      // 这里有bug,当父节点输入未展开状态时,此时聚焦到输入框不起作用
      expandedKeys.value.push(node.id);
      focusInput(newNode);
    }
  };
  // 编辑节点
  const handleEdit = (node: TreeNode) => {
    // 重置所有节点的编辑状态
    resetEditState();
    // 当前为编辑状态
    isEdit.value = true;
    // 设置当前节点为编辑状态
    node.isEditing = true;
    // 自动聚焦到输入框
    focusInput(node);
  };

  // 通过node,聚焦到已经变成输入框的元素
  const focusInput = (node: TreeNode) => {
    nextTick(() => {
      const inputElement = document.getElementsByClassName(node.id)[0] as HTMLInputElement | undefined;
      if (inputElement) {
        // 增加延迟确保DOM完全渲染
        setTimeout(() => {
          inputElement.focus();
          inputElement.select();
        }, 50);
      }
    });
  };

  // 确认编辑
  const handleEditConfirm = (node: TreeNode) => {
    if (!node.name?.trim()) {
      message.warn('文件夹的名称不能为空');
    } else if (node.name.length > 20) {
      message.warn('文件夹的名称不能超过20个字符');
    } else if (node.name.length < 2) {
      message.warn('文件夹的名称不能少于2个字符');
    } else {
      resetEditState();
      // Todo: 保存修改后的节点
    }
  };

  // 重置所有节点的编辑状态
  const resetEditState = () => {
    isEdit.value = false;
    const resetNode = (nodes) => {
      nodes.forEach((node) => {
        node.isEditing = false;
        node.editingText = '';
        if (node.children && node.children.length > 0) {
          resetNode(node.children);
        }
      });
    };
    resetNode(treeData);
  };

  // 处理删除节点
  const handleDelete = (node: TreeNode) => {
    // 检查是否有子节点
    if (node.children && node.children.length > 0) {
      showHasChildrenAlert.value = true;
      return;
    }
    // TODO: 判断该节点下是否还有文件,有文件或者有子目录则不能删除
    // 显示删除确认
    currentDeleteNode.value = node;
    showDeleteConfirm.value = true;
  };
  // 查找节点的父节点
  const findParentNode = (nodes: TreeNode[], targetId: string, parent: TreeNode | null = null): TreeNode | null => {
    for (const node of nodes) {
      if (node.id === targetId) {
        return parent;
      }
      if (node.children && node.children.length > 0) {
        const foundParent = findParentNode(node.children, targetId, node);
        if (foundParent) {
          return foundParent;
        }
      }
    }
    return null;
  };
  // 确认删除
  const handleDeleteConfirm = () => {
    if (!currentDeleteNode.value) return;

    // 查找父节点
    const parent = findParentNode(treeData, currentDeleteNode.value.id);

    if (parent && parent.children) {
      // 从父节点的 children 中删除该节点
      const index = parent.children.findIndex((child) => child.id === currentDeleteNode.value!.id);
      if (index !== -1) {
        parent.children.splice(index, 1);
      }
    } else {
      // 如果没有父节点,说明是根节点,直接从 treeData 中删除
      const index = treeData.findIndex((node) => node.id === currentDeleteNode.value!.id);
      if (index !== -1) {
        treeData.splice(index, 1);
      }
    }
    // TODO: 发送删除请求,并刷新树
    showDeleteConfirm.value = false;
    currentDeleteNode.value = null;
  };
  // 请求目录列表
  const getTreeList = async () => {
    // TODO: 请求目录列表
  };
  // 监听选中节点的变化
  watch(
    () => datumStore.selectNodeId,
    (newSelectedKeys, oldSelectedKeys) => {
      if (newSelectedKeys && newSelectedKeys.length > 0) {
        // 如果有新选中的节点,则展开它
        if (!expandedKeys.value.includes(newSelectedKeys[0])) {
          expandedKeys.value = [...expandedKeys.value, newSelectedKeys[0]];
        }
      } else if (!newSelectedKeys && oldSelectedKeys && oldSelectedKeys.length > 0) {
        // 如果新选中节点为空,而旧节点有值,则表示要折叠节点
        if (expandedKeys.value.includes(oldSelectedKeys[0])) {
          expandedKeys.value = expandedKeys.value.filter((key) => key !== oldSelectedKeys[0]);
        }
      }
    },
    { deep: true }
  );
</script>

<style scoped lang="less">
  .tree-container {
    padding: 20px;
    max-width: 600px;
    margin: 0 auto;
  }

  .add-root-btn {
    margin-bottom: 16px;
  }

  .node-content {
    display: flex;
    align-items: center;
    width: 100%;
    position: relative;
    padding-right: 100px; /* 为操作按钮预留空间 */
  }

  .node-title-text {
    flex: 1;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  .node-actions {
    display: flex;
    gap: 8px;
    opacity: 0;
    transition: opacity 0.3s;
    position: absolute;
    right: 0;
    top: 50%;
    transform: translateY(-50%);
  }

  .node-content:hover .node-actions {
    opacity: 1;
  }

  .action-icon {
    font-size: 12px;
  }

  /* 隐藏原始树节点的图标 */
  :deep(.ant-tree-node-icon) {
    display: none !important;
  }

  /* 调整输入框大小 */
  :deep(.ant-input-sm) {
    width: 160px;
  }
  /* 确保树节点标题区域正确对齐 */
  :deep(.ant-tree-node-content-wrapper) {
    display: flex;
    align-items: center;
  }
  /* 隐藏树节点的展开/折叠按钮 */
  :deep(.ant-tree-switcher) {
    display: none !important;
  }
</style>


网站公告

今日签到

点亮在社区的每一天
去签到