<div class="waterfall-container" ref="container">
<div
v-for="(item, index) in items"
:key="item.id"
class="waterfall-item"
:style="item.style"
>
<div class="image-container">
<img
:src="item.image"
:alt="item.title"
ref="imageEls"
@load="onImageLoad(index)"
@error="onImageError(index)"
:style="{ opacity: item.loaded ? 1 : 0 }"
/>
<div class="image-loading" v-if="!item.loaded">图片加载中...</div>
</div>
<div class="content" v-if="item.loaded">
<h3>{{ item.title }}</h3>
<p>{{ item.description }}</p>
</div>
</div>
<div class="loading" v-if="loading">加载更多...</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from "vue";
const container = ref(null);
const imageEls = ref([]);
// 配置项
const config = ref({
columnCount: 3,
gap: 15,
responsive: {
640: 1,
768: 2,
1024: 3,
1440: 4,
},
maxImageHeight: 300, // 图片最大高度
contentMinHeight: 80, // 内容最小高度
});
const items = ref([]);
const columnHeights = ref([]);
const loading = ref(false);
// 生成更合理的假数据
function generateMockData(count = 10) {
const mockTitles = [
"山水风光",
"城市夜景",
"自然奇观",
"野生动物",
"美食摄影",
"旅行记录",
"人文纪实",
"建筑艺术",
];
const mockDescriptions = [
"壮丽的自然景色",
"城市的光影交错",
"大自然的鬼斧神工",
"野生动物的精彩瞬间",
"令人垂涎的美食",
"旅途中的美好回忆",
];
// 使用更稳定的随机图片尺寸
const imageHeights = [300, 350, 400, 450, 500];
return Array.from({ length: count }, (_, i) => {
const height =
imageHeights[Math.floor(Math.random() * imageHeights.length)];
return {
id: `item-${Date.now()}-${i}`,
title: mockTitles[i % mockTitles.length],
description: mockDescriptions[i % mockDescriptions.length],
image: `https://picsum.photos/id/${10 + i}/300/${height}`,
style: { opacity: 0 }, // 初始不可见
loaded: false,
height: 0,
};
});
}
// 初始化数据
function initItems() {
items.value = generateMockData(20);
}
// 响应式调整列数
function updateColumnCount() {
if (!container.value) return;
const width = window.innerWidth;
let newColumnCount = 1;
Object.entries(config.value.responsive)
.sort((a, b) => a[0] - b[0])
.forEach(([breakpoint, count]) => {
if (width >= parseInt(breakpoint)) {
newColumnCount = count;
}
});
if (newColumnCount !== config.value.columnCount) {
config.value.columnCount = newColumnCount;
calculateLayout();
}
}
// 图片加载成功
function onImageLoad(index) {
const img = imageEls.value[index];
if (!img) return;
nextTick(() => {
const itemWidth = getItemWidth();
const aspectRatio = img.naturalHeight / img.naturalWidth;
// 计算图片高度(不超过最大高度)
let imageHeight = aspectRatio * itemWidth;
if (imageHeight > config.value.maxImageHeight) {
imageHeight = config.value.maxImageHeight;
}
// 总高度 = 图片高度 + 内容最小高度
const totalHeight = imageHeight + config.value.contentMinHeight;
items.value[index].loaded = true;
items.value[index].height = totalHeight;
items.value[index].style.opacity = 1;
calculateLayout();
});
}
// 图片加载失败处理
function onImageError(index) {
items.value[index].image = "https://placehold.co/300x200?text=Image+Error";
items.value[index].loaded = true;
items.value[index].height = 200 + config.value.contentMinHeight; // 默认高度
items.value[index].style.opacity = 1;
calculateLayout();
}
// 获取每项宽度
function getItemWidth() {
if (!container.value) return 0;
return (
(container.value.offsetWidth -
config.value.gap * (config.value.columnCount - 1)) /
config.value.columnCount
);
}
// 计算布局
function calculateLayout() {
if (!container.value) return;
const itemWidth = getItemWidth();
columnHeights.value = new Array(config.value.columnCount).fill(0);
items.value.forEach((item) => {
if (!item.loaded) return;
const shortestColumnIndex = columnHeights.value.indexOf(
Math.min(...columnHeights.value)
);
item.style = {
...item.style,
width: `${itemWidth}px`,
height: `${item.height}px`,
transform: `translate(${
shortestColumnIndex * (itemWidth + config.value.gap)
}px, ${columnHeights.value[shortestColumnIndex]}px)`,
position: "absolute",
top: "0",
left: "0",
transition: "transform 0.3s ease, opacity 0.3s ease",
};
columnHeights.value[shortestColumnIndex] += item.height + config.value.gap;
});
container.value.style.height = `${Math.max(...columnHeights.value)}px`;
}
// 加载更多数据
async function loadMore() {
if (loading.value) return;
loading.value = true;
await new Promise((resolve) => setTimeout(resolve, 800)); // 模拟网络延迟
const newItems = generateMockData(10);
items.value = [...items.value, ...newItems];
// 等待DOM更新后重新计算布局
nextTick(() => {
loading.value = false;
});
}
// 滚动加载检测
function handleScroll() {
if (loading.value || !container.value) return;
const { scrollTop, clientHeight } = document.documentElement;
const containerBottom =
container.value.offsetTop + container.value.offsetHeight;
if (scrollTop + clientHeight >= containerBottom - 100) {
loadMore();
}
}
// 初始化
onMounted(() => {
initItems();
updateColumnCount();
window.addEventListener("resize", updateColumnCount);
window.addEventListener("scroll", handleScroll);
});
onUnmounted(() => {
window.removeEventListener("resize", updateColumnCount);
window.removeEventListener("scroll", handleScroll);
});
</script>
<style scoped>
.waterfall-container {
position: relative;
width: 100%;
margin: 0 auto;
padding: 0 15px;
box-sizing: border-box;
min-height: 100vh;
}
.waterfall-item {
box-sizing: border-box;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: transform 0.3s ease, opacity 0.3s ease;
display: flex;
flex-direction: column;
}
.image-container {
position: relative;
width: 100%;
overflow: hidden;
}
.waterfall-item img {
width: 100%;
height: auto;
max-height: 300px;
display: block;
object-fit: cover;
transition: opacity 0.3s ease;
}
.image-loading {
height: 200px;
display: flex;
align-items: center;
justify-content: center;
color: #999;
background: #f5f5f5;
border-radius: 4px;
}
.content {
padding: 12px;
flex: 1;
display: flex;
flex-direction: column;
min-height: 80px;
}
.content h3 {
margin: 0 0 8px 0;
font-size: 16px;
color: #333;
line-height: 1.3;
}
.content p {
margin: 0;
font-size: 14px;
color: #666;
line-height: 1.4;
flex: 1;
}
.loading {
text-align: center;
padding: 20px;
color: #999;
font-size: 14px;
}
</style>