Files
lgp-wx-new/pages/product/product.vue
2026-06-05 14:06:20 +08:00

559 lines
13 KiB
Vue

<template>
<view class="container">
<!-- Search Bar (独立按钮版) -->
<view class="header-section">
<view class="search-bar-row">
<view class="search-input-box">
<up-search
type="text"
placeholder="搜索产品型号"
placeholder-class="search-placeholder"
v-model="title"
:showAction="false"
:animation="false"
bgColor="#f5f5f5"
shape="round"
@search="handleSearch"
/>
</view>
<view class="custom-search-btn" @tap="handleSearch" hover-class="btn-hover">
搜索
</view>
</view>
<!-- Category Tabs (Sticky) -->
<view class="tabs-wrapper">
<scroll-view
class="category-tabs"
scroll-x
show-scrollbar="false"
:scroll-into-view="'tab-' + currentTab"
scroll-with-animation
>
<view
v-for="(tab, index) in tabs"
:key="index"
:id="'tab-' + index"
:class="['tab-item', { active: currentTab === index }]"
@tap="switchTab(index, tab.id)"
>
<text class="tab-text">{{ tab.name }}</text>
<view class="active-dot" v-if="currentTab === index"></view>
</view>
</scroll-view>
</view>
</view>
<!-- Content Area -->
<view class="content-area">
<!-- Loading State (Skeleton Screen) -->
<view class="loading-container" v-if="loading && products.length === 0">
<!-- CSS 骨架屏结构 -->
<view class="waterfall-container">
<view class="waterfall-column">
<view class="skeleton-card" v-for="i in 3" :key="'l'+i">
<view class="skeleton-img pulse"></view>
<view class="skeleton-text pulse"></view>
</view>
</view>
<view class="waterfall-column">
<view class="skeleton-card" v-for="i in 3" :key="'r'+i">
<view class="skeleton-img pulse" style="height: 300rpx"></view>
<view class="skeleton-text pulse"></view>
</view>
</view>
</view>
</view>
<!-- Empty State -->
<view class="empty-container" v-else-if="products.length === 0">
<image src="/static/images/empty.png" mode="aspectFit" class="empty-image animate-float" />
<text class="empty-text">暂无相关产品</text>
</view>
<!-- Waterfall Layout -->
<view class="waterfall-container" v-else>
<view class="waterfall-column">
<view
v-for="(product, index) in leftProducts"
:key="product.id"
class="product-item animate-card-enter"
:style="{ animationDelay: index * 0.1 + 's' }"
@tap="navigateToDetail(product.id)"
>
<view class="img-box">
<image
:src="product.cover"
mode="widthFix"
class="product-image"
@load="imageLoaded(index * 2)"
/>
</view>
<view class="info-box">
<text class="product-code">{{ product.title }}</text>
</view>
</view>
</view>
<view class="waterfall-column">
<view
v-for="(product, index) in rightProducts"
:key="product.id"
class="product-item animate-card-enter"
:style="{ animationDelay: (index * 0.1 + 0.05) + 's' }"
@tap="navigateToDetail(product.id)"
>
<view class="img-box">
<image
:src="product.cover"
mode="widthFix"
class="product-image"
@load="imageLoaded(index * 2 + 1)"
/>
</view>
<view class="info-box">
<text class="product-code">{{ product.title }}</text>
</view>
</view>
</view>
</view>
<!-- Footer State -->
<view class="footer-state">
<view class="loading-more" v-if="loadingMore">
<view class="loading-spinner"></view>
</view>
<view class="no-more-data" v-if="noMoreData && products.length > 0">
<text class="divider-line"></text>
<text>THE END</text>
<text class="divider-line"></text>
</view>
</view>
</view>
</view>
</template>
<script>
import { getCategoryListByPidApi, getProductListApi } from "@/api/page/product"
export default {
data() {
return {
page: 1,
pageSize: 10,
pid: 0,
currentTab: 0,
categoryId: 0,
title: "",
tabs: [],
products: [],
loading: false,
loadingMore: false,
noMoreData: false,
totalPages: 1,
}
},
computed: {
// 将产品分为左右两列,实现瀑布流布局
leftProducts() {
return this.products.filter((_, index) => index % 2 === 0)
},
rightProducts() {
return this.products.filter((_, index) => index % 2 === 1)
},
},
onLoad(options) {
if (options && options.pid) {
this.pid = options.pid
this.categoryId = options.pid
this.getCategoryList()
} else {
uni.showToast({
title: "参数错误",
icon: "none",
})
}
},
onPullDownRefresh() {
this.refreshProductList()
},
onReachBottom() {
this.loadMoreProducts()
},
mounted() {
this.getProductList()
},
methods: {
navigateBack() {
uni.navigateBack()
},
switchTab(index, id) {
this.currentTab = index
this.categoryId = id
this.page = 1
this.products = []
this.noMoreData = false
this.getProductList()
},
navigateToDetail(id) {
uni.navigateTo({
url: `/pages/product/detail?id=${id}`,
})
},
getCategoryList() {
getCategoryListByPidApi(this.pid).then((res) => {
res.unshift({
id: 0,
name: "全部",
})
this.tabs = res
})
},
handleSearch() {
// 这里的搜索逻辑是正确的:重置分页和数据,重新请求
this.page = 1
this.products = []
this.noMoreData = false
this.getProductList()
},
refreshProductList() {
this.page = 1
this.products = []
this.noMoreData = false
this.getProductList()
.then(() => {
uni.stopPullDownRefresh()
uni.showToast({
title: "刷新成功",
icon: "success",
duration: 1500,
})
})
.catch(() => {
uni.stopPullDownRefresh()
uni.showToast({
title: "刷新失败",
icon: "none",
duration: 1500,
})
})
},
loadMoreProducts() {
if (this.loadingMore || this.noMoreData) return
this.page++
this.loadingMore = true
getProductListApi({
page: this.page,
size: this.pageSize,
title: this.title,
category_id: this.categoryId,
})
.then((res) => {
if (res.data && res.data.length > 0) {
this.products = [...this.products, ...res.data]
if (res.data.length < this.pageSize || this.page >= this.totalPages) {
this.noMoreData = true
}
} else {
this.noMoreData = true
}
this.loadingMore = false
})
.catch(() => {
this.page--
this.loadingMore = false
uni.showToast({
title: "加载失败",
icon: "none",
})
})
},
getProductList() {
this.loading = true
return new Promise((resolve, reject) => {
getProductListApi({
page: this.page,
size: this.pageSize,
title: this.title,
category_id: this.categoryId,
})
.then((res) => {
if (this.page === 1) {
this.products = res.data || []
} else {
this.products = [...this.products, ...(res.data || [])]
}
if (!res.data || res.data.length < this.pageSize) {
this.noMoreData = true
}
if (res.last_page) {
this.totalPages = res.last_page
}
this.loading = false
resolve(res)
})
.catch((err) => {
this.loading = false
uni.showToast({
title: "加载失败",
icon: "none",
})
reject(err)
})
})
},
imageLoaded(index) {
},
},
}
</script>
<style lang="scss">
.container {
min-height: 100vh;
background-color: #F7F8FA;
display: flex;
flex-direction: column;
}
.header-section {
background: #fff;
position: sticky;
top: 0;
z-index: 10;
box-shadow: 0 4rpx 10rpx rgba(0,0,0,0.02);
}
/* 独立的搜索行样式 */
.search-bar-row {
display: flex;
align-items: center;
padding: 20rpx 30rpx;
gap: 20rpx;
}
.search-input-box {
flex: 1;
}
.custom-search-btn {
padding: 12rpx 30rpx;
background: #B4854D;
color: #fff;
font-size: 28rpx;
border-radius: 40rpx;
font-weight: 500;
box-shadow: 0 4rpx 10rpx rgba(180, 133, 77, 0.3);
transition: opacity 0.2s;
}
.btn-hover {
opacity: 0.8;
}
.tabs-wrapper {
border-bottom: 1rpx solid #f0f0f0;
}
.category-tabs {
white-space: nowrap;
padding: 0 10rpx;
height: 90rpx;
}
.tab-item {
display: inline-block;
padding: 0 30rpx;
height: 90rpx;
line-height: 90rpx;
position: relative;
transition: all 0.3s ease;
}
.tab-text {
font-size: 28rpx;
color: #666;
font-weight: 400;
transition: all 0.3s;
}
.tab-item.active .tab-text {
color: #B4854D;
font-size: 30rpx;
font-weight: 600;
}
.active-dot {
position: absolute;
bottom: 16rpx;
left: 50%;
transform: translateX(-50%);
width: 8rpx;
height: 8rpx;
background-color: #B4854D;
border-radius: 50%;
box-shadow: 0 2rpx 4rpx rgba(180, 133, 77, 0.4);
}
/* 瀑布流布局优化 */
.waterfall-container {
display: flex;
padding: 24rpx;
align-items: flex-start;
}
.waterfall-column {
flex: 1;
display: flex;
flex-direction: column;
gap: 24rpx;
}
.waterfall-column:first-child {
padding-right: 12rpx;
}
.waterfall-column:last-child {
padding-left: 12rpx;
}
.product-item {
background-color: #ffffff;
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0 8rpx 20rpx rgba(0, 0, 0, 0.03);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.product-item:active {
transform: scale(0.98);
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.02);
}
.product-image {
width: 100%;
height: auto;
display: block;
}
.info-box {
padding: 24rpx 20rpx;
}
.product-code {
display: block;
font-size: 28rpx;
color: #333333;
line-height: 1.4;
font-weight: 500;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 动画 Keyframes */
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(40rpx); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse {
0% { opacity: 0.6; background: #eee; }
50% { opacity: 1; background: #ddd; }
100% { opacity: 0.6; background: #eee; }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes float {
0% { transform: translateY(0); }
50% { transform: translateY(-10rpx); }
100% { transform: translateY(0); }
}
.animate-card-enter {
animation: fadeInUp 0.6s cubic-bezier(0.2, 0.8, 0.2, 1) backwards;
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
/* 骨架屏样式 */
.skeleton-card {
background: #fff;
border-radius: 16rpx;
overflow: hidden;
margin-bottom: 24rpx;
}
.skeleton-img {
width: 100%;
height: 240rpx;
background: #eee;
}
.skeleton-text {
height: 30rpx;
margin: 20rpx;
width: 60%;
background: #eee;
border-radius: 8rpx;
}
.pulse {
animation: pulse 1.5s infinite ease-in-out;
}
/* 底部状态 */
.loading-more {
display: flex;
justify-content: center;
padding: 30rpx 0;
}
.loading-spinner {
width: 30rpx;
height: 30rpx;
border: 4rpx solid #ddd;
border-top-color: #B4854D;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.no-more-data {
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx 0;
color: #ccc;
font-size: 22rpx;
letter-spacing: 2rpx;
}
.divider-line {
width: 40rpx;
height: 2rpx;
background: #eee;
margin: 0 16rpx;
}
/* 空状态 */
.empty-container {
padding-top: 200rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.empty-image {
width: 240rpx;
height: 240rpx;
opacity: 0.8;
}
</style>