uniapp | u-waterfall实现瀑布流商品列表(支持筛选查询)

一、组件结构和主要功能模块

实现了商品分类筛选和瀑布流展示功能,主要包含以下模块:

  • 顶部分类筛选栏(dropdown-tab)
  • 自定义分类选择弹窗(custom-picker)
  • 瀑布流商品列表展示(waterfall-list)

1.数据筛选

(1)分类筛选触发:

(2)顶部筛选触发

(3)自定义分类选择流程

2.参数传递和数据结构

javascript 复制代码
// 商品类型映射
const goodsTypeMap = {
  '全部商品': undefined,
  '定制商品': 1,
  '现货商品': 0
}

// 排序类型映射
const sortTypeMap = {
  '热门推荐': undefined,
  '浏览最多': 'hot',
  '销量最多': 'sales',
  '价格最低': 'price'
}

通过 emitCategoryChange 方法将筛选参数发送给父组件:

javascript 复制代码
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)组件引用:

javascript 复制代码
<waterfall-list
            v-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来实现页面及时渲染更新

javascript 复制代码
// 监听商品列表数据变化
watch(
  () => props.productList,
  () => {
    isWaterfall.value = false
    nextTick(() => {
      isWaterfall.value = true
    })
  }
)

4.整体数据流

二、实现效果

三、实现代码

父组件product-list

html 复制代码
<template>
    <view class="product-library-container">
        <u-picker
            v-model="show"
            mode="selector"
            @confirm="handleConfirm"
            :range="tabList"
            range-key="label"
            :default-selector="currentTab"
        ></u-picker>
        <!-- 下拉分类选择 -->
        <view
            class="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">
                    <view
                        v-for="(category, index) in list"
                        :key="index"
                        class="category-item"
                        :class="{ active: currentIndex === index }"
                        @click="selectCategory(index)"
                    >
                        {{ category.name }}
                    </view>
                </view>
                <!-- 右边具体商品分类 -->
                <view class="content">
                    <view
                        v-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-list
            v-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: number
    virtual_sales_num: number
    goods_type_name: string
    goods_name: string
    goods_pic: string
    price: string
    weight_spec: string
    sales_num: number
    goods_type: number
    cate_id: number
    sub_cate_id: number
    goods_ips_id: number
}
// 定义 props 接收父组件传递的数据
interface Props {
    list: CategoryItem[]
    productList: ProductItem[]
    arriveProduct: boolean
    navbarWrapperHeight: number
}
const props = defineProps<Props>()
// 添加 emit 定义
const emit = defineEmits<{
    (
        e: 'categoryChange',
        params: {
            cate_id: number
            sub_cate_id: number
            goods_type?: number
            sort?: string
        }
    ): void
}>()
// 商品分类数据结构
interface CategoryItem {
    id: number
    name: string
    image: string
    pid: number
    sub_cate: SubCategoryItem[]
}

// 子分类数据结构
interface SubCategoryItem {
    id: number
    name: string
    image: string
    sort: number
    pid: 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 = false
    tabListText.value[1] = subcategory.name
    legacyQueryParams.value.type2 = subcategory.id

    // 更新查询参数
    queryParams.value.cate_id = props.list[currentIndex.value]?.id || 0
    queryParams.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 = false
        nextTick(() => {
            isWaterfall.value = true
        })
    }
)
// 分类 tab
const tabList = ref<any[]>()
// 记录点击的下拉
let noteType = '1'
const listMap: {
    [key: string]: {
        value: number
        label: 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: number
    sub_cate_id: number
    goods_type?: number
    sort?: 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: number
    type2: number
    type3: 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: number
            label: string
        }[]
    ).findIndex((l) => l.label == unref(tabListText)[Number(noteType) - 1])
    currentTab.value = [index]
    noteType = type
    show.value = true
}
const handleConfirm = (item: any) => {
    const index = item[0]
    const textIndex = Number(noteType) - 1
    const arrCopy = unref(tabListText)
    const selectedLabel = listMap[noteType][index].label
    arrCopy[textIndex] = selectedLabel
    tabListText.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

html 复制代码
<template>
    <view class="wrap">
        <u-waterfall v-model="flowList" ref="uWaterfall1">
            <template v-slot:left="{ leftList }">
                <view
                    class="demo-warter"
                    v-for="(item, index) in leftList"
                    :key="index"
                    @click="$emit('click', item)"
                >
                    <view class="product-img-wrap">
                        <u-lazy-load
                            threshold="-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 }">
                <view
                    class="demo-warter"
                    v-for="(item, index) in rightList"
                    :key="index"
                    @click="$emit('click', item)"
                >
                    <view class="product-img-wrap">
                        <u-lazy-load
                            threshold="-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: number
    virtual_sales_num?: number
    goods_type_name: string
    goods_name: string
    goods_pic: string
    price: string
    weight_spec: string
    sales_num: number
    goods_type: number
    cate_id?: number
    sub_cate_id?: number
    goods_ips_id?: number
    del?: boolean
}
// 定义组件 props
interface Props {
    // 外部传入的商品数据列表
    productList?: TopicsProductItem[]
    // 是否启用下拉加载更多功能
    enableLoadMore?: boolean
    // 是否显示加载更多组件
    showLoadMore?: boolean
    isLoggedIn?: 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 = 0
        flowList.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 = 0
        flowList.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>
相关推荐
草字4 小时前
uniapp、devceo华为鸿蒙运行模拟器报错:未开启Hyper-V
uni-app
FinelyYang5 小时前
uniapp富文本editor在插入emoji表情后,如何不显示软键盘?
uni-app
游戏开发爱好者85 小时前
iPhone HTTPS 抓包实战,原理、常见工具、SSL Pinning 问题与替代工具的解决方案
android·ios·小程序·https·uni-app·iphone·ssl
大棋局6 小时前
基于 UniApp 的弹出层选择器单选、多选组件,支持单选、多选、搜索、数量输入等功能。专为移动端优化,提供丰富的交互体验。
前端·uni-app
游戏开发爱好者88 小时前
App 上架平台全解析,iOS 应用发布流程、苹果 App Store 审核步骤
android·ios·小程序·https·uni-app·iphone·webview
2501_916007478 小时前
iOS 上架 App 费用详解 苹果应用发布成本、App Store 上架收费标准、开发者账号与审核实战经验
android·ios·小程序·https·uni-app·iphone·webview
anyup3 天前
历时 10 天+,速度提升 20 倍,新文档正式上线!uView Pro 开源组件库体验再升级!
前端·typescript·uni-app
CHB6 天前
uni-ai:让你的App快速接入AI
uni-app·deepseek
小徐_23338 天前
uni-app vue3 也能使用 Echarts?Wot Starter 是这样做的!
前端·uni-app·echarts