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

This commit is contained in:
lt5227 2024-12-09 14:36:16 +08:00
parent 9af5c99c2c
commit 5a00171c9a
7 changed files with 435 additions and 3 deletions

13
.vscode/settings.json vendored
View File

@ -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"
}
}

View File

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

View File

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

View File

@ -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>

View File

@ -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.',

View File

@ -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: '种一棵树最好的时间是十年前,其次就是现在。',

View File

@ -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>