使用vue3加antd-design实现树形控件(三级分类)
主要内容包括:
- 使用a-tree组件构建树形结构,支持三级分类展示
- 实现分类的增删改查功能:
- 通过右键菜单提供创建、编辑、删除操作
- 使用模态框进行表单交互
- 添加搜索功能,可过滤分类并自动展开匹配节点
- 响应式设计:
- 动态计算分类层级
- 实时更新树形数据
- 自动展开/收起节点
<template> <div class="cbox"> <div style="display:flex;align-items:center;justify-content: space-between"> <span style="font-size: 15px;">创建分类</span> <PlusOutlined @click="openCreateModal(null, 1)" style="cursor: pointer" /> </div> <div> <a-input-search v-model:value="searchValue" allowClear style="width: 100%; margin: 10px 0px" placeholder="搜索分类" @input="onSearch" /> </div> <!-- 使用 Ant Design Tree 组件 --> <a-tree :tree-data="filteredTreeData" :expanded-keys="expandedKeys" :selected-keys="selectedKeys" @expand="onExpand" @select="onSelect" block-node > <template #title="{ node }"> <div class="tree-node-title"> <span>{{ node.title }}</span> <a-dropdown :trigger="['click']" placement="bottomRight"> <MoreOutlined class="node-actions-icon" @click.stop /> <template #overlay> <a-menu @click="({ key }) => handleMenuAction(key, node)"> <a-menu-item key="create" v-if="node.level < 3">创建{{ getSubLevelName(node.level) }}分类</a-menu-item> <a-menu-item key="edit">编辑</a-menu-item> <a-menu-item key="delete">删除</a-menu-item> </a-menu> </template> </a-dropdown> </div> </template> </a-tree> <!-- 创建/编辑弹窗 --> <a-modal v-model:visible="modalVisible" :title="modalTitle" @ok="handleModalOk" @cancel="handleModalCancel" > <a-form layout="vertical"> <a-form-item label="分类名称" required> <a-input v-model:value="formData.name" placeholder="请输入分类名称" /> </a-form-item> </a-form> </a-modal> </div> </template> <script lang="ts" setup> import { ref, computed } from 'vue'; import { PlusOutlined, MoreOutlined } from '@ant-design/icons-vue'; import { message, TreeProps } from 'ant-design-vue'; interface TreeNode { key: string; title: string; level: number; parentKey?: string; children?: TreeNode[]; scopedSlots?: { title: string }; } const treeData = ref<TreeNode[]>([ { key: '1', title: '全部分类 (13)', level: 1, scopedSlots: { title: 'title' }, children: [ { key: '1-1', title: '未分组 (13)', level: 2, parentKey: '1', scopedSlots: { title: 'title' }, }, { key: '1-2', title: '分类1 (0)', level: 2, parentKey: '1', scopedSlots: { title: 'title' }, children: [ { key: '1-2-1', title: '子分类1 (0)', level: 3, parentKey: '1-2', scopedSlots: { title: 'title' }, } ] } ] } ]); const searchValue = ref(''); const expandedKeys = ref<string[]>(['1']); const selectedKeys = ref<string[]>([]); const modalVisible = ref(false); const modalTitle = ref(''); const formData = ref({ name: '' }); const currentEditNode = ref<TreeNode | null>(null); const currentParentNode = ref<TreeNode | null>(null); const createLevel = ref(1); const getSubLevelName = (level: number) => { if (level === 1) return '二级'; if (level === 2) return '三级'; return ''; }; const handleMenuAction = (action: string, node: TreeNode) => { if (action === 'create') { openCreateModal(node, node.level + 1); } else if (action === 'edit') { openEditModal(node); } else if (action === 'delete') { handleDelete(node); } }; const openCreateModal = (parentNode: TreeNode | null, level: number) => { if (level > 3) { message.warning('最多支持三级分类'); return; } currentParentNode.value = parentNode; currentEditNode.value = null; createLevel.value = level; modalTitle.value = `创建${level === 2 ? '二级' : level === 3 ? '三级' : ''}分类`; formData.value.name = ''; modalVisible.value = true; }; const openEditModal = (node: TreeNode) => { currentEditNode.value = node; modalTitle.value = '编辑分类'; formData.value.name = node.title.replace(/\(\d+\)$/, '').trim(); modalVisible.value = true; }; const handleDelete = (node: TreeNode) => { const deleteNode = (nodes: TreeNode[], key: string): boolean => { for (let i = 0; i < nodes.length; i++) { if (nodes[i].key === key) { nodes.splice(i, 1); return true; } if (nodes[i].children && deleteNode(nodes[i].children, key)) { return true; } } return false; }; const newData = [...treeData.value]; deleteNode(newData, node.key); treeData.value = newData; message.success('删除成功'); }; const handleModalOk = () => { if (!formData.value.name.trim()) { message.warning('请输入分类名称'); return; } if (currentEditNode.value) { // 编辑逻辑 const updateNode = (nodes: TreeNode[]): boolean => { for (let i = 0; i < nodes.length; i++) { if (nodes[i].key === currentEditNode.value!.key) { const countMatch = nodes[i].title.match(/\((\d+)\)$/); nodes[i].title = countMatch ? `${formData.value.name} (${countMatch[1]})` : formData.value.name; return true; } if (nodes[i].children && updateNode(nodes[i].children)) { return true; } } return false; }; const newData = [...treeData.value]; updateNode(newData); treeData.value = newData; message.success('编辑成功'); } else { // 创建逻辑 const newNode: TreeNode = { key: `key_${Date.now()}_${Math.random()}`, title: `${formData.value.name} (0)`, level: createLevel.value, scopedSlots: { title: 'title' }, children: createLevel.value === 3 ? undefined : [], }; if (!currentParentNode.value) { // 根节点创建 treeData.value = [...treeData.value, newNode]; } else { // 子节点创建 const addToParent = (nodes: TreeNode[]): boolean => { for (let i = 0; i < nodes.length; i++) { if (nodes[i].key === currentParentNode.value!.key) { if (!nodes[i].children) nodes[i].children = []; nodes[i].children!.push(newNode); // 自动展开父节点 if (!expandedKeys.value.includes(nodes[i].key)) { expandedKeys.value.push(nodes[i].key); } return true; } if (nodes[i].children && addToParent(nodes[i].children)) { return true; } } return false; }; const newData = [...treeData.value]; addToParent(newData); treeData.value = newData; } message.success('创建成功'); } modalVisible.value = false; }; const handleModalCancel = () => { modalVisible.value = false; }; const onExpand = (keys: string[]) => { expandedKeys.value = keys; }; const onSelect = (keys: string[]) => { selectedKeys.value = keys; }; // 搜索过滤 const onSearch = () => { if (!searchValue.value) { expandedKeys.value = ['1']; return; } // 展开所有匹配的节点路径 const getAllParentKeys = (nodes: TreeNode[], keyword: string): string[] => { let keys: string[] = []; for (const node of nodes) { if (node.title.toLowerCase().includes(keyword.toLowerCase())) { keys.push(node.key); let parent = findParentNode(treeData.value, node.key); while (parent) { keys.push(parent.key); parent = findParentNode(treeData.value, parent.key); } } if (node.children) { keys = [...keys, ...getAllParentKeys(node.children, keyword)]; } } return [...new Set(keys)]; }; const findParentNode = (nodes: TreeNode[], childKey: string): TreeNode | null => { for (const node of nodes) { if (node.children?.some(child => child.key === childKey)) { return node; } if (node.children) { const found = findParentNode(node.children, childKey); if (found) return found; } } return null; }; if (searchValue.value) { expandedKeys.value = getAllParentKeys(treeData.value, searchValue.value); } }; const filteredTreeData = computed(() => { if (!searchValue.value) return treeData.value; const filterNodes = (nodes: TreeNode[]): TreeNode[] => { return nodes.reduce<TreeNode[]>((acc, node) => { const matches = node.title.toLowerCase().includes(searchValue.value.toLowerCase()); let filteredChildren: TreeNode[] = []; if (node.children) { filteredChildren = filterNodes(node.children); } if (matches || filteredChildren.length > 0) { acc.push({ ...node, children: filteredChildren.length > 0 ? filteredChildren : node.children, }); } return acc; }, []); }; return filterNodes(treeData.value); }); </script> <style scoped lang="scss"> .cbox { padding: 16px; background: #fff; border-radius: 8px; } .tree-node-title { display: flex; align-items: center; justify-content: space-between; width: 100%; padding-right: 8px; .node-actions-icon { opacity: 0; transition: opacity 0.2s; cursor: pointer; font-size: 14px; color: #999; &:hover { color: #1890ff; } } &:hover .node-actions-icon { opacity: 1; } } :deep(.ant-tree-node-content-wrapper) { width: 100%; } </style>