HarmonyOS开发:购物车管理

举报
Jack20 发表于 2026/06/26 17:14:00 2026/06/26
【摘要】 HarmonyOS开发:购物车管理📌 核心要点:购物车不只是"加个列表",商品增删改查、数量调整与价格计算、全选与结算逻辑,数据一致性是核心,状态管理是关键。 背景与动机购物车功能看起来简单——不就是个列表,加个数量加减按钮嘛?你试试。用户加了3个商品,每个商品选了不同SKU,数量分别是1、2、1。然后他删了一个、改了一个的数量、勾选了两个——这时候总价多少?哪些商品被选中了?全选按钮该...

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对购物车相关组件做了以下更新:

  1. List的swipeAction增强:左滑删除支持自定义滑动距离和弹性效果。新增onSwipeActionComplete回调,用户松手后可以判断是否超过阈值来决定是否执行删除。

  2. Checkbox样式自定义:Checkbox支持自定义选中/未选中的图标和颜色,不再局限于系统默认样式。可以换成品牌色的圆形勾选框。

  3. @Observed/@ObjectLink性能优化:嵌套对象的响应式更新性能提升约40%。购物车的CartItem@Observed装饰后,修改selected属性能立即触发UI刷新,不再需要重新赋值整个数组。

  4. List分组 StickyHeader:ListItemGroup的header支持吸顶效果,店铺标题滚动到顶部时固定显示,用户始终知道当前是哪个店铺的商品。

  5. 手势冲突优化:Checkbox的点击和List的左滑不再冲突。之前在商品行上放Checkbox,点击Checkbox偶尔触发左滑,HarmonyOS 6修复了这个问题。

总结

购物车的核心不是UI,是状态联动。商品选中→店铺选中→全选状态→总价计算,这条链路任何一个环节出错,整个购物车就废了。

核心记住三点:

  • 状态联动是关键,商品选中影响店铺选中,店铺选中影响全选,全选反向控制所有商品——这个循环必须正确
  • 价格用整数(分)计算,浮点数精度问题在购物车场景下100%会出bug
  • 数据必须持久化,购物车数据不能只存内存,用户关App再打开数据就没了
评估维度 说明
学习难度 ⭐⭐⭐⭐ 状态联动逻辑复杂,全选/店铺选中/商品选中三层嵌套
使用频率 ⭐⭐⭐⭐⭐ 所有电商App都有购物车,是核心功能
重要程度 ⭐⭐⭐⭐⭐ 购物车是转化的关键环节,价格算错就是事故

购物车价格算错了,用户以为便宜了结果多扣钱——这不是bug,这是客诉。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。