Merge pull request #570 from lt5227/master
feat: 新增支持右键自定义菜单进行节点编辑的树形组件
This commit is contained in:
commit
72d6fd5a4e
|
@ -348,6 +348,14 @@ const adminList = [
|
||||||
meta: {
|
meta: {
|
||||||
title: 'router.iAgree'
|
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',
|
personalCenter: 'Personal center',
|
||||||
personal: 'Personal',
|
personal: 'Personal',
|
||||||
avatars: 'Avatars',
|
avatars: 'Avatars',
|
||||||
iAgree: 'I agree'
|
iAgree: 'I agree',
|
||||||
|
tree: 'Tree'
|
||||||
},
|
},
|
||||||
permission: {
|
permission: {
|
||||||
hasPermission: 'Please set the operation permission value'
|
hasPermission: 'Please set the operation permission value'
|
||||||
|
@ -393,6 +394,11 @@ export default {
|
||||||
logoStyle: 'Logo style',
|
logoStyle: 'Logo style',
|
||||||
size: 'size config'
|
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: {
|
highlightDemo: {
|
||||||
highlight: 'Highlight',
|
highlight: 'Highlight',
|
||||||
message: 'The best time to plant a tree is ten years ago, followed by now.',
|
message: 'The best time to plant a tree is ten years ago, followed by now.',
|
||||||
|
|
|
@ -186,7 +186,8 @@ export default {
|
||||||
personalCenter: '个人中心',
|
personalCenter: '个人中心',
|
||||||
personal: '个人',
|
personal: '个人',
|
||||||
avatars: '头像列表',
|
avatars: '头像列表',
|
||||||
iAgree: '我同意'
|
iAgree: '我同意',
|
||||||
|
tree: 'Tree 树形控件'
|
||||||
},
|
},
|
||||||
permission: {
|
permission: {
|
||||||
hasPermission: '请设置操作权限值'
|
hasPermission: '请设置操作权限值'
|
||||||
|
@ -385,6 +386,10 @@ export default {
|
||||||
logoStyle: 'logo样式',
|
logoStyle: 'logo样式',
|
||||||
size: '大小配置'
|
size: '大小配置'
|
||||||
},
|
},
|
||||||
|
treeDemo: {
|
||||||
|
treeTitle: '树形控件(节点右键可自定义菜单选项)',
|
||||||
|
message: '基于 ElementPlus 的 Tree 组件二次封装'
|
||||||
|
},
|
||||||
highlightDemo: {
|
highlightDemo: {
|
||||||
highlight: '高亮',
|
highlight: '高亮',
|
||||||
message: '种一棵树最好的时间是十年前,其次就是现在。',
|
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