Files
xin-procurement-weapp/src/pages/product/index.tsx
T
2026-08-14 16:21:41 +08:00

362 lines
13 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 { 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>
)
}