From 260d8086bffa6bed930c8950057dce5f90aacced Mon Sep 17 00:00:00 2001 From: liu <2302563948@qq.com> Date: Thu, 27 Aug 2026 18:08:14 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B4=AD=E7=89=A9=E8=BD=A6=E6=82=AC=E6=B5=AE?= =?UTF-8?q?=E5=8A=A0=E5=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/CartBall/index.less | 57 ++++++++++++ src/components/CartBall/index.tsx | 38 ++++++++ src/components/CartStepper/index.less | 42 +++++++++ src/components/CartStepper/index.tsx | 121 ++++++++++++++++++++++++++ src/pages/index/index.less | 3 +- src/pages/index/index.tsx | 37 ++++++-- src/pages/product/index.less | 3 +- src/pages/product/index.tsx | 50 +++++++---- src/services/cart.ts | 7 +- src/services/home.ts | 3 + src/services/product.ts | 11 ++- src/stores/cart/useCartStore.ts | 70 ++++++++++++++- src/types/cart.ts | 13 +++ src/types/product.ts | 10 +++ src/utils/format.ts | 11 +++ src/utils/request.ts | 4 +- 16 files changed, 445 insertions(+), 35 deletions(-) create mode 100644 src/components/CartBall/index.less create mode 100644 src/components/CartBall/index.tsx create mode 100644 src/components/CartStepper/index.less create mode 100644 src/components/CartStepper/index.tsx diff --git a/src/components/CartBall/index.less b/src/components/CartBall/index.less new file mode 100644 index 0000000..3f1cdb6 --- /dev/null +++ b/src/components/CartBall/index.less @@ -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; + } +} diff --git a/src/components/CartBall/index.tsx b/src/components/CartBall/index.tsx new file mode 100644 index 0000000..0b61d62 --- /dev/null +++ b/src/components/CartBall/index.tsx @@ -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 ( + + + + + {formatQuantity(totalQuantity)} + + + ¥{totalAmount} + + ) +} diff --git a/src/components/CartStepper/index.less b/src/components/CartStepper/index.less new file mode 100644 index 0000000..161c6d7 --- /dev/null +++ b/src/components/CartStepper/index.less @@ -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; + } +} diff --git a/src/components/CartStepper/index.tsx b/src/components/CartStepper/index.tsx new file mode 100644 index 0000000..b621b25 --- /dev/null +++ b/src/components/CartStepper/index.tsx @@ -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(null) + /** 最新待提交的目标数量 */ + const targetRef = useRef(null) + const timerRef = useRef | null>(null) + /** 提交串行队列 */ + const chainRef = useRef>(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 ( + e.stopPropagation()}> + handleTap(-1)}> + + + {formatQuantity(shown)} + handleTap(1)}> + + + + ) +} diff --git a/src/pages/index/index.less b/src/pages/index/index.less index 9d8e736..310a405 100644 --- a/src/pages/index/index.less +++ b/src/pages/index/index.less @@ -1,7 +1,8 @@ .home-page { min-height: 100vh; 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; // ===== 自定义顶部导航栏 ===== diff --git a/src/pages/index/index.tsx b/src/pages/index/index.tsx index e443d73..da0c4ef 100644 --- a/src/pages/index/index.tsx +++ b/src/pages/index/index.tsx @@ -8,8 +8,10 @@ import { getHomeConfigApi } from '@/services/home' import type { HomeConfig } from '@/services/home' import { getProductListApi } from '@/services/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 CartBall from '@/components/CartBall' +import CartStepper from '@/components/CartStepper' import { formatSpec } from '@/utils/format' import './index.less' @@ -41,6 +43,7 @@ function getStatusBarHeight(): number { export default function IndexPage() { const addItem = useCartStore(s => s.addItem) + const setSummary = useCartStore(s => s.setSummary) /** 首页配置(轮播图 / 宫格导航 / 促销卡片) */ const [config, setConfig] = useState({ banners: [], navs: [], promos: [] }) @@ -56,15 +59,17 @@ export default function IndexPage() { loadRecommend() }) - /** 首页配置聚合数据 */ + /** 首页配置聚合数据(响应附带悬浮球汇总) */ const loadHomeConfig = useCallback(async () => { try { const res = await getHomeConfigApi() setConfig(res.data) + // 旧版本后端可能未返回 cart 块 + if (res.data.cart) setSummary(res.data.cart) } catch { // 错误已由 request 层 toast } - }, []) + }, [setSummary]) /** 推荐商品 */ const loadRecommend = useCallback(async () => { @@ -112,18 +117,24 @@ export default function IndexPage() { goProduct(keyword.trim()) }, [goProduct, keyword]) - /** 快捷加购 */ + /** 行内加减购确认后回写推荐商品项的购物车字段 */ + const handleRowSync = useCallback((productId: number, patch: ProductCartPatch) => { + setProducts(prev => prev.map(p => (p.id === productId ? { ...p, ...patch } : p))) + }, []) + + /** 快捷加购(用返回的购物车行回写,卡片随即展示加减器) */ const handleQuickAdd = useCallback( async (product: Product, e: any) => { e.stopPropagation() 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' }) } catch { // 错误(未设等级价等)已由 request 层 toast } }, - [addItem], + [addItem, handleRowSync], ) return ( @@ -261,9 +272,14 @@ export default function IndexPage() { ) : ( 登陆后查看价格 )} - handleQuickAdd(product, e)}> - - + {/* 已加购展示行内加减器,否则展示快捷加购按钮 */} + {Number(product.cart_quantity ?? 0) > 0 ? ( + + ) : ( + handleQuickAdd(product, e)}> + + + )} @@ -271,6 +287,9 @@ export default function IndexPage() { )} + + {/* ========== 购物车悬浮球 ========== */} + ) } diff --git a/src/pages/product/index.less b/src/pages/product/index.less index 3a04ef6..e3afbbb 100644 --- a/src/pages/product/index.less +++ b/src/pages/product/index.less @@ -96,7 +96,8 @@ flex: 1; min-width: 0; height: 100%; - padding: 20rpx 20rpx 40rpx; + // 底部留白避免最后一行被购物车悬浮球遮挡 + padding: 20rpx 20rpx 20rpx; box-sizing: border-box; } diff --git a/src/pages/product/index.tsx b/src/pages/product/index.tsx index be608b8..698b4c2 100644 --- a/src/pages/product/index.tsx +++ b/src/pages/product/index.tsx @@ -6,8 +6,10 @@ 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 type { Category, Product, ProductCartPatch } from '@/types/product' import PriceText from '@/components/PriceText' +import CartBall from '@/components/CartBall' +import CartStepper from '@/components/CartStepper' import { formatSpec } from '@/utils/format' import './index.less' @@ -18,6 +20,7 @@ const PENDING_KEYWORD_KEY = 'product_keyword' export default function ProductPage() { const addItem = useCartStore(s => s.addItem) + const setSummary = useCartStore(s => s.setSummary) /** 分类树 */ const [categories, setCategories] = useState([]) @@ -70,10 +73,12 @@ export default function ProductPage() { if (keyword) params.keyword = keyword const res = await getProductListApi(params) if (seq !== reqSeqRef.current) return // 已有更新的请求,丢弃本次响应 - const { data, total: totalCount } = res.data + const { data, total: totalCount, cart } = res.data setProducts(prev => (reset ? data : [...prev, ...data])) setPage(pageNum) setFinished(pageNum * PAGE_SIZE >= totalCount) + // 列表响应附带悬浮球汇总(旧版本后端可能未返回) + if (cart) setSummary(cart) } catch { // 错误已由 request 层 toast } finally { @@ -83,7 +88,7 @@ export default function ProductPage() { } } }, - [effectiveCategoryId, searchKey], + [effectiveCategoryId, searchKey, setSummary], ) /** 分类/搜索词变化时重新加载第一页(首屏由 useDidShow 触发,跳过首次执行) */ @@ -193,17 +198,23 @@ export default function ProductPage() { 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) => { Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` }) }, []) - /** 确认加购 */ + /** 确认加购(用返回的购物车行回写列表项,行内随即展示加减器) */ const handleConfirmAdd = useCallback(async () => { if (!current || addingRef.current) return addingRef.current = true 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' }) setShowPopup(false) } catch { @@ -211,7 +222,7 @@ export default function ProductPage() { } finally { addingRef.current = false } - }, [current, qty, addItem]) + }, [current, qty, addItem, handleRowSync]) return ( @@ -303,15 +314,20 @@ export default function ProductPage() { ) : ( 价格待定 )} - { - e.stopPropagation() - handleAddTap(product) - }} - > - - + {/* 已加购展示行内加减器,否则展示加购按钮(点击开弹层选数量) */} + {Number(product.cart_quantity ?? 0) > 0 ? ( + + ) : ( + { + e.stopPropagation() + handleAddTap(product) + }} + > + + + )} @@ -323,6 +339,7 @@ export default function ProductPage() { {finished && products.length > 0 && ( 没有更多了 )} + @@ -376,6 +393,9 @@ export default function ProductPage() { )} + + {/* ========== 购物车悬浮球 ========== */} + ) } diff --git a/src/services/cart.ts b/src/services/cart.ts index fbb9505..d234907 100644 --- a/src/services/cart.ts +++ b/src/services/cart.ts @@ -1,5 +1,5 @@ import { del, get, post, put } from '@/utils/request' -import type { CartData } from '@/types/cart' +import type { CartData, CartSummary } from '@/types/cart' /** 加购 / 改数量返回 */ export interface CartMutationResult { @@ -17,6 +17,11 @@ export function getCartApi() { return get('/mini/cart') } +/** 轻量汇总(悬浮球单独刷新用;必须登录,未登录 401):GET /mini/cart/summary */ +export function getCartSummaryApi() { + return get('/mini/cart/summary') +} + /** 修改数量:PUT /mini/cart/{id} */ export function updateCartItemApi(id: number, quantity: number) { return put(`/mini/cart/${id}`, { quantity }) diff --git a/src/services/home.ts b/src/services/home.ts index 4a7f57d..91b30d9 100644 --- a/src/services/home.ts +++ b/src/services/home.ts @@ -1,4 +1,5 @@ import { get } from '@/utils/request' +import type { CartSummary } from '@/types/cart' /** 首页轮播图项 */ export interface HomeBanner { @@ -36,6 +37,8 @@ export interface HomeConfig { banners: HomeBanner[] navs: HomeNav[] promos: HomePromo[] + /** 购物车悬浮球汇总(未登录返回零值结构;旧版本后端可能未返回,调用方判空) */ + cart?: CartSummary } /** 首页配置(轮播图 + 宫格导航 + 促销卡片):GET /mini/home */ diff --git a/src/services/product.ts b/src/services/product.ts index 489c128..a353713 100644 --- a/src/services/product.ts +++ b/src/services/product.ts @@ -1,5 +1,6 @@ import { get } from '@/utils/request' import type { PaginatedData } from '@/types/api' +import type { CartSummary } from '@/types/cart' import type { Category, Product } from '@/types/product' /** 商品分类树(仅含上架商品的分类及其祖先):GET /mini/product/categories */ @@ -17,9 +18,15 @@ export interface ProductListParams { pageSize?: number } -/** 商品列表(当前门店等级实际价):GET /mini/product/list */ +/** 商品列表响应(分页 + 购物车悬浮球汇总) */ +export interface ProductListData extends PaginatedData { + /** 购物车悬浮球汇总(未登录返回零值结构;旧版本后端可能未返回,调用方判空) */ + cart?: CartSummary +} + +/** 商品列表(当前门店等级实际价 + 行内购物车字段):GET /mini/product/list */ export function getProductListApi(params: ProductListParams = {}) { - return get>('/mini/product/list', { data: params }) + return get('/mini/product/list', { data: params }) } /** 商品详情(免登录;未登录/未绑店/未设等级 price=null;下架或不存在业务报错):GET /mini/product/{id} */ diff --git a/src/stores/cart/useCartStore.ts b/src/stores/cart/useCartStore.ts index 7751dcb..492cb2f 100644 --- a/src/stores/cart/useCartStore.ts +++ b/src/stores/cart/useCartStore.ts @@ -5,13 +5,26 @@ import { clearCartApi, deleteCartItemApi, getCartApi, + getCartSummaryApi, updateCartItemApi, } 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 */ const STORAGE_KEY = 'cart_data' +/** 汇总请求序号(并发时仅采用最后一次响应) */ +let summarySeq = 0 +/** 汇总防抖校准定时器(列表加减停止 800ms 后整体拉取一次,以服务端为准) */ +let summaryTimer: ReturnType | null = null + +/** 数值 → 2 位小数字符串(与服务端金额/数量口径一致) */ +function to2(n: number): string { + return (Math.round(n * 100) / 100).toFixed(2) +} + /** 持久化的购物车快照(服务端为准,此处仅作展示缓存) */ interface StoredCart { items: CartItem[] @@ -46,8 +59,8 @@ interface CartState { loading: boolean /** 拉取购物车(以服务端为准,金额一律服务端重算) */ fetchCart: () => Promise - /** 加购 */ - addItem: (productId: number, quantity: number) => Promise + /** 加购(返回合并后的购物车行,供列表页回写 cart_id/cart_quantity) */ + addItem: (productId: number, quantity: number) => Promise /** 修改数量 */ updateQuantity: (id: number, quantity: number) => Promise /** 删除单项 */ @@ -56,6 +69,12 @@ interface CartState { clearCart: () => Promise /** 下单成功后本地清空(不请求接口) */ clearLocal: () => void + /** 用接口附带的汇总块(首页/商品列表响应的 cart 字段)直接更新悬浮球 */ + setSummary: (summary: CartSummary) => void + /** 拉取轻量汇总(需登录;未登录跳过,避免 401 跳转) */ + fetchSummary: () => Promise + /** 列表行内加减后本地增减悬浮球(乐观展示),并防抖调 fetchSummary 校准 */ + applyDelta: (delta: { quantity: number; amount: number; count?: number }) => void } /** 空的购物车快照 */ @@ -80,6 +99,18 @@ const useCartStore = create((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 { ...EMPTY_SNAPSHOT, items: cached?.items ?? [], @@ -111,8 +142,9 @@ const useCartStore = create((set, get) => { /** 加购:服务端校验上架与等级价,成功后重新同步 */ addItem: async (productId, quantity) => { - await addCartApi({ product_id: productId, quantity }) + const res = await addCartApi({ product_id: productId, quantity }) await get().fetchCart() + return res.data }, /** 修改数量 */ @@ -139,6 +171,36 @@ const useCartStore = create((set, get) => { set(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) + }, } }) diff --git a/src/types/cart.ts b/src/types/cart.ts index 2272e09..0fca8ac 100644 --- a/src/types/cart.ts +++ b/src/types/cart.ts @@ -27,3 +27,16 @@ export interface CartData { /** 可购项总金额 */ 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 +} diff --git a/src/types/product.ts b/src/types/product.ts index 95e65c6..37d4d5a 100644 --- a/src/types/product.ts +++ b/src/types/product.ts @@ -36,6 +36,16 @@ export interface Product { shelf_life?: number | null stock?: number | null status?: number + /** 该商品对应的购物车行 ID(不在购物车/未登录为 0;列表加减、删除时需要) */ + cart_id?: number + /** 购物车中该商品数量(2 位小数字符串;不在购物车/未登录为 "0.00") */ + cart_quantity?: string +} + +/** 商品行购物车字段回写(行内加减购确认后更新列表项) */ +export interface ProductCartPatch { + cart_id: number + cart_quantity: string } /** 商品首图地址 */ diff --git a/src/utils/format.ts b/src/utils/format.ts index 6279767..8c30be7 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -78,3 +78,14 @@ export function formatRetailPrice(price?: string | number | null, spec?: string if (!Number.isFinite(p) || !Number.isFinite(s) || s <= 0) return null 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) +} diff --git a/src/utils/request.ts b/src/utils/request.ts index df6beb4..1093a8a 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -10,8 +10,8 @@ const LOGIN_PATH = '/pages/login/index' /** 默认请求超时(ms) */ const DEFAULT_TIMEOUT = 15000 /** 接口根地址(uploadFile 等原生请求同样使用) */ -// export const BASE_URL = "http://localhost:8000/index.php" -export const BASE_URL = "https://purchase.henanklkj.com/index.php" +export const BASE_URL = "http://localhost:8000/index.php" +// export const BASE_URL = "https://purchase.henanklkj.com/index.php" /** * HTTP 状态码 → 错误提示映射