uniapp | u-waterfall实现瀑布流商品列表(支持筛选查询)
一、组件结构和主要功能模块
实现了商品分类筛选和瀑布流展示功能,主要包含以下模块:
- 顶部分类筛选栏(dropdown-tab)
- 自定义分类选择弹窗(custom-picker)
- 瀑布流商品列表展示(waterfall-list)
1.数据筛选
(1)分类筛选触发:
(2)顶部筛选触发
(3)自定义分类选择流程
2.参数传递和数据结构
// 商品类型映射
const goodsTypeMap = {'全部商品': undefined,'定制商品': 1,'现货商品': 0
}// 排序类型映射
const sortTypeMap = {'热门推荐': undefined,'浏览最多': 'hot','销量最多': 'sales','价格最低': 'price'
}
通过 emitCategoryChange 方法将筛选参数发送给父组件:
const emitCategoryChange = () => {// 构造参数对象const params = {cate_id: queryParams.value.cate_id,sub_cate_id: queryParams.value.sub_cate_id,goods_type: queryParams.value.goods_type,sort: queryParams.value.sort}// 移除undefined属性// 发送事件给父组件emit('categoryChange', params)
}
3.瀑布流实现机制
(1)组件引用:
<waterfall-listv-if="isWaterfall":product-list="productList":enable-load-more="true":is-logged-in="userStore.isLogin"ref="waterfallRef"@click="handleProductClick"/>const waterfallRef = ref<any>(null)// 刷新瀑布流数据
const refreshWaterfall = () => {waterfallRef.value?.refreshData()
}// 在emitCategoryChange中触发刷新
setTimeout(() => {if (waterfallRef.value?.refreshData) {waterfallRef.value.refreshData()}
}, 100)
(2)监听数据变化,更新商品列表:
其实这里u-waterfall组件它有一个小bug,也不算是bug,就是在需要频繁更新商品列表操作的情况下(删除,修改,查询.......)等,在父子组件中更新数据渲染页面会有问题,所以我这里用了isWaterfall来实现页面及时渲染更新
// 监听商品列表数据变化
watch(() => props.productList,() => {isWaterfall.value = falsenextTick(() => {isWaterfall.value = true})}
)
4.整体数据流
二、实现效果
三、实现代码
父组件product-list
<template><view class="product-library-container"><u-pickerv-model="show"mode="selector"@confirm="handleConfirm":range="tabList"range-key="label":default-selector="currentTab"></u-picker><!-- 下拉分类选择 --><viewclass="dropdown-tab":class="{ fixed: arriveProduct }":style="{ top: arriveProduct ? navbarWrapperHeight + 'px' : '8px' }"><view class="picker-wrapper"><view class="picker-box" @click="handleClick('1')"><text>{{ tabListText[0] }}</text><u-icon name="arrow-down"></u-icon></view><view class="picker-box border-line" @click="showCustomPicker = true"><text>{{ tabListText[1] }}</text><u-icon name="arrow-down"></u-icon></view><view class="picker-box" @click="handleClick('3')"><text>{{ tabListText[2] }}</text><u-icon name="arrow-down"></u-icon></view></view></view><!-- 自定义弹出框 --><u-popup v-model="showCustomPicker" mode="bottom" height="70%" border-radius="10"><view class="custom-picker"><!-- 左边侧边栏 --><view class="sidebar"><viewv-for="(category, index) in list":key="index"class="category-item":class="{ active: currentIndex === index }"@click="selectCategory(index)">{{ category.name }}</view></view><!-- 右边具体商品分类 --><view class="content"><viewv-for="(subcategory, subIndex) in currentSubcategories":key="subIndex"class="subcategory-item"@click="selectSubcategory(subcategory)"><image :src="subcategory.image" mode="aspectFit"></image><text>{{ subcategory.name }}</text></view></view></view></u-popup><waterfall-listv-if="isWaterfall":product-list="productList":enable-load-more="true":is-logged-in="userStore.isLogin"ref="waterfallRef"@click="handleProductClick"/></view>
</template>
<script setup lang="ts">
import { ref, computed, unref, watch, reactive, nextTick } from 'vue'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const waterfallRef = ref<any>(null)
const isWaterfall = ref(false)
// 刷新瀑布流数据
const refreshWaterfall = () => {waterfallRef.value?.refreshData()
}
// 商品数据结构
interface ProductItem {id: numbervirtual_sales_num: numbergoods_type_name: stringgoods_name: stringgoods_pic: stringprice: stringweight_spec: stringsales_num: numbergoods_type: numbercate_id: numbersub_cate_id: numbergoods_ips_id: number
}
// 定义 props 接收父组件传递的数据
interface Props {list: CategoryItem[]productList: ProductItem[]arriveProduct: booleannavbarWrapperHeight: number
}
const props = defineProps<Props>()
// 添加 emit 定义
const emit = defineEmits<{(e: 'categoryChange',params: {cate_id: numbersub_cate_id: numbergoods_type?: numbersort?: string}): void
}>()
// 商品分类数据结构
interface CategoryItem {id: numbername: stringimage: stringpid: numbersub_cate: SubCategoryItem[]
}// 子分类数据结构
interface SubCategoryItem {id: numbername: stringimage: stringsort: numberpid: number
}
const showCustomPicker = ref(false)const currentIndex = ref(0)
const currentSubcategories = computed(() => {const currentCategory = props.list[currentIndex.value]return currentCategory?.sub_cate ?? []
})const selectCategory = (index: number) => {// console.log('Selected category:', index)if (index >= 0 && index < props.list.length) {currentIndex.value = index}
}const selectSubcategory = (subcategory: SubCategoryItem) => {// console.log('Selected subcategory:', subcategory)showCustomPicker.value = falsetabListText.value[1] = subcategory.namelegacyQueryParams.value.type2 = subcategory.id// 更新查询参数queryParams.value.cate_id = props.list[currentIndex.value]?.id || 0queryParams.value.sub_cate_id = subcategory.id// console.log('选择子分类:', {// cate_id: queryParams.value.cate_id,// sub_cate_id: queryParams.value.sub_cate_id,// category_name: props.list[currentIndex.value]?.name,// subcategory_name: subcategory.name// })// 发送查询请求emitCategoryChange()
}
//监听商品列表数据
watch(() => props.productList,() => {isWaterfall.value = falsenextTick(() => {isWaterfall.value = true})}
)
// 分类 tab
const tabList = ref<any[]>()
// 记录点击的下拉
let noteType = '1'
const listMap: {[key: string]: {value: numberlabel: string}[]
} = {1: [{value: 1,label: '全部商品'},{value: 2,label: '定制商品'},{value: 3,label: '现货商品'}],2: [{value: 1,label: '全部商品'}],3: [{value: 1,label: '热门推荐'},{value: 2,label: '浏览最多'},{value: 3,label: '销量最多'},{value: 4,label: '价格最低'}]
}
const tabListText = ref(['全部商品', '全部商品', '热门推荐'])// 定义查询参数接口
interface QueryParams {cate_id: numbersub_cate_id: numbergoods_type?: numbersort?: string
}// 查询参数状态
const queryParams = ref<QueryParams>({cate_id: 0,sub_cate_id: 0
})// 商品类型映射配置
const goodsTypeMap: Record<string, number | undefined> = {全部商品: undefined,定制商品: 1,现货商品: 0
}// 排序类型映射配置
const sortTypeMap: Record<string, string | undefined> = {热门推荐: undefined,浏览最多: 'hot',销量最多: 'sales',价格最低: 'price'
}interface queryParama {type1: numbertype2: numbertype3: number
}
const legacyQueryParams = ref<queryParama>({type1: 0,type2: 0,type3: 0
})
const currentTab = ref([0])
const show = ref(false)
const handleClick = (type: string) => {tabList.value = listMap[type]const index = (unref(tabList) as {value: numberlabel: string}[]).findIndex((l) => l.label == unref(tabListText)[Number(noteType) - 1])currentTab.value = [index]noteType = typeshow.value = true
}
const handleConfirm = (item: any) => {const index = item[0]const textIndex = Number(noteType) - 1const arrCopy = unref(tabListText)const selectedLabel = listMap[noteType][index].labelarrCopy[textIndex] = selectedLabeltabListText.value = arrCopy// 更新传统查询参数(保持向后兼容)if (noteType == '1') {legacyQueryParams.value.type1 = listMap[noteType][index].value// 处理商品类型查询const goodsType = goodsTypeMap[selectedLabel]queryParams.value.goods_type = goodsType// console.log('选择商品类型:', selectedLabel, '映射值:', goodsType)} else if (noteType == '2') {legacyQueryParams.value.type2 = listMap[noteType][index].value// 分类查询在selectSubcategory中处理} else if (noteType == '3') {legacyQueryParams.value.type3 = listMap[noteType][index].value// 处理排序类型查询const sortType = sortTypeMap[selectedLabel]queryParams.value.sort = sortType// console.log('选择排序类型:', selectedLabel, '映射值:', sortType)}// 发送查询请求emitCategoryChange()console.log('查询参数更新:', queryParams.value)
}
// 统一发送查询事件的函数
const emitCategoryChange = () => {const params = {cate_id: queryParams.value.cate_id,sub_cate_id: queryParams.value.sub_cate_id,goods_type: queryParams.value.goods_type,sort: queryParams.value.sort}// 移除undefined的属性Object.keys(params).forEach((key) => {if (params[key as keyof typeof params] === undefined) {delete params[key as keyof typeof params]}})console.log('发送查询参数:', params)emit('categoryChange', params)// 等待数据更新后刷新(延迟执行以确保数据已更新)setTimeout(() => {if (waterfallRef.value?.refreshData) {waterfallRef.value.refreshData()}}, 100)
}// const fetchApi = (params: queryParama) => {
// console.log('发送请求-----》', params)
// }
// watch(
// queryParams,
// (newVal) => {
// console.log('触发----》', newVal.type2)
// fetchApi(newVal)
// },
// {
// deep: true
// }
// )
const handleProductClick = (product: ProductItem) => {console.log('点击了商品')//如果用户没有登录,则跳转到登录页面if (!userStore.userInfo.id) {uni.navigateTo({url: '/pages/user/login'})return} else {const productInfo = JSON.stringify({id: product.id,goods_name: product.goods_name})uni.navigateTo({url: `/pages/index/product-detail?productInfo=${encodeURIComponent(productInfo)}`})}
}
</script>
<style scoped>
.product-library-container {background-color: #fff;padding-top: 10rpx;
}/* 自定义弹出框 */
.custom-picker {display: flex;height: 100%;
}.sidebar {width: 200rpx;background-color: #f5f5f5;overflow-y: auto; /* 如果类别较多,允许滚动 */
}.category-item {padding: 24rpx;text-align: center;font-size: 28rpx;border-bottom: 1rpx solid #ddd;
}.category-item.active {background-color: #fff;
}.content {flex: 1;padding: 20rpx;display: flex;flex-wrap: wrap;overflow-y: auto; /* 如果子类别较多,允许滚动 */
}.subcategory-item {width: 33.33%;text-align: center;margin-bottom: 20rpx;
}.subcategory-item image {width: 100rpx;height: 100rpx;
}.subcategory-item text {display: block;margin-top: 10rpx;font-size: 24rpx;
}
/* 分类标签栏 */
.dropdown-tab {width: 100%;height: 75rpx;padding: 5rpx 20rpx;background-color: #fff;border-bottom: 1px solid #f5f5f5;display: flex;align-items: center;justify-content: center;/* margin-top: -15rpx; */
}
.fixed {position: fixed;left: 0;right: 0;z-index: 999;
}
.dropdown-trigger {display: flex;justify-content: space-between;align-items: center;padding: 0 20rpx;height: 60rpx;background-color: #f5f5f5;border-radius: 30rpx;color: #333;font-size: 28rpx;
}.picker-wrapper {display: flex;justify-content: space-between;width: 100%;
}.picker-box {display: flex;justify-content: center;align-items: center;flex: 1;/* margin: 0 10px;padding: 12rpx; */
}
.border-line {border-left: 5rpx solid #a4a4a4;border-right: 5rpx solid #a4a4a4;
}
</style>
子组件waterfall-list
<template><view class="wrap"><u-waterfall v-model="flowList" ref="uWaterfall1"><template v-slot:left="{ leftList }"><viewclass="demo-warter"v-for="(item, index) in leftList":key="index"@click="$emit('click', item)"><view class="product-img-wrap"><u-lazy-loadthreshold="-450"border-radius="10":image="item.goods_pic[0]":index="index"></u-lazy-load><view class="product-badge" v-if="item.goods_type_name"><view class="badge-capsule">{{ item.goods_type_name }}</view></view><view class="del-badge" v-if="item.del"><view class="badge-circle"><u-icon name="trash" color="#fff" size="34"></u-icon></view></view></view><view class="demo"><view class="demo-title">{{ item.goods_name }}</view><view class="flex-row"><view class="demo-info"><view class="flex-row"><view class="demo-spec">工费:</view><view class="demo-price" v-if="isLoggedIn">¥{{ item.price }}</view><view class="demo-price" v-else>¥???</view></view><view class="demo-spec"> 重量:{{ item.weight_spec }} </view></view><view class="demo-sales">销量:{{item.sales_num >= item.virtual_sales_num? item.sales_num: item.virtual_sales_num}}</view></view></view></view></template><template v-slot:right="{ rightList }"><viewclass="demo-warter"v-for="(item, index) in rightList":key="index"@click="$emit('click', item)"><view class="product-img-wrap"><u-lazy-loadthreshold="-450"border-radius="10":image="item.goods_pic[0]":index="index"></u-lazy-load><view class="product-badge" v-if="item.goods_type_name"><view class="badge-capsule">{{ item.goods_type_name }}</view></view><view class="del-badge" v-if="item.del"><view class="badge-circle"><u-icon name="trash" color="#fff" size="34"></u-icon></view></view></view><view class="demo"><view class="demo-title">{{ item.goods_name }}</view><view class="flex-row"><view class="demo-info"><view class="flex-row"><view class="demo-spec">工费:</view><view class="demo-price" v-if="isLoggedIn">¥{{ item.price }}</view><view class="demo-price" v-else>¥???</view></view><view class="demo-spec"> 重量:{{ item.weight_spec }} </view></view><view class="demo-sales">销量:{{item.sales_num >= item.virtual_sales_num? item.sales_num: item.virtual_sales_num}}</view></view></view></view></template></u-waterfall><u-loadmore v-if="props.showLoadMore" :status="loadStatus"></u-loadmore></view>
</template><script lang="ts" setup>
import { ref, onMounted, watch } from 'vue'
import { onLoad, onReachBottom } from '@dcloudio/uni-app'
interface TopicsProductItem {id: numbervirtual_sales_num?: numbergoods_type_name: stringgoods_name: stringgoods_pic: stringprice: stringweight_spec: stringsales_num: numbergoods_type: numbercate_id?: numbersub_cate_id?: numbergoods_ips_id?: numberdel?: boolean
}
// 定义组件 props
interface Props {// 外部传入的商品数据列表productList?: TopicsProductItem[]// 是否启用下拉加载更多功能enableLoadMore?: boolean// 是否显示加载更多组件showLoadMore?: booleanisLoggedIn?: boolean
}// 设置默认值
const props = withDefaults(defineProps<Props>(), {productList: () => [],enableLoadMore: true,showLoadMore: true, // 添加默认值isLoggedIn: false // 默认未登录
})// 响应式数据
const loadStatus = ref<string>('nomore')
const flowList = ref<TopicsProductItem[]>([])
const uWaterfall1 = ref<any>(null) // 用于引用u-waterfall组件实例
const loadedIndex = ref<number>(0) // 跟踪已加载的数据索引// 监听 productList 的变化
watch(() => props.productList,(newList) => {console.log(`数据更新: ${newList?.length || 0}条`)// 重置状态loadedIndex.value = 0flowList.value = [...newList]},{ immediate: true, deep: true }
)
//监听isLoggedIn的值
watch(() => props.isLoggedIn,(newValue) => {console.log('isLoggedIn变化了------->', newValue)},{ immediate: true }
)// onReachBottom 函数
onReachBottom(() => {// 只有在还有数据可加载时才继续加载if (props.enableLoadMore && loadStatus.value !== 'nomore') {loadStatus.value = 'loading'}
})// 暴露给父组件的方法
defineExpose({// 刷新数据refreshData: () => {loadedIndex.value = 0flowList.value = []loadStatus.value = 'loadmore'},// 重新加载所有数据(不启用分页)loadAllData: () => {flowList.value = [...props.productList]loadStatus.value = 'nomore'}
})
</script><style lang="scss" scoped>
.wrap {padding-bottom: 20rpx;
}
.demo {width: 50vw;padding: 8px;
}
.demo-warter {width: 49vw;border-radius: 8px;margin: 2px;background-color: #ffffff;position: relative;box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
}.product-badge {position: absolute;top: 10rpx;left: 10rpx;
}
.del-badge {position: absolute;top: 10rpx;right: 10rpx;
}.badge-capsule {display: inline-block;padding: 4rpx 16rpx;background-color: rgba(255, 255, 255, 0.5);border-radius: 50rpx;font-size: 24rpx;color: #fff;
}.badge-circle {width: 50rpx;height: 50rpx;padding: 9rpx;border-radius: 50%;background-color: rgba(255, 255, 255, 0.5);
}.product-img-wrap {height: 49vw;width: 49vw;position: relative;overflow: hidden;
}
.u-close {position: absolute;top: 32rpx;right: 32rpx;
}.demo-image {width: 100%;border-radius: 4px;
}.demo-title {width: 100%;font-size: 30rpx;font-weight: bold;color: $u-main-color;text-overflow: ellipsis;white-space: nowrap;overflow: hidden;
}
.flex-row {display: flex;flex-direction: row;justify-content: space-between;align-items: center;
}.demo-info {display: flex;margin-left: 5rpx;flex-direction: column;
}.demo-price {font-size: 30rpx;color: $u-type-error;
}.demo-sales {font-size: 24rpx;color: #636363;// 垂直居中justify-self: center;align-self: center;// 居右text-align: right;
}.demo-spec {font-size: 24rpx;color: #636363;margin-top: 5px;
}.demo-tag {display: flex;margin-top: 5px;
}.demo-tag-owner {background-color: $u-type-error;color: #ffffff;display: flex;align-items: center;padding: 4rpx 14rpx;border-radius: 50rpx;font-size: 20rpx;line-height: 1;
}.demo-tag-text {border: 1px solid $u-type-primary;color: $u-type-primary;margin-left: 10px;border-radius: 50rpx;line-height: 1;padding: 4rpx 14rpx;display: flex;align-items: center;border-radius: 50rpx;font-size: 20rpx;
}.demo-shop {font-size: 22rpx;color: $u-tips-color;margin-top: 5px;
}
</style>