需求:
- 左侧menu每次只保持一个子菜单的展开,右侧每次只能展开一个面板(手风琴效果)
- 滚动左侧的 menu 菜单,右侧的折叠面板(collapse)会跟随滚动到对应区域
- 滚动右侧的折叠面板,左侧的menu 菜单会跟随滚动到对应区域
- 展开左侧的menu 菜单,右侧的折叠面板对应的面板展开,滚动到顶部
- 点击左侧的 menu 子项item,右侧折叠面板中的二级菜单滚动到顶部
- 点击展开右侧的折叠面板,左侧对应的菜单项展开
遇到的问题:
- 左侧menu 的数据获取是一个接口,右侧菜单的子项是根据左侧的一级菜单id获取,每次展开左侧右侧需要去异步获取数据填充到面板中,展开的时候数据没有填充完滚动条还未撑起来滚动位置会出错
- 左右侧滚动联动的时候,左侧滚动去滚动右侧,右侧滚动去设置左侧滚动,当左右两侧都设置了对方跟随自己滚动的时候,滚动时会出现卡顿,他们会互相循环调用
解决问题:
- 动态计算前面有多少个兄弟节点,每个节点是固定高度,设置计算后的值
- 设置一个拦截标识,左侧滚动的时候禁止右侧去触发左侧的滚动;右侧滚动的时候禁止左侧去触发右侧的滚动
效果:
代码实现:
menu 组件:
<template>
<div class="menuList">
<div class="search-box">
<el-autocomplete
class="search-input"
v-model="keyword"
size="small"
:clearable="true"
placeholder="请输入关键字"
:trigger-on-focus="false"
:fetch-suggestions="querySeachSync"
@select="handleSelect" />
<div @click="seachData" class="bth-search el-icon-search"></div>
</div>
<el-menu
id="label-mid-el-menu"
:default-active="curMenuId[1]"
@select="handleSelectMenu"
@open="handleOpenMenu"
:unique-opened="true">
<nav-item v-for="nav in navList"
:key="nav.classificationId"
:ref="'menu_' + nav.classificationId"
:nav="nav"
@clickNav="hanleClickNavItem" />
</el-menu>
</div>
</template>
<script>
import NavItem from './NavItem.vue'
import { keywordLabels } from '@/api/modules/labelMiddleware.js'
export default {
props: {
navList: {
type: Array,
default: () => []
},
curMenuId: {
type: Array
}
},
watch: {
curMenuId(arr) {
// 折叠面板展开时展开对应的menu
this.scrollToTarget(arr[0])
}
},
components: {
NavItem
},
data() {
return {
keyword: '',
isScroll: false,
// 定时器
scrollTimer: null
}
},
methods: {
// 展开右侧折叠面板的时候当前menu滚动到顶部
scrollToTarget(classificationId) {
// 设置当前为左侧滚动标识,不触发右侧滚动
window.rightScroll = true
window.clearTimeout(this.scrollTimer)
this.scrollTimer = setTimeout(_ => {
// 放开标识
window.rightScroll = false
}, 800)
// 找到目标元素
const id = classificationId
let el = this.$refs['menu_' + id][0]
el = el.$el ? el.$el : el
// 通过计算前面有多少元素来手动计算scrollTop高度
const scrollBox = document.querySelector('#label-mid-el-menu')
const children = Array.from(el.parentNode.children)
const prevSiblings = []
for (const child of children) {
if (child === el) {
break
}
prevSiblings.push(child)
}
const top = prevSiblings.length * 44
this.$nextTick(_ => {
scrollBox.scrollTo({
top: top,
behavior: 'smooth'
})
})
},
// 模糊搜索
async seachData() {
const { data } = await keywordLabels({
keyword: this.keyword
})
return data
},
// 搜索出来选择
handleSelect(label) {
this.$emit('setCurIdArea', label.firstClassificationId)
},
// 键入关键字搜索
async querySeachSync(keyworld, callback) {
const data = await this.seachData()
const res = data.map(item => {
return {
...item,
value: item.labelName
}
})
callback(res)
},
// 设置菜单默认选中项为第一个
setDefaultActive(list) {
while (true) {
if (Array.isArray(list)) {
list = list[0]
continue
}
if (list.childList && list.childList.length > 0) {
list = list.childList
continue
}
break
}
this.defaultActive = list.classificationId
},
// 点击item
hanleClickNavItem(key) {
},
// 展开某个菜单列表
handleOpenMenu(key, keyPath) {
// console.log(key, keyPath, 'openMenu');
this.$emit('setCurIdArea', keyPath, 'menu')
},
// 选择菜单列表菜单
handleSelectMenu(key, keyPath) {
// console.log(typeof key, keyPath, 'itemMenu')
if (keyPath.length === 1) {
// 菜单没有子项,触发menu
this.$emit('setCurIdArea', keyPath, 'menu')
} else {
this.$emit('setCurIdArea', keyPath, 'item')
}
},
// 右侧滚动的时候左侧滚动到目标位置
menuScroll() {
// 右侧如果正在滚动不触发左侧滚动
if (window.rightScroll === true) {
return
}
// 设置左侧正在滚动的标识,此时不触发右侧滚动
window.leftScroll = true
window.clearTimeout(this.scrollTimer)
this.scrollTimer = setTimeout(_ => {
// 左侧滚动结束,放开标识,此时才能触发右侧的滚动
window.leftScroll = false
}, 600)
// 左侧滚动的menu
const menuEl = document.querySelector('#label-mid-el-menu')
// menu的滚动高度
const scrollMenu = menuEl.scrollHeight - menuEl.offsetHeight
// 右侧的折叠面板
const labelContent = document.querySelector('.label-middleware-list-wrapper .content')
// 折叠面板的滚动高度
const labelScroll = labelContent.scrollHeight - labelContent.offsetHeight
// 计算滚动比率
const ratio = scrollMenu / labelScroll
labelContent.scrollTo({
top: menuEl.scrollTop / ratio
})
}
},
mounted() {
this.setDefaultActive(this.navList)
const menuEl = document.querySelector('#label-mid-el-menu')
menuEl.addEventListener('scroll', this.menuScroll)
},
beforeDestroy() {
const menuEl = document.querySelector('#label-mid-el-menu')
menuEl.removeEventListener('scroll', this.menuScroll)
}
}
</script>
<style lang="scss" scoped>
.menuList {
height: 100%;
display: flex;
flex-direction: column;
padding-top: 16px;
border-right: solid 1px #e6e6e6;
.search-box {
display: flex;
align-items: center;
width: 208px;
height: 32px;
background: #FFFFFF;
border-radius: 2px;
margin-left: 16px;
margin-bottom: 16px;
.search-input {
flex: 1;
margin-right: -2px;
/deep/ .el-input__inner {
height: 32px;
line-height: 32px;
border-right: none;
}
}
.bth-search {
flex: 0 0 auto;
width: 32px;
height: 32px;
background: #E02D39;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 2;
cursor: pointer;
}
}
.el-menu {
flex: 1;
overflow: auto;
border-right: none;
/deep/ {
.is-active:not(.el-submenu) {
color: #e02d39;
position: relative;
background: #F9F9FA;
font-weight: bold;
&::after {
content: "";
position: absolute;
left: 0;
top: 0;
width: 2px;
height: 100%;
background: #e02d39;
}
}
}
}
}
</style>
menu 里面的 item 组件:
<template>
<el-submenu
:key="nav.classificationId"
v-if="nav.childList && nav.childList.length>0"
:index="String(nav.classificationId)">
<template slot="title">
<span>{{nav.classificationName}}</span>
</template>
<nav-item
v-for="item in nav.childList"
:key="item.classificationId"
:nav="item"
v-on="$listeners" />
</el-submenu>
<el-menu-item
v-else
:key="nav.classificationId"
:index="String(nav.classificationId)"
@click="$emit('clickNav', nav.classificationId)">
<span slot="title">{{nav.classificationName}}</span>
</el-menu-item>
</template>
<script>
export default {
name: 'NavItem',
props: {
nav: {
type: Object,
default: () => {}
}
}
}
</script>
<style lang="scss" scoped>
.el-submenu {
/deep/ {
.el-menu-item,
.el-submenu__title {
height: 44px;
line-height: 44px;
}
// .el-submenu__title {
// padding-right: 32px !important;
// }
// .el-submenu .el-menu-item {
// padding: 0 32px;
// }
.is-active:not(.el-submenu) {
color: #e02d39;
position: relative;
background: #F9F9FA;
font-weight: bold;
&::after {
content: "";
position: absolute;
left: 0;
top: 0;
width: 2px;
height: 100%;
background: #e02d39;
}
}
}
}
/*菜单关闭*/
.el-submenu /deep/ .el-submenu__title .el-submenu__icon-arrow{
-webkit-transform: rotateZ(-90deg);
-ms-transform: rotate(-90deg);
transform: rotateZ(-90deg);
}
/*菜单展开*/
.el-submenu.is-opened /deep/ .el-submenu__title .el-submenu__icon-arrow{
-webkit-transform: rotateZ(0deg);
-ms-transform: rotate(0deg);
transform: rotateZ(0deg);
}
</style>
右侧的 collapse LabelList 组件:
<template>
<div class="label-middleware-list-wrapper"
v-loading="loading"
element-loading-text="加载中请稍后..."
element-loading-background="rgba(255, 255, 255, .8)">
<div class="content">
<el-collapse
accordion
v-model="activeItem"
@change="handleChangeCollapse">
<el-collapse-item
v-for="nav in labelList"
:ref="'collapse_'+nav.classificationId"
:key="nav.classificationId"
:title="nav.classificationName"
:name="nav.classificationId"
>
<div class="sub-box"
v-for="subs in nav.children"
:key="subs.classificationId"
:ref="'collapse_sub_'+subs.classificationId">
<div class="sub-title">{{subs.classificationName}}</div>
<popover-label
:labelList="subs.labList"
:selectedLabels="selectedLabels"
:popoverVisible="popoverVisible"
@addLabels="addLabels"
@closePopover="closePopover"
@showPopover="showPopover" />
</div>
</el-collapse-item>
</el-collapse>
</div>
</div>
</template>
<script>
import { queryLabels } from '@/api/modules/labelMiddleware.js'
import PopoverLabel from './PopoverLabel.vue'
export default {
components: {
PopoverLabel
},
props: {
navList: {
type: Array,
default: () => []
},
selectedLabels: {
type: Array,
default: () => []
},
curIdArea: {
type: Array,
defaut: () => []
},
curAreaType: {
type: String
}
},
data() {
return {
loading: false,
activeItem: 'org',
labelList: [],
childLabels: [],
popoverVisible: {},
// 定时器
scrollTimer: null
}
},
watch: {
// 展开当前区域
async curIdArea(keyPath) {
const classificationId = keyPath[0]
this.activeItem = isNaN(classificationId) ? classificationId : Number(classificationId)
// 如果左侧是展开的菜单
if (this.curAreaType === 'menu') {
await this.openCollapse(classificationId)
this.scrollToTarget()
}
// 如果左侧是点击的item
if (this.curAreaType === 'item') {
const curItemId = keyPath[keyPath.length - 1]
this.scrollToItem(curItemId)
}
}
},
methods: {
// 滚动到目标位置, 点击navItem时
scrollToItem(navId) {
// 设置当前为左侧滚动标识,不触发右侧滚动
window.leftScroll = true
window.clearTimeout(this.scrollTimer)
this.scrollTimer = setTimeout(_ => {
// 放开标识
window.leftScroll = false
}, 800)
let el = this.$refs['collapse_sub_' + navId][0]
el = el.$el ? el.$el : el
const scrollBox = document.querySelector('.label-middleware-list-wrapper .content')
this.$nextTick(_ => {
scrollBox.scrollTo({
top: el.offsetTop - 8,
behavior: 'smooth'
})
})
},
// 滚动到目标位置, 展开menu时
scrollToTarget(classificationId) {
// 设置当前为右侧滚动标识,不触发左侧滚动
window.leftScroll = true
window.clearTimeout(this.scrollTimer)
this.scrollTimer = setTimeout(_ => {
// 放开标识
window.leftScroll = false
}, 800)
// 找到目标元素
const id = classificationId ?? this.curIdArea
let el = this.$refs['collapse_' + id][0]
el = el.$el ? el.$el : el
while (el) {
if (!el.classList.contains('el-collapse-item')) {
el = el.parentNode
} else {
break
}
}
// 通过计算前面有多少元素来手动计算scrollTop高度
const scrollBox = document.querySelector('.label-middleware-list-wrapper .content')
const children = Array.from(el.parentNode.children)
const prevSiblings = []
for (const child of children) {
if (child === el) {
break
}
prevSiblings.push(child)
}
const top = prevSiblings.length * 48
this.$nextTick(_ => {
scrollBox.scrollTo({
top: top,
behavior: 'smooth'
})
})
},
// 添加标签addLabels
addLabels(label) {
this.$emit('addLabels', label)
},
// 关闭popover
closePopover() {
for (let item in this.popoverVisible) {
this.popoverVisible[item] = false
}
},
// 展示特定的popover
showPopover(item) {
this.popoverVisible[item] = true
},
// 获取label列表
async getLabels() {
try {
this.loading = true
const { data } = await queryLabels({
classificationId: Array.isArray(this.activeItem) ? this.activeItem[this.activeItem.length - 1] : this.activeItem
})
this.childLabels = data
this.loading = false
const target = this.labelList.find(item => item.classificationId === this.activeItem)
// 设置数据结构
data.forEach(item => {
if (item.labList) {
item.labList.forEach(label => {
// 最终值
label.tagAttributes = []
label.curRanges = []
// 临时修改的值,点击确定后才赋值给最终的值
label.tempTagAttributes = []
label.tempCurRanges = []
})
}
})
if (target) {
this.$set(target, 'children', data)
}
} catch (err) {
console.log(err)
}
},
// 设置机构标签
async setOrgLabels(id) {
const target = this.labelList.find(() => id === this.activeItem)
const data = [{
classificationId: 'org',
classificationName: "组织机构",
labList: [
{
labelId: "org_label",
labelName: "中台机构",
labelPropType: "org_label",
labelPropList: [],
labelRuleDescription: "",
selected: false,
tagAttributes: [],
curRanges: [],
value: "",
orgLabel: ''
}
],
selected: false
}]
if (target) {
this.$set(target, 'children', data)
}
},
// 折叠面板展开设置数据
async openCollapse(id) {
if (id) {
// 初始化手动添加的组织机构
if (id === 'org') {
await this.setOrgLabels('org')
} else {
// 获取当前详细labels
await this.getLabels()
}
}
},
// 展开关闭collapse
async handleChangeCollapse(id) {
if (id) {
await this.openCollapse(id)
// 当前展开项的第0或者第一个子元素,由于左侧没有本身子元素所以设为第一个
let classificationId = (this.childLabels[1]?.classificationId ?? this.childLabels[0]?.classificationId) || ''
// 组织机构是手动添加的数据单独处理
classificationId = id === 'org' ? 'org_label' : classificationId
this.$emit('changeCollapse', [String(id), String(classificationId) || String(id)])
// 先将右侧滚动到目标位置
this.scrollToTarget(id)
// 左侧滚动到目标位置
this.labelListScroll()
}
},
// 右侧滚动的时候左侧滚动到目标位置
labelListScroll() {
// 左侧如果正在滚动不触发右侧滚动
if (window.leftScroll === true) {
return
}
// 设置右侧正在滚动的标识,此时不触发左侧滚动
window.rightScroll = true
window.clearTimeout(this.scrollTimer)
this.scrollTimer = setTimeout(_ => {
// 右侧滚动结束,放开标识,此时才能触发左侧的滚动
window.rightScroll = false
}, 600)
// 左侧滚动的menu
const menuEl = document.querySelector('#label-mid-el-menu')
// menu的滚动高度
const scrollMenu = menuEl.scrollHeight - menuEl.offsetHeight
// 右侧的折叠面板
const labelContent = document.querySelector('.label-middleware-list-wrapper .content')
// 折叠面板的滚动高度
const labelScroll = labelContent.scrollHeight - labelContent.offsetHeight
// 计算滚动比率
const ratio = scrollMenu / labelScroll
menuEl.scrollTo({
top: labelContent.scrollTop * ratio
})
}
},
mounted() {
const labelContent = document.querySelector('.label-middleware-list-wrapper .content')
labelContent.addEventListener('scroll', this.labelListScroll)
},
beforeDestroy() {
const labelContent = document.querySelector('.label-middleware-list-wrapper .content')
labelContent.removeEventListener('scroll', this.labelListScroll)
},
created() {
this.labelList = this.navList
this.openCollapse(this.activeItem)
}
}
</script>
<style lang="scss" scoped>
.label-middleware-list-wrapper {
height: 100%;
.content {
height: 100%;
overflow: auto;
}
.el-collapse {
position: relative;
}
.el-collapse-item /deep/ .el-collapse-item__content {
border-top: 1px solid #EBEEF5;
padding-bottom: 0;
}
/deep/ .el-collapse-item__header{
font-size: 14px;
font-family: PingFangSC-Regular, PingFang SC;
font-weight: 500;
color: #262626;
}
.sub-box {
padding: 8px 16px 0 12px;
background: #FAFAFA;
.sub-title {
height: 18px;
font-size: 13px;
font-family: PingFangSC-Regular, PingFang SC;
font-weight: 400;
color: #292B33;
line-height: 18px;
margin-bottom: 9px;
}
.label-box {
display: flex;
/deep/ .label-item {
white-space: nowrap;
display: flex;
align-items: center;
height: 28px;
padding: 0 8px;
background: #F1F1F1;
border-radius: 3px;
font-size: 12px;
font-family: PingFangSC-Regular, PingFang SC;
font-weight: 400;
color: #595959;
margin-right: 8px;
margin-bottom: 8px;
cursor: pointer;
}
}
}
}
</style>
menu 和 右侧的 LbelList 组件的父组件:
<template>
<el-dialog
class="dialog"
title="标签组合筛选"
width="80%"
:visible="visible"
:close-on-click-modal="false"
@close="handleClose"
>
<div class="body">
<div class="line"></div>
<div class="content">
<div class="left-nav">
<nav-list
:navList="navList"
:curMenuId.sync="curMenuId"
@setCurIdArea="setCurIdArea" />
</div>
<div class="label-collapse">
<label-list
:navList="navList"
:selectedLabels="selectedLabels"
:curIdArea="curIdArea"
:curAreaType="curAreaType"
@addLabels="addLabels"
@changeCollapse="changeCollapse" />
</div>
</div>
<div class="selected-label">
<div class="title">已选标签:</div>
<div class="selected-box labels-box">
<div class="label-item" v-for="(label, index) in selectedLabels" :key="label.labelId">
<div class="text">
<div>{{label.labelName}}:</div>
<div class="value-item"
v-for="item in renderLabelInfo(label)"
:key="item.showValue">{{item.showValue}}</div>
</div>
<div @click="deleteLabel(label, index)" class="delete-label el-icon-close"></div>
</div>
</div>
</div>
</div>
<div class="footer" slot="footer">
<div class="tip">共筛选出<span class="number">{{total}}</span>个</div>
<div class="buttons">
<button class="btn-reset" @click="handleClickClearAll">清空重选</button>
<button class="btn-confirm" @click="handleClickSubmit">确定</button>
</div>
</div>
</el-dialog>
</template>
<script>
import NavList from './NavList.vue'
import LabelList from './LabelList.vue'
import { getClassificationTree } from '@/api/modules/labelMiddleware.js'
export default {
components: {
NavList,
LabelList
},
props: {
visible: {
type: Boolean,
default: false
},
selectedLabels: {
type: Array,
default: () => []
},
total: {
type: Number
},
renderLabelInfo: {
type: Function,
default: () => () => {}
}
},
data() {
return {
navList: [],
search: '',
curIdArea: [],
// 左侧选中的type,展开还是点击item
curAreaType: '',
curMenuId: ['org', 'org_label']
}
},
methods: {
// 添加label
addLabels(label) {
this.$emit('addLabels', label)
},
// 删除label
deleteLabel(label, index) {
this.$emit('deleteLabel', label, index)
},
// 左侧菜单当前展开项
setCurIdArea(keyPath, type) {
this.curIdArea = keyPath
this.curAreaType = type
},
// 获取navList,左侧的menu菜单数据,右侧的面板一级数据
async getNavList() {
const { data } = await getClassificationTree()
this.navList = data || []
this.navList.unshift({
classificationId: 'org',
classificationName: "组织机构",
childList: [
{
childList: null,
classificationId: 'org_label',
classificationName: "组织机构"
}
]
})
},
// 点击清空重选
handleClickClearAll() {
this.$emit('resetSelectLabels')
},
// 点击确定
handleClickSubmit() {
this.$emit('getTableData')
this.handleClose()
},
// 关闭dialog
handleClose() {
this.$emit('closeDialog')
},
// 修改了折叠面板,设置当前展开想的父级和当前id
changeCollapse(idArr) {
this.curMenuId = idArr
}
},
mounted() {
this.getNavList()
}
}
</script>
<style lang="scss" scoped>
.dialog {
/deep/ .el-dialog{
margin-top: 8vh !important;
}
/deep/ .el-dialog__body {
padding: 0;
}
.left-nav {
height: 100%;
width: 240px;
}
.content {
display: flex;
border: 1px solid rgba(0, 0, 0, 0.1);
border-right: none;
border-left: none;
margin-bottom: 8px;
height: 556px;
padding: 0;
padding-right: 16px;
.label-collapse {
flex: 1;
overflow: auto;
/deep/ {
.el-collapse-item__header {
padding-left: 20px;
}
}
}
}
.selected-label {
margin-bottom: 18px;
padding: 0 16px;
.title {
height: 20px;
font-size: 14px;
font-family: PingFangSC-Medium, PingFang SC;
font-weight: 500;
color: #262626;
line-height: 20px;
margin-bottom: 12px;
}
.labels-box {
display: flex;
min-height: 50px;
max-height: 76px;
overflow: auto;
flex: 1;
flex-wrap: wrap;
.label-item {
display: flex;
align-items: center;
height: 28px;
background: rgba(224, 45, 57, 0.06);
border-radius: 3px;
padding: 4px 11px 4px 8px;
margin-right: 8px;
margin-bottom: 8px;
white-space: nowrap;
.text {
display: flex;
align-items: center;
white-space: nowrap;
font-size: 14px;
font-family: PingFangSC-Regular, PingFang SC;
font-weight: 400;
color: #595959;
margin-right: 24px;
.value-item:not(:last-child){
margin-right: 8px;
}
}
.delete-label {
color: #8C8C8C;
cursor: pointer;
font-size: 16px;
}
}
}
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
.tip {
font-size: 12px;
font-family: PingFangSC-Regular, PingFang SC;
font-weight: 400;
color: #595959;
line-height: 17px;
.number {
font-size: 12px;
font-family: PingFangSC-Regular, PingFang SC;
font-weight: 400;
color: #E02D39;
line-height: 17px;
padding: 0 4px;
}
}
.buttons {
display: flex;
align-items: center;
.btn-reset {
width: 72px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 3px;
border: 1px solid #E02D39;
font-size: 12px;
font-family: PingFangSC-Medium, PingFang SC;
font-weight: 500;
color: #E02D39;
margin-right: 8px;
cursor: pointer;
}
.btn-confirm {
height: 28px;
display: flex;
align-items: center;
justify-content: center;
width: 60px;
background: #E02D39;
border-radius: 3px;
font-size: 12px;
font-family: PingFangSC-Medium, PingFang SC;
font-weight: 500;
color: #FFFFFF;
cursor: pointer;
}
}
}
}
</style>