refactor(product): 将商品列表组件拆分为独立组件

将ProductList.vue中的商品展示和购物车逻辑提取到新的ProductContainer组件中,提升代码可维护性
This commit is contained in:
dzq 2025-06-26 17:47:58 +08:00
parent b9e0077b4d
commit c43ab6a184
2 changed files with 406 additions and 164 deletions

View File

@ -13,6 +13,7 @@ import { useWxStore } from "@/pinia/stores/wx"
import { bindQyUserApi } from "@/common/apis/ab98"
import { getShopListApi } from "@/common/apis/shop"
import { ShopEntity } from "@/common/apis/shop/type"
import ProductContainer from "./components/ProductContainer.vue"
const router = useRouter()
const route = useRoute()
@ -20,13 +21,10 @@ const route = useRoute()
const productStore = useProductStore();
const cartStore = useCartStore();
const wxStore = useWxStore();
const { cartItems, totalPrice, totalQuantity } = storeToRefs(cartStore); //
// store
const { labels, categories } = storeToRefs(productStore);
const { openid, corpidLogin, ab98User, qyUserId } = storeToRefs(wxStore);
//
const activeCategory = ref(0)
//
const scrollContainer = ref<HTMLElement>()
//
@ -40,19 +38,6 @@ const showShopList = ref(true);
const shopList = ref<ShopEntity[]>([]);
const shopId = ref<number>(0);
//
const showDetailPopup = ref(false)
// ID
const currentProductId = ref<number>()
//
const currentProduct = computed(() =>
categories.value.find(p => p.id === currentProductId.value)
)
//
const showCartPopup = ref(false)
const searchQuery = ref('');
const name = ref('');
const idNum = ref('');
const showAb98BindPopup = ref(false);
@ -64,77 +49,8 @@ function handleShopSelect(selectedShopId: number) {
productStore.setSelectedShop(shopList.value.find(shop => shop.shopId === selectedShopId)!);
productStore.getGoods(selectedShopId);
cartStore.clearCart();
activeCategory.value = 0;
}
function handleBackToShopList() {
showShopList.value = true;
shopId.value = 0;
cartStore.clearCart();
}
function handleCategoryClick(index: number) {
activeCategory.value = index
}
//
/* const throttledUpdate = throttle(() => {
if (!scrollContainer.value || !categoryRefs.value.length) return
//
const validRefs = categoryRefs.value.filter(Boolean)
if (!validRefs.length) return
const containerTop = scrollContainer.value.getBoundingClientRect().top
const offsets = validRefs.map((el) => {
return el.getBoundingClientRect().top - containerTop
})
let activeIndex = 0
for (let i = offsets.length - 1; i >= 0; i--) {
if (offsets[i] < 100) {
activeIndex = i
break
}
}
// activeCategory.value = activeIndex
}, 100) */
//
const throttledScroll = throttle(() => {
if (!scrollContainer.value) return
const scrollTop = scrollContainer.value.scrollTop
headerHeight.value = Math.max(150 - scrollTop * 0.5, 60)
}, 100)
//
function showProductDetail(productId: number) {
currentProductId.value = productId
showDetailPopup.value = true
}
function handleAddToCart(product: Product) {
cartStore.addToCart(product)
}
function handleRemoveFromCart(product: Product) {
cartStore.removeFromCart(product.cellId)
}
function getCartItemCount(cellId: number) {
const item = cartItems.value.find(item => item.product.cellId === cellId)
return item ? item.quantity : 0
}
function filterProductsByName(products: Product[], query: string) {
if (!query) return products
return products.filter(p =>
p.name.toLowerCase().includes(query.toLowerCase())
)
}
const currentProducts = computed(() => {
const filteredByCategory = categories.value.filter(c => c.label === labels.value[activeCategory.value].id)
return filterProductsByName(filteredByCategory, searchQuery.value)
})
//
onMounted(() => {
@ -153,7 +69,7 @@ onMounted(() => {
} else {
productStore.getGoods(shopId.value);
}
scrollListener.push(scrollContainer.value?.addEventListener("scroll", throttledScroll))
// scrollListener.push(scrollContainer.value?.addEventListener("scroll", throttledScroll))
// scrollListener.push(scrollContainer.value?.addEventListener("scroll", throttledUpdate))
})
@ -241,83 +157,11 @@ async function handleAb98Bind() {
<div class="shop-header" :style="{ height: `${headerHeight}px` }">
<van-image :src="`${publicPath}cover.png`" class="shop-cover" fit="cover" />
</div>
<div class="product-container">
<!-- 左侧分类导航 -->
<div>
<van-button icon="revoke" type="default" class="showShopListBtn" @click="showShopList = true">重选地址</van-button>
<van-sidebar v-model="activeCategory" class="category-nav" @change="handleCategoryClick">
<van-sidebar-item v-for="label in labels" :key="label.id" :title="label.name" />
</van-sidebar>
<ProductContainer
@backToShopList="showShopList = true"
@checkout="handleCheckout"
/>
</div>
<!-- 右侧商品列表 -->
<div ref="scrollContainer" class="product-list">
<van-search v-model="searchQuery" placeholder="搜索商品名称" shape="round" class="search-box" />
<div class="category-section">
<van-cell v-for="product in currentProducts" :key="product.id" class="product-item">
<template #icon>
<van-image :src="product.image" width="80" height="80" @click.stop="showProductDetail(product.id)"
class="product-image">
<div v-if="product.stock === 0" class="sold-out-overlay">
<span class="sold-out-text">已售罄</span>
</div>
<!-- 真正的图片错误处理 -->
<template #error>
<div class="custom-error">
图片加载失败
</div>
</template>
</van-image>
</template>
<div class="product-info">
<div class="product-name van-ellipsis" @click.stop="showProductDetail(product.id)">
{{ product.name }}
</div>
<div class="product-price" @click.stop="showProductDetail(product.id)">
¥{{ product.price.toFixed(2) }}
</div>
<div class="action-row">
<span v-if="product.stock > 0" class="stock-count">
还剩{{ product.stock }}
</span>
<div class="cart-actions">
<van-button v-if="getCartItemCount(product.cellId)" size="mini" icon="minus"
@click.stop="handleRemoveFromCart(product)" />
<span v-if="getCartItemCount(product.cellId)" class="cart-count">{{ getCartItemCount(product.cellId)
}}</span>
<van-button size="mini" type="primary" class="add-cart-btn" icon="plus"
:disabled="product.stock === 0" @click.stop="handleAddToCart(product)" :style="{
opacity: product.stock === 0 ? 0.6 : 1,
}" />
</div>
</div>
</div>
</van-cell>
</div>
</div>
<!-- 底部购物车栏 -->
<div v-if="cartItems.length" class="shopping-cart-bar">
<div class="cart-info">
<van-badge :content="totalQuantity" @click.stop="showCartPopup = true">
<van-icon name="shopping-cart-o" size="24" />
</van-badge>
<div class="total-price">
合计¥{{ totalPrice.toFixed(2) }}
</div>
</div>
<van-button type="primary" size="small" @click="handleCheckout" class="checkout-btn">
去结算
</van-button>
</div>
</div>
</div>
<VanPopup v-model:show="showDetailPopup" position="bottom" :style="{ height: '80%' }" round>
<Detail class="detail-container" @detail-close="showDetailPopup = false" v-if="currentProduct"
:product="currentProduct" />
</VanPopup>
<VanPopup v-model:show="showCartPopup" position="bottom" :style="{ height: '80%' }" round>
<Cart class="detail-container" @cart-close="showCartPopup = false" />
</VanPopup>
<VanPopup v-model:show="showAb98BindPopup" position="center" :style="{ padding: '12px' }" round>
<div style="text-align: center; font-size: 16px; font-weight: bold; margin-bottom: 16px;">请绑定汇邦云账号</div>
<van-form @submit="handleAb98Bind">
@ -601,6 +445,7 @@ async function handleAb98Bind() {
justify-content: start;
padding: 12px;
}
.shop-cover-img {
width: 100%;
height: 80px;

View File

@ -0,0 +1,397 @@
<!-- ProductList.vue -->
<script setup lang="ts">
import { publicPath } from "@/common/utils/path"
import { useCartStore } from "@/pinia/stores/cart"
import { Product, useProductStore } from "@/pinia/stores/product"
import { throttle } from "lodash-es"
import { storeToRefs } from "pinia"
import VanPopup from "vant/es/popup"
import { computed, onMounted, onBeforeUnmount, ref, watch } from "vue"
import Cart from "./cart.vue" // Cart Detail components
import Detail from "./detail.vue" // Cart Detail components
// Props
// Emit
const emit = defineEmits<{
(e: 'backToShopList'): void;
(e: 'checkout'): void;
}>();
//
const productStore = useProductStore();
const { labels, categories } = storeToRefs(productStore);
const cartStore = useCartStore();
const { cartItems, totalPrice, totalQuantity } = storeToRefs(cartStore);
// props
const activeCategory = ref(0);
//
const scrollContainer = ref<HTMLElement>();
// prop
const headerHeight = ref(150);
let scrollListener: any[] = [];
//
const showDetailPopup = ref(false);
// ID
const currentProductId = ref<number>();
//
const currentProduct = computed(() =>
categories.value.find(p => p.id === currentProductId.value)
);
//
const showCartPopup = ref(false);
const searchQuery = ref('');
//
function handleCategoryClick(index: number) {
activeCategory.value = index;
}
//
/* const throttledScroll = throttle(() => {
if (!scrollContainer.value) return;
const scrollTop = scrollContainer.value.scrollTop;
headerHeight.value = Math.max(150 - scrollTop * 0.5, 60);
}, 100); */
//
function showProductDetail(productId: number) {
currentProductId.value = productId;
showDetailPopup.value = true;
}
function handleAddToCart(product: Product) {
cartStore.addToCart(product);
}
function handleRemoveFromCart(cellId: number) { // cellId
cartStore.removeFromCart(cellId);
}
function getCartItemCount(cellId: number) {
const item = cartItems.value.find(item => item.product.cellId === cellId);
return item ? item.quantity : 0;
}
function filterProductsByName(products: Product[], query: string) {
if (!query) return products;
return products.filter(p =>
p.name.toLowerCase().includes(query.toLowerCase())
);
}
const currentProducts = computed(() => {
const filteredByCategory = categories.value.filter(c => c.label === labels.value[activeCategory.value]?.id);
return filterProductsByName(filteredByCategory, searchQuery.value);
});
//
onMounted(() => {
// scrollListener.push(scrollContainer.value?.addEventListener("scroll", throttledScroll));
});
//
onBeforeUnmount(() => {
scrollListener.forEach(listener => scrollContainer.value?.removeEventListener("scroll", listener));
scrollListener = [];
});
//
function handleCheckout() {
// emitrouter
// emit
emit('checkout');
}
</script>
<template>
<div class="product-container">
<!-- 左侧分类导航 -->
<div>
<van-button icon="revoke" type="default" class="showShopListBtn" @click="emit('backToShopList')">重选地址</van-button>
<van-sidebar v-model="activeCategory" class="category-nav" @change="handleCategoryClick">
<van-sidebar-item v-for="label in labels" :key="label.id" :title="label.name" />
</van-sidebar>
</div>
<!-- 右侧商品列表 -->
<div ref="scrollContainer" class="product-list">
<van-search v-model="searchQuery" placeholder="搜索商品名称" shape="round" class="search-box" />
<div class="category-section">
<van-cell v-for="product in currentProducts" :key="product.id" class="product-item">
<template #icon>
<van-image :src="product.image" width="80" height="80" @click.stop="showProductDetail(product.id)"
class="product-image">
<div v-if="product.stock === 0" class="sold-out-overlay">
<span class="sold-out-text">已售罄</span>
</div>
<!-- 真正的图片错误处理 -->
<template #error>
<div class="custom-error">
图片加载失败
</div>
</template>
</van-image>
</template>
<div class="product-info">
<div class="product-name van-ellipsis" @click.stop="showProductDetail(product.id)">
{{ product.name }}
</div>
<div class="product-price" @click.stop="showProductDetail(product.id)">
¥{{ product.price.toFixed(2) }}
</div>
<div class="action-row">
<span v-if="product.stock > 0" class="stock-count">
还剩{{ product.stock }}
</span>
<div class="cart-actions">
<van-button v-if="getCartItemCount(product.cellId)" size="mini" icon="minus"
@click.stop="handleRemoveFromCart(product.cellId)" />
<span v-if="getCartItemCount(product.cellId)" class="cart-count">{{ getCartItemCount(product.cellId)
}}</span>
<van-button size="mini" type="primary" class="add-cart-btn" icon="plus" :disabled="product.stock === 0"
@click.stop="handleAddToCart(product)" :style="{
opacity: product.stock === 0 ? 0.6 : 1,
}" />
</div>
</div>
</div>
</van-cell>
</div>
</div>
<!-- 底部购物车栏 -->
<div v-if="cartItems.length" class="shopping-cart-bar">
<div class="cart-info">
<van-badge :content="totalQuantity" @click.stop="showCartPopup = true">
<van-icon name="shopping-cart-o" size="24" />
</van-badge>
<div class="total-price">
合计¥{{ totalPrice.toFixed(2) }}
</div>
</div>
<van-button type="primary" size="small" @click="handleCheckout" class="checkout-btn">
去结算
</van-button>
</div>
</div>
<VanPopup v-model:show="showDetailPopup" position="bottom" :style="{ height: '80%' }" round>
<Detail class="detail-container" @detail-close="showDetailPopup = false" v-if="currentProduct"
:product="currentProduct" />
</VanPopup>
<VanPopup v-model:show="showCartPopup" position="bottom" :style="{ height: '80%' }" round>
<Cart class="detail-container" @cart-close="showCartPopup = false" />
</VanPopup>
</template>
<style scoped lang="scss">
.product-container {
display: flex;
height: 100vh;
background: #f7f8fa;
flex: 1;
min-height: 0;
position: relative;
border-top: 1px solid #e0e0e0;
/* 顶部边框 */
border-top-left-radius: 8px;
/* 左上圆角 */
border-top-right-radius: 8px;
/* 右上圆角 */
overflow: hidden;
/* 确保圆角生效 */
}
.category-nav {
width: 100px;
flex-shrink: 0;
}
.showShopListBtn {
width: 100%;
padding: 0;
border: 0;
}
.product-list {
flex: 1;
overflow-y: auto;
padding: 10px 16px 60px;
background: #ffffff;
}
.category-section {
margin-bottom: 20px;
}
.category-title {
margin: 5px;
padding: 0;
font-size: 16px;
color: #333;
font-weight: bold;
/* background: linear-gradient(to right, #f7f8fa, transparent); */
/* position: sticky; */
top: 0;
z-index: 1;
}
.product-item {
margin-bottom: 10px;
padding: min(2.667vw, 20px) 0;
/* border-radius: 4px; */
/* box-shadow: 0 2px 4px rgba(0,0,0,0.05); */
}
.product-image {
margin-right: 12px;
border-radius: 4px;
overflow: hidden;
}
.product-info {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 80px;
position: relative;
}
.product-name {
font-size: 14px;
color: #333;
line-height: 1.4;
text-align: left;
}
.product-price {
font-size: 16px;
color: #e95d5d;
font-weight: bold;
text-align: left;
}
.add-cart-btn {
align-self: flex-end;
margin-top: 0;
}
.van-sidebar-item--select:before {
background-color: #e95d5d;
}
.van-button--primary {
background-color: #e95d5d;
border: 0;
border-color: #e95d5d;
}
.sold-out-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
}
.sold-out-text {
color: #999;
font-size: 14px;
transform: rotate(-15deg);
border: 1px solid #eee;
padding: 2px 8px;
border-radius: 4px;
}
.action-row {
display: flex;
justify-content: space-between;
align-items: flex-end;
/* 底部对齐 */
margin-top: auto;
/* 推到容器底部 */
}
.stock-count {
font-size: 11px;
color: #bbbbbb;
margin-right: 8px;
display: flex;
align-items: flex-end;
height: 100%;
line-height: 1;
}
/* 禁用状态样式 */
.van-button--disabled {
background-color: #ccc !important;
border-color: #ccc !important;
}
/* 商品项置灰 */
.product-item:has(.sold-out-overlay) {
opacity: 0.6;
}
.detail-container {
height: 100%;
}
/* 修改购物车栏样式 */
.shopping-cart-bar {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 50px;
background: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.05);
z-index: 100;
}
.cart-info {
display: flex;
align-items: center;
gap: 12px;
}
.total-price {
font-size: 14px;
color: #333;
font-weight: bold;
}
.checkout-btn {
background-color: #e95d5d;
border: none;
border-radius: 16px;
padding: 0 24px;
}
.cart-actions {
margin-left: auto;
align-self: flex-end;
display: flex;
align-items: center;
gap: 4px;
/* margin-left: 8px; */
.cart-count {
font-size: 12px;
min-width: 20px;
text-align: center;
}
}
</style>