Files
xinlong-shop-taro/src/pages/shop/index.tsx
赵忠林 70af5426e8 feat(shop): 添加商城列表页浮动购物车按钮
- 在 shop 页面右下角添加固定定位的绿色圆形购物车按钮
- 按钮右上角显示购物车商品数量角标,超过 99 显示为 99+
- 点击按钮跳转到购物车页面,非 tabBar 页面
- 使用 useCartContext 的 totalCount 实时更新商品数量显示
- 注释掉用户页面 “我的钱包” 入口,暂时隐藏该功能
2026-07-14 02:31:49 +08:00

392 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useCallback, useRef } from 'react'
import { View, Text, ScrollView, Image, Input } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { listShopGoodsCategory } from '@/api/shop/shopGoodsCategory'
import { pageShopGoods } from '@/api/shop/shopGoods'
import { useCartContext } from '@/contexts/CartContext'
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 { getCompressedImageUrl } from '@/utils/image'
import { requireLogin } from '@/utils/login-guard'
import { useShare } from '@/hooks/useShare'
import EmptyState from '@/components/common/EmptyState'
import LoadMore from '@/components/common/LoadMore'
import Price from '@/components/common/Price'
definePageConfig({
navigationBarTitleText: '商品分类',
})
// 预置分类
const PRESET_CATEGORIES = [
{ id: 0, title: '全部' },
{ id: -1, title: '推荐' },
]
// ─── 排序选项 ──────────────────────────────────────────────────
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 ShopPage: React.FC = () => {
const { addItem, totalCount } = useCartContext()
const [categoryList, setCategoryList] = useState<any[]>(PRESET_CATEGORIES)
const [activeCategory, setActiveCategory] = useState(0)
// 商品列表
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)
// 搜索
const [keyword, setKeyword] = useState('')
const searchTimerRef = useRef<any>(null)
// 分享tabBar 页支持好友分享 + 朋友圈 + 复制链接)
useShare({
title: '购物商城 - 精选好物,实惠到家,等你来逛~',
path: '/pages/shop/index',
enableTimeline: true,
enableCopyUrl: true,
})
useEffect(() => {
loadCategories()
}, [])
const loadCategories = async () => {
try {
const list = await listShopGoodsCategory()
if (list && list.length > 0) {
const filtered = list.filter((c: any) => c.status === 0)
setCategoryList([...PRESET_CATEGORIES, ...filtered])
}
} catch { /* ignore */ }
}
/** 构建排序参数 */
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) return
loadingRef.current = true
setLoading(true)
try {
const params: ShopGoodsParam = { page: p, limit: 10, status: 0, ...buildSortParams() }
if (activeCategory > 0) params.categoryId = activeCategory
if (activeCategory === -1) params.recommend = 1
if (keyword.trim()) params.keywords = keyword.trim()
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)
}, [activeCategory, buildSortParams, keyword])
// 分类或排序变化时重新加载
useEffect(() => {
setGoodsList([])
setFinished(false)
setPage(1)
loadGoods(1)
}, [activeCategory, 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
addItem(product, undefined, product.step || 1).catch(() => {})
}
/** 搜索输入(防抖 500ms */
const handleSearchInput = (e: any) => {
const val = e.detail.value
setKeyword(val)
if (searchTimerRef.current) clearTimeout(searchTimerRef.current)
searchTimerRef.current = setTimeout(() => {
setGoodsList([])
setFinished(false)
setPage(1)
loadGoods(1)
}, 500)
}
/** 跳转商品详情 */
const goDetail = (product: ShopGoods) => {
Taro.navigateTo({ url: `/pages/shop/product-detail?id=${product.goodsId}` })
}
return (
<View className='flex flex-col h-screen bg-gray-50'>
{/* 顶部搜索栏 */}
<View className='bg-white px-3 py-2 flex-shrink-0 border-b border-gray-100'>
<View className='flex items-center bg-gray-100 rounded-full px-3 py-1.5'>
<Text className='text-gray-400 text-sm mr-1'>🔍</Text>
<Input
type='text'
value={keyword}
placeholder='搜索商品'
placeholderClass='text-gray-400'
confirmType='search'
className='flex-1 text-sm text-gray-700'
onInput={handleSearchInput}
onConfirm={() => {
setGoodsList([])
setFinished(false)
setPage(1)
loadGoods(1)
}}
/>
{keyword ? (
<Text
className='text-gray-400 text-sm pl-1'
onClick={() => {
setKeyword('')
setGoodsList([])
setFinished(false)
setPage(1)
loadGoods(1)
}}
>
</Text>
) : null}
</View>
</View>
{/* 左右分栏 */}
<View className='flex flex-1 min-h-0'>
{/* 左侧分类栏 */}
<ScrollView scrollY className='w-20 bg-gray-100 h-full flex-shrink-0'>
{categoryList.map(cat => (
<View
key={cat.categoryId || cat.id}
className={`py-3 px-2 text-center text-xs ${
(cat.categoryId || cat.id) === activeCategory
? 'bg-white text-green-600 font-medium border-l-2 border-green-500'
: 'text-gray-600'
}`}
onClick={() => setActiveCategory(cat.categoryId || cat.id)}
>
<Text>{cat.title}</Text>
</View>
))}
</ScrollView>
{/* 右侧:排序栏 + 商品列表 */}
<View className='flex-1 flex flex-col h-full min-w-0'>
{/* 排序栏 */}
<View className='flex items-center bg-white py-2 border-b border-gray-100 flex-shrink-0'>
{SORT_TABS.map(tab => (
<View
key={tab.key}
className='flex-1 flex items-center justify-center'
onClick={() => handleSortChange(tab.key)}
>
<Text
className={`text-xs ${
sortKey === tab.key ? 'text-green-600 font-medium' : 'text-gray-500'
}`}
>
{tab.label}
</Text>
{tab.key === 'price' && sortKey === 'price' && (
<Text className='text-xs text-green-600 ml-0.5'>
{priceAsc ? '↑' : '↓'}
</Text>
)}
</View>
))}
<View className='flex-1 flex items-center justify-center'>
<Text className='text-xs text-gray-400'></Text>
</View>
</View>
{/* 商品列表 */}
<ScrollView
scrollY
className='flex-1 min-h-0'
onScrollToLower={handleLoadMore}
lowerThreshold={100}
>
<View className='p-2'>
{goodsList.length > 0 ? (
<View className='flex flex-wrap'>
{goodsList.map(item => (
<View
key={item.goodsId}
className='w-1/2 p-1'
onClick={() => goDetail(item)}
>
<View className='bg-white rounded-lg overflow-hidden flex flex-col'>
{/* 商品图片 */}
<View className='w-full h-36 bg-gray-100 overflow-hidden'>
<Image
className='w-full h-full'
src={getCompressedImageUrl(item.image || item.files || '')}
mode='aspectFit'
lazyLoad
/>
</View>
{/* 商品信息 */}
<View className='p-2 flex flex-col gap-1'>
{/* 标题 + 积分标签 */}
<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'>
<Price
price={getDisplayPrice(item)}
original={
isVipMember() && item.dealerPrice
? item.price
: item.salePrice && item.salePrice !== item.price
? item.salePrice
: undefined
}
size='small'
loginMask
/>
{item.unitName && !isGuest() ? (
<Text className='text-xs text-gray-400'>/{item.unitName}</Text>
) : null}
{/* 销量 */}
{item.sales !== undefined && item.sales > 0 && (
<Text className='text-xs text-gray-400 block mt-0.5'>
{item.sales}
</Text>
)}
</View>
{/* 加购按钮 */}
<View
className='w-7 h-7 rounded-full bg-green-500 flex items-center justify-center flex-shrink-0 ml-1'
onClick={(e) => handleAddToCart(e, item)}
>
<Text className='text-white text-lg leading-none'>+</Text>
</View>
</View>
</View>
</View>
</View>
))}
</View>
) : (
!loading && <EmptyState text='暂无商品' />
)}
<LoadMore loading={loading} finished={finished} />
</View>
</ScrollView>
</View>
</View>
{/* 浮动购物车按钮 */}
<View
className='flex items-center justify-center'
style={{
position: 'fixed',
right: '16px',
bottom: '80px',
width: '48px',
height: '48px',
borderRadius: '50%',
backgroundColor: '#0e932e',
zIndex: 999,
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
}}
onClick={() => Taro.navigateTo({ url: '/pages/shop/cart' })}
>
<Text className='text-white text-xl'>🛒</Text>
{totalCount > 0 && (
<View
className='flex items-center justify-center'
style={{
position: 'absolute',
top: '-4px',
right: '-4px',
minWidth: '18px',
height: '18px',
borderRadius: '9px',
backgroundColor: '#ef4444',
borderWidth: '1.5px',
borderStyle: 'solid',
borderColor: '#ffffff',
paddingHorizontal: '4px',
}}
>
<Text className='text-white text-xs font-bold leading-none'>
{totalCount > 99 ? '99+' : totalCount}
</Text>
</View>
)}
</View>
</View>
)
}
export default ShopPage