使用 Ant Design Vue 的tree结构实现目录管理
- 取消了展开和折叠图标,使得有些操作比较诡异,如果需要展开和折叠图标,删除css中的最后一段代码,有注释
- 通过用户选中和不选中两个状态决定图标的使用
- 在有子节点,并且未展开状态时,给父节点增加子节点时聚焦到新增节点会失败,原因未知,希望有大神解决后告知一下
- 代码可以直接使用,当组件引入即可
- 视频中宽度发生变化,是因为我宽度设置的不够,宽度足够的情况是不会有变化的
@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>