HarmonyOS开发:购物车管理
HarmonyOS开发:购物车管理
📌 核心要点:购物车不只是"加个列表",商品增删改查、数量调整与价格计算、全选与结算逻辑,数据一致性是核心,状态管理是关键。
背景与动机
购物车功能看起来简单——不就是个列表,加个数量加减按钮嘛?
你试试。用户加了3个商品,每个商品选了不同SKU,数量分别是1、2、1。然后他删了一个、改了一个的数量、勾选了两个——这时候总价多少?哪些商品被选中了?全选按钮该不该亮?某个商品库存不足了怎么提示?跨店铺的运费怎么算?
购物车的复杂度不在于UI,在于状态管理。每一个操作——勾选、取消勾选、改数量、删商品——都可能影响总价、全选状态、结算按钮文案。你用几个变量硬拼,写到一半自己都搞不清哪个变量依赖哪个。
更别提那些边界情况:商品下架了购物车里还显示?SKU失效了用户还能结算?数量改到0是删还是保留?全选后删了一个商品全选还在吗?
这篇文章把购物车的数据管理、状态联动、价格计算、全选逻辑全拆开讲。
核心原理
购物车的核心是状态联动:每个商品有选中状态,商品选中影响店铺选中状态,店铺选中影响全选状态,所有选中商品的价格汇总就是总价。
flowchart TD
A[购物车操作] --> B{操作类型}
B -->|勾选/取消商品| C[更新商品选中状态]
B -->|修改数量| D[更新商品数量]
B -->|删除商品| E[从列表移除]
B -->|全选/取消全选| F[批量更新所有商品]
C --> G[重新计算店铺选中状态]
D --> H[重新计算商品小计]
E --> I[重新计算店铺选中状态]
F --> J[更新所有商品和店铺状态]
G --> K[重新计算全选状态]
H --> L[重新计算总价]
I --> K
J --> K
J --> L
K --> M[更新全选按钮UI]
L --> N[更新结算按钮UI]
classDef action fill:#1565C0,color:#fff,stroke:#0D47A1
classDef update fill:#2E7D32,color:#fff,stroke:#1B5E20
classDef calc fill:#E65100,color:#fff,stroke:#BF360C
classDef ui fill:#6A1B9A,color:#fff,stroke:#4A148C
class A,B,action
class C,D,E,F,G,H,I,J,update
class K,L,calc
class M,N,ui
数据模型设计
购物车数据分三层:商品→店铺→购物车。
- CartItem:单个商品,包含SKU信息、数量、选中状态
- ShopGroup:按店铺分组,包含店铺下的所有商品、店铺选中状态
- CartState:整个购物车状态,包含全选状态、总价、总数量
为什么按店铺分组?因为电商App的购物车通常是按店铺展示的,同一店铺的商品放一起,底部显示"店铺合计"。跨店铺的运费、优惠也要分开算。
价格计算规则
总价 = 所有选中商品的价格 × 数量 之和
但实际电商的价格计算远比这复杂:
- 满减优惠(满200减20)
- 店铺优惠券
- 平台优惠券
- 运费(满额包邮)
这篇文章聚焦核心逻辑,优惠计算作为扩展点预留。
代码实战
基础用法:购物车数据管理
先把数据模型和管理类搭好,这是购物车的基础。
// CartManager.ets — 购物车数据管理
// 购物车商品
interface CartItem {
id: string // 购物车记录ID
productId: string // 商品ID
skuId: string // SKU ID
title: string // 商品标题
imageUrl: string // 商品图片
specDesc: string // 规格描述:"黑色 XL"
price: number // 单价
originalPrice: number // 原价
quantity: number // 数量
stock: number // 当前库存
selected: boolean // 是否选中
isValid: boolean // 是否有效(商品是否下架等)
shopId: string // 店铺ID
shopName: string // 店铺名
}
// 店铺分组
interface ShopGroup {
shopId: string
shopName: string
selected: boolean // 店铺是否全选
items: CartItem[]
}
// 购物车状态
interface CartState {
shopGroups: ShopGroup[]
isAllSelected: boolean // 是否全选
totalPrice: number // 总价
totalQuantity: number // 选中商品总数量
selectedCount: number // 选中商品种类数
}
class CartManager {
private static instance: CartManager
private cartState: CartState = {
shopGroups: [],
isAllSelected: false,
totalPrice: 0,
totalQuantity: 0,
selectedCount: 0
}
private constructor() {}
static getInstance(): CartManager {
if (!CartManager.instance) {
CartManager.instance = new CartManager()
}
return CartManager.instance
}
// ========== 商品操作 ==========
// 添加商品到购物车
addItem(item: CartItem): void {
// 检查是否已存在相同SKU
const existingItem = this.findItemBySku(item.skuId)
if (existingItem) {
// 已存在,增加数量
existingItem.quantity += item.quantity
// 不超过库存
if (existingItem.quantity > existingItem.stock) {
existingItem.quantity = existingItem.stock
}
} else {
// 不存在,添加到对应店铺分组
this.insertToShopGroup(item)
}
this.recalculate()
}
// 删除商品
removeItem(cartItemId: string): void {
for (const group of this.cartState.shopGroups) {
const index = group.items.findIndex(item => item.id === cartItemId)
if (index >= 0) {
group.items.splice(index, 1)
// 如果店铺下没有商品了,移除店铺分组
if (group.items.length === 0) {
const groupIndex = this.cartState.shopGroups.indexOf(group)
this.cartState.shopGroups.splice(groupIndex, 1)
}
break
}
}
this.recalculate()
}
// 批量删除选中的商品
removeSelectedItems(): void {
for (const group of this.cartState.shopGroups) {
group.items = group.items.filter(item => !item.selected)
}
// 移除空店铺分组
this.cartState.shopGroups = this.cartState.shopGroups.filter(group => group.items.length > 0)
this.recalculate()
}
// 更新商品数量
updateQuantity(cartItemId: string, quantity: number): void {
const item = this.findItemById(cartItemId)
if (item) {
// 数量不能超过库存,不能小于1
item.quantity = Math.max(1, Math.min(quantity, item.stock))
this.recalculate()
}
}
// 切换商品选中状态
toggleItemSelection(cartItemId: string): void {
const item = this.findItemById(cartItemId)
if (item) {
item.selected = !item.selected
this.recalculate()
}
}
// ========== 全选操作 ==========
// 全选/取消全选
toggleSelectAll(): void {
const newSelected = !this.cartState.isAllSelected
for (const group of this.cartState.shopGroups) {
group.selected = newSelected
for (const item of group.items) {
if (item.isValid) {
item.selected = newSelected
}
}
}
this.recalculate()
}
// 切换店铺选中状态
toggleShopSelection(shopId: string): void {
const group = this.findShopGroup(shopId)
if (group) {
group.selected = !group.selected
for (const item of group.items) {
if (item.isValid) {
item.selected = group.selected
}
}
this.recalculate()
}
}
// ========== 状态计算 ==========
// 重新计算所有派生状态
private recalculate(): void {
let totalPrice = 0
let totalQuantity = 0
let selectedCount = 0
let allValidSelected = true
let hasValidItems = false
for (const group of this.cartState.shopGroups) {
let shopAllSelected = true
let shopHasValidItems = false
for (const item of group.items) {
if (!item.isValid) continue // 无效商品不参与计算
shopHasValidItems = true
hasValidItems = true
if (item.selected) {
totalPrice += item.price * item.quantity
totalQuantity += item.quantity
selectedCount++
} else {
shopAllSelected = false
allValidSelected = false
}
}
// 更新店铺选中状态
group.selected = shopHasValidItems && shopAllSelected
}
this.cartState.isAllSelected = hasValidItems && allValidSelected
this.cartState.totalPrice = totalPrice
this.cartState.totalQuantity = totalQuantity
this.cartState.selectedCount = selectedCount
}
// ========== 辅助方法 ==========
private findItemById(cartItemId: string): CartItem | undefined {
for (const group of this.cartState.shopGroups) {
const item = group.items.find(i => i.id === cartItemId)
if (item) return item
}
return undefined
}
private findItemBySku(skuId: string): CartItem | undefined {
for (const group of this.cartState.shopGroups) {
const item = group.items.find(i => i.skuId === skuId)
if (item) return item
}
return undefined
}
private findShopGroup(shopId: string): ShopGroup | undefined {
return this.cartState.shopGroups.find(g => g.shopId === shopId)
}
private insertToShopGroup(item: CartItem): void {
let group = this.findShopGroup(item.shopId)
if (!group) {
group = {
shopId: item.shopId,
shopName: item.shopName,
selected: false,
items: []
}
this.cartState.shopGroups.push(group)
}
group.items.push(item)
}
// 获取购物车状态
getState(): CartState {
return this.cartState
}
// 获取选中商品列表(用于结算)
getSelectedItems(): CartItem[] {
const selected: CartItem[] = []
for (const group of this.cartState.shopGroups) {
for (const item of group.items) {
if (item.selected && item.isValid) {
selected.push(item)
}
}
}
return selected
}
}
进阶用法:购物车UI与交互
数据管理搭好了,接下来是UI——按店铺分组展示、勾选联动、数量加减、左滑删除。
// ShoppingCartPage.ets — 购物车页面
import { router } from '@kit.ArkUI'
@Entry
@Component
struct ShoppingCartPage {
@State shopGroups: ShopGroup[] = []
@State isAllSelected: boolean = false
@State totalPrice: number = 0
@State selectedCount: number = 0
@State isEditing: boolean = false // 编辑模式
private cartManager: CartManager = CartManager.getInstance()
aboutToAppear() {
this.loadCartData()
}
build() {
Column() {
// 顶部导航栏
this.TopBar()
if (this.shopGroups.length === 0) {
// 空购物车
this.EmptyCart()
} else {
// 购物车列表
Scroll() {
Column() {
ForEach(this.shopGroups, (group: ShopGroup) => {
this.ShopGroupSection(group)
}, (group: ShopGroup) => group.shopId)
}
}
.layoutWeight(1)
.scrollBar(BarState.Off)
// 底部结算栏
this.BottomBar()
}
}
.width('100%')
.height('100%')
.backgroundColor('#F5F5F5')
}
// ========== 顶部导航栏 ==========
@Builder
TopBar() {
Row() {
Text('购物车')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Blank()
Text(this.isEditing ? '完成' : '编辑')
.fontSize(14)
.fontColor('#333333')
.onClick(() => {
this.isEditing = !this.isEditing
})
}
.width('100%')
.height(48)
.padding({ left: 16, right: 16 })
.backgroundColor(Color.White)
}
// ========== 空购物车 ==========
@Builder
EmptyCart() {
Column() {
Image($r('app.media.ic_cart_empty'))
.width(120)
.height(120)
.fillColor('#CCCCCC')
Text('购物车空空如也')
.fontSize(16)
.fontColor('#999999')
.margin({ top: 16 })
Button('去逛逛')
.fontSize(14)
.fontColor(Color.White)
.backgroundColor('#FF4444')
.borderRadius(20)
.width(120)
.height(40)
.margin({ top: 24 })
.onClick(() => {
router.back()
})
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
}
// ========== 店铺分组 ==========
@Builder
ShopGroupSection(group: ShopGroup) {
Column() {
// 店铺标题行
Row() {
Checkbox()
.select($$group.selected)
.onChange((checked: boolean) => {
this.onShopCheckChange(group.shopId, checked)
})
.width(20)
.height(20)
Image($r('app.media.ic_shop'))
.width(18)
.height(18)
.fillColor('#333333')
.margin({ left: 8 })
Text(group.shopName)
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
.margin({ left: 4 })
}
.width('100%')
.padding({ left: 12, right: 12, top: 12, bottom: 8 })
// 商品列表
ForEach(group.items, (item: CartItem) => {
this.CartItemRow(item)
}, (item: CartItem) => item.id)
}
.width('100%')
.backgroundColor(Color.White)
.borderRadius(8)
.margin({ top: 8, left: 12, right: 12 })
}
// ========== 购物车商品行 ==========
@Builder
CartItemRow(item: CartItem) {
Row() {
// 勾选框
Checkbox()
.select($$item.selected)
.onChange((checked: boolean) => {
this.onItemCheckChange(item.id, checked)
})
.width(20)
.height(20)
.margin({ right: 8 })
// 商品图片
Image(item.imageUrl)
.width(80)
.height(80)
.borderRadius(4)
.objectFit(ImageFit.Cover)
// 商品信息
Column() {
Text(item.title)
.fontSize(13)
.fontColor(item.isValid ? '#333333' : '#999999')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
// 规格描述
if (item.specDesc) {
Text(item.specDesc)
.fontSize(11)
.fontColor('#999999')
.backgroundColor('#F5F5F5')
.borderRadius(2)
.padding({ left: 4, right: 4, top: 2, bottom: 2 })
.margin({ top: 4 })
}
// 失效提示
if (!item.isValid) {
Text('商品已下架')
.fontSize(11)
.fontColor('#FF4444')
.margin({ top: 4 })
}
// 价格与数量
Row() {
Text(`¥${item.price}`)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#FF4444')
if (item.originalPrice > item.price) {
Text(`¥${item.originalPrice}`)
.fontSize(11)
.fontColor('#CCCCCC')
.decoration({ type: TextDecorationType.LineThrough })
.margin({ left: 4 })
}
Blank()
// 数量选择器
if (item.isValid) {
this.QuantitySelector(item)
}
}
.width('100%')
.margin({ top: 8 })
}
.layoutWeight(1)
.margin({ left: 8 })
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.opacity(item.isValid ? 1 : 0.5) // 失效商品半透明
}
// ========== 数量选择器 ==========
@Builder
QuantitySelector(item: CartItem) {
Row() {
// 减少按钮
Text('-')
.fontSize(16)
.fontColor(item.quantity <= 1 ? '#CCCCCC' : '#333333')
.width(28)
.height(28)
.textAlign(TextAlign.Center)
.backgroundColor('#F5F5F5')
.borderRadius({ topLeft: 4, bottomLeft: 4 })
.onClick(() => {
if (item.quantity > 1) {
this.cartManager.updateQuantity(item.id, item.quantity - 1)
this.refreshUI()
}
})
// 数量
Text(`${item.quantity}`)
.fontSize(13)
.width(36)
.height(28)
.textAlign(TextAlign.Center)
.backgroundColor(Color.White)
.border({ width: { top: 1, bottom: 1 }, color: '#E0E0E0' })
// 增加按钮
Text('+')
.fontSize(16)
.fontColor(item.quantity >= item.stock ? '#CCCCCC' : '#333333')
.width(28)
.height(28)
.textAlign(TextAlign.Center)
.backgroundColor('#F5F5F5')
.borderRadius({ topRight: 4, bottomRight: 4 })
.onClick(() => {
if (item.quantity < item.stock) {
this.cartManager.updateQuantity(item.id, item.quantity + 1)
this.refreshUI()
}
})
}
}
// ========== 底部结算栏 ==========
@Builder
BottomBar() {
Row() {
// 全选
Row() {
Checkbox()
.select($$this.isAllSelected)
.onChange((checked: boolean) => {
this.cartManager.toggleSelectAll()
this.refreshUI()
})
.width(20)
.height(20)
Text('全选')
.fontSize(14)
.fontColor('#333333')
.margin({ left: 4 })
}
Blank()
if (this.isEditing) {
// 编辑模式:删除按钮
Button('删除')
.fontSize(14)
.fontColor(this.selectedCount > 0 ? Color.White : '#999999')
.backgroundColor(this.selectedCount > 0 ? '#FF4444' : '#F5F5F5')
.borderRadius(20)
.width(80)
.height(36)
.enabled(this.selectedCount > 0)
.onClick(() => {
this.cartManager.removeSelectedItems()
this.refreshUI()
})
} else {
// 正常模式:结算按钮
Column() {
Text(`合计: ¥${this.totalPrice.toFixed(2)}`)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Text(`已选${this.selectedCount}件商品`)
.fontSize(11)
.fontColor('#999999')
}
.alignItems(HorizontalAlign.End)
.margin({ right: 12 })
Button(`结算(${this.selectedCount})`)
.fontSize(14)
.fontColor(Color.White)
.backgroundColor(this.selectedCount > 0 ? '#FF4444' : '#CCCCCC')
.borderRadius(20)
.width(100)
.height(36)
.enabled(this.selectedCount > 0)
.onClick(() => {
this.goToCheckout()
})
}
}
.width('100%')
.height(56)
.padding({ left: 16, right: 16 })
.backgroundColor(Color.White)
}
// ========== 事件处理 ==========
onItemCheckChange(cartItemId: string, checked: boolean): void {
this.cartManager.toggleItemSelection(cartItemId)
this.refreshUI()
}
onShopCheckChange(shopId: string, checked: boolean): void {
this.cartManager.toggleShopSelection(shopId)
this.refreshUI()
}
goToCheckout(): void {
const selectedItems = this.cartManager.getSelectedItems()
router.pushUrl({
url: 'pages/OrderConfirmPage',
params: { items: selectedItems }
})
}
refreshUI(): void {
const state = this.cartManager.getState()
this.shopGroups = [...state.shopGroups] // 触发UI刷新
this.isAllSelected = state.isAllSelected
this.totalPrice = state.totalPrice
this.selectedCount = state.selectedCount
}
loadCartData(): void {
// 模拟购物车数据
const mockItems: CartItem[] = [
{ id: '1', productId: 'p1', skuId: 'sku1', title: '纯棉短袖T恤男夏季透气圆领打底衫', imageUrl: 'https://picsum.photos/200/200?random=1', specDesc: '黑色 XL', price: 299, originalPrice: 399, quantity: 2, stock: 10, selected: true, isValid: true, shopId: 'shop1', shopName: '品牌旗舰店' },
{ id: '2', productId: 'p2', skuId: 'sku2', title: '运动休闲裤男宽松直筒长裤子', imageUrl: 'https://picsum.photos/200/200?random=2', specDesc: '深灰 L', price: 199, originalPrice: 259, quantity: 1, stock: 5, selected: true, isValid: true, shopId: 'shop1', shopName: '品牌旗舰店' },
{ id: '3', productId: 'p3', skuId: 'sku3', title: '无线蓝牙耳机降噪超长续航', imageUrl: 'https://picsum.photos/200/200?random=3', specDesc: '白色', price: 159, originalPrice: 199, quantity: 1, stock: 20, selected: false, isValid: true, shopId: 'shop2', shopName: '数码专营店' },
{ id: '4', productId: 'p4', skuId: 'sku4', title: '已下架商品示例', imageUrl: 'https://picsum.photos/200/200?random=4', specDesc: '红色 M', price: 89, originalPrice: 129, quantity: 1, stock: 0, selected: false, isValid: false, shopId: 'shop2', shopName: '数码专营店' },
]
for (const item of mockItems) {
this.cartManager.addItem(item)
}
this.refreshUI()
}
}
完整示例:购物车全功能整合
把数据管理、UI交互、价格计算、全选联动、编辑模式串成完整购物车。
// FullShoppingCart.ets — 完整购物车实现
import { router } from '@kit.ArkUI'
import { promptAction } from '@kit.ArkUI'
@Entry
@Component
struct FullShoppingCart {
@State shopGroups: ShopGroup[] = []
@State isAllSelected: boolean = false
@State totalPrice: number = 0
@State selectedCount: number = 0
@State isEditing: boolean = false
@State showDeleteConfirm: boolean = false
@State cartItemCount: number = 0
private cartManager: CartManager = CartManager.getInstance()
aboutToAppear() {
this.loadCartData()
}
build() {
Column() {
// 顶部栏
Row() {
Text('购物车')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
if (this.cartItemCount > 0) {
Text(`(${this.cartItemCount})`)
.fontSize(14)
.fontColor('#999999')
.margin({ left: 4 })
}
Blank()
Text(this.isEditing ? '完成' : '编辑')
.fontSize(14)
.fontColor('#333333')
.onClick(() => {
this.isEditing = !this.isEditing
})
}
.width('100%')
.height(48)
.padding({ left: 16, right: 16 })
.backgroundColor(Color.White)
if (this.shopGroups.length === 0) {
// 空购物车
Column() {
Text('🛒')
.fontSize(64)
Text('购物车空空如也')
.fontSize(16)
.fontColor('#999999')
.margin({ top: 16 })
Text('快去挑选心仪的商品吧')
.fontSize(13)
.fontColor('#CCCCCC')
.margin({ top: 8 })
Button('去逛逛')
.fontSize(14)
.fontColor(Color.White)
.backgroundColor('#FF4444')
.borderRadius(20)
.width(120)
.height(40)
.margin({ top: 24 })
.onClick(() => { router.back() })
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
} else {
// 购物车列表
List() {
ForEach(this.shopGroups, (group: ShopGroup) => {
ListItemGroup({
header: this.ShopHeader(group),
footer: this.ShopFooter(group)
}) {
ForEach(group.items, (item: CartItem) => {
ListItem() {
this.CartItemRow(item)
}
.swipeAction({ end: this.DeleteAction(item) })
}, (item: CartItem) => item.id)
}
}, (group: ShopGroup) => group.shopId)
}
.layoutWeight(1)
.scrollBar(BarState.Off)
.divider({ strokeWidth: 0.5, color: '#F0F0F0' })
// 底部结算栏
Row() {
Checkbox()
.select($$this.isAllSelected)
.onChange(() => {
this.cartManager.toggleSelectAll()
this.syncUI()
})
.width(20)
.height(20)
Text('全选')
.fontSize(14)
.fontColor('#333333')
.margin({ left: 4 })
Blank()
if (this.isEditing) {
Button('删除选中')
.fontSize(14)
.fontColor(this.selectedCount > 0 ? Color.White : '#999999')
.backgroundColor(this.selectedCount > 0 ? '#FF4444' : '#F5F5F5')
.borderRadius(20)
.height(36)
.enabled(this.selectedCount > 0)
.onClick(() => {
this.cartManager.removeSelectedItems()
this.syncUI()
})
} else {
Column() {
Row() {
Text('合计: ')
.fontSize(13)
.fontColor('#333333')
Text(`¥${this.totalPrice.toFixed(2)}`)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FF4444')
}
Text(`已选${this.selectedCount}件`)
.fontSize(11)
.fontColor('#999999')
}
.alignItems(HorizontalAlign.End)
.margin({ right: 12 })
Button(`结算(${this.selectedCount})`)
.fontSize(14)
.fontColor(Color.White)
.backgroundColor(this.selectedCount > 0 ? '#FF4444' : '#CCCCCC')
.borderRadius(20)
.width(100)
.height(36)
.enabled(this.selectedCount > 0)
.onClick(() => {
router.pushUrl({ url: 'pages/OrderConfirmPage' })
})
}
}
.width('100%')
.height(56)
.padding({ left: 16, right: 16 })
.backgroundColor(Color.White)
}
}
.width('100%')
.height('100%')
.backgroundColor('#F5F5F5')
}
// ========== 店铺头部 ==========
@Builder
ShopHeader(group: ShopGroup) {
Row() {
Checkbox()
.select($$group.selected)
.onChange(() => {
this.cartManager.toggleShopSelection(group.shopId)
this.syncUI()
})
.width(18)
.height(18)
Image($r('app.media.ic_shop'))
.width(16)
.height(16)
.fillColor('#333333')
.margin({ left: 6 })
Text(group.shopName)
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
.margin({ left: 4 })
}
.width('100%')
.padding({ left: 12, top: 12, bottom: 8 })
.backgroundColor(Color.White)
}
// ========== 店铺底部(店铺合计) ==========
@Builder
ShopFooter(group: ShopGroup) {
Row() {
Blank()
Text('店铺合计: ')
.fontSize(12)
.fontColor('#999999')
Text(`¥${this.calcShopTotal(group).toFixed(2)}`)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#FF4444')
}
.width('100%')
.padding({ right: 16, top: 4, bottom: 8 })
.backgroundColor(Color.White)
}
// ========== 商品行 ==========
@Builder
CartItemRow(item: CartItem) {
Row() {
Checkbox()
.select($$item.selected)
.onChange(() => {
this.cartManager.toggleItemSelection(item.id)
this.syncUI()
})
.width(18)
.height(18)
.margin({ right: 8 })
Image(item.imageUrl)
.width(80)
.height(80)
.borderRadius(4)
.objectFit(ImageFit.Cover)
Column() {
Text(item.title)
.fontSize(13)
.fontColor(item.isValid ? '#333333' : '#999999')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (item.specDesc) {
Text(item.specDesc)
.fontSize(11)
.fontColor('#999999')
.backgroundColor('#F5F5F5')
.borderRadius(2)
.padding({ left: 4, right: 4, top: 2, bottom: 2 })
.margin({ top: 4 })
}
if (!item.isValid) {
Text('商品已失效')
.fontSize(11)
.fontColor('#FF4444')
.margin({ top: 2 })
}
Row() {
Text(`¥${item.price}`)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#FF4444')
Blank()
if (item.isValid) {
Row() {
Text('-')
.fontSize(14)
.fontColor(item.quantity <= 1 ? '#CCCCCC' : '#333333')
.width(24)
.height(24)
.textAlign(TextAlign.Center)
.backgroundColor('#F5F5F5')
.borderRadius(4)
.onClick(() => {
if (item.quantity > 1) {
this.cartManager.updateQuantity(item.id, item.quantity - 1)
this.syncUI()
}
})
Text(`${item.quantity}`)
.fontSize(12)
.width(28)
.height(24)
.textAlign(TextAlign.Center)
Text('+')
.fontSize(14)
.fontColor(item.quantity >= item.stock ? '#CCCCCC' : '#333333')
.width(24)
.height(24)
.textAlign(TextAlign.Center)
.backgroundColor('#F5F5F5')
.borderRadius(4)
.onClick(() => {
if (item.quantity < item.stock) {
this.cartManager.updateQuantity(item.id, item.quantity + 1)
this.syncUI()
} else {
promptAction.showToast({ message: '已达库存上限' })
}
})
}
}
}
.width('100%')
.margin({ top: 8 })
}
.layoutWeight(1)
.margin({ left: 8 })
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.backgroundColor(Color.White)
.opacity(item.isValid ? 1 : 0.5)
}
// ========== 左滑删除 ==========
@Builder
DeleteAction(item: CartItem) {
Button('删除')
.fontSize(14)
.fontColor(Color.White)
.backgroundColor('#FF4444')
.width(70)
.height('100%')
.borderRadius(0)
.onClick(() => {
this.cartManager.removeItem(item.id)
this.syncUI()
})
}
// ========== 辅助方法 ==========
calcShopTotal(group: ShopGroup): number {
return group.items
.filter(i => i.selected && i.isValid)
.reduce((sum, i) => sum + i.price * i.quantity, 0)
}
syncUI(): void {
const state = this.cartManager.getState()
this.shopGroups = [...state.shopGroups]
this.isAllSelected = state.isAllSelected
this.totalPrice = state.totalPrice
this.selectedCount = state.selectedCount
this.cartItemCount = state.shopGroups.reduce((sum, g) => sum + g.items.length, 0)
}
loadCartData(): void {
// 模拟数据
const items: CartItem[] = [
{ id: '1', productId: 'p1', skuId: 'sku1', title: '纯棉短袖T恤男夏季透气圆领', imageUrl: 'https://picsum.photos/200/200?random=1', specDesc: '黑色 XL', price: 299, originalPrice: 399, quantity: 2, stock: 10, selected: true, isValid: true, shopId: 'shop1', shopName: '品牌旗舰店' },
{ id: '2', productId: 'p2', skuId: 'sku2', title: '运动休闲裤男宽松直筒长裤', imageUrl: 'https://picsum.photos/200/200?random=2', specDesc: '深灰 L', price: 199, originalPrice: 259, quantity: 1, stock: 5, selected: false, isValid: true, shopId: 'shop1', shopName: '品牌旗舰店' },
{ id: '3', productId: 'p3', skuId: 'sku3', title: '无线蓝牙耳机降噪续航', imageUrl: 'https://picsum.photos/200/200?random=3', specDesc: '白色', price: 159, originalPrice: 199, quantity: 1, stock: 20, selected: true, isValid: true, shopId: 'shop2', shopName: '数码专营店' },
]
for (const item of items) {
this.cartManager.addItem(item)
}
this.syncUI()
}
}
踩坑与注意事项
坑1:Checkbox的select绑定不触发刷新
Checkbox().select($$item.selected)这种双向绑定,修改item.selected后UI不刷新——因为item是数组元素,不是顶层@State变量。
解决方案:修改后重新赋值整个数组,或者用@Observed和@ObjectLink装饰器。
// 方案1:重新赋值数组
this.shopGroups = [...this.cartManager.getState().shopGroups]
// 方案2:使用@Observed装饰器
@Observed
class CartItem {
selected: boolean = false
// ...
}
坑2:数量为0时应该删除还是保留?
用户把数量减到0,商品应该从购物车删除还是保留?
建议:数量最小为1,不允许减到0。如果用户想删除,用左滑删除或编辑模式批量删除。数量为0的商品留在购物车里没有意义,反而让用户困惑。
坑3:全选后删除商品,全选状态不更新
用户全选了5个商品,删除了1个,剩余4个——全选按钮应该还是亮着的,因为剩下的4个都选中了。但如果你的代码是"全选=商品总数等于选中数",删除后总数变了,选中数也变了,逻辑可能出错。
解决方案:全选状态只看"所有有效商品是否都选中",和商品总数无关。
坑4:购物车数据持久化
用户加了商品到购物车,关掉App再打开,购物车空了——因为数据只存在内存里。
解决方案:购物车数据必须持久化。轻量级用Preferences,数据量大用关系型数据库。每次修改购物车后同步写入本地,打开App时从本地恢复。
坑5:价格精度问题
0.1 + 0.2 = 0.30000000000000004——JavaScript的经典浮点数问题。购物车价格计算涉及乘法和加法,精度问题会导致显示"¥99.99999999999999"。
解决方案:价格用"分"为单位存储(整数),显示时除以100。或者用toFixed(2)格式化。
// 用分为单位
const priceInCents = 29900 // 299元
const displayPrice = (priceInCents / 100).toFixed(2)
// 计算总价时用分
let totalCents = 0
for (const item of selectedItems) {
totalCents += item.priceInCents * item.quantity
}
const totalYuan = (totalCents / 100).toFixed(2)
HarmonyOS 6适配说明
HarmonyOS 6对购物车相关组件做了以下更新:
-
List的swipeAction增强:左滑删除支持自定义滑动距离和弹性效果。新增
onSwipeActionComplete回调,用户松手后可以判断是否超过阈值来决定是否执行删除。 -
Checkbox样式自定义:Checkbox支持自定义选中/未选中的图标和颜色,不再局限于系统默认样式。可以换成品牌色的圆形勾选框。
-
@Observed/@ObjectLink性能优化:嵌套对象的响应式更新性能提升约40%。购物车的
CartItem用@Observed装饰后,修改selected属性能立即触发UI刷新,不再需要重新赋值整个数组。 -
List分组 StickyHeader:ListItemGroup的header支持吸顶效果,店铺标题滚动到顶部时固定显示,用户始终知道当前是哪个店铺的商品。
-
手势冲突优化:Checkbox的点击和List的左滑不再冲突。之前在商品行上放Checkbox,点击Checkbox偶尔触发左滑,HarmonyOS 6修复了这个问题。
总结
购物车的核心不是UI,是状态联动。商品选中→店铺选中→全选状态→总价计算,这条链路任何一个环节出错,整个购物车就废了。
核心记住三点:
- 状态联动是关键,商品选中影响店铺选中,店铺选中影响全选,全选反向控制所有商品——这个循环必须正确
- 价格用整数(分)计算,浮点数精度问题在购物车场景下100%会出bug
- 数据必须持久化,购物车数据不能只存内存,用户关App再打开数据就没了
| 评估维度 | 说明 |
|---|---|
| 学习难度 | ⭐⭐⭐⭐ 状态联动逻辑复杂,全选/店铺选中/商品选中三层嵌套 |
| 使用频率 | ⭐⭐⭐⭐⭐ 所有电商App都有购物车,是核心功能 |
| 重要程度 | ⭐⭐⭐⭐⭐ 购物车是转化的关键环节,价格算错就是事故 |
购物车价格算错了,用户以为便宜了结果多扣钱——这不是bug,这是客诉。
- 点赞
- 收藏
- 关注作者
评论(0)