自定义vue通用左侧菜单组件(未完善版本)

使用到的技术:

vue3、pinia、view-ui-plus

实现的功能:

传入一个菜单数组数据,自动生成一个左侧菜单栏。菜单栏可以添加、删除、展开、重命名,拖动插入位置等。

效果预览:

代码:

c-menu-wrap.vue

<template>
	<div class="main-container">
		<div class="nav-top">
			<Tooltip content="展开全部" placement="top">
				<Icon type="ios-code" @click="expandAll"/>
			</Tooltip>

			<Tooltip content="创建文档" placement="top">
				<Icon type="ios-add" @click.stop="showPopper({e: $event, item: null})"/>
			</Tooltip>
		</div>

		<div class="nav-list">
			<cMenu 
				:list="menuList"
			/>
		</div>

		<div 
			@click.stop="hidePopper"
			class="menu-modal"
			ref="modal"
			v-show="isShowPopper"
			:style="{
				left: modalX+'px',
				top: modalY+'px'
			}">
			<ul>
				<li v-if="!editItem" @click.stop="createNew(1)">
					<Icon type="ios-add" />
					<span>新建文件夹</span>
				</li>
				<li v-if="editItem && editItem.type === 'folder'" @click.stop="createNew(1)">
					<Icon type="ios-add" />
					<span>新建子文件夹</span>
				</li>
				<li v-if="!editItem || (editItem && editItem.type === 'folder')" @click.stop="createNew(2)">
					<Icon type="ios-add" />
					<span>新建文档</span>
				</li>
				<!-- <li v-if="editItem">
					<Icon type="ios-add" />
					<span>上方新建模块</span>
				</li>
				<li v-if="editItem">
					<Icon type="ios-add" />
					<span>下方新建模块</span>
				</li> -->
				<li @click.stop="openRename" v-if="editItem">
					<Icon type="ios-create-outline" />
					<span>重命名</span>
				</li>
				<li @click.stop="delDoc" v-if="editItem">
					<Icon type="ios-trash-outline" />
					<span>删除</span>
				</li>
			</ul>
		</div>
	</div>
</template>

<script setup lang="ts">
	import { storeToRefs } from 'pinia'
	import { useMenuStore } from '@/stores/menu'

	import { defineProps, defineEmits, withDefaults, ref, nextTick, onMounted, onBeforeUnmount, provide } from 'vue'
	import { Modal, Message } from 'view-ui-plus'
	import cMenu from './c-menu.vue'

	const menuStore = useMenuStore()
	const { 
		modal,
		modalX,
		modalY,
		menuList, 
		isShowPopper, 
		editItem 
	} = storeToRefs(menuStore)
	const { 
		expandAll, 
		showPopper, 
		hidePopper, 
		createNew, 
		openRename
	} = menuStore

	const emit = defineEmits(['doAction'])


	onMounted(() => {
		// document.addEventListener('click', hidePopper)
	})

	onBeforeUnmount(() => {
		// document.removeEventListener('click', hidePopper)
	})

	// 删除文档
	function delDoc() {
		Modal.confirm({
			title: '提示',
			content: '确定要删除该文档吗?',
			onOk: () => {

				
			},
			onCancel: () => {
				Message.info('取消操作~');
			}
		});
	}

</script>

<style lang="scss" scoped>

	.main-container {
		display: flex;
		flex-direction: column;
		height: 100%;

		.nav-top {
			flex-shrink: 0;
			box-sizing: border-box;
			padding: 0 24px;
			height: 48px;
			display: flex;
			justify-content: flex-end;
			align-items: center;
			font-size: 25px;
			// position: absolute;
			// top: 0;
			// left: 0;
			// width: 100%;
			border-bottom: 1px solid rgba(37, 55, 92, 0.10);
			
			i {		
				cursor: pointer;
				margin-left: 15px;
				&:hover {
					color: #05F;
				}
			}

			:deep(.ivu-icon-ios-code) {
				font-size: 20px;
				transform: rotate(90deg);
				transform-origin: center;
				margin: 0;	
			}
		}

		.nav-list {
			flex: auto;
			margin-top: 10px;
			overflow: auto;
		}

		.menu-modal {
			z-index: 10;
			position: fixed;
			top: 0;
			left: 0;
			&:after {
				z-index: 20;
				content: '';
				position: fixed;
				top: 0;
				left: 0;
				right: 0;
				bottom: 0;
			}
			ul {
				z-index: 21;
				position: relative;
				background: white;
				border: 1px solid rgba(37, 55, 92, 0.10);
				li {
					cursor: pointer;
					padding: 10px 20px;
					display: flex;
					align-items: center;
					&:hover {
						i, span {
							color: #05F;
						}
					}
					i {
						font-size: 15px;
					}
					span {
						margin-left: 10px;
						font-size: 14px;
					}
				}
			}
		}
	}
	
</style>

c-menu.vue

<template>

	<div class="menu-box">
		<div 
			class="li"
			v-for="(item, key) in list" 
			:key="key"
			:class="{
				active: item.id == currentMenuId
			}">

			<div
				class="edit-title" 
				v-if="isEditRename && editItem && editItem.id == item.id" >
				<Input 
					ref="editInput"
					@click.stop=""
					class="edit-input"
					v-model="editItem.val"
					maxlength="50"
					:placeholder="editItem.type === 'folder' ? '请输入文件夹名称' : '请输入文档名称'" 
					@on-enter="editTitle"
					@on-blur="hidePopper"
				/>
				<Icon class="icon" type="md-checkmark-circle" @click.stop="editTitle" />
			</div>
				

			<div 
				v-else
				class="title-box"
				:class="dragItem?.id == item.id ? 'drag' : ''"
				:node-id="item.id"
				:draggable="draggable"
				@dragstart="dragStart($event, item)" 
				@drop="drop($event, item)"
				@dragend="dragEnd($event)"

				@dragover="dragOver($event)"
				@dragenter="dragEnter($event)"
				@dragleave="dragLeave($event)"
				@click.stop="doMenuAction($event, item)">
				<div 
					:style="{
						pointerEvents: 'none',
						marginLeft: (index*10)+'px'
					}">
					<span class="txt">{
  { item.val }}</span>
					<i 
						v-if="item.children && item.type === 'folder'" 
						class="icon" :class="{
						active: item.showSub
					}"></i>
				</div>
				
				<Icon class="md-reorder" type="md-reorder" @mousedown="changeDraggable(true)"/>
				<Icon class="md-more" type="md-more" @click.stop="showPopper({e: $event, item})"/>
			</div>

			<div class="sub-box" 
				:style="{
					height: item.showSub ? 'auto': 0
				}" 
				v-if="item.children">
				<c-menu 
					:index="index+1" 
					:list="item.children" 
				></c-menu>

			</div>
	
		</div>
	</div>

</template>

<script setup lang="ts">
	import { storeToRefs } from 'pinia'
	import { useMenuStore } from '@/stores/menu.ts'
	import { defineProps, defineEmits, withDefaults, ref, onMounted, inject } from 'vue';

	interface Props {
		list: Array<any>,
		index?: number
	}

	const props = withDefaults(defineProps<Props>(), {
		list: [],
		index: 0
	})

	const menuStore = useMenuStore()
	const { 
		currentMenuId, 
		editItem,
		isEditRename,
		editInput,
		dragItem,
		dropPosition
	} = storeToRefs(menuStore)
	const { 
		doMenuAction, 
		editTitle, 
		showPopper,
		hidePopper
	} = menuStore

	
	const draggable = ref(false)


	onMounted(() => {
	})


	function changeDraggable(_draggable: boolean) {
		draggable.value = _draggable
		console.log(_draggable)
	}

	function drop($event: any, item: string) {
		$event.preventDefault()
		$event.stopPropagation()

		let data = $event.dataTransfer.getData("item");
	
		if(data) {
			let mitem = JSON.parse(data)
			console.log('拖动放置:', mitem)

			if(mitem.id == item.id) {
				console.log('同一个元素')
			} else {
				console.log('放置位置', dropPosition.value)
			}
		}

		$event.target.classList.remove('over')
		$event.target.classList.remove('over-top')
		$event.target.classList.remove('over-bottom')
		dragItem.value = null
		dropPosition.value = 0
	}

	// 拖动时触发
	function dragStart($event: any, item: any) {
		console.log("开始拖动:", item);

		$event.stopPropagation()

		$event.dataTransfer.setData(
			"item",
			JSON.stringify(item)
		)

		dragItem.value = JSON.parse(JSON.stringify(item))
	}

	function dragEnd($event: any) {
		$event.preventDefault()

		$event.stopPropagation()
		draggable.value = false
	}

	function dragOver($event: any) {
		$event.preventDefault()
		$event.stopPropagation()

		let t = $event.target
		let e = '.title-box'

		for (var i = t.matches || t.webkitMatchesSelector || t.mozMatchesSelector || t.msMatchesSelector; t && !i.call(t, e); ) {
			t = t.parentElement;
		}
		// 判断是否是同一个元素
		if(t.className.indexOf('title-box')!== -1 && t.getAttribute('node-id') != dragItem.value?.id) {
			t.classList.add('over')
			let dom = t.getBoundingClientRect()

			if($event.clientY < dom.top + 5) {
				// console.log('上')
				t.classList.add('over-top')
				t.classList.remove('over-bottom')
				dropPosition.value = 1
			
			} else if($event.clientY > dom.bottom - 5) {
				// console.log('下')
				t.classList.add('over-bottom')
				t.classList.remove('over-top')
				dropPosition.value = 2
			} else {
				// console.log('中')
				t.classList.remove('over-bottom')
				t.classList.remove('over-top')
				dropPosition.value = 0
			}
		}
	}

	function dragEnter($event: any) {
		console.log('dragEnter')
		$event.preventDefault()
		$event.stopPropagation()
	}

	function dragLeave($event: any) {
		$event.stopPropagation();
		$event.target.classList.remove('over')
		$event.target.classList.remove('over-top')
		$event.target.classList.remove('over-bottom')
	}

</script>

<style lang="scss" scoped>

	.menu-box {
		.li {
			cursor: pointer;
			

			.edit-title {
				position: relative;
				.edit-input {
					padding: 0 24px;
					:deep(.ivu-input) {
						padding-right: 25px;
					}
				}
				.icon {
					position: absolute;
					top: 50%;
					right: 30px;
					transform: translate(0, -50%);
					font-size: 18px;
					cursor: pointer;
					&:hover {
						color: #0055FF;
					}
				}
			}

			.title-box {
				position: relative;
				padding: 0 24px;
				border: 1px solid transparent;
				&.drag {
					background: rgb(203, 218, 245, 0.5);
				}

				&::before {
					background: transparent;
					content: "";
					height: 2px;
					top: 0;
					left: 0;
					position: absolute;
					width: 100%;
				}

				&::after {
					background: transparent;
					content: "";
					height: 2px;
					bottom: 0;
					left: 0;
					position: absolute;
					width: 100%;
				}

				&.over {
					border: 1px dashed #0055FF;
				}

				&.over-top {
					&::before {
						background: #0055FF;
					}
				}

				&.over-bottom {
					&::after {
						background: #0055FF;
					}
				}

				.md-reorder {
					display: none;
					position: absolute;
					left: 5px;
					top: 50%;
					transform: translate(0, -50%);
					font-size: 15px;
					cursor: move;

					&:hover {
						color: #0055FF;
					}
				}

				.md-more {
					display: none;
					position: absolute;
					right: 5px;
					top: 50%;
					transform: translate(0, -50%);	
					font-size: 15px;
				
					&:hover {
						color: #0055FF;
					}			
				}
				
				&>div {
					padding: 8px 12px;
					display: flex;
					align-items: center;
					justify-content: space-between;
					.txt {
						display: block;
						color:  #81838C;
						font-size: 14px;
						font-style: normal;
						font-weight: 500;
						overflow: hidden;
						text-overflow: ellipsis;
						white-space: nowrap;
					}
					.icon {
						width: 8px;
						height: 8px;
						background: url(../../assets/ic8_draw-down_normal.png) no-repeat;
						background-size: 100%;
						&.active {
							transform: rotate(180deg)
						}
					}
				}
				
				&:hover {
					&>div{
						.txt {
							color: #0055FF;
						}
					}
					.md-reorder, .md-more {
						display: block;
					}
				}
			}

			.sub-box {
				overflow: hidden;
			}
		

			&.active {
				.title-box {
					&>div {
						border-radius: 8px;
						background: #EBF2FF;
						.txt {
							color: #0055FF;
						}
						.icon {
							background-image: url(../../assets/ic8_draw-down_normal_hover.png);
							transition: all 0.5s;
							transform: rotate(180deg);
						}
					}
					
				}
				
			}
			
		}
	}

</style>

menu.ts

import { defineStore } from 'pinia'
import { ref, nextTick } from 'vue'
import { randomString } from '@/utils/index.js'

export const useMenuStore = defineStore('menu', () => {

	const menuList = ref([
		{
			id: 1,
			val: '标题1',
			type: 'folder'
		},
		{
			id: 2,
			val: '标题1',
			type: 'folder',
			children: [{
				id: 21,
				val: '标题2',
				type: 'folder',
				children: [
					{
						id: 211,
						val: '标题3',
						type: 'file',
						content: '123'
					}, 
					{
						id: 212,
						val: '标题3',
						type: 'file',
						content: '345'
					}
				]
			}]
		}
	])
	const preMenuList = ref([])

	const currentMenuId = ref('')
	const isShowPopper = ref(false)
	const modal = ref(null)
	const modalX = ref(0)
	const modalY = ref(0)

	const editItem = ref(null) // 当前标题编辑对象(文件夹或文件)
	const editInput = ref(null)
	const selectItem = ref(null) // 当前选中的文件对象
	const preSelectItem = ref(null)

	const isEdit = ref(false) // 文档是否开启编辑状态
	const isNew = ref(false) // 是否新建
	const isEditRename = ref(false) // 是否重命名

	const dragItem = ref(null) // 当前拖动元素
	const dropPosition = ref(0) // 拖动元素插入的位置:0 中,1 上,2 下

	// 点击菜单
	function doMenuAction(e:any, item: any) {

		hidePopper()
	
		if(item.type === 'folder') {
			item.showSub = !item.showSub
		} 
		// 选中的文档
		else if(item.type === 'file') {
			if(item.id !== currentMenuId.value) {
				isEdit.value = false
				currentMenuId.value = item.id.toString()
				selectItem.value = JSON.parse(JSON.stringify(item))
				preSelectItem.value = JSON.parse(JSON.stringify(selectItem.value))
			}
		}
	}

	// 显示更多菜单
	function showPopper(param: any) {
		hidePopper()

		isShowPopper.value = true

		let e = param.e
		let item = param.item

		editItem.value = item

		nextTick(() => {
			
			let _w = modal.value?.offsetWidth || 0
			let _h = modal.value?.offsetHeight || 0
			
			modalX.value = e.clientX + _w > window.innerWidth ? window.innerWidth - _w : e.clientX+2
			modalY.value = e.clientY + _h > window.innerHeight ? window.innerHeight - _h : e.clientY+2
			
		})
	}

	// 隐藏更多菜单
	function hidePopper() {
		console.log('隐藏更多菜单')
		

		if(isNew.value || (!isNew.value && isEditRename.value)) {	
			menuList.value = JSON.parse(JSON.stringify(preMenuList.value))
		}

		isNew.value = false
		isEditRename.value = false
		editItem.value = null
		isShowPopper.value = false
	}

	

	// 确定修改文档标题
	function editTitle() {
		// 新建
		if(isNew.value) {
			console.log('确定新建标题', editItem.value)
			if(editItem.value && editItem.value.type === 'file') {
				currentMenuId.value = editItem.value.id
				isEdit.value = true
				selectItem.value = JSON.parse(JSON.stringify(editItem.value))
				preSelectItem.value = JSON.parse(JSON.stringify(selectItem.value ))
			}
		} 
		// 修改
		else {
			console.log('确定修改标题', editItem.value)
			if(editItem.value && editItem.value.type === 'file' && currentMenuId.value === editItem.value.id) {
				
				selectItem.value = JSON.parse(JSON.stringify(editItem.value))
				preSelectItem.value = JSON.parse(JSON.stringify(selectItem.value ))
			}
		}

		isNew.value = false
		isEditRename.value = false
		editItem.value = null
	}

	function callBack(name: string) {
		console.log('callBack: ', name)
	}

	// 开启重命名
	function openRename() {
		preMenuList.value = JSON.parse(JSON.stringify(menuList.value))

		isShowPopper.value = false
		isEditRename.value = true

		nextTick(() => {
			editInput.value && editInput.value[0] && editInput.value[0].focus()
		})
	}

	// 全部展开
	function expandAll(_list: Array<any> = []) {
	
		if(!_list || _list.length <= 0 || !(_list instanceof Array)) {
			_list = menuList.value
		}
		for(let i = 0; i < _list.length; i++) {
			if(_list[i].children && _list[i].children.length > 0) {
				_list[i].showSub = true
				expandAll(_list[i].children)
			}
		}
	}

	// 创建文件夹或文档
	function createNew(type: number) {
		isNew.value = true

		isShowPopper.value = false
		isEditRename.value = true

		preMenuList.value = JSON.parse(JSON.stringify(menuList.value))

		// 文件夹
		if(type === 1) {
			// 新建文件夹
			if(!editItem.value) {
				console.log('新建文件夹')
				editItem.value = JSON.parse(JSON.stringify({
					id: randomString(32),
					val: '',
					type: 'folder',
				}))

				menuList.value.push(editItem.value)
			} 
			// 新建子文件夹
			else {
				console.log('新建子文件夹')
				findParentMenu(menuList.value, type)
			}
		} 
		// 文档
		else if(type === 2) {
			// 新建文档
			if(!editItem.value) {
				console.log('新建文档')
				editItem.value = JSON.parse(JSON.stringify({
					id: randomString(32),
					val: '',
					type: 'file',
				}))

				menuList.value.push(editItem.value)
			} 
			// 新建子文档
			else {
				console.log('新建子文档')
				findParentMenu(menuList.value, type)
			}
		}

		nextTick(() => {
			editInput.value && editInput.value[0] && editInput.value[0].focus()
		})
		
	}

	function findParentMenu(list: Array<any>, type: number) {
		console.log(type)
		
		for(let i = 0; i < list.length; i++) {
			if(list[i].id === editItem.value.id) {
				list[i].showSub = true
				editItem.value = JSON.parse(JSON.stringify({
					id: randomString(32),
					val: '',
					type: type === 1 ? 'folder' : 'file',
				}))

				if(!list[i].children) {
					list[i].children = []
				}

				list[i].children.push(editItem.value)
				break
			} else {
				if(list[i].children && list[i].children.length > 0) {
					findParentMenu(list[i].children, type)
				}
			}
		}
	}

	return {
		editInput,
		isEdit,
		isNew,
		isEditRename,
		isShowPopper,
		modal,
		modalX,
		modalY,
		currentMenuId,
		editItem,
		menuList,
		preMenuList,
		selectItem,
		preSelectItem,
		dragItem,
		dropPosition,

		doMenuAction,
		expandAll,
		createNew,
		openRename,
		editTitle,
		showPopper,
		hidePopper,
		callBack
	}

})

使用

import cMenuWrap from '@/components/menu/c-menu-wrap.vue'

<c-menu-wrap></c-menu-wrap>

相关推荐

  1. Vue:定义消息通知组件

    2024-01-31 22:30:01       19 阅读
  2. vue 定义通用的表格组件(使用div)

    2024-01-31 22:30:01       46 阅读
  3. Vue 定义菜单、tabBar效果

    2024-01-31 22:30:01       14 阅读
  4. Vue 定义组件通过配置调整样式?

    2024-01-31 22:30:01       14 阅读

最近更新

  1. TCP协议是安全的吗?

    2024-01-31 22:30:01       18 阅读
  2. 阿里云服务器执行yum,一直下载docker-ce-stable失败

    2024-01-31 22:30:01       19 阅读
  3. 【Python教程】压缩PDF文件大小

    2024-01-31 22:30:01       18 阅读
  4. 通过文章id递归查询所有评论(xml)

    2024-01-31 22:30:01       20 阅读

热门阅读

  1. c++11学习笔记

    2024-01-31 22:30:01       41 阅读
  2. nginx+ gunicorn部署flask项目

    2024-01-31 22:30:01       31 阅读
  3. 20240130

    20240130

    2024-01-31 22:30:01      36 阅读
  4. 2024.1.20 用户画像标签开发,面向过程方法

    2024-01-31 22:30:01       35 阅读
  5. 基于Qt 音乐播放器mp3(进阶)

    2024-01-31 22:30:01       35 阅读
  6. Python 因果推断(下)

    2024-01-31 22:30:01       46 阅读
  7. 【PyRestTest】基本测试集编写语法

    2024-01-31 22:30:01       41 阅读
  8. C++ 模板

    2024-01-31 22:30:01       30 阅读