Kaynağa Gözat

✨ feat(goods-card): 为商品卡片添加无货状态显示和库存检查功能

- 新增无货标记样式,当商品库存为0时显示半透明遮罩和"无货"文字
- 为多规格商品添加子商品库存检查逻辑,通过API获取所有子商品库存状态
- 在加入购物车前检查商品库存,无货商品显示提示并阻止操作
- 优化商品卡片图片区域结构,支持无货状态叠加显示

♻️ refactor(cart): 优化购物车规格切换功能

- 修改CartContext的switchSpec函数,支持传入自定义数量参数
- 在规格切换时验证数量有效性,确保新数量不超过库存且大于0
- 更新购物车页面,将规格选择器中的数量传递给switchSpec函数
- 优化商品图片加载日志,减少控制台输出

💄 style(goods-detail): 优化商品详情页面样式

- 调整商品标题和描述样式,支持自动换行和文本缩进
- 修复商品描述显示问题,确保换行符正确渲染
- 优化页面布局,提升商品详情区域的可读性

🐛 fix(home): 修复首页滚动和刷新问题

- 调整首页ScrollView高度,修复页面滚动异常
- 将页面级下拉刷新改为ScrollView自带刷新,提升用户体验
- 修复未登录状态下首页渲染问题,确保登录后才显示内容
- 优化商品点击跳转逻辑,正确处理商品ID类型转换

📝 docs(test): 更新组件测试配置

- 为商品卡片和规格选择器测试添加Taro模拟对象
- 补充Input组件的模拟实现,支持测试中的输入操作
- 确保单元测试能够正确运行组件交互逻辑

🔧 chore(goods-management): 优化子商品列表查询

- 调整子商品列表查询参数,增加pageSize到1000并添加排序参数
- 提升管理后台子商品加载性能,支持更多子商品显示
yourname 1 ay önce
ebeveyn
işleme
51c9eaeb6b

+ 26 - 0
mini/src/components/goods-card/index.css

@@ -139,4 +139,30 @@
   position: absolute;
   bottom: 0;
   right: 0;
+}
+
+/* 无货标记样式 */
+.goods-card__out-of-stock {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.5);
+  border-radius: 16rpx 16rpx 0 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 10;
+}
+
+.goods-card__out-of-stock-text {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: white;
+  background: rgba(0, 0, 0, 0.7);
+  padding: 16rpx 32rpx;
+  border-radius: 8rpx;
+  text-align: center;
+  white-space: nowrap;
 }

+ 65 - 7
mini/src/components/goods-card/index.tsx

@@ -1,7 +1,9 @@
 import { View, Image, Text } from '@tarojs/components'
 import TDesignIcon from '../tdesign/icon'
-import { useState } from 'react'
+import { useState, useEffect } from 'react'
 import { GoodsSpecSelector } from '../goods-spec-selector'
+import { goodsClient } from '@/api'
+import Taro from '@tarojs/taro'
 import './index.css'
 
 export interface GoodsData {
@@ -44,9 +46,47 @@ export default function GoodsCard({
   const [showSpecModal, setShowSpecModal] = useState(false)
   const [selectedSpec, setSelectedSpec] = useState<SelectedSpec | null>(null)
   const [pendingAction, setPendingAction] = useState<'add-to-cart' | null>(null)
+  const [allChildGoodsOutOfStock, setAllChildGoodsOutOfStock] = useState(false)
 
   const independentID = id || `goods-card-${Math.floor(Math.random() * 10 ** 8)}`
 
+  // 检查多规格商品的所有子商品库存
+  useEffect(() => {
+    if (data.hasSpecOptions && data.parentGoodsId && data.parentGoodsId > 0) {
+      const fetchChildGoodsStock = async () => {
+        try {
+          const response = await goodsClient[':id'].children.$get({
+            param: { id: data.parentGoodsId! },
+            query: {
+              page: 1,
+              pageSize: 100, // 获取所有子商品
+              sortBy: 'createdAt',
+              sortOrder: 'ASC'
+            }
+          })
+
+          if (response.status === 200) {
+            const result = await response.json()
+            const childGoods = result.data || []
+
+            // 检查是否所有子商品库存都为0
+            const allOutOfStock = childGoods.length > 0 &&
+              childGoods.every((goods: any) => (goods.stock || 0) <= 0)
+
+            setAllChildGoodsOutOfStock(allOutOfStock)
+          }
+        } catch (error) {
+          console.error('获取子商品库存失败:', error)
+        }
+      }
+
+      fetchChildGoodsStock()
+    } else {
+      // 单规格商品,重置状态
+      setAllChildGoodsOutOfStock(false)
+    }
+  }, [data.hasSpecOptions, data.parentGoodsId])
+
   const handleClick = () => {
     onClick?.(data)
   }
@@ -60,6 +100,17 @@ export default function GoodsCard({
   const handleAddCart = (e: any) => {
     e.stopPropagation()
 
+    // 检查商品是否无货
+    const isOutOfStock = (data.stock !== undefined && data.stock <= 0) || allChildGoodsOutOfStock
+    if (isOutOfStock) {
+      Taro.showToast({
+        title: '商品已售罄',
+        icon: 'none',
+        duration: 1500
+      })
+      return
+    }
+
     // 检查是否有规格选项
     if (data.hasSpecOptions && data.parentGoodsId && data.parentGoodsId > 0) {
       // 有多规格选项,弹出规格选择器
@@ -118,12 +169,19 @@ export default function GoodsCard({
         {/* 商品图片 */}
         <View className="goods-card__thumb">
           {data.cover_image && (
-            <Image
-              src={data.cover_image}
-              mode="aspectFill"
-              className="goods-card__img"
-              lazyLoad
-            />
+            <>
+              <Image
+                src={data.cover_image}
+                mode="aspectFill"
+                className="goods-card__img"
+                lazyLoad
+              />
+              {(data.stock !== undefined && data.stock <= 0) || allChildGoodsOutOfStock ? (
+                <View className="goods-card__out-of-stock">
+                  <Text className="goods-card__out-of-stock-text">无货</Text>
+                </View>
+              ) : null}
+            </>
           )}
         </View>
 

+ 7 - 0
mini/src/components/goods-spec-selector/index.tsx

@@ -150,6 +150,13 @@ export function GoodsSpecSelector({
     validatePriceCalculation()
   }, [selectedSpec, quantity])
 
+  // 监听 currentQuantity 变化,同步更新 quantity 状态
+  useEffect(() => {
+    if (visible) {
+      setQuantity(currentQuantity)
+    }
+  }, [visible, currentQuantity])
+
   // 获取最大可购买数量
   const getMaxQuantity = () => {
     if (!selectedSpec) return 999

+ 20 - 5
mini/src/contexts/CartContext.tsx

@@ -22,7 +22,7 @@ interface CartContextType {
   addToCart: (item: CartItem) => void
   removeFromCart: (id: number) => void
   updateQuantity: (id: number, quantity: number) => void
-  switchSpec: (cartItemId: number, newChildGoods: { id: number; name: string; price: number; stock: number; image?: string }) => void
+  switchSpec: (cartItemId: number, newChildGoods: { id: number; name: string; price: number; stock: number; image?: string }, quantity?: number) => void
   clearCart: () => void
   isInCart: (id: number) => boolean
   getItemQuantity: (id: number) => number
@@ -191,7 +191,8 @@ export const CartProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
   // 切换购物车项规格
   const switchSpec = (
     cartItemId: number,
-    newChildGoods: { id: number; name: string; price: number; stock: number; image?: string }
+    newChildGoods: { id: number; name: string; price: number; stock: number; image?: string },
+    quantity?: number
   ) => {
     try {
       const item = cart.items.find(item => item.id === cartItemId)
@@ -224,8 +225,21 @@ export const CartProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
         return
       }
 
-      if (item.quantity > newChildGoods.stock) {
-        console.error('切换规格失败:库存不足', { currentQuantity: item.quantity, newStock: newChildGoods.stock })
+      // 确定要使用的数量:如果提供了quantity参数则使用,否则使用原有数量
+      const finalQuantity = quantity !== undefined ? quantity : item.quantity
+
+      // 验证数量有效性
+      if (finalQuantity <= 0) {
+        console.error('切换规格失败:数量无效', { finalQuantity })
+        Taro.showToast({
+          title: '数量不能小于1',
+          icon: 'none'
+        })
+        return
+      }
+
+      if (finalQuantity > newChildGoods.stock) {
+        console.error('切换规格失败:库存不足', { finalQuantity, newStock: newChildGoods.stock })
         Taro.showToast({
           title: `规格库存不足,仅剩${newChildGoods.stock}件`,
           icon: 'none'
@@ -250,7 +264,8 @@ export const CartProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
         name: newChildGoods.name,
         price: newChildGoods.price,
         stock: newChildGoods.stock,
-        image: newChildGoods.image || item.image
+        image: newChildGoods.image || item.image,
+        quantity: finalQuantity
       }
 
       // 更新购物车

+ 3 - 4
mini/src/pages/cart/index.tsx

@@ -132,15 +132,14 @@ export default function CartPage() {
     }
 
     // 调用CartContext的switchSpec函数
-    // 注意:quantity参数来自规格选择器,但在规格切换场景中,我们保持原有数量不变
-    // 因为switchSpec函数会保持购物车项的原有数量
+    // quantity参数来自规格选择器,现在会传递给switchSpec函数
     switchSpec(specSelectorState.cartItemId, {
       id: selectedSpec.id,
       name: selectedSpec.name,
       price: selectedSpec.price,
       stock: selectedSpec.stock,
       image: selectedSpec.image
-    })
+    }, quantity)
 
     closeSpecSelector()
   }
@@ -307,7 +306,7 @@ export default function CartPage() {
                               console.warn('商品图片加载失败:', item.id, goodsName, goodsImage)
                             }}
                             onLoad={() => {
-                              console.log('商品图片加载成功:', item.id, goodsName)
+                              // console.log('商品图片加载成功:', item.id, goodsName)
                             }}
                             errorPlaceholder={
                               <View className="absolute inset-0 flex items-center justify-center bg-gray-100">

+ 6 - 2
mini/src/pages/goods-detail/index.css

@@ -66,13 +66,17 @@
   color: #333;
   line-height: 1.4;
   margin-bottom: 16rpx;
+  text-align: left;
+  white-space: normal;
+  word-wrap: break-word;
 }
 
 .goods-description {
   font-size: 28rpx;
   color: #666;
-  line-height: 1.5;
-  margin-bottom: 32rpx;
+  line-height: 2;
+  text-indent: 2em;
+  display: block;
 }
 
 /* 规格选择区域 */

+ 12 - 0
mini/src/pages/goods-detail/index.tsx

@@ -256,6 +256,14 @@ export default function GoodsDetailPage() {
     }
   }
 
+  // 处理商品描述中的换行符
+  const renderGoodsDescription = (description: string | undefined) => {
+    if (!description) return '暂无商品描述'
+
+    // 将换行符转换为可显示的格式,确保换行生效
+    return description
+  }
+
   // 规格选择确认
   const handleSpecConfirm = (spec: SelectedSpec | null, qty: number, actionType?: 'add-to-cart' | 'buy-now') => {
     if (spec) {
@@ -536,7 +544,10 @@ export default function GoodsDetailPage() {
           </View>
 
           <Text className="goods-title">{goods.name}</Text>
+
+        <View>
           <Text className="goods-description">{goods.instructions || '暂无商品描述'}</Text>
+        </View>
 
         </View>
 
@@ -545,6 +556,7 @@ export default function GoodsDetailPage() {
         {/* 商品详情区域 */}
         <View className="detail-section">
           <Text className="detail-title">商品详情</Text>
+
           {goods.detail ? (
             <RichText
               className="detail-content"

+ 2 - 2
mini/src/pages/index/index.css

@@ -54,8 +54,8 @@
 
 /* ScrollView 样式 */
 .home-scroll-view {
-  height: 115vh;
-  min-height: 115vh;
+  height: 100vh;
+  min-height: 100vh;
 }
 
 /* 加载状态样式 */

+ 61 - 32
mini/src/pages/index/index.tsx

@@ -1,5 +1,5 @@
 import React from 'react'
-import { View, Text, ScrollView, Swiper, SwiperItem, Image  } from '@tarojs/components'
+import { View, Text, ScrollView } from '@tarojs/components'
 import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
 import { TabBarLayout } from '@/layouts/tab-bar-layout'
 import TDesignSearch from '@/components/tdesign/search'
@@ -12,17 +12,15 @@ import { useAuth } from '@/utils/auth'
 import { useCart } from '@/contexts/CartContext'
 import { Navbar } from '@/components/ui/navbar'
 import { Carousel } from '@/components/ui/carousel'
-import Taro, { usePullDownRefresh, useReachBottom, useShareAppMessage } from '@tarojs/taro'
+import Taro, { usePullDownRefresh, useShareAppMessage } from '@tarojs/taro'
 
 type GoodsResponse = InferResponseType<typeof goodsClient.$get, 200>
 type Goods = GoodsResponse['data'][0]
-type AdvertisementResponse = InferResponseType<typeof advertisementClient.$get, 200>
-type Advertisement = AdvertisementResponse['data'][0]
 
 const HomePage: React.FC = () => {
   const { isLoggedIn } = useAuth();
   const { addToCart } = useCart();
-  if( !isLoggedIn ) return null;
+  const [refreshing, setRefreshing] = React.useState(false);
   
   // 广告数据查询
   const {
@@ -58,7 +56,7 @@ const HomePage: React.FC = () => {
   } = useInfiniteQuery({
     queryKey: ['home-goods-infinite'],
     queryFn: async ({ pageParam = 1 }) => {
-      console.debug('请求商品数据,页码:', pageParam)
+      // console.debug('请求商品数据,页码:', pageParam)
       const response = await goodsClient.$get({
         query: {
           page: pageParam,
@@ -72,14 +70,14 @@ const HomePage: React.FC = () => {
         throw new Error('获取商品失败')
       }
       const result = await response.json()
-      console.debug('API响应数据:', {
-        page: pageParam,
-        dataCount: result.data?.length || 0,
-        pagination: result.pagination
-      })
+      // console.debug('API响应数据:', {
+      //   page: pageParam,
+      //   dataCount: result.data?.length || 0,
+      //   pagination: result.pagination
+      // })
       return result
     },
-    getNextPageParam: (lastPage, allPages) => {
+    getNextPageParam: (lastPage, _allPages) => {
       const { pagination } = lastPage
       const totalPages = Math.ceil(pagination.total / pagination.pageSize)
 
@@ -98,6 +96,7 @@ const HomePage: React.FC = () => {
     },
     staleTime: 5 * 60 * 1000,
     initialPageParam: 1,
+    enabled: isLoggedIn,
   })
 
   // 合并所有分页数据
@@ -118,7 +117,7 @@ const HomePage: React.FC = () => {
       name: goods?.name || '',
       cover_image: imageUrl,
       price: goods?.price || 0,
-      originPrice: goods?.originPrice || 0,
+      originPrice: (goods as any)?.originPrice || 0,
       tags: (goods?.salesNum || 0) > 100 ? ['热销'] : ['新品'],
       hasSpecOptions,
       parentGoodsId,
@@ -167,12 +166,30 @@ const HomePage: React.FC = () => {
     // }
   }
 
-  // 使用Taro全局钩子 - 下拉刷新
-  usePullDownRefresh(() => {
+  // ScrollView下拉刷新处理函数
+  const handleRefresh = () => {
+    //console.log('首页下拉刷新触发 - ScrollView刷新处理函数被调用')
+    //console.log('refetch函数:', refetch)
+    //console.log('用户登录状态:', isLoggedIn)
+    setRefreshing(true)
     refetch().finally(() => {
+      //console.log('首页下拉刷新完成,停止刷新状态')
+      setRefreshing(false)
+      // 同时停止页面下拉刷新动画(如果同时启用)
       Taro.stopPullDownRefresh()
     })
-  })
+  }
+
+  // 暂时注释页面级下拉刷新钩子,使用ScrollView自带刷新
+  // usePullDownRefresh(() => {
+  //   console.log('首页下拉刷新触发 - 钩子被调用')
+  //   console.log('refetch函数:', refetch)
+  //   console.log('用户登录状态:', isLoggedIn)
+  //   refetch().finally(() => {
+  //     console.log('首页下拉刷新完成,停止刷新动画')
+  //     Taro.stopPullDownRefresh()
+  //   })
+  // })
 
   // 分享功能
   useShareAppMessage(() => {
@@ -183,14 +200,30 @@ const HomePage: React.FC = () => {
     }
   })
 
-  // // 商品点击
-  // const handleGoodsClick = (goods: GoodsData, index: number) => {
-  //   console.log('点击商品:', goods, index)
-  // }
+  // 如果未登录,不渲染页面内容
+  if (!isLoggedIn) return null
+
   // 跳转到商品详情
-  const handleGoodsClick = (goods: Goods) => {
+  const handleGoodsClick = (goods: GoodsData, index: number) => {
+    console.log('点击商品:', goods, index)
+    // 安全解析商品ID:GoodsData.id是string类型,需要转换为number
+    let goodsId: number
+    if (typeof goods.id === 'number') {
+      goodsId = goods.id
+    } else if (typeof goods.id === 'string') {
+      goodsId = parseInt(goods.id, 10)
+    } else {
+      console.error('商品ID类型无效:', goods.id, typeof goods.id)
+      Taro.showToast({ title: '商品ID错误', icon: 'none' })
+      return
+    }
+    if (isNaN(goodsId)) {
+      console.error('商品ID解析失败:', goods.id)
+      Taro.showToast({ title: '商品ID错误', icon: 'none' })
+      return
+    }
     Taro.navigateTo({
-      url: `/pages/goods-detail/index?id=${goods.id}`
+      url: `/pages/goods-detail/index?id=${goodsId}`
     })
   }
 
@@ -242,11 +275,6 @@ const HomePage: React.FC = () => {
     })
   }
 
-  // 商品图片点击
-  const handleThumbClick = (goods: GoodsData, index: number) => {
-    console.log('点击商品图片:', goods, index)
-  }
-
   // 搜索框点击
   const handleSearchClick = () => {
     Taro.navigateTo({
@@ -265,9 +293,11 @@ const HomePage: React.FC = () => {
       />
       <ScrollView
         className="home-scroll-view"
-        scrollY        
-        onScrollToLower={handleScrollToLower}
-      >
+        scrollY
+        refresherEnabled={true}
+        refresherTriggered={refreshing}
+        onRefresherRefresh={handleRefresh}
+        onScrollToLower={handleScrollToLower}      >
         {/* 页面头部 - 搜索栏和轮播图 */}
         <View className="home-page-header">
           {/* 搜索栏 */}
@@ -295,7 +325,7 @@ const HomePage: React.FC = () => {
                 items={finalImgSrcs.filter(item => item.imageFile?.fullUrl).map(item => ({
                   src: item.imageFile!.fullUrl,
                   title: item.title || '',
-                  description: item.description || ''
+                  description: (item as any).description || ''
                 }))}
                 height={800}
                 autoplay={true}
@@ -333,7 +363,6 @@ const HomePage: React.FC = () => {
                 goodsList={goodsList}
                 onClick={handleGoodsClick}
                 onAddCart={handleAddCart}
-                onThumbClick={handleThumbClick}
               />
 
               {/* 加载更多状态 */}

+ 20 - 0
mini/tests/unit/components/goods-card/goods-card.test.tsx

@@ -31,9 +31,29 @@ jest.mock('@tarojs/components', () => ({
     <div className={className} data-scroll-y={scrollY}>
       {children}
     </div>
+  ),
+  Input: ({ className, type, value, onInput, onBlur, placeholder, maxlength, confirmType }: any) => (
+    <input
+      className={className}
+      type={type}
+      value={value}
+      onChange={(e) => onInput?.({ detail: { value: e.target.value } })}
+      onBlur={onBlur}
+      placeholder={placeholder}
+      maxLength={maxlength}
+      data-confirm-type={confirmType}
+    />
   )
 }))
 
+// Mock Taro对象
+jest.mock('@tarojs/taro', () => ({
+  showToast: jest.fn(),
+  navigateTo: jest.fn(),
+  navigateBack: jest.fn(),
+  stopPullDownRefresh: jest.fn()
+}))
+
 // Mock TDesignIcon组件
 jest.mock('@/components/tdesign/icon', () => ({
   __esModule: true,

+ 12 - 0
mini/tests/unit/components/goods-spec-selector/goods-spec-selector.test.tsx

@@ -25,6 +25,18 @@ jest.mock('@tarojs/components', () => ({
   ),
   ScrollView: ({ children, className }: any) => (
     <div className={className}>{children}</div>
+  ),
+  Input: ({ className, type, value, onInput, onBlur, placeholder, maxlength, confirmType }: any) => (
+    <input
+      className={className}
+      type={type}
+      value={value}
+      onChange={(e) => onInput?.({ detail: { value: e.target.value } })}
+      onBlur={(e) => onBlur?.({ detail: { value: e.target.value } })}
+      placeholder={placeholder}
+      maxLength={maxlength}
+      data-testid="input"
+    />
   )
 }))
 

+ 1 - 1
packages/goods-management-ui-mt/src/components/ChildGoodsList.tsx

@@ -74,7 +74,7 @@ export const ChildGoodsList: React.FC<ChildGoodsListProps> = ({
 
         const res = await client[':id'].children.$get({
           param: { id: parentGoodsId },
-          query: { page: 1, pageSize: 50 }
+          query: { page: 1, pageSize: 1000, sortBy: 'sort', sortOrder: 'DESC' }
         });
 
         if (!res || res.status !== 200) {