mirror of
https://gitee.com/xiangheng/x_admin.git
synced 2025-12-24 08:12:55 +08:00
虚拟折叠面板组件
This commit is contained in:
@@ -1,229 +1,229 @@
|
||||
<!-- 虚拟折叠面板 -->
|
||||
<template>
|
||||
<DynamicScroller
|
||||
ref="scrollbarRef"
|
||||
class="scroller"
|
||||
:items="list"
|
||||
:min-item-size="props.minItemSize"
|
||||
:key-field="props.keyField"
|
||||
v-slot="{ item, active }"
|
||||
style="height: 100%"
|
||||
@scroll="onScroll"
|
||||
>
|
||||
<DynamicScrollerItem :item="item" :active="active" :size-dependencies="[item.__expanded]">
|
||||
<div class="collapse-header" @click="toggleExpand(item)">
|
||||
<div class="collapse-header-left">
|
||||
<slot name="header" :item="item">{{ item[props.nameField] }}</slot>
|
||||
</div>
|
||||
<DynamicScroller
|
||||
ref="scrollbarRef"
|
||||
class="scroller"
|
||||
:items="list"
|
||||
:min-item-size="props.minItemSize"
|
||||
:key-field="props.keyField"
|
||||
v-slot="{ item, active }"
|
||||
style="height: 100%"
|
||||
@scroll="onScroll"
|
||||
>
|
||||
<DynamicScrollerItem :item="item" :active="active" :size-dependencies="[item.__expanded]">
|
||||
<div class="collapse-header" @click="toggleExpand(item)">
|
||||
<div class="collapse-header-left">
|
||||
<slot name="header" :item="item">{{ item[props.nameField] }}</slot>
|
||||
</div>
|
||||
|
||||
<div class="collapse-header-right" v-if="props.showArrowRight">
|
||||
<el-icon
|
||||
class="arrow-right"
|
||||
:class="{ 'rotate-icon': item.__expanded }"
|
||||
:size="12"
|
||||
:color="'#c1c1c1'"
|
||||
>
|
||||
<ArrowRight />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse-header-right" v-if="props.showArrowRight">
|
||||
<el-icon
|
||||
class="arrow-right"
|
||||
:class="{ 'rotate-icon': item.__expanded }"
|
||||
:size="12"
|
||||
:color="'#c1c1c1'"
|
||||
>
|
||||
<ArrowRight />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div class="collapse-content" v-show="item.__expanded">
|
||||
<slot name="content" :item="item"></slot>
|
||||
</div>
|
||||
</Transition>
|
||||
</DynamicScrollerItem>
|
||||
</DynamicScroller>
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div class="collapse-content" v-show="item.__expanded">
|
||||
<slot name="content" :item="item"></slot>
|
||||
</div>
|
||||
</Transition>
|
||||
</DynamicScrollerItem>
|
||||
</DynamicScroller>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, onBeforeUnmount, onDeactivated } from 'vue'
|
||||
import { ref, computed, onBeforeUnmount, onDeactivated, useTemplateRef } from 'vue'
|
||||
import type { PropType } from 'vue'
|
||||
import { ArrowRight } from '@element-plus/icons-vue'
|
||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
|
||||
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'
|
||||
const emit = defineEmits(['scroll'])
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Array as PropType<Record<string, any>[]>,
|
||||
default: () => []
|
||||
},
|
||||
keyField: {
|
||||
type: String,
|
||||
default: 'id'
|
||||
},
|
||||
nameField: {
|
||||
type: String,
|
||||
default: 'name'
|
||||
},
|
||||
/** 每个项的最小高度 */
|
||||
minItemSize: {
|
||||
type: Number,
|
||||
default: 48
|
||||
},
|
||||
showArrowRight: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
data: {
|
||||
type: Array as PropType<Record<string, any>[]>,
|
||||
default: () => []
|
||||
},
|
||||
keyField: {
|
||||
type: String,
|
||||
default: 'id'
|
||||
},
|
||||
nameField: {
|
||||
type: String,
|
||||
default: 'name'
|
||||
},
|
||||
/** 每个项的最小高度 */
|
||||
minItemSize: {
|
||||
type: Number,
|
||||
default: 48
|
||||
},
|
||||
showArrowRight: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const itemHeight = computed(() => props.minItemSize + 'px')
|
||||
|
||||
const activeItem = ref<Record<string, any> | null>(null)
|
||||
const list = computed(() => {
|
||||
console.time('list')
|
||||
const activeItemId = activeItem.value ? activeItem.value[props.keyField] : null
|
||||
const newList = props.data.map((listItem: Record<string, any>) => {
|
||||
return {
|
||||
...listItem,
|
||||
__expanded: activeItemId && listItem[props.keyField] === activeItemId ? true : false
|
||||
}
|
||||
})
|
||||
console.timeEnd('list')
|
||||
return newList
|
||||
console.time('list')
|
||||
const activeItemId = activeItem.value ? activeItem.value[props.keyField] : null
|
||||
const newList = props.data.map((listItem: Record<string, any>) => {
|
||||
return {
|
||||
...listItem,
|
||||
__expanded: activeItemId && listItem[props.keyField] === activeItemId ? true : false
|
||||
}
|
||||
})
|
||||
console.timeEnd('list')
|
||||
return newList
|
||||
})
|
||||
|
||||
const scrollbarRef = useTemplateRef('scrollbarRef')
|
||||
const scrollbarScrollTop = ref(0)
|
||||
function onScroll(e) {
|
||||
scrollbarScrollTop.value = e.target.scrollTop
|
||||
emit('scroll', e)
|
||||
scrollbarScrollTop.value = e.target.scrollTop
|
||||
emit('scroll', e)
|
||||
}
|
||||
/**
|
||||
* 切换折叠面板展开状态
|
||||
* @param item 要切换展开状态的项
|
||||
*/
|
||||
function toggleExpand(item) {
|
||||
activeItem.value = item.__expanded ? null : item
|
||||
activeItem.value = item.__expanded ? null : item
|
||||
}
|
||||
/**
|
||||
* 滚动到指定项
|
||||
* @param item 要滚动到的项
|
||||
*/
|
||||
function scrollToItem(item) {
|
||||
if (!item) return
|
||||
activeItem.value = item
|
||||
var findIndex = list.value.findIndex((i) => i[props.keyField] === item[props.keyField])
|
||||
if (findIndex === -1) {
|
||||
return
|
||||
}
|
||||
var top = props.minItemSize * findIndex
|
||||
// 取消之前的动画
|
||||
cancelAnimationFrame(AnimationId.value as number)
|
||||
scrollTo(scrollbarScrollTop.value, top, scrollbarScrollTop.value)
|
||||
if (!item) return
|
||||
activeItem.value = item
|
||||
const findIndex = list.value.findIndex((i) => i[props.keyField] === item[props.keyField])
|
||||
if (findIndex === -1) {
|
||||
return
|
||||
}
|
||||
const top = props.minItemSize * findIndex
|
||||
// 取消之前的动画
|
||||
cancelAnimationFrame(AnimationId.value as number)
|
||||
scrollTo(scrollbarScrollTop.value, top, scrollbarScrollTop.value)
|
||||
}
|
||||
/**
|
||||
* 滚动到指定索引项
|
||||
* @param index 要滚动到的索引项
|
||||
*/
|
||||
function scrollToIndex(index) {
|
||||
if (index < 0 || index >= list.value.length) return
|
||||
if (index < 0 || index >= list.value.length) return
|
||||
|
||||
activeItem.value = list.value[index]
|
||||
activeItem.value = list.value[index]
|
||||
|
||||
var top = props.minItemSize * index
|
||||
// 取消之前的动画
|
||||
cancelAnimationFrame(AnimationId.value as number)
|
||||
scrollTo(scrollbarScrollTop.value, top, scrollbarScrollTop.value)
|
||||
const top = props.minItemSize * index
|
||||
// 取消之前的动画
|
||||
cancelAnimationFrame(AnimationId.value as number)
|
||||
scrollTo(scrollbarScrollTop.value, top, scrollbarScrollTop.value)
|
||||
}
|
||||
/**
|
||||
* 滚动到指定位置
|
||||
* @param position 要滚动到的位置
|
||||
*/
|
||||
function scrollToPosition(position: number) {
|
||||
|
||||
if (position < 0) return
|
||||
// 取消之前的动画
|
||||
cancelAnimationFrame(AnimationId.value as number)
|
||||
scrollTo(scrollbarScrollTop.value, position, scrollbarScrollTop.value)
|
||||
if (position < 0) return
|
||||
// 取消之前的动画
|
||||
cancelAnimationFrame(AnimationId.value as number)
|
||||
scrollTo(scrollbarScrollTop.value, position, scrollbarScrollTop.value)
|
||||
}
|
||||
|
||||
// 滚动到指定位置
|
||||
const AnimationId = ref<number>()
|
||||
function scrollTo(from: number, to: number, current: number) {
|
||||
if (scrollbarScrollTop.value === to) {
|
||||
return
|
||||
}
|
||||
const speed = (to - from) / 30
|
||||
|
||||
if (speed < 0) {
|
||||
if (current <= to) {
|
||||
return
|
||||
if (scrollbarScrollTop.value === to) {
|
||||
return
|
||||
}
|
||||
} else if (speed > 0) {
|
||||
if (current >= to) {
|
||||
return
|
||||
}
|
||||
}
|
||||
current = current + speed
|
||||
const speed = (to - from) / 30
|
||||
|
||||
// DynamicScroller内部用的RecycleScroller组件,所以滚动到指定位置用RecycleScroller方法
|
||||
scrollbarRef.value?.$refs?.scroller?.scrollToPosition(current)
|
||||
AnimationId.value = requestAnimationFrame(() => {
|
||||
scrollTo(from, to, current)
|
||||
})
|
||||
if (speed < 0) {
|
||||
if (current <= to) {
|
||||
return
|
||||
}
|
||||
} else if (speed > 0) {
|
||||
if (current >= to) {
|
||||
return
|
||||
}
|
||||
}
|
||||
current = current + speed
|
||||
|
||||
// DynamicScroller内部用的RecycleScroller组件,所以滚动到指定位置用RecycleScroller方法
|
||||
scrollbarRef.value?.$refs?.scroller?.scrollToPosition(current)
|
||||
AnimationId.value = requestAnimationFrame(() => {
|
||||
scrollTo(from, to, current)
|
||||
})
|
||||
}
|
||||
onDeactivated(() => {
|
||||
// 组件销毁时取消动画
|
||||
cancelAnimationFrame(AnimationId.value as number)
|
||||
// 组件销毁时取消动画
|
||||
cancelAnimationFrame(AnimationId.value as number)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
// 组件销毁时取消动画
|
||||
cancelAnimationFrame(AnimationId.value as number)
|
||||
// 组件销毁时取消动画
|
||||
cancelAnimationFrame(AnimationId.value as number)
|
||||
})
|
||||
defineExpose({
|
||||
toggleExpand,
|
||||
scrollToItem,
|
||||
scrollToIndex,
|
||||
scrollToPosition
|
||||
toggleExpand,
|
||||
scrollToItem,
|
||||
scrollToIndex,
|
||||
scrollToPosition
|
||||
})
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.collapse-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
height: v-bind(itemHeight);
|
||||
padding: 0 4px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-sizing: border-box;
|
||||
.collapse-header-left {
|
||||
font-size: 12px;
|
||||
color: #303133;
|
||||
flex: 1;
|
||||
}
|
||||
height: v-bind(itemHeight);
|
||||
padding: 0 4px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-sizing: border-box;
|
||||
.collapse-header-left {
|
||||
font-size: 12px;
|
||||
color: #303133;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
.collapse-content {
|
||||
transform-origin: top;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
transform-origin: top;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
// collapse-content显示隐藏动画
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: scaleY(0);
|
||||
opacity: 0;
|
||||
transform: scaleY(0);
|
||||
}
|
||||
.arrow-right {
|
||||
transition: all 0.2s ease;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.rotate-icon {
|
||||
transform: rotate(90deg);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.1) !important;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.1) !important;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
background-color: #f5f5f5;
|
||||
width: 8px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user