| | |
| | | :expand-on-click-node="false" |
| | | :filter-node-method="filterNode" |
| | | :props="props" |
| | | :lazy="false" |
| | | :load="undefined" |
| | | :render-after-expand="true" |
| | | :auto-expand-parent="true" |
| | | :indent="20" |
| | | class="custom-tree" |
| | | node-key="id" |
| | | @node-click="handleNodeClick" |
| | | @node-expand="handleNodeExpand" |
| | | > |
| | | <template #default="{ node, data }"> |
| | | <div |
| | | class="tree-node-content" |
| | | :data-temp-id="data._tempId" |
| | | :data-node-id="data.id" |
| | | @dblclick="headerDbClick(node, data)" |
| | | > |
| | | <div class="node-icon"> |
| | |
| | | }}</span> |
| | | <el-input |
| | | v-else |
| | | :ref="(el) => setInputRef(el, data)" |
| | | :ref="(el) => setInputRef(data.id || data._tempId, el)" |
| | | v-model="newName" |
| | | autofocus |
| | | class="tree-input" |
| | |
| | | size="small" |
| | | @blur="(event) => handleInputBlur(event, data, node)" |
| | | @keyup.enter="(event) => handleInputBlur(event, data, node)" |
| | | @keyup.esc="() => cancelEdit(data, node)" |
| | | /> |
| | | </div> |
| | | <div v-show="!data.isEdit" class="node-actions"> |
| | |
| | | icon="Plus" |
| | | link |
| | | size="small" |
| | | title="新增子节点" |
| | | :title="getNodeDepth(data) >= 7 ? '已达到最大嵌套层级(7层)' : '新增子节点'" |
| | | :disabled="getNodeDepth(data) >= 7" |
| | | @click.stop="append(data)" |
| | | ></el-button> |
| | | <el-button |
| | |
| | | </el-card> |
| | | </template> |
| | | <script setup> |
| | | import { nextTick, onMounted, reactive, ref } from "vue"; |
| | | import { computed, nextTick, onMounted, reactive, ref } from "vue"; |
| | | import ETable from "@/components/Table/ETable.vue"; |
| | | import { |
| | | ElButton, |
| | |
| | | label: "name", |
| | | children: "children", |
| | | isLeaf: "leaf", |
| | | }; |
| | | |
| | | // ===== 计算属性 ===== |
| | | // 计算总节点数 |
| | | const totalNodeCount = computed(() => { |
| | | const countNodes = (nodes) => { |
| | | let count = 0; |
| | | for (const node of nodes || []) { |
| | | count += 1; |
| | | if (node.children) { |
| | | count += countNodes(node.children); |
| | | } |
| | | } |
| | | return count; |
| | | }; |
| | | return countNodes(treeData.value); |
| | | }); |
| | | |
| | | // 检查是否为大量节点(超过1000个节点时提示性能优化) |
| | | const isLargeTree = computed(() => totalNodeCount.value > 1000); |
| | | |
| | | // 获取节点深度的函数 |
| | | const getNodeDepth = (nodeData) => { |
| | | if (!nodeData || !nodeData.id) return 0; |
| | | |
| | | let depth = 1; |
| | | const node = treeRef.value?.getNode(nodeData.id); |
| | | let parentNode = node?.parent; |
| | | |
| | | while (parentNode && parentNode.data && parentNode.data.id) { |
| | | depth++; |
| | | parentNode = parentNode.parent; |
| | | } |
| | | |
| | | return depth; |
| | | }; |
| | | |
| | | // ===== 工具函数 ===== |
| | |
| | | getArchiveListData(); |
| | | }; |
| | | |
| | | // 节点展开事件处理 |
| | | const handleNodeExpand = (data, node, instance) => { |
| | | // 展开后稍微延迟,确保子节点渲染完成 |
| | | setTimeout(() => { |
| | | // 如果有新添加的编辑状态节点,聚焦到它 |
| | | if (data.children && data.children.length > 0) { |
| | | const editingChild = data.children.find(child => child.isEdit && child._tempId); |
| | | if (editingChild) { |
| | | focusInput(editingChild._tempId, 200); |
| | | } |
| | | } |
| | | }, 100); |
| | | }; |
| | | |
| | | const handlePageChange = (pagination) => { |
| | | try { |
| | | const { page, limit } = pagination; |
| | |
| | | } |
| | | }; |
| | | // ===== 树节点编辑函数 ===== |
| | | const setInputRef = (el, data) => { |
| | | if (el) { |
| | | inputRefs.value.set(data.id || data, el); |
| | | const setInputRef = (key, el) => { |
| | | if (el && key) { |
| | | inputRefs.value.set(key, el); |
| | | } |
| | | }; |
| | | |
| | | const headerDbClick = (node, data) => { |
| | | data.isEdit = true; |
| | | newName.value = data.name; |
| | | nextTick(() => { |
| | | const inputEl = inputRefs.value.get(data.id || data); |
| | | if (inputEl) { |
| | | inputEl.focus(); |
| | | inputEl.select(); |
| | | try { |
| | | data.isEdit = true; |
| | | newName.value = data.name; |
| | | |
| | | nextTick(() => { |
| | | const key = data._tempId || data.id; |
| | | if (key) { |
| | | focusInput(key, 50); |
| | | } |
| | | }); |
| | | } catch (error) { |
| | | console.error('进入编辑模式失败:', error); |
| | | ElMessage.error('进入编辑模式失败'); |
| | | } |
| | | }; |
| | | |
| | | // 取消编辑功能 |
| | | const cancelEdit = (data, node) => { |
| | | try { |
| | | data.isEdit = false; |
| | | |
| | | // 如果是新创建的临时节点,删除它 |
| | | if (data._tempId && !data.id) { |
| | | if (node.parent) { |
| | | const parent = node.parent.data; |
| | | const index = parent.children?.indexOf(data); |
| | | if (index > -1) { |
| | | parent.children.splice(index, 1); |
| | | } |
| | | } else { |
| | | // 根节点 |
| | | const index = treeData.value.indexOf(data); |
| | | if (index > -1) { |
| | | treeData.value.splice(index, 1); |
| | | } |
| | | } |
| | | |
| | | // 清理输入框引用 |
| | | const key = data._tempId; |
| | | if (inputRefs.value.has(key)) { |
| | | inputRefs.value.delete(key); |
| | | } |
| | | } |
| | | }); |
| | | |
| | | // 重置名称 |
| | | newName.value = ""; |
| | | } catch (error) { |
| | | console.error('取消编辑失败:', error); |
| | | } |
| | | }; |
| | | |
| | | const expandParentNodes = (node) => { |
| | |
| | | comeTreeData.isEdit = false; |
| | | const newValue = newName.value.trim(); |
| | | |
| | | if (comeTreeData.name === newValue) return; |
| | | |
| | | // 如果名称为空,处理空名称情况 |
| | | if (!newValue) { |
| | | newName.value = comeTreeData.name || "新节点"; |
| | | // 如果是新创建的临时节点,删除它 |
| | | if (comeTreeData._tempId && !comeTreeData.id) { |
| | | cancelEdit(comeTreeData, node); |
| | | } else { |
| | | // 已存在的节点,恢复原名称 |
| | | newName.value = comeTreeData.name || "新节点"; |
| | | } |
| | | ElMessage.warning("节点名称不能为空"); |
| | | return; |
| | | } |
| | | |
| | | // 如果名称没有改变,直接返回 |
| | | if (comeTreeData.name === newValue) { |
| | | return; |
| | | } |
| | | |
| | |
| | | comeTreeData.name = newValue; |
| | | if (!comeTreeData.id && result.data) { |
| | | comeTreeData.id = result.data.id || result.data; |
| | | // 清理临时ID |
| | | if (comeTreeData._tempId) { |
| | | const tempKey = comeTreeData._tempId; |
| | | delete comeTreeData._tempId; |
| | | |
| | | // 更新引用映射 |
| | | if (inputRefs.value.has(tempKey)) { |
| | | const inputEl = inputRefs.value.get(tempKey); |
| | | inputRefs.value.delete(tempKey); |
| | | if (comeTreeData.id && inputEl) { |
| | | inputRefs.value.set(comeTreeData.id, inputEl); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | showSuccess("保存成功"); |
| | | |
| | |
| | | if (currentNodeId && treeRef.value) { |
| | | const targetNode = treeRef.value.getNode(currentNodeId); |
| | | if (targetNode) { |
| | | // 展开当前节点 |
| | | targetNode.expanded = true; |
| | | |
| | | // 展开所有父节点 |
| | | expandParentNodes(targetNode); |
| | | |
| | | // 滚动到节点位置 |
| | | setTimeout(() => { |
| | | const nodeElement = document.querySelector(`[data-node-id="${currentNodeId}"]`); |
| | | if (nodeElement) { |
| | | nodeElement.scrollIntoView({ |
| | | behavior: 'smooth', |
| | | block: 'center' |
| | | }); |
| | | } |
| | | }, 300); |
| | | } |
| | | } |
| | | }); |
| | | } else { |
| | | comeTreeData.name = comeTreeData.name || "新节点"; |
| | | // 保存失败,恢复原名称或删除临时节点 |
| | | if (comeTreeData._tempId && !comeTreeData.id) { |
| | | cancelEdit(comeTreeData, node); |
| | | } else { |
| | | comeTreeData.name = comeTreeData.name || "新节点"; |
| | | } |
| | | ElMessage.error("保存失败: " + (result.msg || "未知错误")); |
| | | } |
| | | } catch (error) { |
| | | handleError(error, "保存节点失败"); |
| | | comeTreeData.name = comeTreeData.name || "新节点"; |
| | | // 出错时处理临时节点 |
| | | if (comeTreeData._tempId && !comeTreeData.id) { |
| | | cancelEdit(comeTreeData, node); |
| | | } else { |
| | | comeTreeData.name = comeTreeData.name || "新节点"; |
| | | } |
| | | } |
| | | }; |
| | | |
| | |
| | | const createNewNode = (name, isEdit = true) => ({ |
| | | name, |
| | | isEdit, |
| | | _tempId: Date.now() + Math.random(), // 添加临时ID |
| | | }); |
| | | |
| | | const focusInput = (nodeData, delay = 50) => { |
| | | const focusInput = (nodeKey, delay = 100) => { |
| | | setTimeout(() => { |
| | | const inputEl = inputRefs.value.get(nodeData.id || nodeData); |
| | | const inputEl = inputRefs.value.get(nodeKey); |
| | | if (inputEl) { |
| | | inputEl.focus(); |
| | | inputEl.select(); |
| | | inputEl.$el?.scrollIntoView?.({ |
| | | behavior: "smooth", |
| | | block: "nearest", |
| | | }); |
| | | try { |
| | | // 先滚动到可视区域 |
| | | inputEl.$el?.scrollIntoView?.({ |
| | | behavior: "smooth", |
| | | block: "center", |
| | | }); |
| | | |
| | | // 聚焦并选中所有文本,确保可以直接编辑 |
| | | setTimeout(() => { |
| | | inputEl.focus(); |
| | | inputEl.select(); |
| | | |
| | | // 确保光标在输入框末尾(如果select失败的话) |
| | | const inputElement = inputEl.ref || inputEl.input || inputEl.$el?.querySelector('input'); |
| | | if (inputElement) { |
| | | inputElement.setSelectionRange(0, inputElement.value.length); |
| | | |
| | | // 确保输入框在视口中央 |
| | | setTimeout(() => { |
| | | inputElement.scrollIntoView({ |
| | | behavior: 'smooth', |
| | | block: 'center' |
| | | }); |
| | | }, 100); |
| | | } |
| | | }, 100); |
| | | } catch (error) { |
| | | console.warn('聚焦输入框失败:', error); |
| | | // 备用方案:直接聚焦 |
| | | try { |
| | | inputEl.focus(); |
| | | // 尝试选中文本 |
| | | setTimeout(() => { |
| | | const inputElement = inputEl.ref || inputEl.input || inputEl.$el?.querySelector('input'); |
| | | if (inputElement) { |
| | | inputElement.select(); |
| | | } |
| | | }, 200); |
| | | } catch (e) { |
| | | console.warn('备用聚焦方案也失败:', e); |
| | | } |
| | | } |
| | | } else { |
| | | console.warn('未找到输入框元素,key:', nodeKey); |
| | | } |
| | | }, delay); |
| | | }; |
| | | |
| | | const append = async (data) => { |
| | | if (data === "") { |
| | | // 新增根节点 |
| | | const newNode = createNewNode("新节点"); |
| | | treeData.value.push(newNode); |
| | | newName.value = "新节点"; |
| | | try { |
| | | // 检查嵌套层级限制 |
| | | const getNodeDepth = (nodeData, currentDepth = 1) => { |
| | | if (!nodeData || data === "") return 0; // 根节点不算层级 |
| | | |
| | | let depth = currentDepth; |
| | | let current = nodeData; |
| | | |
| | | // 通过树组件获取父节点来计算深度 |
| | | if (current.id) { |
| | | const node = treeRef.value?.getNode(current.id); |
| | | let parentNode = node?.parent; |
| | | |
| | | while (parentNode && parentNode.data && parentNode.data.id) { |
| | | depth++; |
| | | parentNode = parentNode.parent; |
| | | } |
| | | } |
| | | |
| | | return depth; |
| | | }; |
| | | |
| | | nextTick(() => focusInput(newNode)); |
| | | } else { |
| | | const hasChildren = data.children; |
| | | const nodeKey = data.id || data; |
| | | const node = treeRef.value?.getNode(nodeKey); |
| | | const isExpanded = node?.expanded; |
| | | |
| | | // 如果有子级且未展开,先展开节点 |
| | | if (hasChildren && !isExpanded && treeRef.value?.store?.nodesMap[nodeKey]) { |
| | | treeRef.value.store.nodesMap[nodeKey].expanded = true; |
| | | const currentDepth = getNodeDepth(data); |
| | | |
| | | // 限制最多7层嵌套 |
| | | if (currentDepth >= 7) { |
| | | ElMessage.warning('最多只能嵌套7层节点,当前已达到最大层级限制'); |
| | | return; |
| | | } |
| | | |
| | | const newNode = createNewNode("新子节点"); |
| | | |
| | | if (!data.children) { |
| | | data.children = []; |
| | | // 在大量节点时提示性能注意事项 |
| | | if (isLargeTree.value && totalNodeCount.value > 2000) { |
| | | const confirmed = await ElMessageBox.confirm( |
| | | `当前树结构包含 ${totalNodeCount.value} 个节点,节点较多可能影响性能。建议考虑分层管理。是否继续添加?`, |
| | | '性能提示', |
| | | { |
| | | confirmButtonText: '继续添加', |
| | | cancelButtonText: '取消', |
| | | type: 'warning', |
| | | } |
| | | ).catch(() => false); |
| | | |
| | | if (!confirmed) return; |
| | | } |
| | | data.children.push(newNode); |
| | | newName.value = "新子节点"; |
| | | |
| | | const delay = hasChildren && !isExpanded ? 200 : 50; |
| | | nextTick(() => focusInput(newNode, delay)); |
| | | if (data === "") { |
| | | // 新增根节点 |
| | | const newNode = createNewNode("新节点"); |
| | | treeData.value.push(newNode); |
| | | newName.value = "新节点"; |
| | | |
| | | await nextTick(); |
| | | focusInput(newNode._tempId, 200); |
| | | } else { |
| | | const hasChildren = data.children && data.children.length > 0; |
| | | const nodeKey = data.id || data; |
| | | const node = treeRef.value?.getNode(nodeKey); |
| | | |
| | | // 创建新子节点 |
| | | const newNode = createNewNode("新子节点"); |
| | | |
| | | if (!data.children) { |
| | | data.children = []; |
| | | } |
| | | data.children.push(newNode); |
| | | newName.value = "新子节点"; |
| | | |
| | | // 强制更新树结构 |
| | | await nextTick(); |
| | | |
| | | // 确保父节点展开以显示新节点 |
| | | if (node) { |
| | | node.expanded = true; |
| | | |
| | | // 如果是第一次添加子节点,等待展开动画并确保可见 |
| | | if (!hasChildren) { |
| | | await new Promise(resolve => setTimeout(resolve, 300)); |
| | | |
| | | // 展开后滚动到新节点位置 |
| | | setTimeout(() => { |
| | | const newNodeElement = document.querySelector(`[data-temp-id="${newNode._tempId}"]`); |
| | | if (newNodeElement) { |
| | | newNodeElement.scrollIntoView({ |
| | | behavior: 'smooth', |
| | | block: 'center' |
| | | }); |
| | | } |
| | | }, 100); |
| | | } |
| | | |
| | | // 展开所有父节点确保完全可见 |
| | | let parentNode = node.parent; |
| | | while (parentNode && parentNode.data && parentNode.data.id) { |
| | | parentNode.expanded = true; |
| | | parentNode = parentNode.parent; |
| | | } |
| | | } |
| | | |
| | | // 聚焦到新创建的输入框 |
| | | const delay = hasChildren ? 150 : 500; // 如果之前没有子节点,延迟更长时间等展开 |
| | | focusInput(newNode._tempId, delay); |
| | | } |
| | | } catch (error) { |
| | | console.error('新增节点失败:', error); |
| | | ElMessage.error('新增节点失败,请重试'); |
| | | } |
| | | }; |
| | | |
| | |
| | | border-radius: 8px; |
| | | background: #fff; |
| | | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); |
| | | max-height: calc(100vh - 240px); // 限制最大高度,启用滚动 |
| | | |
| | | .custom-tree { |
| | | padding: 8px; |
| | | background: transparent; |
| | | // 使用GPU加速提升滚动性能 |
| | | transform: translateZ(0); |
| | | will-change: scroll-position; |
| | | |
| | | :deep(.el-tree-node) { |
| | | // 减少不必要的重绘 |
| | | contain: layout style; |
| | | |
| | | .el-tree-node__content { |
| | | height: 36px; |
| | | padding: 0 8px; |
| | | border-radius: 6px; |
| | | margin: 2px 0; |
| | | transition: all 0.2s ease; |
| | | // 优化渲染性能 |
| | | will-change: background-color; |
| | | |
| | | &:hover { |
| | | background-color: #f0f9ff; |
| | |
| | | color: #909399; |
| | | min-height: auto; |
| | | |
| | | &:hover { |
| | | &:hover:not(:disabled) { |
| | | color: #1890ff; |
| | | background-color: #f0f9ff; |
| | | } |
| | | |
| | | &.el-button--text:hover { |
| | | &.el-button--text:hover:not(:disabled) { |
| | | background-color: #f0f9ff; |
| | | } |
| | | |
| | | &:disabled { |
| | | color: #c0c4cc; |
| | | cursor: not-allowed; |
| | | background-color: transparent; |
| | | } |
| | | } |
| | | } |
| | |
| | | |
| | | :deep(.el-input__wrapper) { |
| | | border-radius: 4px; |
| | | border: 1px solid #d9d9d9; |
| | | border: 1px solid #40a9ff; |
| | | transition: all 0.2s ease; |
| | | box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1); |
| | | |
| | | &:hover { |
| | | border-color: #40a9ff; |
| | | border-color: #1890ff; |
| | | box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.15); |
| | | } |
| | | |
| | | &.is-focus { |
| | |
| | | padding: 4px 8px; |
| | | font-size: 14px; |
| | | color: #303133; |
| | | background-color: #fff; |
| | | |
| | | &::placeholder { |
| | | color: #bfbfbf; |
| | | } |
| | | |
| | | &:focus { |
| | | background-color: #f8fcff; |
| | | } |
| | | } |
| | | } |
| | | |