使用到的技术:
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>