357 lines
13 KiB
TypeScript
357 lines
13 KiB
TypeScript
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>
|
||
)
|
||
}
|