购物车悬浮加减
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
// 购物车悬浮球:右下角,底部避让自定义 tabBar(110rpx + 安全区)
|
||||
.cart-ball {
|
||||
position: fixed;
|
||||
right: 24rpx;
|
||||
bottom: calc(150rpx + env(safe-area-inset-bottom));
|
||||
z-index: 998;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 88rpx;
|
||||
padding: 0 32rpx 0 8rpx;
|
||||
background: #fff;
|
||||
border-radius: 999rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
|
||||
box-sizing: border-box;
|
||||
|
||||
&__icon {
|
||||
position: relative;
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #ee0a24, #ff6034);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__badge {
|
||||
position: absolute;
|
||||
top: -8rpx;
|
||||
right: -16rpx;
|
||||
min-width: 32rpx;
|
||||
height: 32rpx;
|
||||
padding: 0 8rpx;
|
||||
box-sizing: border-box;
|
||||
background: #fff;
|
||||
border: 2rpx solid #ee0a24;
|
||||
border-radius: 999rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__badge-text {
|
||||
color: #ee0a24;
|
||||
font-size: 20rpx;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__amount {
|
||||
margin-left: 16rpx;
|
||||
color: #ee0a24;
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useCallback } from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { Icon } from '@antmjs/vantui'
|
||||
import useAuthStore from '@/stores/auth/useAuthStore'
|
||||
import useCartStore from '@/stores/cart/useCartStore'
|
||||
import { formatQuantity } from '@/utils/format'
|
||||
import './index.less'
|
||||
|
||||
/**
|
||||
* 购物车悬浮球(首页 / 商品列表页右下角,位于自定义 tabBar 上方):
|
||||
* 展示可购总数量徽标与总金额,点击跳转购物车页;
|
||||
* 未登录或购物车为空(total_count = 0)时隐藏
|
||||
*/
|
||||
export default function CartBall() {
|
||||
const token = useAuthStore(s => s.token)
|
||||
const totalCount = useCartStore(s => s.totalCount)
|
||||
const totalQuantity = useCartStore(s => s.totalQuantity)
|
||||
const totalAmount = useCartStore(s => s.totalAmount)
|
||||
|
||||
const goCart = useCallback(() => {
|
||||
Taro.switchTab({ url: '/pages/cart/index' })
|
||||
}, [])
|
||||
|
||||
if (!token || totalCount <= 0) return null
|
||||
|
||||
return (
|
||||
<View className='cart-ball' onClick={goCart}>
|
||||
<View className='cart-ball__icon'>
|
||||
<Icon name='shopping-cart-o' size='40rpx' color='#ffffff' />
|
||||
<View className='cart-ball__badge'>
|
||||
<Text className='cart-ball__badge-text'>{formatQuantity(totalQuantity)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text className='cart-ball__amount'>¥{totalAmount}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
.cart-stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
&__btn {
|
||||
width: 52rpx;
|
||||
height: 52rpx;
|
||||
border-radius: 50%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
border: 2rpx solid #ee0a24;
|
||||
|
||||
&--plus {
|
||||
background: linear-gradient(135deg, #ee0a24, #ff6034);
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__btn-icon {
|
||||
font-size: 30rpx;
|
||||
line-height: 1;
|
||||
color: #ee0a24;
|
||||
}
|
||||
|
||||
&__btn--plus &__btn-icon {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&__qty {
|
||||
min-width: 64rpx;
|
||||
padding: 0 4rpx;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
font-size: 28rpx;
|
||||
color: #323233;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { addCartApi, deleteCartItemApi, updateCartItemApi } from '@/services/cart'
|
||||
import useCartStore from '@/stores/cart/useCartStore'
|
||||
import { formatQuantity } from '@/utils/format'
|
||||
import type { Product, ProductCartPatch } from '@/types/product'
|
||||
import './index.less'
|
||||
|
||||
/** 加减防抖间隔(ms):连续点击合并为一次提交 */
|
||||
const DEBOUNCE_MS = 400
|
||||
|
||||
interface CartStepperProps {
|
||||
/** 商品行(使用 id / price / cart_id / cart_quantity) */
|
||||
product: Product
|
||||
/** 服务端确认后的行数据回写(父组件更新列表项的 cart_id/cart_quantity) */
|
||||
onSync: (productId: number, patch: ProductCartPatch) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 商品行内购物车加减(商品列表 / 首页推荐共用,仅在 cart_quantity > 0 时由父组件渲染):
|
||||
* - 点击即时更新本地数量与悬浮球(乐观展示),防抖后提交服务端
|
||||
* - 不在购物车(cart_id=0)→ POST /mini/cart 合并加购;已存在 → PUT 绝对数量;减到 0 → DELETE
|
||||
* (数量为 0 不能调 PUT,后端校验数量必须 > 0)
|
||||
* - 同一商品的提交串行执行,避免并发导致数量错乱
|
||||
* - 失败回滚本地数量(请求层已 toast),并立即整体校准悬浮球
|
||||
*/
|
||||
export default function CartStepper({ product, onSync }: CartStepperProps) {
|
||||
const applyDelta = useCartStore(s => s.applyDelta)
|
||||
const fetchSummary = useCartStore(s => s.fetchSummary)
|
||||
|
||||
/** 本地编辑数量(乐观值;null = 展示服务端确认值) */
|
||||
const [draft, setDraft] = useState<number | null>(null)
|
||||
/** 最新待提交的目标数量 */
|
||||
const targetRef = useRef<number | null>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
/** 提交串行队列 */
|
||||
const chainRef = useRef<Promise<void>>(Promise.resolve())
|
||||
/** 最新商品行快照(供防抖/串行回调读取服务端确认值,避免闭包过期) */
|
||||
const productRef = useRef(product)
|
||||
productRef.current = product
|
||||
|
||||
/** 卸载时清理防抖定时器 */
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
/** 提交目标数量(串行执行;与服务器一致时跳过) */
|
||||
const runSubmit = useCallback(
|
||||
async (target: number) => {
|
||||
const p = productRef.current
|
||||
const confirmed = Number(p.cart_quantity ?? 0)
|
||||
if (target === confirmed) return
|
||||
try {
|
||||
if (target <= 0) {
|
||||
if (p.cart_id) await deleteCartItemApi(p.cart_id)
|
||||
onSync(p.id, { cart_id: 0, cart_quantity: '0.00' })
|
||||
} else if (p.cart_id) {
|
||||
const res = await updateCartItemApi(p.cart_id, target)
|
||||
onSync(p.id, { cart_id: p.cart_id, cart_quantity: res.data.quantity })
|
||||
} else {
|
||||
// 未加购过:POST 合并加购,用返回的行 id 回写本地 cart_id
|
||||
const res = await addCartApi({ product_id: p.id, quantity: target })
|
||||
onSync(p.id, { cart_id: res.data.id, cart_quantity: res.data.quantity })
|
||||
}
|
||||
// 提交期间用户未再改动 → 本地数量落回服务端确认值(onSync 已回写,展示不变)
|
||||
setDraft(prev => (prev === target ? null : prev))
|
||||
} catch {
|
||||
// 失败(超上限等,请求层已 toast):放弃后续目标,回滚本地展示并校准悬浮球
|
||||
targetRef.current = null
|
||||
setDraft(null)
|
||||
fetchSummary().catch(() => {})
|
||||
}
|
||||
},
|
||||
[onSync, fetchSummary],
|
||||
)
|
||||
|
||||
/** 点击加/减:乐观更新本地数量与悬浮球,防抖后入队提交 */
|
||||
const handleTap = useCallback(
|
||||
(delta: 1 | -1) => {
|
||||
const before = draft ?? Number(productRef.current.cart_quantity ?? 0)
|
||||
const after = Math.round(Math.max(0, before + delta) * 100) / 100
|
||||
if (after === before) return
|
||||
setDraft(after)
|
||||
targetRef.current = after
|
||||
// 悬浮球乐观增减(金额按行内售价估算,防抖结束后由服务端汇总校准);
|
||||
// 数量跨过 0 时同步增减商品种数
|
||||
const price = Number(productRef.current.price ?? 0)
|
||||
applyDelta({
|
||||
quantity: delta,
|
||||
amount: Math.round(price * delta * 100) / 100,
|
||||
count: before === 0 && after > 0 ? 1 : before > 0 && after === 0 ? -1 : 0,
|
||||
})
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(() => {
|
||||
timerRef.current = null
|
||||
const target = targetRef.current
|
||||
if (target === null) return
|
||||
targetRef.current = null
|
||||
chainRef.current = chainRef.current.then(() => runSubmit(target))
|
||||
}, DEBOUNCE_MS)
|
||||
},
|
||||
[draft, applyDelta, runSubmit],
|
||||
)
|
||||
|
||||
const shown = draft ?? Number(product.cart_quantity ?? 0)
|
||||
|
||||
return (
|
||||
<View className='cart-stepper' onClick={e => e.stopPropagation()}>
|
||||
<View className='cart-stepper__btn' onClick={() => handleTap(-1)}>
|
||||
<Text className='cart-stepper__btn-icon'>-</Text>
|
||||
</View>
|
||||
<Text className='cart-stepper__qty'>{formatQuantity(shown)}</Text>
|
||||
<View className='cart-stepper__btn cart-stepper__btn--plus' onClick={() => handleTap(1)}>
|
||||
<Text className='cart-stepper__btn-icon'>+</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user