购物车悬浮加减
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
.home-page {
|
.home-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #f7f8fa;
|
background: #f7f8fa;
|
||||||
padding-bottom: calc(140rpx + env(safe-area-inset-bottom));
|
// 底部预留自定义 tabBar(110rpx + 安全区)+ 购物车悬浮球空间,避免内容被遮挡
|
||||||
|
padding-bottom: calc(250rpx + env(safe-area-inset-bottom));
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
// ===== 自定义顶部导航栏 =====
|
// ===== 自定义顶部导航栏 =====
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import { getHomeConfigApi } from '@/services/home'
|
|||||||
import type { HomeConfig } from '@/services/home'
|
import type { HomeConfig } from '@/services/home'
|
||||||
import { getProductListApi } from '@/services/product'
|
import { getProductListApi } from '@/services/product'
|
||||||
import { getProductCover } from '@/types/product'
|
import { getProductCover } from '@/types/product'
|
||||||
import type { Product } from '@/types/product'
|
import type { Product, ProductCartPatch } from '@/types/product'
|
||||||
import PriceText from '@/components/PriceText'
|
import PriceText from '@/components/PriceText'
|
||||||
|
import CartBall from '@/components/CartBall'
|
||||||
|
import CartStepper from '@/components/CartStepper'
|
||||||
import { formatSpec } from '@/utils/format'
|
import { formatSpec } from '@/utils/format'
|
||||||
import './index.less'
|
import './index.less'
|
||||||
|
|
||||||
@@ -41,6 +43,7 @@ function getStatusBarHeight(): number {
|
|||||||
|
|
||||||
export default function IndexPage() {
|
export default function IndexPage() {
|
||||||
const addItem = useCartStore(s => s.addItem)
|
const addItem = useCartStore(s => s.addItem)
|
||||||
|
const setSummary = useCartStore(s => s.setSummary)
|
||||||
|
|
||||||
/** 首页配置(轮播图 / 宫格导航 / 促销卡片) */
|
/** 首页配置(轮播图 / 宫格导航 / 促销卡片) */
|
||||||
const [config, setConfig] = useState<HomeConfig>({ banners: [], navs: [], promos: [] })
|
const [config, setConfig] = useState<HomeConfig>({ banners: [], navs: [], promos: [] })
|
||||||
@@ -56,15 +59,17 @@ export default function IndexPage() {
|
|||||||
loadRecommend()
|
loadRecommend()
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 首页配置聚合数据 */
|
/** 首页配置聚合数据(响应附带悬浮球汇总) */
|
||||||
const loadHomeConfig = useCallback(async () => {
|
const loadHomeConfig = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getHomeConfigApi()
|
const res = await getHomeConfigApi()
|
||||||
setConfig(res.data)
|
setConfig(res.data)
|
||||||
|
// 旧版本后端可能未返回 cart 块
|
||||||
|
if (res.data.cart) setSummary(res.data.cart)
|
||||||
} catch {
|
} catch {
|
||||||
// 错误已由 request 层 toast
|
// 错误已由 request 层 toast
|
||||||
}
|
}
|
||||||
}, [])
|
}, [setSummary])
|
||||||
|
|
||||||
/** 推荐商品 */
|
/** 推荐商品 */
|
||||||
const loadRecommend = useCallback(async () => {
|
const loadRecommend = useCallback(async () => {
|
||||||
@@ -112,18 +117,24 @@ export default function IndexPage() {
|
|||||||
goProduct(keyword.trim())
|
goProduct(keyword.trim())
|
||||||
}, [goProduct, keyword])
|
}, [goProduct, keyword])
|
||||||
|
|
||||||
/** 快捷加购 */
|
/** 行内加减购确认后回写推荐商品项的购物车字段 */
|
||||||
|
const handleRowSync = useCallback((productId: number, patch: ProductCartPatch) => {
|
||||||
|
setProducts(prev => prev.map(p => (p.id === productId ? { ...p, ...patch } : p)))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/** 快捷加购(用返回的购物车行回写,卡片随即展示加减器) */
|
||||||
const handleQuickAdd = useCallback(
|
const handleQuickAdd = useCallback(
|
||||||
async (product: Product, e: any) => {
|
async (product: Product, e: any) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
try {
|
try {
|
||||||
await addItem(product.id, 1)
|
const res = await addItem(product.id, 1)
|
||||||
|
handleRowSync(product.id, { cart_id: res.id, cart_quantity: res.quantity })
|
||||||
Taro.showToast({ title: '已加入购物车', icon: 'success' })
|
Taro.showToast({ title: '已加入购物车', icon: 'success' })
|
||||||
} catch {
|
} catch {
|
||||||
// 错误(未设等级价等)已由 request 层 toast
|
// 错误(未设等级价等)已由 request 层 toast
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[addItem],
|
[addItem, handleRowSync],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -261,9 +272,14 @@ export default function IndexPage() {
|
|||||||
) : (
|
) : (
|
||||||
<Text className='product-card__price product-card__price--none'>登陆后查看价格</Text>
|
<Text className='product-card__price product-card__price--none'>登陆后查看价格</Text>
|
||||||
)}
|
)}
|
||||||
<View className='product-card__add' onClick={e => handleQuickAdd(product, e)}>
|
{/* 已加购展示行内加减器,否则展示快捷加购按钮 */}
|
||||||
<Text className='product-card__add-icon'>+</Text>
|
{Number(product.cart_quantity ?? 0) > 0 ? (
|
||||||
</View>
|
<CartStepper product={product} onSync={handleRowSync} />
|
||||||
|
) : (
|
||||||
|
<View className='product-card__add' onClick={e => handleQuickAdd(product, e)}>
|
||||||
|
<Text className='product-card__add-icon'>+</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -271,6 +287,9 @@ export default function IndexPage() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* ========== 购物车悬浮球 ========== */}
|
||||||
|
<CartBall />
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,8 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 20rpx 20rpx 40rpx;
|
// 底部留白避免最后一行被购物车悬浮球遮挡
|
||||||
|
padding: 20rpx 20rpx 20rpx;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+35
-15
@@ -6,8 +6,10 @@ import useCartStore from '@/stores/cart/useCartStore'
|
|||||||
import { getCategoriesApi, getProductListApi } from '@/services/product'
|
import { getCategoriesApi, getProductListApi } from '@/services/product'
|
||||||
import type { ProductListParams } from '@/services/product'
|
import type { ProductListParams } from '@/services/product'
|
||||||
import { getProductCover } from '@/types/product'
|
import { getProductCover } from '@/types/product'
|
||||||
import type { Category, Product } from '@/types/product'
|
import type { Category, Product, ProductCartPatch } from '@/types/product'
|
||||||
import PriceText from '@/components/PriceText'
|
import PriceText from '@/components/PriceText'
|
||||||
|
import CartBall from '@/components/CartBall'
|
||||||
|
import CartStepper from '@/components/CartStepper'
|
||||||
import { formatSpec } from '@/utils/format'
|
import { formatSpec } from '@/utils/format'
|
||||||
import './index.less'
|
import './index.less'
|
||||||
|
|
||||||
@@ -18,6 +20,7 @@ const PENDING_KEYWORD_KEY = 'product_keyword'
|
|||||||
|
|
||||||
export default function ProductPage() {
|
export default function ProductPage() {
|
||||||
const addItem = useCartStore(s => s.addItem)
|
const addItem = useCartStore(s => s.addItem)
|
||||||
|
const setSummary = useCartStore(s => s.setSummary)
|
||||||
|
|
||||||
/** 分类树 */
|
/** 分类树 */
|
||||||
const [categories, setCategories] = useState<Category[]>([])
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
@@ -70,10 +73,12 @@ export default function ProductPage() {
|
|||||||
if (keyword) params.keyword = keyword
|
if (keyword) params.keyword = keyword
|
||||||
const res = await getProductListApi(params)
|
const res = await getProductListApi(params)
|
||||||
if (seq !== reqSeqRef.current) return // 已有更新的请求,丢弃本次响应
|
if (seq !== reqSeqRef.current) return // 已有更新的请求,丢弃本次响应
|
||||||
const { data, total: totalCount } = res.data
|
const { data, total: totalCount, cart } = res.data
|
||||||
setProducts(prev => (reset ? data : [...prev, ...data]))
|
setProducts(prev => (reset ? data : [...prev, ...data]))
|
||||||
setPage(pageNum)
|
setPage(pageNum)
|
||||||
setFinished(pageNum * PAGE_SIZE >= totalCount)
|
setFinished(pageNum * PAGE_SIZE >= totalCount)
|
||||||
|
// 列表响应附带悬浮球汇总(旧版本后端可能未返回)
|
||||||
|
if (cart) setSummary(cart)
|
||||||
} catch {
|
} catch {
|
||||||
// 错误已由 request 层 toast
|
// 错误已由 request 层 toast
|
||||||
} finally {
|
} finally {
|
||||||
@@ -83,7 +88,7 @@ export default function ProductPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[effectiveCategoryId, searchKey],
|
[effectiveCategoryId, searchKey, setSummary],
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 分类/搜索词变化时重新加载第一页(首屏由 useDidShow 触发,跳过首次执行) */
|
/** 分类/搜索词变化时重新加载第一页(首屏由 useDidShow 触发,跳过首次执行) */
|
||||||
@@ -193,17 +198,23 @@ export default function ProductPage() {
|
|||||||
setShowPopup(true)
|
setShowPopup(true)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
/** 行内加减购确认后回写列表项的购物车字段 */
|
||||||
|
const handleRowSync = useCallback((productId: number, patch: ProductCartPatch) => {
|
||||||
|
setProducts(prev => prev.map(p => (p.id === productId ? { ...p, ...patch } : p)))
|
||||||
|
}, [])
|
||||||
|
|
||||||
/** 跳转商品详情 */
|
/** 跳转商品详情 */
|
||||||
const goDetail = useCallback((id: number) => {
|
const goDetail = useCallback((id: number) => {
|
||||||
Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` })
|
Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
/** 确认加购 */
|
/** 确认加购(用返回的购物车行回写列表项,行内随即展示加减器) */
|
||||||
const handleConfirmAdd = useCallback(async () => {
|
const handleConfirmAdd = useCallback(async () => {
|
||||||
if (!current || addingRef.current) return
|
if (!current || addingRef.current) return
|
||||||
addingRef.current = true
|
addingRef.current = true
|
||||||
try {
|
try {
|
||||||
await addItem(current.id, qty)
|
const res = await addItem(current.id, qty)
|
||||||
|
handleRowSync(current.id, { cart_id: res.id, cart_quantity: res.quantity })
|
||||||
Taro.showToast({ title: '已加入购物车', icon: 'success' })
|
Taro.showToast({ title: '已加入购物车', icon: 'success' })
|
||||||
setShowPopup(false)
|
setShowPopup(false)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -211,7 +222,7 @@ export default function ProductPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
addingRef.current = false
|
addingRef.current = false
|
||||||
}
|
}
|
||||||
}, [current, qty, addItem])
|
}, [current, qty, addItem, handleRowSync])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='product-page'>
|
<View className='product-page'>
|
||||||
@@ -303,15 +314,20 @@ export default function ProductPage() {
|
|||||||
) : (
|
) : (
|
||||||
<Text className='product-item__price product-item__price--none'>价格待定</Text>
|
<Text className='product-item__price product-item__price--none'>价格待定</Text>
|
||||||
)}
|
)}
|
||||||
<View
|
{/* 已加购展示行内加减器,否则展示加购按钮(点击开弹层选数量) */}
|
||||||
className='product-item__add'
|
{Number(product.cart_quantity ?? 0) > 0 ? (
|
||||||
onClick={e => {
|
<CartStepper product={product} onSync={handleRowSync} />
|
||||||
e.stopPropagation()
|
) : (
|
||||||
handleAddTap(product)
|
<View
|
||||||
}}
|
className='product-item__add'
|
||||||
>
|
onClick={e => {
|
||||||
<Text className='product-item__add-icon'>+</Text>
|
e.stopPropagation()
|
||||||
</View>
|
handleAddTap(product)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className='product-item__add-icon'>+</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -323,6 +339,7 @@ export default function ProductPage() {
|
|||||||
{finished && products.length > 0 && (
|
{finished && products.length > 0 && (
|
||||||
<View className='product-loading'><Text>没有更多了</Text></View>
|
<View className='product-loading'><Text>没有更多了</Text></View>
|
||||||
)}
|
)}
|
||||||
|
<View style={{ height: 68 }}></View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -376,6 +393,9 @@ export default function ProductPage() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</Popup>
|
</Popup>
|
||||||
|
|
||||||
|
{/* ========== 购物车悬浮球 ========== */}
|
||||||
|
<CartBall />
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { del, get, post, put } from '@/utils/request'
|
import { del, get, post, put } from '@/utils/request'
|
||||||
import type { CartData } from '@/types/cart'
|
import type { CartData, CartSummary } from '@/types/cart'
|
||||||
|
|
||||||
/** 加购 / 改数量返回 */
|
/** 加购 / 改数量返回 */
|
||||||
export interface CartMutationResult {
|
export interface CartMutationResult {
|
||||||
@@ -17,6 +17,11 @@ export function getCartApi() {
|
|||||||
return get<CartData>('/mini/cart')
|
return get<CartData>('/mini/cart')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 轻量汇总(悬浮球单独刷新用;必须登录,未登录 401):GET /mini/cart/summary */
|
||||||
|
export function getCartSummaryApi() {
|
||||||
|
return get<CartSummary>('/mini/cart/summary')
|
||||||
|
}
|
||||||
|
|
||||||
/** 修改数量:PUT /mini/cart/{id} */
|
/** 修改数量:PUT /mini/cart/{id} */
|
||||||
export function updateCartItemApi(id: number, quantity: number) {
|
export function updateCartItemApi(id: number, quantity: number) {
|
||||||
return put<CartMutationResult>(`/mini/cart/${id}`, { quantity })
|
return put<CartMutationResult>(`/mini/cart/${id}`, { quantity })
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { get } from '@/utils/request'
|
import { get } from '@/utils/request'
|
||||||
|
import type { CartSummary } from '@/types/cart'
|
||||||
|
|
||||||
/** 首页轮播图项 */
|
/** 首页轮播图项 */
|
||||||
export interface HomeBanner {
|
export interface HomeBanner {
|
||||||
@@ -36,6 +37,8 @@ export interface HomeConfig {
|
|||||||
banners: HomeBanner[]
|
banners: HomeBanner[]
|
||||||
navs: HomeNav[]
|
navs: HomeNav[]
|
||||||
promos: HomePromo[]
|
promos: HomePromo[]
|
||||||
|
/** 购物车悬浮球汇总(未登录返回零值结构;旧版本后端可能未返回,调用方判空) */
|
||||||
|
cart?: CartSummary
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 首页配置(轮播图 + 宫格导航 + 促销卡片):GET /mini/home */
|
/** 首页配置(轮播图 + 宫格导航 + 促销卡片):GET /mini/home */
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { get } from '@/utils/request'
|
import { get } from '@/utils/request'
|
||||||
import type { PaginatedData } from '@/types/api'
|
import type { PaginatedData } from '@/types/api'
|
||||||
|
import type { CartSummary } from '@/types/cart'
|
||||||
import type { Category, Product } from '@/types/product'
|
import type { Category, Product } from '@/types/product'
|
||||||
|
|
||||||
/** 商品分类树(仅含上架商品的分类及其祖先):GET /mini/product/categories */
|
/** 商品分类树(仅含上架商品的分类及其祖先):GET /mini/product/categories */
|
||||||
@@ -17,9 +18,15 @@ export interface ProductListParams {
|
|||||||
pageSize?: number
|
pageSize?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 商品列表(当前门店等级实际价):GET /mini/product/list */
|
/** 商品列表响应(分页 + 购物车悬浮球汇总) */
|
||||||
|
export interface ProductListData extends PaginatedData<Product> {
|
||||||
|
/** 购物车悬浮球汇总(未登录返回零值结构;旧版本后端可能未返回,调用方判空) */
|
||||||
|
cart?: CartSummary
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 商品列表(当前门店等级实际价 + 行内购物车字段):GET /mini/product/list */
|
||||||
export function getProductListApi(params: ProductListParams = {}) {
|
export function getProductListApi(params: ProductListParams = {}) {
|
||||||
return get<PaginatedData<Product>>('/mini/product/list', { data: params })
|
return get<ProductListData>('/mini/product/list', { data: params })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 商品详情(免登录;未登录/未绑店/未设等级 price=null;下架或不存在业务报错):GET /mini/product/{id} */
|
/** 商品详情(免登录;未登录/未绑店/未设等级 price=null;下架或不存在业务报错):GET /mini/product/{id} */
|
||||||
|
|||||||
@@ -5,13 +5,26 @@ import {
|
|||||||
clearCartApi,
|
clearCartApi,
|
||||||
deleteCartItemApi,
|
deleteCartItemApi,
|
||||||
getCartApi,
|
getCartApi,
|
||||||
|
getCartSummaryApi,
|
||||||
updateCartItemApi,
|
updateCartItemApi,
|
||||||
} from '@/services/cart'
|
} from '@/services/cart'
|
||||||
import type { CartItem } from '@/types/cart'
|
import type { CartMutationResult } from '@/services/cart'
|
||||||
|
import { getToken } from '@/utils/request'
|
||||||
|
import type { CartItem, CartSummary } from '@/types/cart'
|
||||||
|
|
||||||
/** 存储 key */
|
/** 存储 key */
|
||||||
const STORAGE_KEY = 'cart_data'
|
const STORAGE_KEY = 'cart_data'
|
||||||
|
|
||||||
|
/** 汇总请求序号(并发时仅采用最后一次响应) */
|
||||||
|
let summarySeq = 0
|
||||||
|
/** 汇总防抖校准定时器(列表加减停止 800ms 后整体拉取一次,以服务端为准) */
|
||||||
|
let summaryTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
/** 数值 → 2 位小数字符串(与服务端金额/数量口径一致) */
|
||||||
|
function to2(n: number): string {
|
||||||
|
return (Math.round(n * 100) / 100).toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
/** 持久化的购物车快照(服务端为准,此处仅作展示缓存) */
|
/** 持久化的购物车快照(服务端为准,此处仅作展示缓存) */
|
||||||
interface StoredCart {
|
interface StoredCart {
|
||||||
items: CartItem[]
|
items: CartItem[]
|
||||||
@@ -46,8 +59,8 @@ interface CartState {
|
|||||||
loading: boolean
|
loading: boolean
|
||||||
/** 拉取购物车(以服务端为准,金额一律服务端重算) */
|
/** 拉取购物车(以服务端为准,金额一律服务端重算) */
|
||||||
fetchCart: () => Promise<void>
|
fetchCart: () => Promise<void>
|
||||||
/** 加购 */
|
/** 加购(返回合并后的购物车行,供列表页回写 cart_id/cart_quantity) */
|
||||||
addItem: (productId: number, quantity: number) => Promise<void>
|
addItem: (productId: number, quantity: number) => Promise<CartMutationResult>
|
||||||
/** 修改数量 */
|
/** 修改数量 */
|
||||||
updateQuantity: (id: number, quantity: number) => Promise<void>
|
updateQuantity: (id: number, quantity: number) => Promise<void>
|
||||||
/** 删除单项 */
|
/** 删除单项 */
|
||||||
@@ -56,6 +69,12 @@ interface CartState {
|
|||||||
clearCart: () => Promise<void>
|
clearCart: () => Promise<void>
|
||||||
/** 下单成功后本地清空(不请求接口) */
|
/** 下单成功后本地清空(不请求接口) */
|
||||||
clearLocal: () => void
|
clearLocal: () => void
|
||||||
|
/** 用接口附带的汇总块(首页/商品列表响应的 cart 字段)直接更新悬浮球 */
|
||||||
|
setSummary: (summary: CartSummary) => void
|
||||||
|
/** 拉取轻量汇总(需登录;未登录跳过,避免 401 跳转) */
|
||||||
|
fetchSummary: () => Promise<void>
|
||||||
|
/** 列表行内加减后本地增减悬浮球(乐观展示),并防抖调 fetchSummary 校准 */
|
||||||
|
applyDelta: (delta: { quantity: number; amount: number; count?: number }) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 空的购物车快照 */
|
/** 空的购物车快照 */
|
||||||
@@ -80,6 +99,18 @@ const useCartStore = create<CartState>((set, get) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 写入悬浮球汇总并持久化(列表项快照保持不变) */
|
||||||
|
const applySummary = (summary: CartSummary) => {
|
||||||
|
const next = {
|
||||||
|
items: get().items,
|
||||||
|
totalCount: summary.total_count,
|
||||||
|
totalQuantity: summary.total_quantity,
|
||||||
|
totalAmount: summary.total_amount,
|
||||||
|
}
|
||||||
|
set(next)
|
||||||
|
persist(next)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...EMPTY_SNAPSHOT,
|
...EMPTY_SNAPSHOT,
|
||||||
items: cached?.items ?? [],
|
items: cached?.items ?? [],
|
||||||
@@ -111,8 +142,9 @@ const useCartStore = create<CartState>((set, get) => {
|
|||||||
|
|
||||||
/** 加购:服务端校验上架与等级价,成功后重新同步 */
|
/** 加购:服务端校验上架与等级价,成功后重新同步 */
|
||||||
addItem: async (productId, quantity) => {
|
addItem: async (productId, quantity) => {
|
||||||
await addCartApi({ product_id: productId, quantity })
|
const res = await addCartApi({ product_id: productId, quantity })
|
||||||
await get().fetchCart()
|
await get().fetchCart()
|
||||||
|
return res.data
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 修改数量 */
|
/** 修改数量 */
|
||||||
@@ -139,6 +171,36 @@ const useCartStore = create<CartState>((set, get) => {
|
|||||||
set(EMPTY_SNAPSHOT)
|
set(EMPTY_SNAPSHOT)
|
||||||
persist(EMPTY_SNAPSHOT)
|
persist(EMPTY_SNAPSHOT)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 写入接口附带的汇总块(首页/商品列表) */
|
||||||
|
setSummary: (summary) => {
|
||||||
|
applySummary(summary)
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 拉取轻量汇总(并发时仅采用最后一次响应) */
|
||||||
|
fetchSummary: async () => {
|
||||||
|
// 未登录无汇总(接口固定 401,会触发清理登录态),直接跳过
|
||||||
|
if (!getToken()) return
|
||||||
|
const seq = ++summarySeq
|
||||||
|
const res = await getCartSummaryApi()
|
||||||
|
if (seq !== summarySeq) return // 已有更新的请求,丢弃本次响应
|
||||||
|
applySummary(res.data)
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 列表加减后的本地增减:即时反馈,防抖后以服务端汇总校准 */
|
||||||
|
applyDelta: ({ quantity, amount, count = 0 }) => {
|
||||||
|
const s = get()
|
||||||
|
applySummary({
|
||||||
|
total_count: Math.max(0, s.totalCount + count),
|
||||||
|
total_quantity: to2(Math.max(0, Number(s.totalQuantity) + quantity)),
|
||||||
|
total_amount: to2(Math.max(0, Number(s.totalAmount) + amount)),
|
||||||
|
})
|
||||||
|
if (summaryTimer) clearTimeout(summaryTimer)
|
||||||
|
summaryTimer = setTimeout(() => {
|
||||||
|
summaryTimer = null
|
||||||
|
get().fetchSummary().catch(() => {})
|
||||||
|
}, 800)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -27,3 +27,16 @@ export interface CartData {
|
|||||||
/** 可购项总金额 */
|
/** 可购项总金额 */
|
||||||
total_amount: string
|
total_amount: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 购物车悬浮球汇总(/mini/home、/mini/product/list 响应附带;
|
||||||
|
* GET /mini/cart/summary 同构。未登录时列表/首页返回零值结构)
|
||||||
|
*/
|
||||||
|
export interface CartSummary {
|
||||||
|
/** 商品种数(全部行数,含已下架项) */
|
||||||
|
total_count: number
|
||||||
|
/** 总数量(仅可购项,2 位小数字符串) */
|
||||||
|
total_quantity: string
|
||||||
|
/** 总金额(仅可购项,元,2 位小数字符串) */
|
||||||
|
total_amount: string
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,6 +36,16 @@ export interface Product {
|
|||||||
shelf_life?: number | null
|
shelf_life?: number | null
|
||||||
stock?: number | null
|
stock?: number | null
|
||||||
status?: number
|
status?: number
|
||||||
|
/** 该商品对应的购物车行 ID(不在购物车/未登录为 0;列表加减、删除时需要) */
|
||||||
|
cart_id?: number
|
||||||
|
/** 购物车中该商品数量(2 位小数字符串;不在购物车/未登录为 "0.00") */
|
||||||
|
cart_quantity?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 商品行购物车字段回写(行内加减购确认后更新列表项) */
|
||||||
|
export interface ProductCartPatch {
|
||||||
|
cart_id: number
|
||||||
|
cart_quantity: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 商品首图地址 */
|
/** 商品首图地址 */
|
||||||
|
|||||||
@@ -78,3 +78,14 @@ export function formatRetailPrice(price?: string | number | null, spec?: string
|
|||||||
if (!Number.isFinite(p) || !Number.isFinite(s) || s <= 0) return null
|
if (!Number.isFinite(p) || !Number.isFinite(s) || s <= 0) return null
|
||||||
return String(Math.round((p / s) * 100) / 100)
|
return String(Math.round((p / s) * 100) / 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数量展示:保留两位小数并去掉尾零("2.50" → "2.5","3.00" → "3")
|
||||||
|
* 用于悬浮球徽标、行内加减器等窄空间;非法值按 0 处理
|
||||||
|
*/
|
||||||
|
export function formatQuantity(value?: string | number | null): string {
|
||||||
|
if (value === null || value === undefined || value === '') return '0'
|
||||||
|
const n = Number(value)
|
||||||
|
if (!Number.isFinite(n)) return '0'
|
||||||
|
return String(Math.round(n * 100) / 100)
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ const LOGIN_PATH = '/pages/login/index'
|
|||||||
/** 默认请求超时(ms) */
|
/** 默认请求超时(ms) */
|
||||||
const DEFAULT_TIMEOUT = 15000
|
const DEFAULT_TIMEOUT = 15000
|
||||||
/** 接口根地址(uploadFile 等原生请求同样使用) */
|
/** 接口根地址(uploadFile 等原生请求同样使用) */
|
||||||
// export const BASE_URL = "http://localhost:8000/index.php"
|
export const BASE_URL = "http://localhost:8000/index.php"
|
||||||
export const BASE_URL = "https://purchase.henanklkj.com/index.php"
|
// export const BASE_URL = "https://purchase.henanklkj.com/index.php"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP 状态码 → 错误提示映射
|
* HTTP 状态码 → 错误提示映射
|
||||||
|
|||||||
Reference in New Issue
Block a user