362 lines
13 KiB
TypeScript
362 lines
13 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||
import Taro, { useDidShow, useReachBottom } from '@tarojs/taro'
|
||
import { View, Text, Image, ScrollView } from '@tarojs/components'
|
||
import { Button, Empty, Popup, Search, Stepper } from '@antmjs/vantui'
|
||
import useCartStore from '@/stores/cart/useCartStore'
|
||
import { getCategoriesApi, getProductListApi } from '@/services/product'
|
||
import type { ProductListParams } from '@/services/product'
|
||
import { getProductCover } from '@/types/product'
|
||
import type { Category, Product } from '@/types/product'
|
||
import './index.less'
|
||
|
||
const PAGE_SIZE = 10
|
||
/** 存储 key:首页点击分类/搜索跳转时经本地存储传参(switchTab 无法带参) */
|
||
const PENDING_CATEGORY_KEY = 'product_category_id'
|
||
const PENDING_KEYWORD_KEY = 'product_keyword'
|
||
|
||
export default function ProductPage() {
|
||
const addItem = useCartStore(s => s.addItem)
|
||
|
||
/** 分类树 */
|
||
const [categories, setCategories] = useState<Category[]>([])
|
||
/** 选中的分类ID(null = 全部;仅叶子分类可选:二级分类或无子分类的一级分类) */
|
||
const [activeId, setActiveId] = useState<number | null>(null)
|
||
/** 展开的一级分类ID(纯 UI 状态:有子分类的一级分类不可选中,点击只展开/收起) */
|
||
const [expandedTop, setExpandedTop] = useState<number | null>(null)
|
||
/** 已提交的搜索词(onSearch 才生效) */
|
||
const [searchKey, setSearchKey] = useState('')
|
||
/** 输入框内容 */
|
||
const [inputKey, setInputKey] = useState('')
|
||
|
||
/** 商品列表 */
|
||
const [products, setProducts] = useState<Product[]>([])
|
||
const [page, setPage] = useState(1)
|
||
const [loading, setLoading] = useState(false)
|
||
const [finished, setFinished] = useState(false)
|
||
/** 请求序号:并发时仅采用最后一次请求的结果,避免分类快速切换时旧响应覆盖新数据 */
|
||
const reqSeqRef = useRef(0)
|
||
/** 是否有请求进行中(仅用于避免"加载更多"并发) */
|
||
const loadingRef = useRef(false)
|
||
|
||
/** 加购弹层 */
|
||
const [showPopup, setShowPopup] = useState(false)
|
||
const [current, setCurrent] = useState<Product | null>(null)
|
||
const [qty, setQty] = useState(1)
|
||
const addingRef = useRef(false)
|
||
|
||
/** 当前选中二级分类所属的一级分类ID(用于父级高亮) */
|
||
const activeParentId = useMemo(() => {
|
||
if (activeId == null) return null
|
||
return categories.find(c => (c.children ?? []).some(ch => ch.id === activeId))?.id ?? null
|
||
}, [categories, activeId])
|
||
|
||
/** 当前生效的分类ID */
|
||
const effectiveCategoryId = activeId ?? undefined
|
||
|
||
/** 拉取商品列表(keywordOverride 用于状态未更新时显式传本次搜索词) */
|
||
const fetchList = useCallback(
|
||
async (pageNum: number, reset: boolean, keywordOverride?: string) => {
|
||
if (!reset && loadingRef.current) return
|
||
const seq = ++reqSeqRef.current
|
||
loadingRef.current = true
|
||
setLoading(true)
|
||
try {
|
||
// undefined 会被序列化成字符串 "undefined" 拼进 URL,导致后端过滤为空,只传有值参数
|
||
const params: ProductListParams = { page: pageNum, pageSize: PAGE_SIZE }
|
||
if (effectiveCategoryId != null) params.category_id = effectiveCategoryId
|
||
const keyword = keywordOverride ?? (searchKey || undefined)
|
||
if (keyword) params.keyword = keyword
|
||
const res = await getProductListApi(params)
|
||
if (seq !== reqSeqRef.current) return // 已有更新的请求,丢弃本次响应
|
||
const { data, total: totalCount } = res.data
|
||
setProducts(prev => (reset ? data : [...prev, ...data]))
|
||
setPage(pageNum)
|
||
setFinished(pageNum * PAGE_SIZE >= totalCount)
|
||
} catch {
|
||
// 错误已由 request 层 toast
|
||
} finally {
|
||
if (seq === reqSeqRef.current) {
|
||
loadingRef.current = false
|
||
setLoading(false)
|
||
}
|
||
}
|
||
},
|
||
[effectiveCategoryId, searchKey],
|
||
)
|
||
|
||
/** 分类/搜索词变化时重新加载第一页(首屏由 useDidShow 触发,跳过首次执行) */
|
||
const firstEffectRef = useRef(true)
|
||
useEffect(() => {
|
||
if (firstEffectRef.current) {
|
||
firstEffectRef.current = false
|
||
return
|
||
}
|
||
fetchList(1, true)
|
||
}, [fetchList])
|
||
|
||
/** 分类树(加载完成后处理首页跳转带入的分类) */
|
||
const loadCategories = useCallback(async () => {
|
||
try {
|
||
const res = await getCategoriesApi()
|
||
setCategories(res.data)
|
||
// 处理首页跳转带入的分类(switchTab 无法带参,经本地存储传递)
|
||
let pending: number | null = null
|
||
try {
|
||
pending = Taro.getStorageSync(PENDING_CATEGORY_KEY) || null
|
||
Taro.removeStorageSync(PENDING_CATEGORY_KEY)
|
||
} catch {
|
||
// noop
|
||
}
|
||
if (pending) {
|
||
const parentOfChild = res.data.find(c => (c.children ?? []).some(ch => ch.id === pending))
|
||
const top = res.data.find(c => c.id === pending)
|
||
if (parentOfChild) {
|
||
// 带入的是二级分类:展开父级并选中
|
||
setExpandedTop(parentOfChild.id)
|
||
setActiveId(pending)
|
||
} else if (top) {
|
||
const children = top.children ?? []
|
||
if (children.length > 0) {
|
||
// 有子分类的一级分类不可选中:展开并默认选中第一个子分类
|
||
setExpandedTop(top.id)
|
||
setActiveId(children[0].id)
|
||
} else {
|
||
setActiveId(top.id)
|
||
}
|
||
}
|
||
}
|
||
} catch {
|
||
// 错误已由 request 层 toast
|
||
}
|
||
}, [])
|
||
|
||
useDidShow(() => {
|
||
// 处理首页搜索跳转带入的关键词
|
||
let pendingKeyword: string | null = null
|
||
try {
|
||
pendingKeyword = Taro.getStorageSync(PENDING_KEYWORD_KEY) || null
|
||
Taro.removeStorageSync(PENDING_KEYWORD_KEY)
|
||
} catch {
|
||
// noop
|
||
}
|
||
if (pendingKeyword) {
|
||
setInputKey(pendingKeyword)
|
||
setSearchKey(pendingKeyword)
|
||
}
|
||
loadCategories()
|
||
fetchList(1, true, pendingKeyword ?? undefined)
|
||
})
|
||
|
||
useReachBottom(() => {
|
||
if (!finished) {
|
||
fetchList(page + 1, false)
|
||
}
|
||
})
|
||
|
||
/** 点击一级分类:有子分类仅展开/收起(不可选中),无子分类则选中 */
|
||
const handleTopTap = useCallback((cat: Category) => {
|
||
if ((cat.children ?? []).length > 0) {
|
||
setExpandedTop(prev => (prev === cat.id ? null : cat.id))
|
||
return
|
||
}
|
||
setActiveId(cat.id)
|
||
}, [])
|
||
|
||
/** 选中二级分类 */
|
||
const handleChildTap = useCallback((id: number) => {
|
||
setActiveId(id)
|
||
}, [])
|
||
|
||
/** 选中"全部" */
|
||
const handleAllTap = useCallback(() => {
|
||
setActiveId(null)
|
||
}, [])
|
||
|
||
/** 提交搜索 */
|
||
const handleSearch = useCallback(() => {
|
||
setSearchKey(inputKey.trim())
|
||
}, [inputKey])
|
||
|
||
/** 清空搜索 */
|
||
const handleClear = useCallback(() => {
|
||
setInputKey('')
|
||
setSearchKey('')
|
||
}, [])
|
||
|
||
/** 打开加购弹层 */
|
||
const handleAddTap = useCallback((product: Product) => {
|
||
setCurrent(product)
|
||
setQty(1)
|
||
setShowPopup(true)
|
||
}, [])
|
||
|
||
/** 确认加购 */
|
||
const handleConfirmAdd = useCallback(async () => {
|
||
if (!current || addingRef.current) return
|
||
addingRef.current = true
|
||
try {
|
||
await addItem(current.id, qty)
|
||
Taro.showToast({ title: '已加入购物车', icon: 'success' })
|
||
setShowPopup(false)
|
||
} catch {
|
||
// 错误(未设等级价/数量上限)已由 request 层 toast
|
||
} finally {
|
||
addingRef.current = false
|
||
}
|
||
}, [current, qty, addItem])
|
||
|
||
return (
|
||
<View className='product-page'>
|
||
{/* ========== 搜索 ========== */}
|
||
<View className='product-search'>
|
||
<Search
|
||
value={inputKey}
|
||
placeholder='搜索品名/规格'
|
||
shape='round'
|
||
onChange={e => setInputKey(String(e.detail))}
|
||
onSearch={handleSearch}
|
||
onClear={handleClear}
|
||
/>
|
||
</View>
|
||
|
||
<View className='product-body'>
|
||
{/* ========== 左侧分类(一级为分组,叶子分类可选) ========== */}
|
||
<ScrollView scrollY className='product-categories'>
|
||
<View
|
||
className={`category-item ${activeId === null ? 'active' : ''}`}
|
||
onClick={handleAllTap}
|
||
>
|
||
<Text className='category-item__name'>全部</Text>
|
||
</View>
|
||
{categories.map(cat => {
|
||
const children = cat.children ?? []
|
||
const hasChildren = children.length > 0
|
||
return (
|
||
<View key={cat.id}>
|
||
<View
|
||
className={[
|
||
'category-item',
|
||
// 有子分类:子分类被选中时父级高亮;无子分类:自身可选中
|
||
hasChildren && activeParentId === cat.id && 'expanded',
|
||
!hasChildren && activeId === cat.id && 'active',
|
||
].filter(Boolean).join(' ')}
|
||
onClick={() => handleTopTap(cat)}
|
||
>
|
||
<Text className='category-item__name'>{cat.name}</Text>
|
||
{hasChildren && (
|
||
<Text className='category-item__arrow'>
|
||
{expandedTop === cat.id ? '▾' : '▸'}
|
||
</Text>
|
||
)}
|
||
</View>
|
||
{/* 二级分类 */}
|
||
{expandedTop === cat.id &&
|
||
children.map(child => (
|
||
<View
|
||
key={child.id}
|
||
className={`category-item category-item--child ${
|
||
activeId === child.id ? 'active' : ''
|
||
}`}
|
||
onClick={() => handleChildTap(child.id)}
|
||
>
|
||
<Text className='category-item__name'>{child.name}</Text>
|
||
</View>
|
||
))}
|
||
</View>
|
||
)
|
||
})}
|
||
</ScrollView>
|
||
|
||
{/* ========== 右侧商品列表 ========== */}
|
||
<View className='product-main'>
|
||
{/* 商品列表 */}
|
||
{products.length === 0 && !loading ? (
|
||
<Empty description='暂无商品' className='product-empty' />
|
||
) : (
|
||
products.map(product => (
|
||
<View key={product.id} className='product-item'>
|
||
<Image
|
||
className='product-item__image'
|
||
src={getProductCover(product) || 'https://img.yzcdn.cn/vant/cat.jpeg'}
|
||
mode='aspectFill'
|
||
lazyLoad
|
||
/>
|
||
<View className='product-item__info'>
|
||
<Text className='product-item__name'>{product.name}</Text>
|
||
<Text className='product-item__spec'>{product.spec} / {product.unit}</Text>
|
||
<View className='product-item__bottom'>
|
||
{product.price !== null ? (
|
||
<Text className='product-item__price'>¥{product.price}</Text>
|
||
) : (
|
||
<Text className='product-item__price product-item__price--none'>价格待定</Text>
|
||
)}
|
||
<View className='product-item__add' onClick={() => handleAddTap(product)}>
|
||
<Text className='product-item__add-icon'>+</Text>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
))
|
||
)}
|
||
|
||
{/* 加载状态 */}
|
||
{loading && <View className='product-loading'><Text>加载中...</Text></View>}
|
||
{finished && products.length > 0 && (
|
||
<View className='product-loading'><Text>没有更多了</Text></View>
|
||
)}
|
||
</View>
|
||
</View>
|
||
|
||
{/* ========== 加购弹层 ========== */}
|
||
<Popup
|
||
show={showPopup}
|
||
position='bottom'
|
||
round
|
||
closeable
|
||
closeOnClickOverlay
|
||
safeAreaInsetBottom
|
||
onClose={() => setShowPopup(false)}
|
||
>
|
||
{current && (
|
||
<View className='add-popup'>
|
||
<View className='add-popup__product'>
|
||
<Image
|
||
className='add-popup__image'
|
||
src={getProductCover(current) || 'https://img.yzcdn.cn/vant/cat.jpeg'}
|
||
mode='aspectFill'
|
||
/>
|
||
<View className='add-popup__info'>
|
||
<Text className='add-popup__name'>{current.name}</Text>
|
||
<Text className='add-popup__spec'>{current.spec} / {current.unit}</Text>
|
||
{current.price !== null ? (
|
||
<Text className='add-popup__price'>¥{current.price}</Text>
|
||
) : (
|
||
<Text className='add-popup__price add-popup__price--none'>价格待定</Text>
|
||
)}
|
||
</View>
|
||
</View>
|
||
<View className='add-popup__row'>
|
||
<Text className='add-popup__label'>购买数量</Text>
|
||
<Stepper
|
||
value={qty}
|
||
min={1}
|
||
max={99999999.99}
|
||
onChange={e => setQty(Number(e.detail))}
|
||
/>
|
||
</View>
|
||
<Button
|
||
type='danger'
|
||
block
|
||
round
|
||
className='add-popup__submit'
|
||
onClick={handleConfirmAdd}
|
||
>
|
||
加入购物车
|
||
</Button>
|
||
</View>
|
||
)}
|
||
</Popup>
|
||
</View>
|
||
)
|
||
}
|