使用到的技术:
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} = menuStoreconst 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}"><divclass="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-elseclass="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} = menuStoreconst draggable = ref(false)onMounted(() => {})function changeDraggable(_draggable: boolean) {draggable.value = _draggableconsole.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 = nulldropPosition.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.targetlet 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 = falsecurrentMenuId.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 = truelet e = param.elet item = param.itemeditItem.value = itemnextTick(() => {let _w = modal.value?.offsetWidth || 0let _h = modal.value?.offsetHeight || 0modalX.value = e.clientX + _w > window.innerWidth ? window.innerWidth - _w : e.clientX+2modalY.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 = falseisEditRename.value = falseeditItem.value = nullisShowPopper.value = false}// 确定修改文档标题function editTitle() {// 新建if(isNew.value) {console.log('确定新建标题', editItem.value)if(editItem.value && editItem.value.type === 'file') {currentMenuId.value = editItem.value.idisEdit.value = trueselectItem.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 = falseisEditRename.value = falseeditItem.value = null}function callBack(name: string) {console.log('callBack: ', name)}// 开启重命名function openRename() {preMenuList.value = JSON.parse(JSON.stringify(menuList.value))isShowPopper.value = falseisEditRename.value = truenextTick(() => {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 = trueexpandAll(_list[i].children)}}}// 创建文件夹或文档function createNew(type: number) {isNew.value = trueisShowPopper.value = falseisEditRename.value = truepreMenuList.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 = trueeditItem.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>