Files
xin-procurement-weapp/src/pages/cart/index.tsx
T
2026-08-29 13:52:57 +08:00

357 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, useRef, useState } from 'react'
import Taro, { useDidShow } from '@tarojs/taro'
import { View, Text, Image, Textarea, ScrollView } from '@tarojs/components'
import { Button, Empty, Icon, Popup, Stepper } from '@antmjs/vantui'
import useAuthStore from '@/stores/auth/useAuthStore'
import useCartStore from '@/stores/cart/useCartStore'
import { createOrderApi } from '@/services/order'
import { getStoreInfoApi } from '@/services/store'
import {formatRetailPrice, formatSpec} from '@/utils/format'
import type { CartItem } from '@/types/cart'
import type { StoreDetail } from '@/types/store'
import './index.less'
export default function CartPage() {
const token = useAuthStore(s => s.token)
const items = useCartStore(s => s.items)
const totalQuantity = useCartStore(s => s.totalQuantity)
const totalAmount = useCartStore(s => s.totalAmount)
const loading = useCartStore(s => s.loading)
const fetchCart = useCartStore(s => s.fetchCart)
const updateQuantity = useCartStore(s => s.updateQuantity)
const removeItem = useCartStore(s => s.removeItem)
const clearCart = useCartStore(s => s.clearCart)
const clearLocal = useCartStore(s => s.clearLocal)
/** 本地编辑中的数量(输入即时生效,500ms 防抖后提交服务端) */
const [qtyMap, setQtyMap] = useState<Record<number, string>>({})
const debounceRef = useRef<Record<number, ReturnType<typeof setTimeout>>>({})
/** 下单弹层 */
const [showOrder, setShowOrder] = useState(false)
const [remark, setRemark] = useState('')
const [submitting, setSubmitting] = useState(false)
/** 配送信息(下单弹层展示,打开时拉取) */
const [storeInfo, setStoreInfo] = useState<StoreDetail | null>(null)
const [storeLoading, setStoreLoading] = useState(false)
/** 可购项(status=1 */
const purchasable = items.filter(item => item.status === 1)
const hasInvalid = items.length > 0 && purchasable.length < items.length
const loggedIn = !!token
const goLogin = useCallback(() => {
Taro.navigateTo({ url: '/pages/login/index' })
}, [])
/** 拉取门店配送信息 */
const fetchStoreInfo = useCallback(() => {
setStoreLoading(true)
getStoreInfoApi()
.then(res => setStoreInfo(res.data))
.catch(() => setStoreInfo(null))
.finally(() => setStoreLoading(false))
}, [])
useDidShow(() => {
// 未登录不请求接口,直接展示去登录空态(参考消息页)
if (!loggedIn) return
fetchCart().catch(() => {})
// 从门店信息编辑页返回且弹层仍打开时 → 刷新配送信息
if (showOrder) fetchStoreInfo()
})
/** 同步本地编辑数量:删除不存在的项,保留在编数量 */
useEffect(() => {
setQtyMap(prev => {
const next: Record<number, string> = {}
for (const item of items) {
if (prev[item.id] !== undefined) {
next[item.id] = prev[item.id]
}
}
return next
})
}, [items])
/** 清理防抖定时器 */
useEffect(() => {
const timers = debounceRef.current
return () => {
Object.values(timers).forEach(t => clearTimeout(t))
}
}, [])
/** 数量变更:本地即时更新 + 防抖提交 */
const handleQtyChange = useCallback(
(item: CartItem, val: string | number) => {
const value = Number(val)
if (!value || value <= 0) return
setQtyMap(prev => ({ ...prev, [item.id]: String(value) }))
if (debounceRef.current[item.id]) {
clearTimeout(debounceRef.current[item.id])
}
debounceRef.current[item.id] = setTimeout(() => {
updateQuantity(item.id, value).catch(() => {
// 服务端拒绝(超上限等)→ 重新同步展示
fetchCart().catch(() => {})
})
}, 500)
},
[updateQuantity, fetchCart],
)
/** 删除单项 */
const handleRemove = useCallback(
(item: CartItem) => {
Taro.showModal({
title: '删除商品',
content: `确定删除「${item.name}」吗?`,
confirmColor: '#ee0a24',
success: res => {
if (res.confirm) {
removeItem(item.id).catch(() => {})
}
},
})
},
[removeItem],
)
/** 清空购物车 */
const handleClear = useCallback(() => {
Taro.showModal({
title: '清空购物车',
content: '确定清空购物车吗?',
confirmColor: '#ee0a24',
success: res => {
if (res.confirm) {
clearCart().catch(() => {})
}
},
})
}, [clearCart])
/** 打开下单弹层(同时拉取配送信息) */
const handleOrderTap = useCallback(() => {
if (!purchasable.length) {
Taro.showToast({ title: '没有可购买的商品', icon: 'none' })
return
}
setShowOrder(true)
fetchStoreInfo()
}, [purchasable.length, fetchStoreInfo])
/** 配送信息点击:拉取失败时重试,否则前往门店信息编辑页 */
const handleDeliveryTap = useCallback(() => {
if (!storeLoading && !storeInfo) {
fetchStoreInfo()
return
}
Taro.navigateTo({ url: '/pages/store-info/index' })
}, [storeLoading, storeInfo, fetchStoreInfo])
/** 提交订单(金额一律服务端重算) */
const handleSubmitOrder = useCallback(async () => {
if (submitting) return
setSubmitting(true)
try {
await createOrderApi({
items: purchasable.map(item => ({
product_id: item.product_id,
quantity: Number(qtyMap[item.id] ?? item.quantity),
})),
remark: remark.trim() || undefined,
})
Taro.showToast({ title: '下单成功', icon: 'success' })
setShowOrder(false)
setRemark('')
// 本地先清空,再以服务端为准同步(服务端可能保留购物车内容)
clearLocal()
fetchCart().catch(() => {})
} catch {
// 错误(已下架/未设等级价)已由 request 层 toast
} finally {
setSubmitting(false)
}
}, [submitting, purchasable, qtyMap, remark, clearLocal, fetchCart])
/** 当前展示的数量 */
const displayQty = (item: CartItem) => qtyMap[item.id] ?? item.quantity
return (
<View className='cart-page'>
{/* ========== 头部 ========== */}
<View className='cart-header'>
<Text className='cart-header__title'>购物车</Text>
{loggedIn && items.length > 0 && (
<Text className='cart-header__clear' onClick={handleClear}>清空</Text>
)}
</View>
{/* ========== 列表 ========== */}
{!loggedIn ? (
<Empty description='登录后查看购物车' className='cart-empty'>
<View className='cart-empty__btn' onClick={goLogin}>去登录</View>
</Empty>
) : items.length === 0 ? (
loading ? (
<View className='cart-loading'><Text>加载中...</Text></View>
) : (
<Empty description='购物车还是空的,去逛逛吧' className='cart-empty'>
<Button
type='danger'
size='small'
round
onClick={() => Taro.switchTab({ url: '/pages/product/index' })}
>
去选购
</Button>
</Empty>
)
) : (
items.map(item => (
<View key={item.id} className={`cart-item ${item.status === 0 ? 'cart-item--invalid' : ''}`}>
<Image className='cart-item__image' src={item.image} mode='aspectFill' lazyLoad />
<View className='cart-item__info'>
<View className='cart-item__title-row'>
<Text className='cart-item__name'>{item.name}</Text>
{item.status === 0 && <Text className='cart-item__invalid-tag'>已失效</Text>}
</View>
<Text className='cart-item__spec'>
{formatSpec(item.spec, item.unit)}{' '}
单价:{formatRetailPrice(item.price, item.spec)} {item.price_unit}
</Text>
<View className='cart-item__bottom'>
<Text className='cart-item__price'>{item.price}</Text>
{item.status === 1 ? (
<Stepper
value={displayQty(item)}
min={1}
max={99999999.99}
inputWidth='90rpx'
buttonSize='56rpx'
onChange={e => handleQtyChange(item, e.detail)}
/>
) : (
<Text className='cart-item__delete' onClick={() => handleRemove(item)}>删除</Text>
)}
</View>
</View>
{item.status === 1 && (
<View className='cart-item__actions'>
<Text className='cart-item__amount'>{item.amount ?? '0.00'}</Text>
<Text className='cart-item__delete' onClick={() => handleRemove(item)}>删除</Text>
</View>
)}
</View>
))
)}
<View style={{ height: 100 }}></View>
{loggedIn && hasInvalid && (
<View className='cart-invalid-hint'>
<Text>部分商品已下架或未设置您所在等级的价格,不可下单</Text>
</View>
)}
{/* ========== 底部结算栏 ========== */}
{loggedIn && items.length > 0 && (
<View className='cart-footer'>
<View className='cart-footer__total'>
<Text className='cart-footer__label'>合计{totalQuantity}</Text>
<Text className='cart-footer__amount'>{totalAmount}</Text>
</View>
<Button type='danger' className='cart-footer__submit' onClick={handleOrderTap}>
去下单
</Button>
</View>
)}
{/* ========== 下单确认弹层 ========== */}
<Popup
show={showOrder}
position='bottom'
round
closeable
closeOnClickOverlay
safeAreaInsetBottom
onClose={() => setShowOrder(false)}
>
<View className='order-popup'>
<Text className='order-popup__title'>确认下单</Text>
{/* 配送信息(点击前往修改门店信息) */}
<View className='order-popup__delivery' onClick={handleDeliveryTap}>
<Icon name='location-o' size={20} color='#ee0a24' className='order-popup__delivery-icon' />
{storeLoading ? (
<Text className='order-popup__delivery-tip'>配送信息加载中...</Text>
) : storeInfo ? (
<View className='order-popup__delivery-info'>
<View className='order-popup__delivery-head'>
<Text className='order-popup__delivery-name'>{storeInfo.name}</Text>
{(storeInfo.contact || storeInfo.phone) && (
<Text className='order-popup__delivery-contact'>
{storeInfo.contact} {storeInfo.phone}
</Text>
)}
</View>
{storeInfo.address ? (
<Text className='order-popup__delivery-address'>{storeInfo.address}</Text>
) : (
<Text className='order-popup__delivery-warn'>收货地址未填写,点击完善配送信息</Text>
)}
</View>
) : (
<Text className='order-popup__delivery-tip'>未获取到门店信息,点击重试</Text>
)}
<Text className='order-popup__delivery-arrow'></Text>
</View>
<ScrollView scrollY className='order-popup__list'>
{purchasable.map(item => (
<View key={item.id} className='order-popup__item'>
<View className='order-popup__item-info'>
<Image className='order-popup__item-image' src={item.image} mode='aspectFill' lazyLoad />
<View className='order-popup__item-title'>
<Text className='order-popup__item-name'>{item.name}</Text>
<Text className='order-popup__item-spec'>{formatSpec(item.spec, item.unit)}</Text>
</View>
</View>
<View className='order-popup__item-right'>
<Text className='order-popup__item-qty'>×{displayQty(item)}</Text>
<Text className='order-popup__item-amount'>{item.amount ?? '0.00'}</Text>
</View>
</View>
))}
</ScrollView>
<View className='order-popup__remark'>
<Text className='order-popup__remark-label'>订单备注</Text>
<Textarea
className='order-popup__remark-input'
value={remark}
placeholder='选填,最多 255 字'
maxlength={255}
onInput={e => setRemark(e.detail.value)}
/>
</View>
<View className='order-popup__footer'>
<Text className='order-popup__total'>合计 {totalAmount}</Text>
<Button
type='danger'
className='order-popup__submit'
loading={submitting}
onClick={handleSubmitOrder}
>
提交订单
</Button>
</View>
</View>
</Popup>
</View>
)
}