You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
434 lines
12 KiB
434 lines
12 KiB
<template>
|
|
<!-- 参考 https://blog.csdn.net/weixin_44198965/article/details/147831036 -->
|
|
<div class="waterfall-container">
|
|
<!-- 列容器 -->
|
|
<div ref="columnWrapper" class="waterfall-column-wrapper">
|
|
<!-- 瀑布流项 -->
|
|
<div
|
|
v-for="(item, index) in renderedList"
|
|
:key="item.id || index"
|
|
v-lazy-load="item.image || item.cover"
|
|
class="waterfall-item"
|
|
>
|
|
<!-- 图片类型 -->
|
|
<div v-if="item.type === 'image'" class="item-content" @click="jump(item.linkUrl)">
|
|
<img
|
|
:src="placeholder"
|
|
:alt="item.title || '图片'"
|
|
class="item-image"
|
|
>
|
|
<div v-if="item.title" class="item-title">{{ item.title }}</div>
|
|
</div>
|
|
|
|
<!-- 视频类型 -->
|
|
<div v-else-if="item.type === 'video'" class="item-content">
|
|
<video
|
|
:poster="placeholder"
|
|
controls
|
|
class="item-video"
|
|
>
|
|
<source :src="item.videoUrl" type="video/mp4">
|
|
</video>
|
|
<div v-if="item.title" class="item-title">{{ item.title }}</div>
|
|
</div>
|
|
|
|
<!-- 图文类型 -->
|
|
<div v-else class="item-content">
|
|
<img
|
|
v-if="item.image"
|
|
:src="placeholder"
|
|
:alt="item.title || '图文'"
|
|
class="item-image"
|
|
>
|
|
<div v-if="item.title" class="item-title">{{ item.title }}</div>
|
|
<div v-if="item.desc" class="item-desc">{{ item.desc }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 加载状态 -->
|
|
<div v-if="loading" class="loading-more">
|
|
加载中...
|
|
</div>
|
|
<div v-if="isComplete && renderedList.length > 0" class="load-complete">
|
|
没有更多内容了
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
export default {
|
|
name: 'Waterfall',
|
|
|
|
directives: {
|
|
lazyLoad: {
|
|
inserted(el, binding, vnode) {
|
|
const observer = new IntersectionObserver((entries) => {
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting) {
|
|
const img = el.tagName === 'IMG' ? el : el.querySelector('img')
|
|
if (img) {
|
|
img.src = binding.value
|
|
// 图片加载完成后重新布局
|
|
img.onload = () => {
|
|
vnode.context.layoutItems()
|
|
}
|
|
// 图片加载错误处理
|
|
img.onerror = () => {
|
|
if (vnode.context.placeholder) {
|
|
img.src = vnode.context.placeholder
|
|
}
|
|
}
|
|
}
|
|
observer.unobserve(el)
|
|
}
|
|
})
|
|
}, {
|
|
rootMargin: '0px 0px 100px 0px' // 提前100px加载
|
|
})
|
|
|
|
observer.observe(el)
|
|
|
|
el._observer = observer
|
|
},
|
|
unbind(el) {
|
|
if (el._observer) {
|
|
el._observer.disconnect()
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
// 定义组件props
|
|
props: {
|
|
// 数据源数组
|
|
list: {
|
|
type: Array,
|
|
required: true,
|
|
default: () => []
|
|
},
|
|
// 列数配置
|
|
columns: {
|
|
type: Number,
|
|
default: 2,
|
|
validator: function(value) {
|
|
return value >= 1 && value <= 5 // 限制列数在1-5之间
|
|
}
|
|
},
|
|
// 列间距
|
|
gap: {
|
|
type: Number,
|
|
default: 10
|
|
},
|
|
// 是否开启懒加载
|
|
lazyLoad: {
|
|
type: Boolean,
|
|
default: true
|
|
},
|
|
// 触底加载的阈值(距离底部多少像素触发加载)
|
|
loadThreshold: {
|
|
type: Number,
|
|
default: 100
|
|
},
|
|
// 图片加载时的占位图
|
|
placeholder: {
|
|
type: String,
|
|
default: '改成你的图片'
|
|
},
|
|
totalItems: {
|
|
type: Number,
|
|
default: 0
|
|
}
|
|
},
|
|
|
|
data() {
|
|
return {
|
|
waterfallRef: null, // 瀑布流容器引用
|
|
columnHeights: [], // 每列当前高度数组
|
|
columnWrapperRef: null, // 列容器引用
|
|
loading: false, // 加载状态
|
|
isComplete: false, // 是否已加载全部数据
|
|
renderedList: [], // 已渲染的数据列表
|
|
pageSize: 20, // 每次加载的数据量
|
|
currentPage: 1 // 当前页码
|
|
}
|
|
},
|
|
|
|
computed: {
|
|
// 计算列宽度(根据容器宽度和列数计算)
|
|
columnWidth() {
|
|
if (!this.waterfallRef) return 0
|
|
const containerWidth = this.waterfallRef.clientWidth
|
|
return (containerWidth - (this.columns - 1) * this.gap) / this.columns
|
|
}
|
|
},
|
|
|
|
watch: {
|
|
// 监听列数变化
|
|
columns(newVal, oldVal) {
|
|
if (newVal !== oldVal) {
|
|
this.resetLayout()
|
|
}
|
|
},
|
|
|
|
// 监听数据源变化
|
|
list: {
|
|
handler(newList) {
|
|
this.renderedList = newList // 直接同步父组件数据(无需分页切片)
|
|
this.$nextTick(() => this.layoutItems())
|
|
},
|
|
deep: true
|
|
}
|
|
},
|
|
|
|
mounted() {
|
|
this.scrollContainer = this.$el
|
|
this.waterfallRef = this.$el
|
|
this.columnWrapperRef = this.$el.querySelector('.waterfall-column-wrapper')
|
|
|
|
this.resetLayout()
|
|
if (this.lazyLoad) {
|
|
this.scrollContainer.addEventListener('scroll', this.handleScroll)
|
|
}
|
|
window.addEventListener('resize', this.handleResize)
|
|
},
|
|
|
|
beforeDestroy() {
|
|
if (this.lazyLoad && this.scrollContainer) {
|
|
this.scrollContainer.removeEventListener('scroll', this.handleScroll)
|
|
}
|
|
window.removeEventListener('resize', this.handleResize)
|
|
},
|
|
|
|
methods: {
|
|
// 初始化列高度数组
|
|
initColumnHeights() {
|
|
this.columnHeights = new Array(this.columns).fill(0)
|
|
},
|
|
|
|
// 初始化渲染数据
|
|
initRenderList() {
|
|
this.renderedList = this.list.slice(0, this.pageSize)
|
|
this.currentPage = 1
|
|
this.isComplete = this.renderedList.length >= this.list.length
|
|
},
|
|
|
|
// 重置瀑布流布局
|
|
resetLayout() {
|
|
this.initColumnHeights()
|
|
this.initRenderList()
|
|
this.$nextTick(() => {
|
|
this.layoutItems()
|
|
})
|
|
},
|
|
|
|
// 瀑布流布局算法
|
|
layoutItems() {
|
|
if (!this.columnWrapperRef || !this.waterfallRef) return
|
|
|
|
// 获取所有子元素
|
|
const items = this.columnWrapperRef.children
|
|
if (!items || items.length === 0) return
|
|
|
|
// 初始化列高度
|
|
this.initColumnHeights()
|
|
|
|
// 临时列高度副本用于计算
|
|
const tempColumnHeights = [...this.columnHeights]
|
|
|
|
// 遍历所有子元素进行布局
|
|
Array.from(items).forEach((item, index) => {
|
|
// 找到当前最短的列
|
|
const minHeight = Math.min(...tempColumnHeights)
|
|
const columnIndex = tempColumnHeights.indexOf(minHeight)
|
|
|
|
// 计算位置
|
|
const left = columnIndex * (this.columnWidth + this.gap)
|
|
const top = minHeight
|
|
|
|
// 应用样式
|
|
item.style.position = 'absolute'
|
|
item.style.width = `${this.columnWidth}px`
|
|
item.style.left = `${left}px`
|
|
item.style.top = `${top}px`
|
|
item.style.transition = 'all 0.3s ease'
|
|
|
|
// 更新列高度
|
|
tempColumnHeights[columnIndex] += item.clientHeight + this.gap
|
|
})
|
|
|
|
// 更新列高度
|
|
this.columnHeights = tempColumnHeights
|
|
|
|
// 设置容器高度
|
|
const maxHeight = Math.max(...tempColumnHeights)
|
|
this.columnWrapperRef.style.height = `${maxHeight}px`
|
|
},
|
|
|
|
// 滚动事件处理
|
|
// handleScroll() {
|
|
// if (this.loading || this.isComplete) return
|
|
|
|
// const scrollTop = document.documentElement.scrollTop || document.body.scrollTop
|
|
// const windowHeight = window.innerHeight
|
|
// const scrollHeight = document.documentElement.scrollHeight
|
|
|
|
// // 判断是否触底
|
|
// if (scrollTop + windowHeight >= scrollHeight - this.loadThreshold) {
|
|
// this.loadMore()
|
|
// }
|
|
// },
|
|
|
|
handleScroll() {
|
|
if (this.loading || (this.totalItems > 0 && this.renderedList.length >= this.totalItems)) {
|
|
return
|
|
}
|
|
|
|
// 获取滚动容器(父组件的.content-main)
|
|
const scrollContainer = this.$parent.$el.querySelector('.content-main') || window
|
|
let scrollTop, clientHeight, scrollHeight
|
|
|
|
if (scrollContainer === window) {
|
|
scrollTop = document.documentElement.scrollTop || document.body.scrollTop
|
|
clientHeight = window.innerHeight
|
|
scrollHeight = document.documentElement.scrollHeight
|
|
} else {
|
|
// 关键:用滚动容器的实际尺寸计算(避免父组件padding影响)
|
|
scrollTop = scrollContainer.scrollTop
|
|
clientHeight = scrollContainer.clientHeight
|
|
scrollHeight = scrollContainer.scrollHeight
|
|
}
|
|
|
|
// 触底阈值:距离底部100px时触发加载(可调整)
|
|
if (scrollTop + clientHeight >= scrollHeight - this.loadThreshold) {
|
|
this.$emit('scroll-end') // 触发父组件的loadMoreData
|
|
console.log('触发加载更多')
|
|
}
|
|
},
|
|
|
|
// 加载更多数据
|
|
loadMore() {
|
|
if (this.loading || this.isComplete) return
|
|
|
|
this.loading = true
|
|
|
|
// 模拟异步加载
|
|
setTimeout(() => {
|
|
try {
|
|
const nextPage = this.currentPage + 1
|
|
const startIndex = (nextPage - 1) * this.pageSize
|
|
const endIndex = nextPage * this.pageSize
|
|
|
|
if (startIndex >= this.list.length) {
|
|
this.isComplete = true
|
|
return
|
|
}
|
|
|
|
const newItems = this.list.slice(startIndex, endIndex)
|
|
this.renderedList = [...this.renderedList, ...newItems]
|
|
this.currentPage = nextPage
|
|
|
|
// 检查是否已加载全部数据
|
|
if (endIndex >= this.list.length) {
|
|
this.isComplete = true
|
|
}
|
|
|
|
// 等待DOM更新后重新布局
|
|
this.$nextTick(() => {
|
|
this.layoutItems()
|
|
})
|
|
} catch (error) {
|
|
console.error('加载更多数据失败:', error)
|
|
} finally {
|
|
this.loading = false
|
|
}
|
|
}, 500)
|
|
},
|
|
jump(url) {
|
|
window.location.href = url
|
|
},
|
|
// 响应式处理窗口大小变化
|
|
handleResize() {
|
|
this.resetLayout()
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.waterfall-container {
|
|
position: relative;
|
|
width: calc(100%);
|
|
height: 800px;
|
|
overflow: hidden;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.waterfall-column-wrapper {
|
|
position: relative;
|
|
width: 100%;
|
|
|
|
}
|
|
|
|
.waterfall-item {
|
|
margin-bottom: 10px;
|
|
break-inside: avoid;
|
|
box-sizing: border-box;
|
|
background: #fff;
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.waterfall-item:hover {
|
|
transform: translateY(-5px);
|
|
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.item-content {
|
|
padding: 12px;
|
|
}
|
|
|
|
.item-image, .item-video {
|
|
width: 100%;
|
|
border-radius: 4px;
|
|
display: block;
|
|
background: #f5f5f5;
|
|
}
|
|
|
|
.item-video {
|
|
aspect-ratio: 9/16;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.item-title {
|
|
margin-top: 8px;
|
|
font-size: 24px;
|
|
line-height: 1.4;
|
|
color: #333;
|
|
font-weight: bold;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.item-desc {
|
|
margin-top: 6px;
|
|
font-size: 12px;
|
|
color: #666;
|
|
line-height: 1.4;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 3;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.loading-more, .load-complete {
|
|
text-align: center;
|
|
padding: 20px 0;
|
|
color: #999;
|
|
font-size: 14px;
|
|
}
|
|
</style>
|