跳转到内容

编程实验:开发购物车功能

本实验将基于 07_shoppingcart 模板工程,在已经完成商品列表页的基础上继续开发点餐 APP 的购物车功能。最终页面需要支持在商品列表中调整商品数量,并通过底部购物车栏和购物车商品面板展示当前购物车数据。

本实验主要完成以下任务:

  • 在商品列表小项中增加商品数量调节组件
  • 使用购物车业务类统一管理商品数量变化
  • 使用 AppStorage 共享购物车数据
  • 将购物车数据与服务端接口同步
  • 开发底部购物车栏,展示购物车商品总数和总价
  • 开发购物车商品面板,支持展开、收起、商品列表展示和面板内数量调节
  • 支持清空购物车

最终效果

购物车功能最终效果

本实验涉及的知识点

知识点使用场景
@State在第一版数量调节组件中保存临时数量状态,快速验证 UI 交互
AppStorage保存应用级购物车数据,让多个组件共享同一份购物车状态
@StorageLink在商品数量组件、底部购物车栏和购物车面板中读取购物车数据
@Watch监听购物车数据变化,重新计算总数、总价或刷新面板列表
Badge在购物车图标上显示购物车商品总数
AlertDialog清空购物车前弹出确认框
CustomDialogController控制清空购物车时的 Loading 弹窗
LazyForEach渲染购物车面板中的商品列表
服务端接口封装拉取购物车数据、调整商品数量、清空购物车

开发思路

本实验不会一开始就对接所有服务端接口,而是按照“先看到效果,再完善业务,再同步服务端”的方式逐步推进:

  1. 先开发商品数量调节 UI,并立即接入商品列表页验证交互效果。
  2. 再创建本地版 ShoppingCart 业务类,让数量调节组件使用统一购物车数据。
  3. 当商品数量调节链路跑通后,接入购物车数据拉取和商品数量调整接口。
  4. 继续开发底部购物车栏和购物车商品面板。
  5. 最后实现清空购物车,并对接清空购物车服务端接口。

这种顺序可以让每个阶段都有明确的页面反馈:先看到商品右侧的加减按钮,再看到数量状态统一保存,接着看到服务端数据同步,最后完成底部栏、面板和清空功能。

目标:导入并运行模板工程,确认当前工程状态,明确本实验需要修改和新建的文件。

2.1 获取模板工程

模板工程仓库:https://cnb.cool/sziit-coding/harmony-coding/07_shoppingcart

请使用 DevEco Studio 打开该模板工程,并确认工程可以正常运行。

2.2 认识模板工程当前状态

模板工程已经完成登录、首页、商铺列表页和商品列表页。用户登录后进入首页第一个 Tab,点击任意商铺可以进入商品列表页。商品列表页已经具备以下能力:

  • 页面 Loading、空数据、异常、内容状态切换
  • 根据商铺 ID 拉取商品分类和商品数据
  • 左侧商品分类列表
  • 右侧商品分组列表
  • 左右列表联动
  • 商品小项基础 UI

entry/src/main/ets/customer/goods/DoubleListComponent.ets 中,商品小项的价格行右侧已经预留了购物车数量调节组件的位置:

entry/src/main/ets/customer/goods/DoubleListComponent.ets
@Component
export struct DoubleListComponent {
...
@Builder
GoodsItemBuilder(goods) {
...
Row() {
Text('¥')
.fontColor($r('app.color.black'))
.fontSize(13)
Text(goods.price.toString())
.fontColor($r('app.color.black'))
.fontSize(15)
Blank()
// todo: 待实现商品添加、移除购物车按钮
}
}
...
}

本实验将从这个预留位置开始,逐步完成商品数量调节、购物车数据管理、底部购物车栏和购物车商品面板。

商品列表页初始状态

2.3 模板工程已提供的购物车相关基础

模板工程中已经提供了部分购物车开发所需的基础代码和资源:

文件或资源作用
entry/src/main/ets/model/ShoppingCartData.ets购物车商品数据模型
entry/src/main/ets/utils/Constants.ets已定义购物车数据在 AppStorage 中使用的 key
entry/src/main/ets/api/ServerApi.ets已预留购物车数据解析函数和购物车接口方法
entry/src/main/resources/base/media/ic_shopping_cart.svg购物车图标
entry/src/main/resources/base/media/ic_increase_goods_count.svg商品数量加号图标
entry/src/main/resources/base/media/ic_decrease_goods_count.svg商品数量减号图标
entry/src/main/resources/base/media/ic_delete_grey.svg清空购物车图标

其中 Constants.ets 中已经定义了购物车数据共享使用的 key:

entry/src/main/ets/utils/Constants.ets
export const APP_SCOPE_KEY_SHOPPING_CART_DATA_LIST = 'key_shopping_cart_data_list'

ServerApi.ets 中已经预留了购物车数据解析函数和待实现接口:

entry/src/main/ets/api/ServerApi.ets
export class ServerApi {
...
private static json2ResponseShoppingCartDataList(json: string): Response<ArrayList<ShoppingCartData>> {
...
}
static async getShoppingCartData(storeId: number): Promise<Response<ArrayList<ShoppingCartData>>> {
// todo: 待实现
return new Response<ArrayList<ShoppingCartData>>(ShoppingCartData)
}
static async setGoodsCountInShoppingCart(goodsId: number, count: number): Promise<Response<Object>> {
// todo: 待实现
return new Response<ArrayList<SpecData>>(SpecData)
}
}

2.4 本实验涉及的文件

文件路径操作作用
entry/src/main/ets/customer/widget/GoodsCounterView.ets新建商品数量调节组件
entry/src/main/ets/customer/shoppingcart/ShoppingCart.ets新建购物车业务类,负责加载、调整和清空购物车数据
entry/src/main/ets/customer/shoppingcart/ShoppingCartBarComponent.ets新建底部购物车栏组件,显示商品总数和总价
entry/src/main/ets/customer/shoppingcart/ShoppingCartView.ets新建购物车整体容器,管理底部栏和面板显示
entry/src/main/ets/customer/shoppingcart/ShoppingCartPanelComponent.ets新建购物车商品面板组件
entry/src/main/ets/customer/goods/DoubleListComponent.ets修改在商品小项中接入数量调节组件
entry/src/main/ets/customer/goods/GoodsListView.ets修改创建购物车实例,并在商品列表页显示购物车 UI
entry/src/main/ets/api/ServerApi.ets修改对接购物车数据拉取、数量调整和清空接口

✅ 预期效果:运行模板工程并完成登录后,进入首页第一个 Tab,点击任意商铺进入商品列表页,可以看到商品列表正常显示,但商品右侧暂时还没有购物车加减按钮。

目标:理解本实验中购物车数据为什么需要共享,以及 ShoppingCart + AppStorage + @StorageLink 如何协同工作。

3.1 先从最简单的数量状态开始

商品数量调节组件最容易想到的写法,是在组件内部定义一个 @State count。这样点击加号、减号时,组件自己修改自己的数量,页面上很快就能看到交互效果。

例如:

组件内部数量状态示意
@Component
export struct GoodsCounterView {
@State count: number = 0
build() {
Row() {
// 减号、数量、加号
}
}
}

这种写法适合用来快速验证商品数量调节 UI 是否正常:点击加号后数量增加,点击减号后数量减少,数量为 0 时隐藏减号和数量。

但是当购物车功能继续扩展时,组件内部的 count 就不够用了。因为购物车数据不只会被商品小项使用,还会同时被多个位置读取:

使用位置需要读取的购物车信息
商品列表小项当前商品在购物车中的数量
底部购物车栏购物车商品总数和总价
购物车商品面板购物车内所有商品列表
清空购物车功能当前商铺下完整购物车数据

如果每个商品小项只维护自己的 @State count,底部购物车栏无法知道所有商品的总数和总价,购物车面板也无法获取完整商品列表。组件滚动复用或页面重新进入后,单个组件内部状态也可能丢失。

所以,商品小项内部状态只适合作为“第一版交互验证方案”,真正的购物车数据还需要放到一个所有相关组件都能访问的位置。

3.2 把购物车数据提升为共享状态

AppStorage 是 ArkUI 提供的应用级状态存储能力,适合保存多个组件都需要访问的共享状态。本实验会把当前商铺的购物车数据保存到 AppStorage 中:

购物车数据共享 key
APP_SCOPE_KEY_SHOPPING_CART_DATA_LIST

组件通过 @StorageLink 读取这份数据:

@StorageLink 基本用法
@StorageLink(APP_SCOPE_KEY_SHOPPING_CART_DATA_LIST)
shoppingCartDataList: ArrayList<ShoppingCartData> = new ArrayList()

AppStorage 中的购物车数据变化后,使用 @StorageLink 的组件会跟随更新。这样商品列表、底部购物车栏和购物车商品面板就能共享同一份购物车状态。

购物车状态共享数据流

可以把这个过程理解为两步:

  1. 点击商品加号或减号时,不再只修改当前组件内部的 count
  2. 而是去修改一份全局共享的购物车数据

这样商品列表中的数量、底部购物车栏中的总数和总价、购物车面板中的商品列表,读取的就是同一份状态。

3.3 用 ShoppingCart 统一处理购物车操作

虽然多个组件都可以通过 @StorageLink 读取购物车数据,但不建议让每个组件都直接修改 AppStorage 或直接调用 ServerApi。本实验会新建 ShoppingCart 业务类,统一封装购物车相关操作:

ShoppingCart 业务类职责
export class ShoppingCart {
// 加载购物车数据
async loadShoppingCartData() {}
// 调整购物车内指定商品数量
async adjustGoodsCount(goodsId: number, diff: number) {}
// 清空购物车
async cleanShoppingCart() {}
}

这样组件只负责展示和响应点击事件,具体的数据修改逻辑交给 ShoppingCart

购物车数据流
点击商品加号 / 减号
GoodsCounterView 调用 ShoppingCart.adjustGoodsCount()
ShoppingCart 更新购物车数据
购物车数据写入 AppStorage
商品列表、底部购物车栏、购物车面板自动刷新

这样设计后,各个对象的职责会更清晰:

对象职责
GoodsCounterView展示数量,响应用户点击
ShoppingCart处理购物车业务逻辑
AppStorage保存共享购物车状态
底部购物车栏 / 面板读取购物车状态并展示

目标:完成商品数量调节组件,并把它接入商品列表页。当前阶段只验证组件自身的加减交互,数量暂时保存在组件内部。

4.1 新建 GoodsCounterView 组件

entry/src/main/ets/customer/ 目录下新建 widget 子目录,并在其中新建 GoodsCounterView.ets。组件内部先声明一个临时数量状态。

entry/src/main/ets/customer/widget/GoodsCounterView.ets
@Component
export struct GoodsCounterView {
@State count: number = 0
build() {
Row() {
// TODO: 显示减号按钮,图片资源使用 ic_decrease_goods_count
// TODO: 显示当前商品数量,文本内容为 this.count.toString()
// TODO: 显示加号按钮,图片资源使用 ic_increase_goods_count
}
}
}

4.2 完成加减按钮 UI 和临时交互

数量调节组件的显示规则如下:

当前数量显示内容
count == 0只显示加号按钮
count > 0显示减号按钮、数量文本和加号按钮

继续完善 GoodsCounterView.ets。本阶段只要求使用 @State count 跑通本地交互。

entry/src/main/ets/customer/widget/GoodsCounterView.ets
@Component
export struct GoodsCounterView {
@State count: number = 0
build() {
Row() {
// TODO: 减号按钮
// · 图片资源:$r('app.media.ic_decrease_goods_count')
// · size: { width: 28, height: 28 }
// · padding: 4
// · count > 0 时可见,否则使用 Visibility.Hidden 隐藏
// · 点击时先判断 count > 0,再执行 this.count--
// TODO: 商品数量文本
// · 文本内容:this.count.toString()
// · fontColor: Color.Black
// · fontSize: 13
// · width: 30
// · textAlign: TextAlign.Center
// · count > 0 时可见,否则使用 Visibility.Hidden 隐藏
// TODO: 加号按钮
// · 图片资源:$r('app.media.ic_increase_goods_count')
// · size: { width: 28, height: 28 }
// · padding: 4
// · 点击时执行 this.count++
}
}
}

提示:这里使用 Visibility.Hidden,组件虽然不可见,但仍然保留原本占位。这样加号按钮的位置不会随着数量变化而左右跳动,交互更稳定。

4.3 在 DoubleListComponent 中导入组件

打开 entry/src/main/ets/customer/goods/DoubleListComponent.ets,导入刚刚创建的 GoodsCounterView

entry/src/main/ets/customer/goods/DoubleListComponent.ets
import { StoreDataCompact } from '../../utils/StoreDataCompact'
import { GoodsCounterView } from '../widget/GoodsCounterView'

4.4 在商品小项中接入 GoodsCounterView

DoubleListComponent.etsGoodsItemBuilder() 中,找到价格行右侧的 todo 注释,将它替换为 GoodsCounterView()

entry/src/main/ets/customer/goods/DoubleListComponent.ets
@Component
export struct DoubleListComponent {
...
@Builder
GoodsItemBuilder(goods) {
...
Row() {
Text('¥')
.fontColor($r('app.color.black'))
.fontSize(13)
Text(goods.price.toString())
.fontColor($r('app.color.black'))
.fontSize(15)
Blank()
// todo: 待实现商品添加、移除购物车按钮
GoodsCounterView()
}
}
...
}

完成后,每个商品小项右侧都会显示一个独立的数量调节组件。

4.5 阶段验证

运行项目并完成登录,进入首页第一个 Tab,点击任意商铺进入商品列表页。

验证以下效果:

  • 每个商品右侧都显示加号按钮
  • 点击某个商品的加号后,该商品右侧显示减号、数量和加号
  • 连续点击加号,数量会递增
  • 点击减号,数量会递减
  • 数量减到 0 后,减号和数量隐藏,只保留加号
  • 不同商品的数量互不影响
商品数量调节组件初始效果
商品数量调节组件点击后效果

4.6 本阶段的临时限制

到这里,商品数量调节组件已经可以在页面中点击使用,但它还不是真正的购物车功能。

当前实现存在两个限制:

限制原因
数量只属于单个 GoodsCounterView 组件当前数量保存在组件内部的 @State count
页面重新进入或组件重建后数量不会保留当前还没有把数量写入统一购物车数据

下一步会创建 ShoppingCart 业务类,并使用 AppStorage 保存购物车数据。到那时,GoodsCounterView 将不再自己保存数量,而是从购物车数据中读取当前商品数量。

目标:把上一步 GoodsCounterView 内部的临时 @State count,改造成由 ShoppingCart 业务类统一管理的本地购物车数据。完成后,商品数量不再只属于单个组件,而是保存到 AppStorage 中,后续底部购物车栏和购物车面板也能读取同一份数据。

本步骤仍然不访问服务端接口,只先完成本地购物车数据流:

本地购物车数据流
GoodsCounterView 点击加号 / 减号
调用 ShoppingCart.adjustGoodsCount(goodsData, diff)
ShoppingCart 修改 AppStorage 中的购物车数据
GoodsCounterView 通过 @StorageLink 自动读取最新数量

5.1 新建 ShoppingCart 业务类

entry/src/main/ets/customer/ 目录下新建 shoppingcart 子目录,并在其中新建 ShoppingCart.ets

entry/src/main/ets/customer/shoppingcart/ShoppingCart.ets
import ArrayList from '@ohos.util.ArrayList'
import { GoodsData } from '../../model/GoodsData'
import { ShoppingCartData } from '../../model/ShoppingCartData'
import { APP_SCOPE_KEY_SHOPPING_CART_DATA_LIST } from '../../utils/Constants'
export class ShoppingCart {
// 当前购物车所属商铺 ID
private storeId: number
constructor(storeId: number) {
this.storeId = storeId
}
}

storeId 暂时还不会参与本地逻辑计算,但后续对接服务端接口时,需要根据商铺 ID 拉取当前商铺的购物车数据。

5.2 初始化本地购物车数据

先在 ShoppingCart 中新增 loadShoppingCartData() 方法,用于初始化 AppStorage 中的购物车数据。

entry/src/main/ets/customer/shoppingcart/ShoppingCart.ets
export class ShoppingCart {
...
/**
* 加载购物车内的数据
*/
async loadShoppingCartData() {
// TODO: 本阶段先不访问服务端,只创建一个空的 ArrayList<ShoppingCartData>
// TODO: 使用 AppStorage.setOrCreate 写入 APP_SCOPE_KEY_SHOPPING_CART_DATA_LIST
}
}

这样页面进入商品列表时,购物车数据一定有一个初始值,后续组件使用 @StorageLink 读取时不会拿到空对象。

5.3 统计指定商品数量

继续在 ShoppingCart 中新增 countOfGoods(goodsId) 方法,用于统计指定商品当前在购物车中的数量。

entry/src/main/ets/customer/shoppingcart/ShoppingCart.ets
private countOfGoods(goodsId: number): number {
// TODO: 定义 count,初始值为 0
// TODO: 从 AppStorage 中读取购物车列表,key 使用 APP_SCOPE_KEY_SHOPPING_CART_DATA_LIST
// TODO: 遍历购物车列表,找到商品 ID 等于 goodsId 的记录后, count = 商品记录.number
// TODO: 返回 count
}

这个方法会从 AppStorage 中取出当前购物车列表,遍历列表中的每一项。如果某一项对应的商品 ID 与传入的 goodsId 一致,就把它的 number 累加到结果中。

5.4 实现本地版数量调整逻辑

接下来实现 adjustGoodsCount(goodsData, diff)。其中:

参数作用
goodsData当前被点击加号或减号的商品
diff数量变化量,点击加号传 1,点击减号传 -1

本地版逻辑可以按下面的思路实现:

  1. 读取当前购物车列表
  2. 根据 goodsData.id 找到当前商品在购物车中的旧数量
  3. 计算新数量:newCount = oldCount + diff
  4. 如果 newCount <= 0,从购物车中移除此商品
  5. 如果 newCount > 0,新增或更新此商品的购物车记录
  6. 把新的购物车列表重新写入 AppStorage
entry/src/main/ets/customer/shoppingcart/ShoppingCart.ets
export class ShoppingCart {
...
async adjustGoodsCount(goodsData: GoodsData, diff: number) {
// TODO: 调用 countOfGoods(goodsData.id) 获取旧数量
// TODO: 计算新数量 newCount,值为旧数量加 diff
// TODO: 从 AppStorage 中读取旧购物车列表;如果为空,则创建新的 ArrayList<ShoppingCartData>
// TODO: 创建新的 ArrayList<ShoppingCartData>(注意:这里一定要创建新的列表对象,目的是触发AppStorage的数据更新),先把其他商品的购物车记录复制进去
// TODO: 如果 newCount > 0,创建 ShoppingCartData 并写入商品 id、名称、图片、单价、数量和小计
// TODO: 将新的购物车列表写回 AppStorage
}
}

提示:建议创建一个新的 ArrayList<ShoppingCartData> 后重新写入 AppStorage,这样更容易让依赖 @StorageLink 的组件感知数据变化。

5.5 修改 GoodsCounterView 的数据来源

现在回到 entry/src/main/ets/customer/widget/GoodsCounterView.ets。上一步中,组件内部使用 @State count 保存数量;现在要把数量来源改成 AppStorage 中的购物车数据。

先补充 import:

entry/src/main/ets/customer/widget/GoodsCounterView.ets
import ArrayList from '@ohos.util.ArrayList'
import { GoodsData } from '../../model/GoodsData'
import { ShoppingCartData } from '../../model/ShoppingCartData'
import { APP_SCOPE_KEY_SHOPPING_CART_DATA_LIST } from '../../utils/Constants'
import { ShoppingCart } from '../shoppingcart/ShoppingCart'

然后修改组件状态和参数:

entry/src/main/ets/customer/widget/GoodsCounterView.ets
@Component
export struct GoodsCounterView {
@State count: number = 0
@StorageLink(APP_SCOPE_KEY_SHOPPING_CART_DATA_LIST)
shoppingCartDataList: ArrayList<ShoppingCartData> = new ArrayList()
goodsData: GoodsData = new GoodsData()
shoppingCart: ShoppingCart = new ShoppingCart(0)
...
}

现在 GoodsCounterView 不再自己保存数量,而是接收两个外部参数:

参数作用
goodsData当前商品数据,用于判断当前商品在购物车中的数量
shoppingCart购物车业务对象,用于执行加减操作

5.6 在 GoodsCounterView 中计算当前商品数量

GoodsCounterView 中新增 countOfGoods(goodsId) 方法:

entry/src/main/ets/customer/widget/GoodsCounterView.ets
@Component
export struct GoodsCounterView {
...
countOfGoods(goodsId: number): number {
// TODO: 定义 count,初始值为 0
// TODO: 遍历 this.shoppingCartDataList
// TODO: 当 item.getGoodsData().id 等于 goodsId 时,count = item.number
// TODO: 返回 count
}
...
}

然后把 build() 中所有 this.count 替换为 this.countOfGoods(this.goodsData.id)

entry/src/main/ets/customer/widget/GoodsCounterView.ets
@Component
export struct GoodsCounterView {
...
build() {
Row() {
Image($r('app.media.ic_decrease_goods_count'))
.size({ width: 28, height: 28 })
.padding(4)
.visibility(this.count > 0 ? Visibility.Visible : Visibility.Hidden)
.visibility(this.countOfGoods(this.goodsData.id) > 0 ? Visibility.Visible : Visibility.Hidden)
.onClick(_ => {
if (this.count > 0) {
this.count--
}
this.shoppingCart.adjustGoodsCount(this.goodsData, -1)
})
Text(this.count.toString())
Text(this.countOfGoods(this.goodsData.id).toString())
.fontColor(Color.Black)
.fontSize(13)
.width(30)
.textAlign(TextAlign.Center)
.visibility(this.count > 0 ? Visibility.Visible : Visibility.Hidden)
.visibility(this.countOfGoods(this.goodsData.id) > 0 ? Visibility.Visible : Visibility.Hidden)
Image($r('app.media.ic_increase_goods_count'))
.size({ width: 28, height: 28 })
.padding(4)
.onClick(_ => {
this.count++
this.shoppingCart.adjustGoodsCount(this.goodsData, 1)
})
}
}
}

完成这一步后,GoodsCounterView 的显示状态由购物车数据决定;点击加减按钮时,也不再直接修改组件内部变量,而是调用 ShoppingCart.adjustGoodsCount()

5.7 在 GoodsListView 中创建 ShoppingCart 实例

打开 entry/src/main/ets/customer/goods/GoodsListView.ets,先导入 ShoppingCart

entry/src/main/ets/customer/goods/GoodsListView.ets
import { ShoppingCart } from '../shoppingcart/ShoppingCart'

在组件中新增购物车对象:

entry/src/main/ets/customer/goods/GoodsListView.ets
@Component
export struct GoodsListView {
...
// 购物车
private shoppingCart: ShoppingCart = new ShoppingCart(0)
...
}

然后在 aboutToAppear() 中,根据当前商铺 ID 创建购物车实例,并初始化本地购物车数据:

entry/src/main/ets/customer/goods/GoodsListView.ets
@Component
export struct GoodsListView {
...
aboutToAppear() {
this.loadGoodsList()
this.shoppingCart = new ShoppingCart(this.storeData.id)
this.shoppingCart.loadShoppingCartData()
}
...
}

这里需要在 aboutToAppear() 中重新给 shoppingCart 赋值,原因有两点:

  1. 组件属性初始化阶段还拿不到当前商铺的真实 ID
    在定义属性时写的 new ShoppingCart(0) 只是一个默认占位对象。真正进入商品列表页后,当前页面对应的商铺信息才已经通过路由参数传入到 storeData 中,这时才能用 this.storeData.id 创建与当前商铺匹配的购物车实例。

  2. 不同商铺应该对应不同的购物车对象
    ShoppingCart 内部保存了 storeId。后续对接服务端接口时,加载购物车数据、清空购物车等操作都要依赖这个 storeId。如果一直沿用默认的 0,后续购物车逻辑就无法正确关联到当前商铺。

可以把这里理解为:属性定义阶段先准备一个“可用的默认对象”,页面真正出现时,再根据当前商铺数据替换成“正确的业务对象”。

5.8 将 ShoppingCart 传给 DoubleListComponent

继续修改 GoodsListView.ets。在初始化 DoubleListComponent 时,把 shoppingCart 传入子组件:

entry/src/main/ets/customer/goods/GoodsListView.ets
@Component
export struct GoodsListView {
...
build() {
...
DoubleListComponent({
specDataSource: $specDataSource,
goodsDataSourceMap: $goodsDataSourceMap,
shoppingCart: this.shoppingCart
})
...
}
}

接着修改 entry/src/main/ets/customer/goods/DoubleListComponent.ets,导入 ShoppingCart

entry/src/main/ets/customer/goods/DoubleListComponent.ets
import { GoodsCounterView } from '../widget/GoodsCounterView'
import { ShoppingCart } from '../shoppingcart/ShoppingCart'

DoubleListComponent 中新增接收参数:

entry/src/main/ets/customer/goods/DoubleListComponent.ets
@Component
export struct DoubleListComponent {
...
// 购物车
shoppingCart: ShoppingCart = new ShoppingCart(0)
...
}

最后,把商品数据和购物车对象传给 GoodsCounterView

entry/src/main/ets/customer/goods/DoubleListComponent.ets
@Component
export struct DoubleListComponent {
...
@Builder
GoodsItemBuilder(goods) {
...
GoodsCounterView({
goodsData: goods,
shoppingCart: this.shoppingCart
})
}
...
}

5.9 阶段验证

重新运行项目,进入任意商铺的商品列表页,验证以下效果:

  • 点击商品加号后,数量可以正常增加
  • 点击商品减号后,数量可以正常减少
  • 数量减到 0 后,减号和数量隐藏
  • 滚动商品列表后,已经调整过的商品数量仍能正确显示
  • 同一份购物车数据已经保存在 AppStorage 中,不再只属于某个 GoodsCounterView 组件

可以在 ShoppingCart.adjustGoodsCount() 的最后临时增加日志,确认购物车数据已经写入:

entry/src/main/ets/customer/shoppingcart/ShoppingCart.ets
export class ShoppingCart {
...
async adjustGoodsCount(goodsData: GoodsData, diff: number) {
...
const shoppingCartDataList =
AppStorage.get<ArrayList<ShoppingCartData>>(APP_SCOPE_KEY_SHOPPING_CART_DATA_LIST)
console.log(`购物车商品数量 = ${shoppingCartDataList?.length}`)
}
}

验证完成后,可以保留或删除这行日志。下一步对接服务端接口时,也可以继续通过日志观察接口返回的购物车数据。

目标:在 ServerApi 中完成购物车数据拉取接口和商品数量调整接口的封装。本步骤只处理购物车数量功能需要的两个服务端接口,清空购物车接口会在后续“清空购物车功能”步骤中再实现。

本步骤需要完成两个方法:

方法作用
getShoppingCartData(storeId)拉取指定商铺下的购物车商品数据
setGoodsCountInShoppingCart(goodsId, count)设置指定商品在购物车中的最新数量

6.1 查看购物车数据拉取接口

本实验使用的购物车数据拉取接口为:

获取购物车数据接口
GET https://api.food2.sziit.top/order-cart/list?storeId={商铺ID}

该接口返回当前商铺下购物车中的商品列表。模板工程已经在 ServerApi.ets 中预留了数据解析函数:

entry/src/main/ets/api/ServerApi.ets
private static json2ResponseShoppingCartDataList(json: string): Response<ArrayList<ShoppingCartData>> {
...
}

因此,本步骤中不需要学生重新编写 JSON 到 ArrayList<ShoppingCartData> 的转换逻辑,只需要在请求成功后调用这个方法完成解析。

6.2 实现 getShoppingCartData 接口

打开 entry/src/main/ets/api/ServerApi.ets,找到下面这个待实现方法:

entry/src/main/ets/api/ServerApi.ets
static async getShoppingCartData(storeId: number): Promise<Response<ArrayList<ShoppingCartData>>> {
// todo: 待实现
return new Response<ArrayList<ShoppingCartData>>(ShoppingCartData)
}

将它改造成真实接口调用方法。请保留方法签名和返回值类型,按下面的骨架补全:

entry/src/main/ets/api/ServerApi.ets
static async getShoppingCartData(storeId: number): Promise<Response<ArrayList<ShoppingCartData>>> {
// TODO: 使用 http.createHttp() 创建 request 对象
// TODO: 创建 HttpRequestOptions,至少设置:
// · method: http.RequestMethod.GET
// · expectDataType: http.HttpDataType.STRING
try {
// TODO: 调用 ServerApi.execute(request, url, options) 发起请求
// · url 形如 `${Constants.SERVER_HOST}/order-cart/list?storeId=${storeId}`
// TODO: 使用 ServerApi.json2ResponseShoppingCartDataList 解析 rsp.result
// TODO: 返回解析后的 response
} catch (e) {
// TODO: 创建错误 Response<ArrayList<ShoppingCartData>>(ShoppingCartData)
// TODO: code 设置为 -1,message 设置为异常信息
// TODO: 返回错误 Response
}
}

提示:这一类接口实现方式和前面商品列表接口 getGoodsListForCustomer(storeId) 的写法非常接近,可以参考它的结构,但不要直接复制后忘记修改返回类型和解析方法。

6.3 查看购物车商品数量调整接口

购物车数量调整接口用于把某个商品在购物车中的数量更新到服务端。

购物车商品数量调整接口
POST https://api.food2.sziit.top/order-cart/add

请求参数:

参数名含义
goodsId商品 ID
number商品在购物车中的最新数量

这个接口不是传“变化量”,而是传“最新数量”。也就是说,如果当前商品原本数量是 2,点击加号后应该把 3 传给接口,而不是只传 1

6.4 实现 setGoodsCountInShoppingCart 接口

继续在 entry/src/main/ets/api/ServerApi.ets 中找到下面这个方法:

entry/src/main/ets/api/ServerApi.ets
static async setGoodsCountInShoppingCart(goodsId: number, count: number): Promise<Response<Object>> {
// todo: 待实现
return new Response<ArrayList<SpecData>>(SpecData)
}

这里返回占位代码的类型本身就是不对的,应该改成 Response<Object>。请按下面的骨架补全:

entry/src/main/ets/api/ServerApi.ets
static async setGoodsCountInShoppingCart(goodsId: number, count: number): Promise<Response<Object>> {
try {
// TODO: 使用 http.createHttp() 创建 request 对象
// TODO: 创建 HttpRequestOptions:
// · method: http.RequestMethod.POST
// · header 中设置 Content-Type 为 application/x-www-form-urlencoded
// · extraData 形如 `goodsId=${goodsId}&number=${count}`
// TODO: 调用 ServerApi.execute(request, url, options) 发起请求
// · url 形如 `${Constants.SERVER_HOST}/order-cart/add`
// TODO: 使用 plainToClassFromExist(new Response<Object>(Object), JSON.parse(rsp.result as string)) 转换返回结果
// TODO: 返回 response
} catch (e) {
// TODO: 创建错误 Response<Object>(Object)
// TODO: code 设置为 -1,message 设置为异常信息
// TODO: 返回错误 Response
}
}

6.5 阶段检查

完成本步骤后,先检查 ServerApi.ets 中这两个方法是否已经不再返回占位对象:

  • getShoppingCartData(storeId) 已经发起 GET /order-cart/list 请求
  • setGoodsCountInShoppingCart(goodsId, count) 已经发起 POST /order-cart/add 请求
  • 清空购物车接口不在本步骤处理,后续开发清空购物车功能时再新增对应方法

本步骤只完成接口封装,页面中的购物车数量还不会自动切换到服务端数据。下一步骤会把 ShoppingCart.loadShoppingCartData()ShoppingCart.adjustGoodsCount() 改为调用本步骤封装好的接口。

目标:将 ShoppingCart 从“本地版购物车逻辑”切换为“服务端驱动购物车逻辑”。完成后,页面中的商品数量将从服务端拉取,并在点击加减按钮后同步更新到服务端。

上一步中,ServerApi 已经完成了两个接口方法:

  • getShoppingCartData(storeId)
  • setGoodsCountInShoppingCart(goodsId, count)

本步骤的任务,就是把 ShoppingCart.ets 中原来纯本地的实现,改造成调用这两个方法。

7.1 在 ShoppingCart 中导入 ServerApi

打开 entry/src/main/ets/customer/shoppingcart/ShoppingCart.ets,补充 ServerApi 的 import:

entry/src/main/ets/customer/shoppingcart/ShoppingCart.ets
import ArrayList from '@ohos.util.ArrayList'
import { GoodsData } from '../../model/GoodsData'
import { ShoppingCartData } from '../../model/ShoppingCartData'
import { APP_SCOPE_KEY_SHOPPING_CART_DATA_LIST } from '../../utils/Constants'
import { ServerApi } from '../../api/ServerApi'

7.2 用服务端接口改造 loadShoppingCartData

上一步中,loadShoppingCartData() 的作用只是向 AppStorage 中写入一个空的 ArrayList<ShoppingCartData>。现在需要把它改造成真实接口调用。

先回顾一下本地版思路:

entry/src/main/ets/customer/shoppingcart/ShoppingCart.ets
export class ShoppingCart {
...
async loadShoppingCartData() {
// 本地阶段只是初始化一个空购物车
AppStorage.setOrCreate(APP_SCOPE_KEY_SHOPPING_CART_DATA_LIST, new ArrayList<ShoppingCartData>())
}
}

现在请把这个方法改成下面的骨架:

entry/src/main/ets/customer/shoppingcart/ShoppingCart.ets
export class ShoppingCart {
...
async loadShoppingCartData() {
// TODO: 调用 ServerApi.getShoppingCartData(this.storeId) 获取 rsp
// TODO: 如果 rsp.isSuccess() 为 false,则直接 return
// TODO: 如果 rsp.data 不为空,使用 AppStorage.setOrCreate 写入购物车数据
// TODO: 如果 rsp.data 为空,也可以写入一个空的 ArrayList<ShoppingCartData>,确保购物车状态有初始值
}
}

这里的关键点有两个:

  1. 使用 this.storeId 拉取当前商铺对应的购物车数据
  2. 请求成功后,把服务端返回的 rsp.data 写回 AppStorage

因为 GoodsCounterView 使用了 @StorageLink(APP_SCOPE_KEY_SHOPPING_CART_DATA_LIST),所以当 AppStorage 更新后,页面中的商品数量会自动刷新。

7.3 用服务端接口改造 adjustGoodsCount

接着改造 adjustGoodsCount(goodsData, diff)。在本地版逻辑中,这个方法会:

  • 先计算旧数量和新数量
  • 再自己拼装新的购物车列表
  • 最后把新的列表写回 AppStorage

现在不再直接修改本地购物车列表,而是改成:

  1. 计算当前商品的新数量
  2. 调用 ServerApi.setGoodsCountInShoppingCart(goodsData.id, newCount)
  3. 请求成功后,调用 this.loadShoppingCartData() 重新同步购物车数据

请将这个方法改造成下面的骨架:

entry/src/main/ets/customer/shoppingcart/ShoppingCart.ets
export class ShoppingCart {
...
async adjustGoodsCount(goodsData: GoodsData, diff: number) {
// TODO: 保留 countOfGoods(goodsData.id) 的调用,用于获取旧数量
// TODO: 计算 newCount = oldCount + diff
// TODO: 删除上一步中本地拼装 ArrayList<ShoppingCartData> 的整段逻辑
// TODO: 调用 ServerApi.setGoodsCountInShoppingCart(goodsData.id, newCount) 获取 rsp
// TODO: 如果 rsp.isSuccess() 为 false,则直接 return
// TODO: 如果请求成功,调用 this.loadShoppingCartData() 重新同步购物车数据
}
}

这里要特别注意:

  • 传给接口的是 newCount,不是 diff
  • 不要再自己手动拼接本地 ArrayList<ShoppingCartData>
  • 统一通过 this.loadShoppingCartData() 重新拉取购物车数据,这样页面状态始终以服务端返回结果为准

7.4 阶段验证

完成改造后,重新运行项目并进入任意商铺商品列表页,验证以下内容:

  • 进入页面后,如果服务端购物车中已经有商品,右侧数量会直接显示出来
  • 点击加号后,商品数量增加
  • 点击减号后,商品数量减少
  • 退出当前商铺商品页后再次进入,商品数量仍然正确

建议临时在 loadShoppingCartData() 中加入日志,观察接口返回的购物车数据:

entry/src/main/ets/customer/shoppingcart/ShoppingCart.ets
export class ShoppingCart {
...
async loadShoppingCartData() {
const rsp = await ServerApi.getShoppingCartData(this.storeId)
console.log(`购物车接口 code = ${rsp.code}, size = ${rsp.data?.length}`)
...
}
}

如果页面没有按预期显示数量,可以优先检查以下几点:

  • ServerApi.getShoppingCartData(this.storeId) 是否请求成功
  • rsp.data 是否正确写入了 AppStorage
  • setGoodsCountInShoppingCart(goodsData.id, newCount) 传递的是否是最新数量
  • 接口成功后是否重新调用了 this.loadShoppingCartData()

✅ 预期效果:商品数量调节已经和服务端联通。进入页面时可以恢复已有购物车数据,点击加减按钮后,数量会同步更新,并在重新进入页面时保持一致。

目标:把已经打通的购物车数据展示到页面底部。完成后,用户在商品列表中点击加减按钮时,底部购物车栏能够实时显示购物车商品总数和总价。

本步骤会新增两个组件:

组件作用
ShoppingCartBarComponent负责显示购物车图标、商品总数、总价和“去结算”按钮
ShoppingCartView负责把购物车栏固定在页面底部

8.1 新建 ShoppingCartBarComponent 组件

entry/src/main/ets/customer/shoppingcart/ 目录下新建 ShoppingCartBarComponent.ets

先搭出组件骨架:

entry/src/main/ets/customer/shoppingcart/ShoppingCartBarComponent.ets
import ArrayList from '@ohos.util.ArrayList'
import promptAction from '@ohos.promptAction'
import { ShoppingCartData } from '../../model/ShoppingCartData'
import { APP_SCOPE_KEY_SHOPPING_CART_DATA_LIST } from '../../utils/Constants'
@Component
export struct ShoppingCartBarComponent {
@StorageLink(APP_SCOPE_KEY_SHOPPING_CART_DATA_LIST)
shoppingCartDataList: ArrayList<ShoppingCartData> = new ArrayList()
@State totalPrice: number = 0
@State totalGoodsCount: number = 0
private barHeight = 50
build() {
Row() {
// TODO: 购物车栏整体布局
}
}
}

这里先定义三类状态:

  • shoppingCartDataList:通过 @StorageLink 读取购物车数据
  • totalPrice:购物车商品总价
  • totalGoodsCount:购物车商品总数

8.2 监听购物车数据变化并计算总数、总价

底部购物车栏不会直接遍历 GoodsData,它只需要根据购物车中的商品记录,计算总数和总价。因此可以在组件中新增一个监听函数:

entry/src/main/ets/customer/shoppingcart/ShoppingCartBarComponent.ets
@Component
export struct ShoppingCartBarComponent {
@StorageLink(APP_SCOPE_KEY_SHOPPING_CART_DATA_LIST)
@Watch('onShoppingCartDataListChanged')
shoppingCartDataList: ArrayList<ShoppingCartData> = new ArrayList()
@State totalPrice: number = 0
@State totalGoodsCount: number = 0
private barHeight = 50
onShoppingCartDataListChanged() {
// TODO: 定义 totalPrice 和 totalCount,初始值都为 0
// TODO: 遍历 shoppingCartDataList,累加 item.countPrice 和 item.number
// TODO: 将结果分别赋值给 this.totalPrice 和 this.totalGoodsCount
}
aboutToAppear() {
// TODO: 进入页面时主动调用一次 onShoppingCartDataListChanged,确保首次显示正确
}
build() {
...
}
}

这里使用 @Watch 的原因是:只要 AppStorage 中的购物车数据发生变化,底部购物车栏就能自动重新计算总数和总价。

8.3 实现购物车栏 UI

继续完善 build() 方法。底部购物车栏需要包含三部分:

  1. 购物车图标和数量标记
  2. 当前购物车总价
  3. “去结算”按钮
entry/src/main/ets/customer/shoppingcart/ShoppingCartBarComponent.ets
@Component
export struct ShoppingCartBarComponent {
...
build() {
Row() {
Row() {
// TODO: 使用 Badge 包裹购物车图标
Badge({count: this.totalGoodsCount, maxCount: 99, style: {}}) {
Image($r('app.media.ic_shopping_cart'))
.size({width: 28, height: 28})
}
.margin({left: this.barHeight / 2})
.width(40)
// TODO: 显示价格前缀“¥”
// · fontColor: Color.White
// · fontSize: 17
// · margin({ left: 10, top: 10 })
// TODO: 显示总价文本 this.totalPrice.toFixed(2)
// · fontColor: Color.White
// · fontSize: 23
// TODO: 使用 Blank().layoutWeight(1) 把右侧按钮推到最右边
// TODO: 显示“去结算”按钮文本
// · 文本资源:$r('app.string.label_create_order')
// · fontColor: Color.White
// · fontSize: 17
// · textAlign: TextAlign.Center
// · height('100%')
// · 左右 padding 都为 this.barHeight / 2
// · backgroundColor: Color.Black
// · 只保留右上和右下圆角,半径为 this.barHeight / 2
// · onClick 中暂时调用 promptAction.showToast({ message: '待实现' })
}
.backgroundColor('#222426')
.borderRadius(this.barHeight / 2)
.height('100%')
}
.margin({ bottom: 40 })
.size({ width: '100%', height: this.barHeight })
.padding({ left: 20, right: 20 })
}
}

提示:这里的购物车栏是一个胶囊形底栏。外层 Row 负责整体定位,内层 Row 负责深色背景、圆角和内部内容布局。

8.4 新建 ShoppingCartView 组件

为了把购物车栏固定在商品列表页底部,再新建一个 ShoppingCartView.ets,专门负责底部定位。

entry/src/main/ets/customer/shoppingcart/ShoppingCartView.ets
import { ShoppingCartBarComponent } from './ShoppingCartBarComponent'
@Component
export struct ShoppingCartView {
build() {
Stack({ alignContent: Alignment.Bottom }) {
// TODO: 引入 ShoppingCartBarComponent()
}
.width('100%')
}
}

这里使用 Stack({ alignContent: Alignment.Bottom }),是为了让购物车栏始终贴在当前区域底部显示。

8.5 在 GoodsListView 中引入购物车栏

打开 entry/src/main/ets/customer/goods/GoodsListView.ets,导入 ShoppingCartView

entry/src/main/ets/customer/goods/GoodsListView.ets
import { DoubleListComponent } from './DoubleListComponent'
import { ShoppingCartView } from '../shoppingcart/ShoppingCartView'

然后在内容状态下,把 ShoppingCartView() 加到 DoubleListComponent 后面:

entry/src/main/ets/customer/goods/GoodsListView.ets
if (this.pageStatus == PageStatus.Content || this.pageStatus == PageStatus.Refreshing) {
DoubleListComponent({
specDataSource: $specDataSource,
goodsDataSourceMap: $goodsDataSourceMap,
shoppingCart: this.shoppingCart
})
ShoppingCartView()
}

因为 GoodsListView 当前使用的是 Stack({ alignContent: Alignment.Bottom }),所以 ShoppingCartView() 放在内容区域中后,会固定显示在页面底部,而不会影响商品列表本身的布局结构。

8.6 阶段验证

完成本步骤后,重新运行项目并进入任意商铺商品列表页,验证以下效果:

  • 页面底部出现购物车栏
  • 初始状态下,数量为 0,总价为 0.00
  • 点击某个商品的加号后,底部栏中的数量增加
  • 底部栏中的总价会随着商品数量变化同步变化
  • 点击减号后,数量和总价同步减少
  • 重新进入商品列表页后,底部栏中的数量和总价能恢复服务端当前购物车数据
购物车栏初始效果
购物车栏数量和总价变化效果

✅ 预期效果:页面底部出现购物车栏。商品列表中的加减按钮和底部栏已经联动,用户可以直观看到当前购物车商品总数和总价。

目标:在底部购物车栏的基础上增加购物车商品面板,并支持点击购物车栏展开面板、点击遮罩区域收起面板。

本步骤会新增 ShoppingCartPanelComponent,并在 ShoppingCartView 中使用状态变量控制它的显示与隐藏。

9.1 新建 ShoppingCartPanelComponent 组件

entry/src/main/ets/customer/shoppingcart/ 目录下新建 ShoppingCartPanelComponent.ets

先搭出面板基础骨架:

entry/src/main/ets/customer/shoppingcart/ShoppingCartPanelComponent.ets
@Component
export struct ShoppingCartPanelComponent {
// TODO: 定义面板收起事件回调
build() {
Column() {
// TODO: 顶部半透明遮罩区域
// TODO: 底部购物车面板容器
}
.height('100%')
.justifyContent(FlexAlign.End)
}
}

这个组件会覆盖在商品列表上方,顶部留给遮罩区,底部留给购物车面板本体。

9.2 实现遮罩点击收起逻辑

继续完善 ShoppingCartPanelComponent。遮罩区域需要满足两个要求:

  1. 占满面板上方的剩余空间
  2. 点击后触发“收起面板”回调
entry/src/main/ets/customer/shoppingcart/ShoppingCartPanelComponent.ets
@Component
export struct ShoppingCartPanelComponent {
// TODO: 定义 onClosePanel: () => void,默认值为一个空函数
build() {
Column() {
Blank()
.layoutWeight(1)
.width('100%')
.backgroundColor('#66000000')
.onClick(_ => {
// TODO: 调用 onClosePanel()
})
// TODO: 底部购物车面板容器,后续步骤继续补充
}
.height('100%')
.justifyContent(FlexAlign.End)
}
}

提示:这里使用带透明度的黑色背景 #66000000 作为遮罩,用户能感知当前处于“弹出层”状态。

9.3 在 ShoppingCartView 中增加面板显示状态

回到 entry/src/main/ets/customer/shoppingcart/ShoppingCartView.ets。目前这个组件只负责显示底部购物车栏,现在需要增加一个状态变量,用来控制面板是否可见。

entry/src/main/ets/customer/shoppingcart/ShoppingCartView.ets
import { ShoppingCartBarComponent } from './ShoppingCartBarComponent'
import { ShoppingCartPanelComponent } from './ShoppingCartPanelComponent'
@Component
export struct ShoppingCartView {
@State isPanelVisible: boolean = false
build() {
Stack({ alignContent: Alignment.Bottom }) {
// TODO: 当 isPanelVisible 为 true 时,显示 ShoppingCartPanelComponent
// 购物车常驻底部栏
ShoppingCartBarComponent()
}
.width('100%')
}
}

9.4 让购物车栏点击后切换面板状态

为了让底部购物车栏控制面板展开与收起,需要先给 ShoppingCartBarComponent 增加点击回调参数。

打开 entry/src/main/ets/customer/shoppingcart/ShoppingCartBarComponent.ets,补充参数:

entry/src/main/ets/customer/shoppingcart/ShoppingCartBarComponent.ets
@Component
export struct ShoppingCartBarComponent {
// TODO: 定义 onClickCartBar: () => void,默认值为空函数
...
}

然后在购物车栏最外层深色容器上增加点击事件:

entry/src/main/ets/customer/shoppingcart/ShoppingCartBarComponent.ets
@Component
export struct ShoppingCartBarComponent {
...
build() {
Row() {
Row() {
Badge({count: this.totalGoodsCount, maxCount: 99, style: {}}) {
...
}
.onClick(_ => {
// TODO: 调用 onClickCartBar()
})
...
}
}
}
}

最后回到 ShoppingCartView.ets,把回调和面板收起逻辑都接起来:

entry/src/main/ets/customer/shoppingcart/ShoppingCartView.ets
@Component
export struct ShoppingCartView {
@State isPanelVisible: boolean = false
build() {
Stack({ alignContent: Alignment.Bottom }) {
if (this.isPanelVisible) {
ShoppingCartPanelComponent() // 删除原来的组件导入
ShoppingCartPanelComponent({
onClosePanel: () => {
this.isPanelVisible = false
}
})
}
ShoppingCartBarComponent() // 删除原来的组件导入
ShoppingCartBarComponent({
onClickCartBar: () => {
this.isPanelVisible = !this.isPanelVisible
}
})
}
.width('100%')
}
}

9.5 阶段验证

完成本步骤后,重新运行项目并进入任意商铺商品列表页,验证以下效果:

  • 点击底部购物车栏,页面上方出现半透明遮罩,底部出现购物车面板区域
  • 点击遮罩区域,购物车面板收起
  • 再次点击购物车栏,面板状态可以重新切换
购物车面板展开效果

✅ 预期效果:购物车面板已经具备基础的展开与收起能力,为后续继续补充标题栏和商品列表做好准备。

目标:为购物车面板补充标题栏,显示“购物车”标题和“清空购物车”入口,让面板结构更完整。

10.1 在 ShoppingCartPanelComponent 中新增 HeadBarBuilder

打开 entry/src/main/ets/customer/shoppingcart/ShoppingCartPanelComponent.ets,新增一个 @Builder 方法用于构建标题栏。

entry/src/main/ets/customer/shoppingcart/ShoppingCartPanelComponent.ets
@Component
export struct ShoppingCartPanelComponent {
...
@Builder
HeadBarBuilder() {
Row() {
// TODO: 左侧显示“购物车”标题
// · 文本资源:$r('app.string.title_shopping_cart')
// · fontColor: Color.Black
// · fontSize: 23
Blank()
// TODO: 右侧显示删除图标和“清空购物车”文本
// · 图标资源:$r('app.media.ic_delete_grey')
// · 图标大小:20 x 20
// · 文本资源:$r('app.string.label_clean_shopping_cart')
// · 文本颜色:$r('app.color.black_60')
// · 文本字号:17
}
.width('100%')
.height(55)
.padding({ left: 20, right: 20 })
.backgroundColor('#FEF8E1')
}
...
}

10.2 在面板容器中显示标题栏

回到 ShoppingCartPanelComponentbuild() 中,把之前的底部面板容器替换为一个真正的面板区域,并在顶部调用 HeadBarBuilder()

entry/src/main/ets/customer/shoppingcart/ShoppingCartPanelComponent.ets
@Component
export struct ShoppingCartPanelComponent {
...
build() {
Column() {
Blank()
.layoutWeight(1)
.width('100%')
.backgroundColor('#66000000')
.onClick(_ => {
this.onClosePanel()
})
Column() {
// TODO: 调用 this.HeadBarBuilder()
// TODO: 商品列表区域后续步骤补充
}
.width('100%')
.height('60%')
.backgroundColor(Color.White)
}
.height('100%')
.justifyContent(FlexAlign.End)
}
}

10.3 阶段验证

完成本步骤后,重新运行项目并展开购物车面板,验证以下效果:

  • 面板顶部显示“购物车”标题
  • 右侧显示删除图标和“清空购物车”文本
  • 标题栏背景为浅黄色
购物车面板标题栏效果

✅ 预期效果:购物车面板顶部已经具备正式标题栏,后续只需要继续补充商品列表和清空逻辑。

目标:在购物车面板中显示当前购物车商品,并支持在面板内继续调整商品数量。

11.1 创建面板列表数据源

打开 entry/src/main/ets/customer/shoppingcart/ShoppingCartPanelComponent.ets,补充 import:

entry/src/main/ets/customer/shoppingcart/ShoppingCartPanelComponent.ets
import ArrayList from '@ohos.util.ArrayList'
import { BasicDataSource } from '../../common/datsource/BasicDataSource'
import { EmptyView } from '../../common/widget/EmptyView'
import { GoodsCounterView } from '../widget/GoodsCounterView'
import { ShoppingCartData } from '../../model/ShoppingCartData'
import { APP_SCOPE_KEY_SHOPPING_CART_DATA_LIST } from '../../utils/Constants'
import { ShoppingCart } from './ShoppingCart'

然后在组件中补充状态:

entry/src/main/ets/customer/shoppingcart/ShoppingCartPanelComponent.ets
@Component
export struct ShoppingCartPanelComponent {
@StorageLink(APP_SCOPE_KEY_SHOPPING_CART_DATA_LIST)
shoppingCartDataList: ArrayList<ShoppingCartData> = new ArrayList()
shoppingCartDataSource: BasicDataSource<ShoppingCartData> = new BasicDataSource()
shoppingCart: ShoppingCart = new ShoppingCart(0)
...
}

11.2 监听购物车数据变化并刷新数据源

entry/src/main/ets/customer/shoppingcart/ShoppingCartPanelComponent.ets
@Component
export struct ShoppingCartPanelComponent {
...
@StorageLink(APP_SCOPE_KEY_SHOPPING_CART_DATA_LIST)
@Watch('onShoppingCartDataListChanged')
shoppingCartDataList: ArrayList<ShoppingCartData> = new ArrayList()
onShoppingCartDataListChanged() {
// TODO: 将 shoppingCartDataList 转成普通数组
// TODO: 调用 shoppingCartDataSource.setDataList(...) 刷新面板列表数据源
}
aboutToAppear() {
// TODO: 页面出现时主动调用一次 onShoppingCartDataListChanged()
}
...
}

11.3 实现购物车商品小项

ShoppingCartPanelComponent 中新增 ShoppingCartItemBuilder(shoppingCartData)

entry/src/main/ets/customer/shoppingcart/ShoppingCartPanelComponent.ets
@Component
export struct ShoppingCartPanelComponent {
...
@Builder
ShoppingCartItemBuilder(shoppingCartData: ShoppingCartData) {
Row() {
// TODO: 左侧商品图片
// · 图片来源:shoppingCartData.goodsImage
// · size: { width: 70, height: 70 }
// · borderRadius: 5
Column() {
// TODO: 商品名称
// · 文本内容:shoppingCartData.goodsName
// · fontSize: 23
// · fontColor: Color.Black
// · maxLines: 1
// TODO: 商品价格
// · 文本内容:`¥${shoppingCartData.goodsPrice}`
// · fontSize: 15
// · fontColor: $r('app.color.primary_color_light')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 10, right: 10 })
// TODO: 右侧放置 GoodsCounterView
// · goodsData 使用 shoppingCartData.getGoodsData()
// · shoppingCart 使用 this.shoppingCart
}
.padding({ left: 12, right: 12, top: 12, bottom: 12 })
.alignItems(VerticalAlign.Top)
}
...
}

11.4 实现购物车商品列表

继续新增 ShoppingCartListBuilder()

entry/src/main/ets/customer/shoppingcart/ShoppingCartPanelComponent.ets
@Component
export struct ShoppingCartPanelComponent {
...
@Builder
ShoppingCartListBuilder() {
// TODO: 当 shoppingCartDataList 为空时,显示 EmptyView
// · message 使用 $r('app.string.message_empty_shopping_cart')
// .padding({bottom: 200})
// TODO: 当 shoppingCartDataList 不为空时,显示 List
// · List({ space: 5 })
// · width('100%')
// · backgroundColor(Color.White)
// · divider({ strokeWidth: 0.5, color: $r('app.color.black_10') })
// · 使用 LazyForEach(this.shoppingCartDataSource, ...) 渲染列表
// · 每一项调用 this.ShoppingCartItemBuilder(shoppingCartData)
}
...
}

然后在 build() 中标题栏下方调用它:

entry/src/main/ets/customer/shoppingcart/ShoppingCartPanelComponent.ets
@Component
export struct ShoppingCartPanelComponent {
...
build() {
Column() {
...
Column() {
this.HeadBarBuilder()
this.ShoppingCartListBuilder()
}
...
}
}
}

11.5 在 ShoppingCartView 中把 shoppingCart 传给面板

打开 entry/src/main/ets/customer/shoppingcart/ShoppingCartView.ets,补充 shoppingCart 参数:

entry/src/main/ets/customer/shoppingcart/ShoppingCartView.ets
import { ShoppingCart } from './ShoppingCart'
@Component
export struct ShoppingCartView {
shoppingCart: ShoppingCart = new ShoppingCart(0)
@State isPanelVisible: boolean = false
...
}

并传给 ShoppingCartPanelComponent

entry/src/main/ets/customer/shoppingcart/ShoppingCartView.ets
@Component
export struct ShoppingCartView {
...
build() {
Stack({ alignContent: Alignment.Bottom }) {
if (this.isPanelVisible) {
ShoppingCartPanelComponent({
onClosePanel: () => {
this.isPanelVisible = false
},
shoppingCart: this.shoppingCart
})
}
...
}
}
}

同时别忘了在 GoodsListView.ets 中把 shoppingCart 传给 ShoppingCartView

entry/src/main/ets/customer/goods/GoodsListView.ets
@Component
export struct GoodsListView {
...
build() {
...
ShoppingCartView({
shoppingCart: this.shoppingCart
})
...
}
}

11.6 阶段验证

完成本步骤后,重新运行项目并展开购物车面板,验证以下效果:

  • 面板中能看到当前已加入购物车的商品
  • 商品小项中显示图片、名称、价格和数量调节组件
  • 在面板中点击加号或减号时,商品数量会同步变化
  • 当某个商品数量减到 0 时,它会从面板列表中消失
  • 当购物车为空时,显示空购物车视图
购物车面板商品列表不为空时的效果
购物车面板商品列表为空时的效果

✅ 预期效果:购物车面板已经能显示购物车中的商品,并且支持在面板内继续调整商品数量。

目标:在购物车面板标题栏的“清空购物车”入口上补充确认、加载和服务端清空逻辑。

12.1 实现清空购物车接口

回到 entry/src/main/ets/api/ServerApi.ets,在 ServerApi 类结尾前新增 cleanShoppingCart(storeId)

entry/src/main/ets/api/ServerApi.ets
export class ServerApi {
...
static async cleanShoppingCart(storeId: number): Promise<Response<Object>> {
// TODO: 使用 http.createHttp() 创建 request 对象
// TODO: 创建 HttpRequestOptions,至少设置:
// · method: http.RequestMethod.DELETE
try {
// TODO: 调用 ServerApi.execute(request, url, options) 发起请求
// · url 形如 `${Constants.SERVER_HOST}/order-cart/all/${storeId}`
// TODO: 使用 plainToClassFromExist(new Response<Object>(Object), JSON.parse(rsp.result as string)) 转换返回值
// TODO: 返回 response
} catch (e) {
// TODO: 创建错误 Response<Object>(Object)
// TODO: code 设置为 -1,message 设置为异常信息
// TODO: 返回错误 Response
}
}
}

12.2 在 ShoppingCart 中增加清空逻辑

打开 entry/src/main/ets/customer/shoppingcart/ShoppingCart.ets,补全 cleanShoppingCart()

entry/src/main/ets/customer/shoppingcart/ShoppingCart.ets
export class ShoppingCart {
...
async cleanShoppingCart() {
// TODO: 调用 ServerApi.cleanShoppingCart(this.storeId) 获取 rsp
// TODO: 如果 rsp.isSuccess() 为 false,则直接 return
// TODO: 如果请求成功,调用 this.loadShoppingCartData() 重新同步购物车数据
}
}

12.3 在面板标题栏实现清空交互

回到 ShoppingCartPanelComponent.ets。先补充 LoadingDialog 的导入和控制器:

entry/src/main/ets/customer/shoppingcart/ShoppingCartPanelComponent.ets
import { LoadingDialog } from '../../common/dialog/LoadingDialog'
@Component
export struct ShoppingCartPanelComponent {
private loadingDialogController = new CustomDialogController({
builder: LoadingDialog({ message: $r('app.string.message_cleaning_shopping_cart') }),
autoCancel: false,
customStyle: true,
})
...
}

然后新增一个清空购物车方法:

entry/src/main/ets/customer/shoppingcart/ShoppingCartPanelComponent.ets
@Component
export struct ShoppingCartPanelComponent {
...
private handleCleanShoppingCart() {
AlertDialog.show({
title: $r('app.string.title_logout_confirm'),
message: $r('app.string.message_confirm_clean_shopping_cart'),
primaryButton: {
value: $r('app.string.label_confirm'),
action: () => {
// TODO: 点击确认后执行以下流程:
// · 打开 loadingDialogController
// · 调用 this.shoppingCart.cleanShoppingCart()
// · finally 中关闭 loadingDialogController
}
},
secondaryButton: {
value: $r('app.string.label_cancel'),
action: null
}
})
}
...
}

最后把标题栏右侧的“清空购物车”区域点击事件绑定到这个方法上:

entry/src/main/ets/customer/shoppingcart/ShoppingCartPanelComponent.ets
@Component
export struct ShoppingCartPanelComponent {
...
@Builder
HeadBarBuilder() {
Row() {
...
Row() {
// 前面步骤中实现的删除图标和文本
Image($r('app.media.ic_delete_grey'))
.size({ width: 20, height: 20 })
Text($r('app.string.label_clean_shopping_cart'))
.fontSize(17)
.fontColor($r('app.color.black_60'))
}
.onClick(_ => {
this.handleCleanShoppingCart()
})
}
}
...
}

12.4 阶段验证

完成本步骤后,重新运行项目并展开购物车面板,验证以下效果:

  • 点击“清空购物车”后弹出确认框
  • 点击确认后显示 Loading 弹窗
  • 清空完成后,底部购物车栏数量归零、总价归零
  • 面板切换为空购物车视图
  • 商品列表中的数量全部恢复为 0
  • 重新进入页面后购物车仍为空
清空购物车确认框
清空购物车后的效果

✅ 预期效果:购物车清空功能已经完成,购物车列表、底部栏和商品数量状态都能同步恢复为空状态。

目标:对购物车功能做一次完整联调,并思考两个常见的体验优化点。

13.1 完整联调清单

请按照下面的顺序完整操作一次,确认当前实验的主要功能已经联通:

  1. 进入任意商铺商品列表页
  2. 点击多个商品的加号,观察数量变化
  3. 查看底部购物车栏中的商品总数和总价
  4. 点击底部购物车栏展开购物车面板
  5. 在面板中继续调整商品数量
  6. 点击遮罩收起面板
  7. 再次展开面板,点击“清空购物车”
  8. 返回商铺商品页后重新进入,确认购物车数据仍与服务端一致

13.2 常见异常排查

如果联调时发现效果不符合预期,可以按下面的方向排查:

问题现象优先检查内容
商品数量不变化adjustGoodsCount() 是否真的调用了服务端接口
页面数量变化但底部栏不变ShoppingCartBarComponent 是否正确使用了 @Watch
面板列表不刷新ShoppingCartPanelComponent 是否正确刷新了 shoppingCartDataSource
重新进入页面数量丢失loadShoppingCartData() 是否真正从服务端拉取了购物车数据
清空后仍有数据ServerApi.cleanShoppingCart() 是否请求了 DELETE /order-cart/all/:storeId,以及 cleanShoppingCart() 是否在成功后重新调用了 loadShoppingCartData()

13.3 拓展任务

拓展任务 1:避免最后一项商品被底部栏遮挡

当购物车面板中的商品较多时,最后一条商品可能会被底部购物车栏遮挡。可以考虑给最后一条商品增加更大的底部留白,保证列表滚动到底部时,最后一项仍然可以完整显示。

拓展任务 2:面板展开时优先拦截返回键

当前如果面板展开,用户直接点击系统返回键,可能会直接返回上一级页面。更合理的交互是:

  • 面板展开时,点击返回键先收起面板
  • 面板收起时,再执行页面返回

可以参考 ArkUI 组件生命周期中的 onBackPress() 实现这个能力。

完成本实验后,请提交一份演示视频和实验工程代码压缩包。

14.1 验收清单

演示视频中至少需要包含以下内容:

  1. 进入商铺商品列表页
  2. 在商品列表中点击加号和减号,调整商品数量
  3. 底部购物车栏正确显示商品数量和总价
  4. 点击底部购物车栏展开购物车面板
  5. 在购物车面板中继续调整商品数量
  6. 点击遮罩收起购物车面板
  7. 清空购物车并展示空状态
  8. 重新进入页面后,购物车数据仍与服务端一致

14.2 提交要求

  • 提交一份演示录屏,建议时长 1 到 3 分钟
  • 视频需清晰展示操作过程和页面反馈
  • 提交完成后的工程代码压缩包
  • 压缩包中删除 oh_modulesbuild.hvigor 等构建产物目录

14.3 评分标准

评分项要求
商品数量调节功能商品列表中可正确增加、减少商品数量
购物车数据同步商品列表、底部栏、购物车面板中的数据保持一致
服务端接口对接购物车数据拉取、数量调整和清空接口工作正常
底部购物车栏能正确显示购物车商品总数和总价
购物车面板面板可展开、收起,并正确显示购物车商品列表
清空购物车功能清空后购物车状态、总数、总价和商品数量都能恢复为空
代码规范文件结构清晰,组件拆分合理,命名规范
拓展任务完成情况是否完成避免遮挡、返回键拦截等体验优化