feat(shop): 改造分类页为左右布局并新增商品排序功能
- 重写分类页,实现左侧一级分类列表和右侧商品列表布局 - 右侧增加商品排序栏,支持默认、销量、最新、价格排序(价格可升降序切换) - 商品列表横向卡片布局,显示图片、标题、积分标签、VIP价格、销量及加购按钮 - 支持滚动触底自动分页加载更多商品 - 点击商品跳转详情页,点击加购按钮调用加入购物车接口 - 复用 VIP 价格逻辑,优化价格显示 - 修复门店中心页 loading 状态,确保数据加载完成后隐藏加载中提示 - 优化订阅消息模板 ID 填充和错误提示提示细节
This commit is contained in:
@@ -145,3 +145,25 @@
|
|||||||
- 新建:`JAVA/shop-api/.../service/WxSubscribeMessageService.java`
|
- 新建:`JAVA/shop-api/.../service/WxSubscribeMessageService.java`
|
||||||
- 新建:`JAVA/shop-api/.../service/impl/WxSubscribeMessageServiceImpl.java`
|
- 新建:`JAVA/shop-api/.../service/impl/WxSubscribeMessageServiceImpl.java`
|
||||||
- 修改:`JAVA/shop-api/.../service/OrderBusinessService.java`
|
- 修改:`JAVA/shop-api/.../service/OrderBusinessService.java`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 12. 订阅消息模板 ID 填入 + 错误提示优化
|
||||||
|
- 模板 ID:`sh1K9iK7vZjebUNFu6OsMsnsJxm4whThWGrhN7I4zVg`(交易提醒)
|
||||||
|
- 字段:订单号(`character_string1`)、商品名称(`thing4`)、联系人(`thing10`)、联系电话(`phone_number7`)、送货地址(`thing8`)
|
||||||
|
- 优化 `requestSubscribeMessage` fail 回调:errCode 20001 提示模板未配置,20002/20004 提示已取消
|
||||||
|
|
||||||
|
### 13. 门店中心页 loading 状态修复
|
||||||
|
- `store/center/index.tsx` 的 `useEffect` 遗漏 `.finally(() => setLoading(false))`,导致页面卡在"加载中"
|
||||||
|
- 已修复
|
||||||
|
|
||||||
|
### 14. 分类页改造为左侧分类 + 右侧商品列表布局
|
||||||
|
- 重写 `src/pages/shop/category.tsx`
|
||||||
|
- 左侧:一级分类列表(绿色高亮选中态,保持不变)
|
||||||
|
- 右侧:
|
||||||
|
- 排序栏:默认/销量/最新/价格(可切换升降序)/筛选
|
||||||
|
- 商品卡片:横向布局(左图 + 右侧标题/积分标签/价格/销量 + 加购按钮)
|
||||||
|
- 滚动触底分页加载
|
||||||
|
- 点击商品跳转详情页,点击加购按钮调用 `addToCart` API
|
||||||
|
- 复用现有 VIP 价格逻辑(`isVipMember` + `dealerPrice`)
|
||||||
|
- 编译通过
|
||||||
|
|||||||
@@ -1,18 +1,46 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { View, Text, Image, ScrollView } from '@tarojs/components'
|
import { View, Text, Image, ScrollView } from '@tarojs/components'
|
||||||
import Taro from '@tarojs/taro'
|
import Taro from '@tarojs/taro'
|
||||||
import { listShopGoodsCategory } from '@/api/shop/shopGoodsCategory'
|
import { listShopGoodsCategory } from '@/api/shop/shopGoodsCategory'
|
||||||
|
import { pageShopGoods } from '@/api/shop/shopGoods'
|
||||||
|
import { addToCart } from '@/api/shop/shopCart'
|
||||||
import type { ShopGoodsCategory } from '@/api/shop/shopGoodsCategory/model'
|
import type { ShopGoodsCategory } from '@/api/shop/shopGoodsCategory/model'
|
||||||
|
import type { ShopGoods, ShopGoodsParam } from '@/api/shop/shopGoods/model'
|
||||||
|
import { isGuest } from '@/utils/auth'
|
||||||
|
import { isVipMember } from '@/utils/vip'
|
||||||
|
import { requireLogin } from '@/utils/login-guard'
|
||||||
|
import LoadMore from '@/components/common/LoadMore'
|
||||||
import EmptyState from '@/components/common/EmptyState'
|
import EmptyState from '@/components/common/EmptyState'
|
||||||
|
|
||||||
definePageConfig({
|
definePageConfig({
|
||||||
navigationBarTitleText: '商品分类',
|
navigationBarTitleText: '商品分类',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ─── 排序选项 ──────────────────────────────────────────────────
|
||||||
|
type SortKey = 'default' | 'sales' | 'newest' | 'price'
|
||||||
|
|
||||||
|
const SORT_TABS: { key: SortKey; label: string }[] = [
|
||||||
|
{ key: 'default', label: '默认' },
|
||||||
|
{ key: 'sales', label: '销量' },
|
||||||
|
{ key: 'newest', label: '最新' },
|
||||||
|
{ key: 'price', label: '价格' },
|
||||||
|
]
|
||||||
|
|
||||||
const CategoryPage: React.FC = () => {
|
const CategoryPage: React.FC = () => {
|
||||||
const [categories, setCategories] = useState<ShopGoodsCategory[]>([])
|
const [categories, setCategories] = useState<ShopGoodsCategory[]>([])
|
||||||
const [activeId, setActiveId] = useState<number | undefined>()
|
const [activeId, setActiveId] = useState<number | undefined>()
|
||||||
|
|
||||||
|
// 商品列表
|
||||||
|
const [goodsList, setGoodsList] = useState<ShopGoods[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [finished, setFinished] = useState(false)
|
||||||
|
const loadingRef = useRef(false)
|
||||||
|
|
||||||
|
// 排序
|
||||||
|
const [sortKey, setSortKey] = useState<SortKey>('default')
|
||||||
|
const [priceAsc, setPriceAsc] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadCategories()
|
loadCategories()
|
||||||
}, [])
|
}, [])
|
||||||
@@ -21,30 +49,112 @@ const CategoryPage: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const list = await listShopGoodsCategory()
|
const list = await listShopGoodsCategory()
|
||||||
if (list) {
|
if (list) {
|
||||||
// 过滤:只保留 status=1 且有子分类的分类,避免空壳分类导致审核被拒
|
// 只保留 status=1 的分类(不限是否有子分类,因为右侧直接展示商品)
|
||||||
const filtered = list
|
const filtered = list.filter(cat => cat.status === 1)
|
||||||
.map(cat => ({
|
|
||||||
...cat,
|
|
||||||
children: (cat.children || []).filter(child => child.status === 1),
|
|
||||||
}))
|
|
||||||
.filter(cat => cat.status === 1 && cat.children.length > 0)
|
|
||||||
setCategories(filtered)
|
setCategories(filtered)
|
||||||
if (filtered.length > 0) setActiveId(filtered[0].categoryId)
|
if (filtered.length > 0) setActiveId(filtered[0].categoryId)
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeCategory = categories.find(c => c.categoryId === activeId)
|
/** 构建排序参数 */
|
||||||
const children = activeCategory?.children || []
|
const buildSortParams = useCallback((): Partial<ShopGoodsParam> => {
|
||||||
|
if (sortKey === 'sales') return { sort: 'sales', order: 'desc' }
|
||||||
|
if (sortKey === 'newest') return { sort: 'create_time', order: 'desc' }
|
||||||
|
if (sortKey === 'price') return { sort: 'price', order: priceAsc ? 'asc' : 'desc' }
|
||||||
|
return {}
|
||||||
|
}, [sortKey, priceAsc])
|
||||||
|
|
||||||
|
/** 加载商品列表 */
|
||||||
|
const loadGoods = useCallback(async (p: number) => {
|
||||||
|
if (loadingRef.current || activeId === undefined) return
|
||||||
|
loadingRef.current = true
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const params: ShopGoodsParam = {
|
||||||
|
page: p,
|
||||||
|
limit: 10,
|
||||||
|
status: 0,
|
||||||
|
categoryId: activeId,
|
||||||
|
...buildSortParams(),
|
||||||
|
}
|
||||||
|
const res = await pageShopGoods(params)
|
||||||
|
const newList = res?.list || []
|
||||||
|
if (p === 1) {
|
||||||
|
setGoodsList(newList)
|
||||||
|
} else {
|
||||||
|
setGoodsList(prev => [...prev, ...newList])
|
||||||
|
}
|
||||||
|
setFinished(newList.length < 10)
|
||||||
|
setPage(p)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
loadingRef.current = false
|
||||||
|
setLoading(false)
|
||||||
|
}, [activeId, buildSortParams])
|
||||||
|
|
||||||
|
// 分类或排序变化时重新加载
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeId !== undefined) {
|
||||||
|
setGoodsList([])
|
||||||
|
setFinished(false)
|
||||||
|
setPage(1)
|
||||||
|
loadGoods(1)
|
||||||
|
}
|
||||||
|
}, [activeId, sortKey, priceAsc])
|
||||||
|
|
||||||
|
const handleLoadMore = () => {
|
||||||
|
if (!finished && !loading) {
|
||||||
|
loadGoods(page + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 排序切换 */
|
||||||
|
const handleSortChange = (key: SortKey) => {
|
||||||
|
if (key === 'price') {
|
||||||
|
if (sortKey === 'price') {
|
||||||
|
setPriceAsc(!priceAsc)
|
||||||
|
} else {
|
||||||
|
setSortKey('price')
|
||||||
|
setPriceAsc(true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSortKey(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取显示价格 */
|
||||||
|
const getDisplayPrice = (product: ShopGoods) => {
|
||||||
|
if (isVipMember() && product.dealerPrice) return product.dealerPrice
|
||||||
|
return product.price || '0'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加入购物车 */
|
||||||
|
const handleAddToCart = (e: any, product: ShopGoods) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (!requireLogin({ action: 'addToCart' })) return
|
||||||
|
addToCart({
|
||||||
|
goodsId: product.goodsId!,
|
||||||
|
num: product.step || 1,
|
||||||
|
}).then(() => {
|
||||||
|
Taro.showToast({ title: '已加入购物车', icon: 'success' })
|
||||||
|
}).catch(err => {
|
||||||
|
Taro.showToast({ title: err.message || '添加失败', icon: 'none' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 跳转商品详情 */
|
||||||
|
const goDetail = (product: ShopGoods) => {
|
||||||
|
Taro.navigateTo({ url: `/pages/shop/product-detail?id=${product.goodsId}` })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='flex min-h-screen bg-gray-50'>
|
<View className='flex min-h-screen bg-gray-50'>
|
||||||
{/* 左侧一级分类 */}
|
{/* 左侧一级分类 */}
|
||||||
<ScrollView scrollY className='w-24 bg-gray-100 h-screen'>
|
<ScrollView scrollY className='w-20 bg-gray-100 h-screen flex-shrink-0'>
|
||||||
{categories.map(cat => (
|
{categories.map(cat => (
|
||||||
<View
|
<View
|
||||||
key={cat.categoryId}
|
key={cat.categoryId}
|
||||||
className={`py-3 px-3 text-center text-sm ${
|
className={`py-3 px-2 text-center text-xs ${
|
||||||
cat.categoryId === activeId
|
cat.categoryId === activeId
|
||||||
? 'bg-white text-green-600 font-medium border-l-2 border-green-500'
|
? 'bg-white text-green-600 font-medium border-l-2 border-green-500'
|
||||||
: 'text-gray-600'
|
: 'text-gray-600'
|
||||||
@@ -56,35 +166,124 @@ const CategoryPage: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
{/* 右侧二级分类 */}
|
{/* 右侧:排序栏 + 商品列表 */}
|
||||||
<ScrollView scrollY className='flex-1 h-screen'>
|
<View className='flex-1 flex flex-col h-screen min-w-0'>
|
||||||
{children.length > 0 ? (
|
{/* 排序栏 */}
|
||||||
<View className='p-3'>
|
<View className='flex items-center bg-white py-2 border-b border-gray-100 flex-shrink-0'>
|
||||||
<View className='grid grid-cols-3 gap-3'>
|
{SORT_TABS.map(tab => (
|
||||||
{children.map(child => (
|
<View
|
||||||
<View
|
key={tab.key}
|
||||||
key={child.categoryId}
|
className='flex-1 flex items-center justify-center'
|
||||||
className='flex flex-col items-center py-2'
|
onClick={() => handleSortChange(tab.key)}
|
||||||
onClick={() => Taro.switchTab({ url: '/pages/shop/index' })}
|
>
|
||||||
>
|
<Text
|
||||||
<View className='w-14 h-14 rounded-lg bg-gray-100 overflow-hidden mb-1'>
|
className={`text-xs ${
|
||||||
{child.image ? (
|
sortKey === tab.key ? 'text-green-600 font-medium' : 'text-gray-500'
|
||||||
<Image className='w-full h-full' src={child.image} mode='aspectFill' />
|
}`}
|
||||||
) : (
|
>
|
||||||
<View className='w-full h-full flex items-center justify-center'>
|
{tab.label}
|
||||||
<Text className='text-xs text-gray-300'>{(child.title || '')[0]}</Text>
|
</Text>
|
||||||
</View>
|
{tab.key === 'price' && sortKey === 'price' && (
|
||||||
)}
|
<Text className='text-xs text-green-600 ml-0.5'>
|
||||||
</View>
|
{priceAsc ? '↑' : '↓'}
|
||||||
<Text className='text-xs text-gray-600 text-center'>{child.title}</Text>
|
</Text>
|
||||||
</View>
|
)}
|
||||||
))}
|
|
||||||
</View>
|
</View>
|
||||||
|
))}
|
||||||
|
<View className='flex-1 flex items-center justify-center'>
|
||||||
|
<Text className='text-xs text-gray-400'>筛选</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
</View>
|
||||||
<EmptyState text='暂无分类' />
|
|
||||||
)}
|
{/* 商品列表 */}
|
||||||
</ScrollView>
|
<ScrollView
|
||||||
|
scrollY
|
||||||
|
className='flex-1'
|
||||||
|
onScrollToLower={handleLoadMore}
|
||||||
|
lowerThreshold={100}
|
||||||
|
>
|
||||||
|
<View className='p-2'>
|
||||||
|
{goodsList.length > 0 ? (
|
||||||
|
<View className='flex flex-col gap-2'>
|
||||||
|
{goodsList.map(item => (
|
||||||
|
<View
|
||||||
|
key={item.goodsId}
|
||||||
|
className='bg-white rounded-lg p-2 flex gap-2'
|
||||||
|
onClick={() => goDetail(item)}
|
||||||
|
>
|
||||||
|
{/* 商品图片 */}
|
||||||
|
<View className='w-20 h-20 rounded-lg bg-gray-100 overflow-hidden flex-shrink-0'>
|
||||||
|
<Image
|
||||||
|
className='w-full h-full'
|
||||||
|
src={item.image || item.files || ''}
|
||||||
|
mode='aspectFill'
|
||||||
|
lazyLoad
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 商品信息 */}
|
||||||
|
<View className='flex-1 flex flex-col justify-between min-w-0'>
|
||||||
|
{/* 标题 + 积分标签 */}
|
||||||
|
<View>
|
||||||
|
<Text className='text-sm text-gray-800 line-clamp-2 leading-5'>
|
||||||
|
{item.name || item.goodsName}
|
||||||
|
</Text>
|
||||||
|
{item.gainIntegral && item.gainIntegral > 0 ? (
|
||||||
|
<View className='inline-block mt-1'>
|
||||||
|
<Text className='text-xs text-orange-500 bg-orange-50 px-1 py-0.5 rounded'>
|
||||||
|
+{item.gainIntegral}积分
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 价格 + 销量 + 加购 */}
|
||||||
|
<View className='flex items-end justify-between'>
|
||||||
|
<View className='flex-1 min-w-0'>
|
||||||
|
<Text className='text-sm text-red-500 font-medium'>
|
||||||
|
¥{getDisplayPrice(item)}
|
||||||
|
</Text>
|
||||||
|
{item.unitName ? (
|
||||||
|
<Text className='text-xs text-gray-400'>/{item.unitName}</Text>
|
||||||
|
) : null}
|
||||||
|
{/* 原价划掉 */}
|
||||||
|
{isVipMember() && item.dealerPrice ? (
|
||||||
|
<Text className='text-xs text-gray-400 line-through ml-1'>
|
||||||
|
¥{item.price}
|
||||||
|
</Text>
|
||||||
|
) : item.salePrice && item.salePrice !== item.price ? (
|
||||||
|
isGuest() ? null : (
|
||||||
|
<Text className='text-xs text-gray-400 line-through ml-1'>
|
||||||
|
¥{item.salePrice}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
{/* 销量 */}
|
||||||
|
{item.sales !== undefined && item.sales > 0 && (
|
||||||
|
<Text className='text-xs text-gray-400 ml-2'>
|
||||||
|
已售{item.sales}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{/* 加购按钮 */}
|
||||||
|
<View
|
||||||
|
className='w-7 h-7 rounded-full bg-green-500 flex items-center justify-center flex-shrink-0'
|
||||||
|
onClick={(e) => handleAddToCart(e, item)}
|
||||||
|
>
|
||||||
|
<Text className='text-white text-lg leading-none'>+</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
!loading && <EmptyState text='暂无商品' />
|
||||||
|
)}
|
||||||
|
<LoadMore loading={loading} finished={finished} />
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user