Browse Source

feat: 新增支持右键自定义菜单进行节点编辑的树形组件. #569

lt5227 4 months ago
parent
commit
5a00171c9a

+ 12 - 1
.vscode/settings.json

@@ -15,5 +15,16 @@
   "i18n-ally.sourceLanguage": "en",
   "i18n-ally.displayLanguage": "zh-CN",
   "i18n-ally.enabledFrameworks": ["vue", "react"],
-  "god.tsconfig": "./tsconfig.json"
+  "god.tsconfig": "./tsconfig.json",
+  "editor.gotoLocation.alternativeDeclarationCommand": "editor.action.revealDefinition",
+  "editor.gotoLocation.alternativeDefinitionCommand": "editor.action.revealDefinition",
+  "editor.gotoLocation.alternativeTypeDefinitionCommand": "editor.action.revealDefinition",
+  "editor.selectionHighlight": false,
+  "files.autoSave": "onFocusChange",
+  "editor.suggest.snippetsPreventQuickSuggestions": false,
+  "editor.quickSuggestions": {
+    "other": "on",
+    "comments": "off",
+    "strings": "on"
+  }
 }

+ 8 - 0
mock/role/index.mock.ts

@@ -348,6 +348,14 @@ const adminList = [
         meta: {
           title: 'router.iAgree'
         }
+      },
+      {
+        path: 'tree',
+        component: 'views/Components/Tree',
+        name: 'Tree',
+        meta: {
+          title: 'router.tree'
+        }
       }
     ]
   },

+ 3 - 0
src/components/Tree/index.ts

@@ -0,0 +1,3 @@
+import Tree from './src/Tree.vue'
+
+export { Tree }

+ 147 - 0
src/components/Tree/src/Tree.vue

@@ -0,0 +1,147 @@
+<script lang="tsx" setup>
+import { defineProps, defineEmits, ref, CSSProperties } from 'vue'
+import { ElTree } from 'element-plus'
+
+interface TreeProps {
+  data: any[]
+  treeProps?: Record<string, any>
+  width?: string
+  height?: string
+}
+const props = defineProps<TreeProps>()
+
+const emit = defineEmits<{
+  (e: 'node-click', nodeData: any): void
+  (e: 'node-expand', nodeData: any): void
+  (e: 'node-collapse', nodeData: any): void
+}>()
+
+const treeContainer = ref<any>(null)
+const showTreeMenu = ref(false)
+const contextNode = ref<any>(null)
+
+const menuStyle = ref<any>({})
+
+const defaultWidth = '300px'
+const defaultHeight = '400px'
+
+// 关闭菜单
+const closeTreeMenu = () => {
+  showTreeMenu.value = false
+  document.removeEventListener('click', closeTreeMenu)
+  document.removeEventListener('contextmenu', closeTreeMenu)
+}
+
+// 右键菜单事件处理函数
+const openTreeMenu = (event: MouseEvent, data: any, _node: any, _target: HTMLElement) => {
+  contextNode.value = data
+  if (!treeContainer.value) return
+
+  const containerRect = treeContainer.value.getBoundingClientRect()
+  const nodeRect = (event.target as HTMLElement).getBoundingClientRect()
+
+  // 计算菜单相对于父容器定位的坐标
+  const top = nodeRect.top - containerRect.top + treeContainer.value.scrollTop
+  const left = nodeRect.left - containerRect.left + treeContainer.value.scrollLeft
+
+  menuStyle.value = {
+    position: 'absolute',
+    top: `${top + 20}px`,
+    left: `${left + 20}px`
+  }
+
+  showTreeMenu.value = true
+
+  // 点击其他地方或再次右键关闭菜单
+  document.addEventListener('click', closeTreeMenu)
+  document.addEventListener('contextmenu', closeTreeMenu)
+}
+
+// 节点点击事件
+const handleNodeClick = (data: any) => {
+  emit('node-click', data)
+  closeTreeMenu()
+}
+
+// 节点展开事件
+const handleNodeExpand = (data: any) => {
+  emit('node-expand', data)
+  closeTreeMenu()
+}
+
+// 节点关闭事件
+const handleNodeCollapse = (data: any) => {
+  emit('node-collapse', data)
+  closeTreeMenu()
+}
+
+// 计算容器样式
+const containerStyle: CSSProperties = {
+  position: 'relative',
+  overflow: 'auto',
+  width: props.width ?? defaultWidth,
+  height: props.height ?? defaultHeight
+}
+</script>
+<template>
+  <div class="tree-container" ref="treeContainer" :style="containerStyle">
+    <ElTree
+      v-bind="treeProps"
+      :data="data"
+      @node-click="handleNodeClick"
+      @node-expand="handleNodeExpand"
+      @node-collapse="handleNodeCollapse"
+      @node-contextmenu="openTreeMenu"
+    >
+      <template #default="{ node }">
+        <!-- 如果使用者提供了 render-node slot,则渲染使用者的内容 -->
+        <template v-if="$slots['render-node']">
+          <slot name="render-node" :node="node"></slot>
+        </template>
+        <!-- 否则使用默认节点显示(比如使用 node.label )-->
+        <template v-else>
+          <span>{{ node.label }}</span>
+        </template>
+      </template>
+    </ElTree>
+    <div class="treeMenu" v-show="showTreeMenu" :style="menuStyle">
+      <!-- 用户通过 context-menu slot 来自定义菜单内容 -->
+      <slot name="context-menu" :node="contextNode" :data="contextNode">
+        <!-- 如果用户不提供 context-menu slot,可给一个默认内容 -->
+        <div style="padding: 8px">No menu defined</div>
+      </slot>
+    </div>
+    <slot></slot>
+  </div>
+</template>
+<style scoped lang="less">
+.treeMenu {
+  position: absolute;
+  padding: 5px;
+  font-size: 14px;
+  color: #606266;
+  background-color: rgb(255 255 255 / 90%);
+  border: 1px solid #dcdcdc;
+  border-radius: 5px;
+  box-shadow: 0 4px 10px rgb(0 0 0 / 40%);
+
+  /* 移除 overflow: hidden; 或尝试不使用负的 top 值 */
+
+  /* overflow: hidden; */
+
+  &::after {
+    position: absolute;
+
+    /* 将箭头向上移动到菜单外部 */
+    top: -6px;
+    left: 50%;
+    border-right: 6px solid transparent;
+    border-bottom: 6px solid rgb(206 194 194);
+
+    /* 创建一个向上的箭头 */
+    border-left: 6px solid transparent;
+    content: '';
+    transform: translateX(-50%);
+  }
+}
+</style>

+ 7 - 1
src/locales/en.ts

@@ -190,7 +190,8 @@ export default {
     personalCenter: 'Personal center',
     personal: 'Personal',
     avatars: 'Avatars',
-    iAgree: 'I agree'
+    iAgree: 'I agree',
+    tree: 'Tree'
   },
   permission: {
     hasPermission: 'Please set the operation permission value'
@@ -393,6 +394,11 @@ export default {
     logoStyle: 'Logo style',
     size: 'size config'
   },
+  treeDemo: {
+    treeTitle: 'Tree control (right-click node to customize menu options)',
+    message:
+      'The tree component is based on the secondary packaging of the tree component of ElementPlus'
+  },
   highlightDemo: {
     highlight: 'Highlight',
     message: 'The best time to plant a tree is ten years ago, followed by now.',

+ 6 - 1
src/locales/zh-CN.ts

@@ -186,7 +186,8 @@ export default {
     personalCenter: '个人中心',
     personal: '个人',
     avatars: '头像列表',
-    iAgree: '我同意'
+    iAgree: '我同意',
+    tree: 'Tree 树形控件'
   },
   permission: {
     hasPermission: '请设置操作权限值'
@@ -385,6 +386,10 @@ export default {
     logoStyle: 'logo样式',
     size: '大小配置'
   },
+  treeDemo: {
+    treeTitle: '树形控件(节点右键可自定义菜单选项)',
+    message: '基于 ElementPlus 的 Tree 组件二次封装'
+  },
   highlightDemo: {
     highlight: '高亮',
     message: '种一棵树最好的时间是十年前,其次就是现在。',

+ 252 - 0
src/views/Components/Tree.vue

@@ -0,0 +1,252 @@
+<script setup lang="tsx">
+import { Icon } from '@/components/Icon'
+import { Tree } from '@/components/Tree'
+import { ContentWrap } from '@/components/ContentWrap'
+import { useI18n } from 'vue-i18n'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { ref } from 'vue'
+
+const { t } = useI18n()
+const treeData = ref([
+  {
+    id: 1,
+    name: '北京',
+    children: [
+      {
+        id: 5,
+        name: '朝阳',
+        children: [
+          {
+            id: 17,
+            name: '双塔',
+            children: []
+          },
+          {
+            id: 18,
+            name: '龙城',
+            children: []
+          }
+        ]
+      },
+      {
+        id: 6,
+        name: '丰台',
+        children: [
+          {
+            id: 19,
+            name: '新村',
+            children: []
+          },
+          {
+            id: 20,
+            name: '大红门',
+            children: []
+          },
+          {
+            id: 21,
+            name: '长辛店',
+            children: [
+              {
+                id: 22,
+                name: '东山坡',
+                children: []
+              },
+              {
+                id: 23,
+                name: '北关',
+                children: []
+              },
+              {
+                id: 24,
+                name: '光明里',
+                children: []
+              },
+              {
+                id: 25,
+                name: '赵辛店',
+                children: []
+              },
+              {
+                id: 26,
+                name: '西峰寺',
+                children: []
+              }
+            ]
+          }
+        ]
+      },
+      {
+        id: 7,
+        name: '海淀',
+        children: []
+      },
+      {
+        id: 8,
+        name: '房山',
+        children: []
+      },
+      {
+        id: 10,
+        name: '顺义',
+        children: []
+      }
+    ]
+  },
+  {
+    id: 2,
+    name: '上海',
+    children: [
+      {
+        id: 11,
+        name: '黄埔',
+        children: []
+      },
+      {
+        id: 12,
+        name: '徐汇',
+        children: []
+      }
+    ]
+  },
+  {
+    id: 3,
+    name: '广州',
+    children: [
+      {
+        id: 13,
+        name: '荔湾',
+        children: []
+      },
+      {
+        id: 14,
+        name: '白云',
+        children: []
+      },
+      {
+        id: 15,
+        name: '越秀',
+        children: []
+      },
+      {
+        id: 16,
+        name: '南沙',
+        children: []
+      }
+    ]
+  }
+])
+
+const handleNodeClick = (data: any) => {
+  console.log('Node clicked:', data)
+}
+
+const addOrg = (node: any) => {
+  ElMessageBox.prompt('请输入分组名称', '添加子分组', {
+    confirmButtonText: '确定',
+    cancelButtonText: '取消',
+    inputPattern: /\S/,
+    inputErrorMessage: '分组名称不能为空'
+  }).then(({ value }) => {
+    node.children.push({
+      id: node.children.length + 1,
+      name: value,
+      children: []
+    })
+    ElMessage.success('添加成功')
+  })
+}
+const editOrg = (node: any) => {
+  ElMessageBox.prompt('请输入新的分组名称', '修改分组名称', {
+    confirmButtonText: '确定',
+    cancelButtonText: '取消',
+    inputValue: node.name,
+    inputPattern: /\S/,
+    inputErrorMessage: '分组名称不能为空'
+  }).then(({ value }) => {
+    node.name = value
+    ElMessage.success('修改成功')
+  })
+}
+
+const deleteOrg = (node: any) => {
+  ElMessageBox.confirm(`删除 [${node.name}] 分组、下级子分组 <br>是否继续?`, '提示', {
+    dangerouslyUseHTMLString: true,
+    confirmButtonText: '确定',
+    cancelButtonText: '取消',
+    type: 'warning',
+    center: true
+  }).then(() => {
+    const id = node.id
+    // 查找 treeData 中对应的节点,并删除
+    const deleteNode = (data: any) => {
+      for (let i = 0; i < data.length; i++) {
+        if (data[i].id === id) {
+          data.splice(i, 1)
+          return
+        }
+        if (data[i].children) {
+          deleteNode(data[i].children)
+        }
+      }
+    }
+    deleteNode(treeData.value)
+    ElMessage.success('删除成功')
+  })
+}
+</script>
+
+<template>
+  <ContentWrap :title="t('treeDemo.treeTitle')" :message="t('qrcodeDemo.qrcodeDes')">
+    <Tree
+      :data="treeData"
+      :tree-props="{
+        highlightCurrent: true,
+        nodeKey: 'id',
+        props: {
+          children: 'children',
+          label: 'name'
+        }
+      }"
+      width="300px"
+      height="400px"
+      @node-click="handleNodeClick"
+    >
+      <!-- 自定义右键菜单 -->
+      <template #context-menu="{ node }">
+        <div class="menuItem" @click="addOrg(node)">
+          <Icon icon="ep:plus" style="color: #1e9fff" />
+          <span>添加子分组</span>
+        </div>
+        <div class="menuItem" @click="editOrg(node)">
+          <Icon icon="ep:edit-pen" style="color: #1e9fff" />
+          修改分组名称
+        </div>
+        <div class="menuItem" @click="deleteOrg(node)">
+          <Icon icon="ep:delete" style="color: #1e9fff" />
+          删除分组及子分组
+        </div>
+      </template>
+
+      <!-- 自定义节点显示 -->
+      <!-- <template #render-node="{ node }">
+      <span v-if="node.isLeaf">[FILE] {{ node.label }}</span>
+      <span v-else>[FOLDER] {{ node.label }}</span>
+    </template> -->
+    </Tree>
+  </ContentWrap>
+</template>
+<style lang="less" scoped>
+.menuItem {
+  display: flex;
+  padding: 2px 10px;
+  text-align: left;
+  box-sizing: border-box;
+  align-items: center; /* 垂直居中 */
+  gap: 5px; /* 图标和文字之间的间距,可根据需要调整 */
+}
+
+.menuItem:hover {
+  cursor: pointer;
+  background-color: #eee;
+}
+</style>