Merge pull request #570 from lt5227/master
feat: 新增支持右键自定义菜单进行节点编辑的树形组件
This commit is contained in:
commit
72d6fd5a4e
|
@ -348,6 +348,14 @@ const adminList = [
|
|||
meta: {
|
||||
title: 'router.iAgree'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'tree',
|
||||
component: 'views/Components/Tree',
|
||||
name: 'Tree',
|
||||
meta: {
|
||||
title: 'router.tree'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
import Tree from './src/Tree.vue'
|
||||
|
||||
export { Tree }
|
|
@ -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>
|
|
@ -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.',
|
||||
|
|
|
@ -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: '种一棵树最好的时间是十年前,其次就是现在。',
|
||||
|
|
|
@ -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>
|
Loading…
Reference in New Issue