最近在研发AI副业项目平台,然后自己设计了一个瀑布流组件,可以随意调整展示的列数、懒加载、每页滚动数量、高度、点击效果等。
一、效果
先看看效果如何,如何随意调整4列、5列、6列、N列展示。
二、实现方法
现建立components/waterfall/index.vue组件
<template>
<div class="waterfall-container" ref="containerRef" @scroll="handleScroll">
<div class="waterfall-list">
<div
class="waterfall-item"
v-for="(item, index) in resultList"
:key="index"
:style="{
width: `${item.width}px`,
height: `${item.height}px`,
transform: `translate3d(${item.x}px, ${item.y}px, 0)`,
}"
>
<slot name="item" v-bind="item"></slot>
</div>
<div v-if="isEnd" class="no-more-data">暂无更多数据</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed, onUnmounted, watch } from "vue";
import { throttle, debounce } from "@/utils/waterfall/utils.js";
const props = defineProps({
gap: {
type: Number,
default: 10,
},
columns: {
type: Number,
default: 3,
},
bottom: {
type: Number,
default: 0,
},
images: {
type: Array,
default: () => [],
},
fetchMoreImages: {
type: Function,
required: true,
},
isEnd: {
type: Boolean,
default: false,
},
});
const containerRef = ref(null);
const cardWidth = ref(0);
const columnHeight = ref(new Array(props.columns).fill(0));
const resultList = ref([]);
const loading = ref(false);
const minColumn = computed(() => {
let minIndex = -1,
minHeight = Infinity;
columnHeight.value.forEach((item, index) => {
if (item < minHeight) {
minHeight = item;
minIndex = index;
}
});
return {
minIndex,
minHeight,
};
});
const handleScroll = throttle(() => {
const { scrollTop, clientHeight, scrollHeight } = containerRef.value;
const bottom = scrollHeight - clientHeight - scrollTop;
if (bottom <= props.bottom && !props.isEnd) {
!loading.value && props.fetchMoreImages();
}
});
const getList = (list) => {
return list.map((x, index) => {
const cardHeight = Math.floor((x.height * cardWidth.value) / x.width);
const { minIndex, minHeight } = minColumn.value;
const isInit = index < props.columns && resultList.value.length < props.columns;
if (isInit) {
columnHeight.value[index] = cardHeight + props.gap;
} else {
columnHeight.value[minIndex] += cardHeight + props.gap;
}
return {
width: cardWidth.value,
height: cardHeight,
x: isInit
? index % props.columns !== 0
? index * (cardWidth.value + props.gap)
: 0
: minIndex % props.columns !== 0
? minIndex * (cardWidth.value + props.gap)
: 0,
y: isInit ? 0 : minHeight,
image: x,
};
});
};
const resizeObserver = new ResizeObserver(() => {
handleResize();
});
const handleResize = debounce(() => {
const containerWidth = containerRef.value.clientWidth;
cardWidth.value =
(containerWidth - props.gap * (props.columns - 1)) / props.columns;
columnHeight.value = new Array(props.columns).fill(0);
resultList.value = getList(resultList.value);
});
const init = () => {
if (containerRef.value) {
const containerWidth = containerRef.value.clientWidth;
cardWidth.value =
(containerWidth - props.gap * (props.columns - 1)) / props.columns;
resultList.value = getList(props.images);
resizeObserver.observe(containerRef.value);
}
};
watch(() => props.images, (newImages) => {
const newList = getList(newImages);
resultList.value = [...resultList.value, ...newList];
});
onMounted(() => {
init();
});
onUnmounted(() => {
containerRef.value && resizeObserver.unobserve(containerRef.value);
});
</script>
<style lang="scss">
.waterfall {
&-container {
width: 100%;
height: 100%;
overflow-y: scroll;
overflow-x: hidden;
}
&-list {
width: 100%;
position: relative;
}
&-item {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
transition: all 0.3s;
}
.no-more-data {
text-align: center;
padding: 20px;
color: #999;
font-size: 14px;
}
}
</style>
其中@/utils/waterfall/utils.js如下
// 用于模拟接口请求
export const getRemoteData = (data = '获取数据', time = 2000) => {
return new Promise((resolve) => {
setTimeout(() => {
console.log(`模拟获取接口数据`, data)
resolve(data)
}, time)
})
}
// 获取数组随机项
export const getRandomElement = (arr) => {
var randomIndex = Math.floor(Math.random() * arr.length);
return arr[randomIndex];
}
// 指定范围随机数
export const getRandomNumber = (min, max) => {
return Math.floor(Math.random() * (max - min + 1) + min);
}
// 节流
export const throttle = (fn, time) => {
let timer = null
return (...args) => {
if (!timer) {
timer = setTimeout(() => {
timer = null
fn.apply(this, args)
}, time)
}
}
}
// 防抖
export const debounce = (fn, time) => {
let timer = null
return (...args) => {
clearTimeout(timer)
timer = setTimeout(() => {
fn.apply(this, args)
}, time)
}
}
调用组件
<template>
<div>
<div class="page-dall">
<el-row>
<el-col :span="6">
<div class="inner">
<div class="sd-box">
<h2>DALL-E 创作中心</h2>
<div>
<el-form label-position="left">
<div style="padding-top: 10px">
<el-form-item :label-style="{ color: 'white' }" label="图片尺寸">
<template #default>
<div>
<el-select v-model="selectedValue" @change="updateSize" style="width:176px">
<el-option label="1024*1024" value="1024*1024"/>
<el-option label="1972*1024" value="1972*1024"/>
<el-option label="1024*1972" value="1024*1972"/>
</el-select>
</div>
</template>
</el-form-item>
</div>
<div style="padding-top: 10px">
<div class="param-line">
<el-input
v-model="dalleParams.prompt"
:autosize="{ minRows: 4, maxRows: 6 }"
type="textarea"
ref="promptRef"
placeholder="请在此输入绘画提示词,系统会自动翻译中文提示词,高手请直接输入英文提示词"
/>
</div>
</div>
</el-form>
</div>
<div class="submit-btn">
<el-button color="#ffffff" :loading="loading" :dark="false" round @click="generate">
立即生成
</el-button>
</div>
</div>
</div>
</el-col>
<el-col :span="18">
<div class="inner">
<div class="right-box">
<h2>创作记录</h2>
<div>
<el-form label-position="left">
<div class="container">
<WaterFall :columns="columns" :gap="10" :images="images" :fetchMoreImages="fetchMoreImages" :isEnd="isEnd">
<template #item="{ image }">
<div class="card-box">
<el-image :src="image.url" @click="previewImg(image)" alt="waterfall image" fit="cover" style="width: 100%; height: 100%;cursor:pointer;" loading="lazy"></el-image>
</div>
</template>
</WaterFall>
</div>
</el-form>
</div>
</div>
</div>
</el-col>
</el-row>
</div>
<el-image-viewer @close="() => { previewURL = '' }" v-if="previewURL !== ''" :url-list="[previewURL]"/>
</div>
</template>
<script lang="ts" setup>
import { ElUpload, ElImage, ElDialog, ElRow, ElCol, ElButton, ElIcon, ElTag, ElInput, ElSelect, ElTooltip, ElForm, ElFormItem, ElOption ,ElImageViewer} from "element-plus";
import {Delete, InfoFilled, Picture} from "@element-plus/icons-vue";
import feedback from "~~/utils/feedback";
import { useUserStore } from '@/stores/user';
import WaterFall from '@/components/waterfall/index.vue';
import * as xmgai from "~~/api/ai";
// 获取图片前缀
const config = useRuntimeConfig();
const filePrefix = config.public.filePrefix;
const router = useRouter();
const selectedValue = ref('1024*1024');
const previewURL = ref("")
const loading = ref(false);
// 请求参数
const dalleParams = reactive({
size:"1024*1024",
prompt: ""
});
// 创建绘图任务
const promptRef = ref(null);
const updateSize = () => {
dalleParams.size = selectedValue.value;
};
const generate = async () => {
loading.value = true;
if (dalleParams.prompt === '') {
promptRef.value.focus();
loading.value = false;
return feedback.msgError("请输入绘画提示词!");
}
const ctdata = await xmgai.dalle3(dalleParams);
console.info("ctdata",ctdata);
if (ctdata.code === 0) {
feedback.msgError(ctdata.msg);
loading.value = false;
return [];
}
if (ctdata.code === 1) {
// 获取新生成的图片地址
const newImage = {
url: filePrefix + ctdata.data,
width: 300 + Math.random() * 300,
height: 400 + Math.random() * 300,
};
// 将新图片插入到 images 数组的开头
// 将新图片插入到 images 数组的开头
images.value = [newImage, ...images.value];
// 将 WaterFall 组件的滚动条滚动到顶部
nextTick(() => {
const waterfallContainer = document.querySelector('.waterfall-container');
if (waterfallContainer) {
waterfallContainer.scrollTop = 0;
}
});
feedback.msgSuccess(ctdata.msg);
loading.value = false;
}
};
const images = ref([]);
const pageNo = ref(1);
const pageSize = ref(10);
const isEnd = ref(false);
// 请求参数
const paramsCreate = reactive({
aiType: "dalle3",
pageNo: pageNo.value,
pageSize: pageSize.value,
});
const fetchImages = async () => {
const ctdata = await xmgai.aiList(paramsCreate);
if (ctdata.code === 0) {
feedback.msgError(ctdata.msg);
return [];
}
if (ctdata.code === 1) {
const data = ctdata.data.lists;
if (data.length === 0) {
isEnd.value = true;
return [];
}
paramsCreate.pageNo++;
return data.map(item => ({
...item, // 保留所有原始字段
url: filePrefix + item.localUrls,
width: 300 + Math.random() * 300,
height: 400 + Math.random() * 300,
}));
}
};
const fetchMoreImages = async () => {
if (isEnd.value) {
return; // 如果已经没有更多数据了,直接返回
}
const newImages = await fetchImages();
images.value = [...newImages];
};
// 列数设置
const columns = ref(4); // 你可以在这里修改列数
//放大预览
const previewImg = (item) => {
console.info("item",item.url);
previewURL.value = item.url
}
onMounted(async () => {
const initialImages = await fetchImages();
images.value = initialImages;
});
</script>
<style scoped>
.page-dall {
background-color: #0c1c9181;
border-radius: 10px; /* 所有角的圆角大小相同 */
border: 1px solid #3399FF;
}
.page-dall .inner {
display: flex;
}
.page-dall .inner .sd-box {
margin: 10px;
background-color: #222542b4;
width: 100%;
padding: 10px;
border-radius: 10px;
color: #ffffff;
font-size: 14px;
}
.page-dall .inner .sd-box h2 {
font-weight: bold;
font-size: 20px;
text-align: center;
color: #ffffff;
}
.page-dall .inner .right-box {
margin: 10px;
background-color: #222542b4;
width: 100%;
padding: 10px;
border-radius: 10px;
color: #ffffff;
font-size: 14px;
}
.page-dall .inner .right-box h2 {
font-weight: bold;
font-size: 20px;
text-align: center;
color: #ffffff;
}
.submit-btn {
padding: 10px 15px 0 15px;
text-align: center;
}
::v-deep(.el-form-item__label) {
color: white !important;
}
.container {
height: 600px;
border: 2px solid #000;
margin-top: 10px;
margin-left: auto;
margin-right: auto; /* 添加居中处理 */
}
.card-box {
position: relative;
width: 100%;
height: 100%;
border-radius: 4px;
overflow: hidden;
}
.card-box img {
width: 100%;
height: 100%;
object-fit: cover;
}
.card-box .remove {
display: none;
position: absolute;
right: 10px;
top: 10px;
}
.card-box:hover .remove {
display: block;
}
</style>
项目源码和问题交流,可以通过文末名片找到我。