前端不规则卡片布局实现
关键代码
// html
<page-table
:api="ajaxApi"
:defaultData="defaultData"
ref="pageRef"
class="table-body-contain"
:pageSize="8"
@passData="onPassDataHandle"
>
<template #="{ datas, loading }">
<div class="img-text-box" ref="imgTextBoxRef" v-loading="loading">
<template v-if="datas.length !== 0">
<div
v-for="item in datas"
:key="item.id"
class="image-text-card"
:class="{ selected: selectedId === item.id }"
@click="setSelect(item)"
>
<div class="image-text-card-content">
<div class="mask">
<Icon class="check-icon" icon="Check"></Icon>
</div>
<div class="title">{{ item.imgTextList[0].title }}</div>
<ImageTextPreview
:list="item.imgTextList"
size="mini"
:maxHeight="200"
:dynamicHeight="true"
/>
</div>
</div>
</template>
<div v-else style="width: 100%">
<el-empty description="暂无数据" />
</div>
</div>
</template>
</page-table>
// 逻辑
const heightArray = ref<number[]>([])
const getWaterfall = () => {
let columns = 4 //定义布局的列数为5
let totalWidth = 0
const items: NodeListOf<HTMLElement> =
document.querySelectorAll('.image-text-card')
for (let i = 0; i < items.length; i++) {
//遍历整个子元素的DOM集合
if (i < columns) {
//小于columns的子元素作为第一行
items[i].style.top = 20 + 'px'
items[i].style.left = (items[0].clientWidth + 20) * i + 'px'
totalWidth += items[0].clientWidth + 20
heightArray.value.push(items[i].clientHeight) //遍历结束时,数组this.array保存的是第一行子元素的元素高度
} else {
//大于等于columns的子元素将作其他行
let minHeight = Math.min(...heightArray.value) // 找到第一列的最小高度
let index = heightArray.value.findIndex((item) => item === minHeight) // 找到最小高度的索引
//设置当前子元素项的位置
items[i].style.top =
heightArray.value[index] + 20 + 20 * Math.floor(i / 4) + 'px'
items[i].style.left = items[index].offsetLeft + 'px'
//重新定义数组最小项的高度 进行累加
heightArray.value[index] += items[i].clientHeight
}
}
if (imgTextBoxRef.value) {
imgTextBoxRef.value.style.height =
Math.max(...heightArray.value) + 40 + 'px'
}
}
完整代码
<template>
<div class="picImgText-dialog">
<dialog-com
:visible="visible"
:width="900"
title="选择图文"
@confrimMethod="confrimMethod"
@changeVisible="$emit('changeVisible', $event)"
>
<div class="search-box">
<el-input
v-model="title"
class="title-input"
placeholder="标题"
clearable
>
<template #append>
<el-button :icon="Search" @click="onSearchHandle" />
</template>
</el-input>
</div>
<div class="content-box" ref="contentBoxRef">
<page-table
:api="ajaxApi"
:defaultData="defaultData"
ref="pageRef"
class="table-body-contain"
:pageSize="8"
@passData="onPassDataHandle"
>
<template #="{ datas, loading }">
<div class="img-text-box" ref="imgTextBoxRef" v-loading="loading">
<template v-if="datas.length !== 0">
<div
v-for="item in datas"
:key="item.id"
class="image-text-card"
:class="{ selected: selectedId === item.id }"
@click="setSelect(item)"
>
<div class="image-text-card-content">
<div class="mask">
<Icon class="check-icon" icon="Check"></Icon>
</div>
<div class="title">{{ item.imgTextList[0].title }}</div>
<ImageTextPreview
:list="item.imgTextList"
size="mini"
:maxHeight="200"
:dynamicHeight="true"
/>
</div>
</div>
</template>
<div v-else style="width: 100%">
<el-empty description="暂无数据" />
</div>
</div>
</template>
</page-table>
</div>
</dialog-com>
</div>
</template>
<script setup="props, { emit }" lang="ts">
import { reactive, toRefs, onMounted, ref, nextTick } from 'vue'
import { Search, Plus, Check } from '@element-plus/icons-vue'
import ImageTextPreview from './common/imageTextPreview.vue'
import { getMessData, addResource } from '@/api/material'
import { getImageTextList } from '@/api/material/imageText'
import { genFileId, UploadRequestOptions } from 'element-plus'
import { tipMsg } from '@/utils/message'
interface PageRef extends HTMLElement {
loadData(): void
}
interface ContentBoxRef extends HTMLElement {}
interface ImgTextBoxRef extends HTMLElement {}
interface Props {
visible: Boolean
withMediaId: Boolean
isSingle: Boolean
}
const props = withDefaults(defineProps<Props>(), {
visible: () => false,
withMediaId: () => false,
isSingle: () => false,
})
const emits = defineEmits(['changeVisible', 'confirm'])
const pageRef = ref<PageRef | null>(null)
const contentBoxRef = ref<ContentBoxRef | null>(null)
const imgTextBoxRef = ref<ImgTextBoxRef | null>(null)
const state = reactive({
title: '',
selectedId: 0,
selectRecource: {},
defaultData: [],
})
const confrimMethod = (e: string) => {
if (e === 'confirm') {
submit()
}
}
const heightArray = ref<number[]>([])
const getWaterfall = () => {
let columns = 4 //定义布局的列数为5
let totalWidth = 0
const items: NodeListOf<HTMLElement> =
document.querySelectorAll('.image-text-card')
for (let i = 0; i < items.length; i++) {
//遍历整个子元素的DOM集合
if (i < columns) {
//小于columns的子元素作为第一行
items[i].style.top = 20 + 'px'
items[i].style.left = (items[0].clientWidth + 20) * i + 'px'
totalWidth += items[0].clientWidth + 20
heightArray.value.push(items[i].clientHeight) //遍历结束时,数组this.array保存的是第一行子元素的元素高度
} else {
//大于等于columns的子元素将作其他行
let minHeight = Math.min(...heightArray.value) // 找到第一列的最小高度
let index = heightArray.value.findIndex((item) => item === minHeight) // 找到最小高度的索引
//设置当前子元素项的位置
items[i].style.top =
heightArray.value[index] + 20 + 20 * Math.floor(i / 4) + 'px'
items[i].style.left = items[index].offsetLeft + 'px'
//重新定义数组最小项的高度 进行累加
heightArray.value[index] += items[i].clientHeight
}
}
if (imgTextBoxRef.value) {
imgTextBoxRef.value.style.height =
Math.max(...heightArray.value) + 40 + 'px'
}
}
onMounted(() => {
nextTick(() => {
if (contentBoxRef.value) {
const { width } = contentBoxRef.value.getBoundingClientRect()
if (imgTextBoxRef.value) {
imgTextBoxRef.value.style.width = width - 40 + 'px'
}
}
if (pageRef.value) {
pageRef.value.loadData()
}
})
})
const onPassDataHandle = () => {
nextTick(() => {
heightArray.value = []
getWaterfall()
})
}
const submit = () => {
if (!state.selectedId) {
tipMsg('请选择图文素材', 'error')
return
}
emits('confirm', {
resourceId: state.selectedId,
resource: state.selectRecource,
})
}
const setSelect = (item: any) => {
if (state.selectedId === item.id) {
state.selectedId = 0
state.selectRecource = {}
} else {
state.selectedId = item.id
state.selectRecource = item
}
}
const onSearchHandle = () => {
if (pageRef.value) {
pageRef.value.loadData()
}
}
const ajaxApi = {
getList(param: MaterialAPI.PageParams) {
return getImageTextList({
resourceTitle: state.title,
subResourceType: props.isSingle ? 1 : undefined, //只查询单图文
withMediaId: props.withMediaId ? 1 : undefined,
...param,
})
},
}
const { title, defaultData, selectedId } = toRefs(state)
</script>
<style scoped lang="scss">
.picImgText-dialog {
.search-box {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
.title-input {
width: 250px;
}
.btns {
display: flex;
align-items: center;
.tips {
font-size: 14px;
color: #9a9a9a;
padding-right: 5px;
}
}
}
.content-box {
display: flex;
width: 100%;
.left {
width: 200px;
ul li {
list-style: none;
padding: 10px 2px;
cursor: pointer;
&:hover {
background-color: #eaf5fb;
}
}
}
.img-text-box {
display: flex;
flex-wrap: wrap;
margin-left: 20px;
position: relative;
width: 100%;
min-height: 200px;
.image-text-card {
cursor: pointer;
position: absolute;
.image-text-card-content {
position: relative;
}
&.selected {
.mask {
display: flex;
}
}
.mask {
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.4);
position: absolute;
top: 0;
left: 0;
z-index: 2;
display: none;
justify-content: center;
align-items: center;
.check-icon {
width: 80px;
height: 80px;
color: #fff;
}
}
.title {
width: 190px;
background-color: #f5f5f5;
border: 1px solid #e4e4e6;
border-bottom: none;
padding: 8px;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
word-wrap: normal;
font-size: 14px;
}
}
}
}
}
</style>
效果
每一个人心中都有一团火,路过的人只看到烟!
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列01:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现
· 25岁的心里话