当前位置: 首页 > news >正文

1. 购物车

1. 购物车

咱们购物车基于 V2 装饰器进行开发,底气来源于

自定义组件混用场景指导

1.1. 素材整合

observedv2和Trace

  1. 数据模型和页面

// 其他略
// 购物车
export interface CartGoods {
  count: number;
  id: string;
  name: string;
  picture: string;
  price: number;
  selected: boolean;
  skuId: string;
  stock: number;
  attrsText: string;
}

@ObservedV2
export class CartGoodsModel implements CartGoods {
  @Trace
  count: number = 0
  id: string = ''
  name: string = ''
  picture: string = ''
  price: number = 0
  @Trace
  selected: boolean = false
  skuId: string = ''
  stock: number = 0
  attrsText: string = ''

  constructor(model: CartGoods) {
    this.count = model.count
    this.id = model.id
    this.name = model.name
    this.picture = model.picture
    this.price = model.price
    this.selected = model.selected
    this.skuId = model.skuId
    this.stock = model.stock
    this.attrsText = model.attrsText
  }
}
  1. 静态页面
import { auth, MkGuess, MkNavbar, MkUser, CartGoodsModel, CartGoods } from 'basic'

@Component
export struct CartView {
  // 用户信息
  @StorageProp(auth.KEY) user: MkUser = {} as MkUser
  // 顶部安全区域
  @StorageProp('safeTop') safeTop: number = 0
  // ObserveV2 无法直接和@State 使用 会报错 但是包到数组中是可以使用的
  // @State cartData: CartGoodsModel = new CartGoodsModel({} as CartGoods)
  // 商品信息
  @State cartList: CartGoodsModel[] = []

  aboutToAppear(): void {

  }

  onCheckOrder() {
    AlertDialog.show({
      message: '去结算'
    })
  }

  @Builder
  DeleteBuilder(onDelete: () => void) {
    Text('删除')
      .fontSize(14)
      .width(60)
      .height(100)
      .backgroundColor($r('[basic].color.red'))
      .fontColor($r('[basic].color.white'))
      .textAlign(TextAlign.Center)
      .onClick(() => {
        onDelete()
      })
  }

  build() {
    Column() {
      MkNavbar({ title: '购物袋', showLeftIcon: false, showRightIcon: true })
        .border({
          width: { bottom: 0.5 },
          color: '#e4e4e4'
        })
      List() {
        if (this.user.token) {

          if (this.cartList.length) {
            ForEach(this.cartList, (cart: CartGoodsModel) => {
              ListItem() {
                CartItemComp({
                  cart
                })
              }
              .backgroundColor($r('[basic].color.under'))
              .padding({ left: 8, right: 8 })
              .transition({ type: TransitionType.Delete, opacity: 0 })
              .swipeAction({
                end: this.DeleteBuilder(async () => {
                  AlertDialog.show({
                    message: 'clickDel'
                  })
                })
              })
            })
          } else {
            ListItem() {
              Text('无商品')
            }
          }
        } else {
          // 未登录
          ListItem() {
            Text('未登录')
          }
        }
        ListItem() {
          MkGuess()
            .margin({ top: 8, bottom: 8 })
        }

      }
      .contentStartOffset(8)
      .width('100%')
      .layoutWeight(1)
      .scrollBar(BarState.Off)

      if (this.cartList.length) {
        Row() {
          // TODO 添加自定义 CheckBox
          Text('全选')
            .fontSize(14)
            .fontColor($r('[basic].color.black'))
            .margin({ right: 20 })
          Text('合计:')
            .fontSize(14)
            .fontColor($r('[basic].color.black'))
            .margin({ right: 2 })
          Text('998')
            .fontSize(16)
            .fontWeight(500)
            .fontColor($r('[basic].color.red'))
            .layoutWeight(1)
          Button('去结算')
            .fontSize(14)
            .height(36)
            .backgroundColor($r('[basic].color.red'))
            .onClick(() => {

            })
        }
        .height(50)
        .width('100%')
        .backgroundColor($r('[basic].color.white'))
        .border({
          width: { top: 0.5, bottom: 0.5 },
          color: '#e4e4e4'
        })
        .padding({ left: 16, right: 16 })
      }
    }
    .height('100%')
    .width('100%')
    .backgroundColor($r('[basic].color.under'))
    .padding({ top: this.safeTop })
  }
}

@ComponentV2
struct CartItemComp {
  @Param // v1 Prop 自己的状态 @Local
  cart: CartGoodsModel = new CartGoodsModel({} as CartGoods)

  build() {
    Row({ space: 10 }) {

      // TODO 添加自定义 CheckBox
      Image(this.cart.picture)
        .width(80)
        .height(80)
      Column({ space: 8 }) {
        Text(this.cart.name)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .maxLines(1)
          .fontSize(14)
          .fontColor($r('[basic].color.black'))
          .width('100%')
        Row() {
          Text(this.cart.attrsText)
            .textOverflow({ overflow: TextOverflow.Ellipsis })
            .maxLines(1)
            .fontSize(12)
            .fontColor($r('[basic].color.text'))
            .layoutWeight(1)
          Image($r('sys.media.ohos_ic_public_arrow_down'))
            .fillColor($r('[basic].color.gray'))
            .width(16)
            .height(16)
        }
        .padding({ left: 6, right: 4 })
        .width(100)
        .height(24)
        .backgroundColor($r('[basic].color.under'))
        .borderRadius(2)

        Row() {
          Text(`¥${this.cart.price}`)
            .fontSize(14)
            .fontWeight(500)
          Counter()
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
    .height(100)
    .padding(10)
    .border({ width: { bottom: 0.5 }, color: '#e4e4e4' })
    .backgroundColor($r('[basic].color.white'))
  }
}
  1. 在 oh-package.json5 文件中导入 common/basic
{
  "name": "cart",
  "version": "1.0.0",
  "description": "Please describe the basic information.",
  "main": "Index.ets",
  "author": "",
  "license": "Apache-2.0",
  "packageType": "InterfaceHar",
  "dependencies": {
    "basic": "file:../../commons/basic"
  }
}

git 记录

Cart-素材整合

1.2. MkEmpty 组件封装(V2实现)

V2装饰器

先来搞定购物车中间部分的显示效果,通过组件来完成

核心步骤:

  1. 创建组件并暴露
  2. 根据文档实现功能
  3. 页面中导入并测试使用
    1. 已登录,无商品,点击去第一个 Tab(下一节做)
    2. 未登录,点击去登录页

基础模版

@ComponentV2
  export struct MkEmpty {

    build() {
      Column({ space: 20 }) {
        Image($r('app.media.ic_public_empty'))
          .width(120)
          .aspectRatio(1)
        Text('信息')
          .fontSize(12)
          .fontColor($r('app.color.gray'))
        Button('去逛逛')
          .fontSize(12)
          .height(26)
          .padding({ left: 20, right: 20 })
          .linearGradient({
            angle: 90,
            colors: [['#FD3F8F', 0], ['#FF773C', 1]]
          })
          .margin({ bottom: 20 })
      }
      .padding(40)
        .width('100%')
    }
  }

属性名

类型

说明

默认值

tip

string

提示信息

空字符串

buttonText

string

按钮文本

空字符串

icon

ResourceStr

中间图标

ic_public_empty

onClickButton

()=>void

点击按钮的逻辑

空方法

参考代码

@ComponentV2
export struct MkEmpty {
  @Param
  tip: string = '空空如也'
  @Param
  buttonText: string = '去逛逛'
  @Param
  icon: ResourceStr = $r('app.media.ic_public_empty')
  @Event
  onClickButton: () => void = () => {
  }

  build() {
    Column({ space: 20 }) {
      Image(this.icon)
        .width(120)
        .aspectRatio(1)
      Text(this.tip)
        .fontSize(12)
        .fontColor($r('app.color.gray'))
      Button(this.buttonText)
        .fontSize(12)
        .height(26)
        .padding({ left: 20, right: 20 })
        .linearGradient({
          angle: 90,
          colors: [['#FD3F8F', 0], ['#FF773C', 1]]
        })
        .margin({ bottom: 20 })
        .onClick(() => {
          this.onClickButton()
        })
    }
    .padding(40)
    .width('100%')
  }
}
List() {
  if (this.user.token) {
    if (this.cartList.length) {
      // 略
    } else {
      ListItem() {
        MkEmpty({
          tip: '购物袋是空的~',
          buttonText: '去逛逛',
          onClickButton: () => {
            // 去第一个 Tab
          }
        })
      }
    }
  } else {
    // 未登录
    ListItem() {
      MkEmpty({
        tip: '您还未登陆~',
        buttonText: '请先登录',
        onClickButton: () => {
          // 去登录页
        }
      })
    }
  }
  ListItem() {
    MkGuess()
      .margin({ top: 8, bottom: 8 })
  }
}

git记录

Cart-MkEmpty组件封装

1.3. 去首页

使用Emitter进行线程间通信

需求及分析:

  1. 已登录,购物车没有商品的时候,点击切换 tab 到第一个【首页】
  2. feature/cart -> products/phone 可以通过??? 来实现通讯
  3. products/phone 如何控制 tab 切换

核心步骤:

  1. 增加 emmit 的事件 id 常量,并导出 (方便管理)
  2. phone/index 注册事件,接收参数
  3. feature/cart 触发事件,传递数据
  4. phone/index 控制 tab 切换
// 其他略
export class EmitterKey {
  // 切换到指定的 tab
  static CHANGE_TAB: emitter.InnerEvent = { eventId: 10001 }
}
aboutToAppear(): void {
  this.registerEvent()
}

// 注册 emmiter
registerEvent() {
  // 订阅事件
  emitter.on(EmitterKey.CHANGE_TAB, (eventData) => {
    this.activeIndex = eventData.data?.index || 0  // 不传值也能回首页
  })
}


Tabs({ barPosition: BarPosition.End, index: this.activeIndex }){
  // 其他略
}
MkEmpty({
  tip: '购物袋是空的',
  buttonText: '去逛逛',
  onClickButton: () => {
    // 发送事件
    emitter.emit(EmitterKey.CHANGE_TAB, {
      data: {
        index: 0
      }
    })

  }
})

git 记录

Cart-去首页

1.4. 加入购物车

接口文档

需求:

  1. 详情页点击添加到购物车
  2. 更新服务器数据

核心步骤:

  1. 抽取 api,丢到 common
  2. 页面实现交互逻辑
    1. token 校验-》sku 校验-》提交-》api调用-》
  1. 通过一个类管理所有购物车接口,因为有一整套的 CRUD
import { RequestAxios } from '../utils/Request'

interface AddParams {
  skuId: string
  count: number
}

class Cart {
  // 添加
  add(data: AddParams) {
    return RequestAxios.post<null>('/member/cart', data)
  }
}

export const cart = new Cart()
  1. 导出
// 导出购物车封装购物车请求 API 的类,这个类只需导出一次即可
export { cart } from './src/main/ets/apis/cart'
  1. 整合业务,在商品详情页调用 加入购物车 接口
  // 加入购物车
  async addToCart() {
    // 1. 判断用户是否登录,未登录需要登录
    const token = auth.getUser().token
    if (!token) {
      promptAction.showToast({ message: '请登录后下单' })
      this.pageStack.pushPath({ name: 'LoginView' })
      return
    }
    // 2. 判断用户是否有选择商品规则(是否完整)
    if (!this.sku.id) {
      promptAction.showToast({ message: '请选择要购买的商品规格' })
      return
    }
    // 3. 加入购物车
    this.loading = true
    // await 等待请求成功
    await cart.add({ skuId: this.sku.id, count: this.count })
    this.loading = false
    promptAction.showToast({ message: '加入购物袋成功' })
    // 4. 关闭半模态弹窗
    this.showSheet = false
  }

// 其他略
Button(this.loading ? '加入中...' : '加入购物袋')
  .buyButton($r('[basic].color.black'), true)
  .onClick(async () => {
    this.addToCart()
  })

git 记录

Cart-加入购物车

1.5. 个数及页面跳转

多个地方需要用到个数,并且在很多时候都需要刷新

1.5.1. 详情页面个数更新

需求:

  1. 获取最新个数(通过接口)
  2. 更新购物车个数(考虑其他界面获取)

核心步骤:

  1. 抽取 api:
    1. 有 Token
      1. 获取数据,具体的值
    1. 没 Token
      1. 格式为 0
    1. 更新到AppStorage
  1. 组件中获取并渲染
import { RequestAxios } from '../utils/Request'

interface AddParams {
  skuId: string
  count: number
}

interface CountResult {
  count: number
}

export class Cart {
  // AppStorage 的 Key
  CartKey: string = 'cartCount'
  // 添加
  add(data: AddParams) {
    return RequestAxios.post<null>('/member/cart', data)
  }

  // 获取购物车数量
  async count() {
    let count = 0
    // 用户已登录才发请求获取
    const userInfo = auth.getUser()
    if (userInfo.token) {
      // 获取购物车数量
      const res = await RequestAxios.get<CountResult>('/member/cart/count')
      // 更新变量,用于全局存储
      count = res.count
    }
    // 全局存储购物车数量
    AppStorage.setOrCreate(this.CartKey, count)
  }
}

export const cart = new Cart()
// 购物车商品数量
@StorageProp(cart.CartKey) cartCount: number = 0

Badge({
  count: this.cartCount,
  style: {},
  position: { x: 30, y: 4 }
}) 

/**
 * 加入购物车
 * */
async addToCart() {
  // 登录判断
  if (!auth.getUser()
    .token) {
    promptAction.showToast({
      message: '请登录后下单'
    })
    return pathStack.pushPathByName('LoginView', null)
  }

  // 是否选择商品
  if (!this.sku.id) {
    return promptAction.showToast({
      message: '请选择商品规格'
    })
  }
  this.loading = true
  // 加入购物车
  await cart.add({
    skuId: this.sku.id,
    count: this.count
  })
  promptAction.showToast({
    message: '加入购物袋成功'
  })
  // 关闭半模态
  this.showSheet = false
  this.loading = false

  // 更新数量
  cart.count()
}


Button(this.loading ? '加入中...' : '加入购物袋')
  .buyButton($r('app.color.black'), true)
  .onClick(async () => {
     this.addToCart()
  })

git 记录

Cart-详情页个数更新

1.5.2. 首页更新数量

需求:

  1. 首页增加角标
  2. 获取个数并渲染
  3. 如果是首次打开页面,获取个数(已登录的情况)

核心步骤:

  1. 增加 Badge 组件
  2. 通过 AppStorage 获取数据并渲染
  3. 生命周期钩子中获取数据
  1. 更新首页购物车数量
@StorageProp(cart.CartKey) cartCount: number = 0

aboutToAppear(): void {
  this.breakpointSystem.register()
  // 获取购物车数量
  cart.count()
}

@Builder
  TabItemBuilder(item: TabItem, index: number) {
  Badge({
    count: index === 2 ? this.cartCount : 0,
    style: {},
  }) {
    Column() {
      Image(this.activeIndex === index ? item.active : item.normal)
        .width(24)
        .aspectRatio(1)
      Text(item.text)
        .fontColor($r('app.color.black'))
        .fontSize(12)
    }
    .justifyContent(FlexAlign.SpaceEvenly)
      .height(50)
  }
}

git记录

Cart-首页个数更新

1.5.3. 登录和登出更新数量

登录和登出:

  1. 登录更新:
    1. 账号密码登录,华为登录之后,保存用户信息同时更新购物车数量
  1. 登出更新:
    1. 设置页,退出登录时,删除用户信息,同时更新购物车数量
    2. cart.count() --> 0
  1. 保存用户信息、删除用户信息时,都要更新购物车数量
import { cart } from "../apis/cart"

export interface MkUser {
  token: string
  nickname: string
  avatar: string
  account: string
}

class Auth {
  KEY: string = 'user'

  initUser() {
    // 把 AppStorage 的用户信息进行持久化 UI 状态存储,状态改变自动同步到本地磁盘
    PersistentStorage.persistProp(this.KEY, {})
  }

  getUser() {
    return AppStorage.get<MkUser>(this.KEY) || {} as MkUser
  }

  saveUser(user: MkUser) {
    AppStorage.setOrCreate<MkUser>(this.KEY, user)
    // 登录时,更新购物车数量
    cart.count()
  }

  removeUser() {
    AppStorage.setOrCreate<MkUser>(this.KEY, {} as MkUser)
    // 登出时,更新购物车数量
    cart.count()
  }
}

// 导出实例化对象,单例
export const auth = new Auth()

git记录

Cart-登录登出更新购物车数量

1.6. 跳转购物车页

需求:

  1. 点击购物车图标,打开购物袋页面
  2. tabs打开时,没有返回按钮
  3. 跳转打开时,显示返回按钮

注意:

  1. 之前购物袋只是 Tabs 渲染的组件,并不是页面,没有注册页面路由。

核心步骤

  1. feature/CartView
    1. 增加 Builder 入口函数 和 NavDestination 组件
    2. 配置路由表 route_map.json
    3. 配置 module.json5
  1. 详情页,点击跳转即可
    1. 优化:详情页跳转的时候【显示返回按钮】,并可以点击【返回上一页】
    2. 通过携带参数实现效果
  1. 增加 Builder 入口函数 和 NavDestination 组件
// 1.1 路由入口函数
@Builder
function CartViewBuilder() {
  // 1.2 子路由组件
  NavDestination() {
    CartView()
  }
  .hideTitleBar(true)
}

@Component
export struct CartView {
  
}
  1. 新建路由表文件,并配置路由表
{
  "routerMap": [
    {
      "name": "CartView",
      "pageSourceFile": "src/main/ets/views/CartView.ets",
      "buildFunction": "CartViewBuilder"
    }
  ]
}
  1. 配置 module.json5,让模块的路由表生效
{
  "module": {
    "name": "cart",
    "type": "shared",
    "description": "$string:shared_desc",
    "deviceTypes": [
      "phone",
      "tablet",
      "2in1"
    ],
    "deliveryWithInstall": true,
    "pages": "$profile:main_pages",
    "routerMap": "$profile:route_map"
  }
}
  1. 页面跳转,并传参
// 购物袋角标
Badge({
  count: this.cartCount,
  style: {},
  position: { x: 30, y: 4 }
}) {
  Image($r('[basic].media.ic_public_cart'))
    .iconButton()
    .onClick(() => {
      pathStack.pushPathByName('CartView', true)
    })
}
  1. 获取参数
@State isShowLeftIcon: boolean = false

aboutToAppear(): void {
  const params = pathStack.getParamByName('CartView') as boolean[]
  if (params.length > 0) {
    this.isShowLeftIcon = params.pop() as boolean
  }
}

// 其他内容略
MkNavbar({
  title: '购物袋',
  showRightIcon: true,
  showLeftIcon: this.isShowLeftIcon,
  leftClickHandler: () => {
     pathStack.pop()
  }
})

git 记录

Cart-去购物车页

1.7. 购物车列表渲染

接口文档

需求:

  1. 获取购物车数据并渲染

核心步骤:

  1. 抽取接口
  2. 获取数据之后通过 new 转对象(嵌套数据更新)
  3. 这个过程需要在 2 种情况下执行(分两步完成)
    1. 首次进入,有 token 就获取(生命周期钩子)
    2. 添加商品到购物车中(详情页--》购物车页)

1.7.1. 默认渲染

import { RequestAxios } from '../utils/Request'
import { CartGoods } from '../viewmodel'

interface AddParams {
  skuId: string
  count: number
}

interface CountResult {
  count: number
}

class Cart {
  CartKey: string = 'cartCount'
  // 添加
  add(data: AddParams) {
    return RequestAxios.post<null>('/member/cart', data)
  }

  // 获取个数
  async count() {
    const res = await RequestAxios.get<CountResult>('/member/cart/count')
    AppStorage.setOrCreate(this.CartKey, res.data.result.count)
    return res.data.result.count
  }

  // 获取列表
  async list() {
    return RequestAxios.get<CartGoods[]>('/member/cart')
  }

}

export const cart = new Cart()
aboutToAppear(): void {
  const params = pathStack.getParamByName('CartView') as boolean[]
  if (params.length > 0) {
    this.isShowLeftIcon = params.pop() as boolean
  }
  this.getData()
}

async getData() {
  if (!auth.getUser()
    .token) {
    return
  }
  const res = await cart.list()
  this.cartList = res.map((item) => new CartGoodsModel(item))
}

git 记录

Cart-默认渲染

1.7.2. 更新购物车列表

组件可见区域变化事件

问题分析:

  1. 购物车,每次打开重新获取列表数据
    1. 不是页面,只有 aboutToAppear 钩子
    2. 第一次打开之后就不会被销毁,不会再次触发
  1. 详情页
    1. 跳转去购物车是新页面,会触发 aboutToAppear 可以刷新
  1. tab 页
    1. 切换到 购物车 tab 时需要获取列表(不会再次触发aboutToAppear)

思考:如何实现 购物车页面数据刷新?

  1. 通过 emitter 触发刷新
  2. 组件可视区域改变时刷新 onVisibleAreaChange

@Component
export struct CartView {
  // ...省略

aboutToAppear(): void {
    const params = pathStack.getParamByName('CartView') as boolean[]
    if (params.length > 0) {
      this.isShowLeftIcon = params.pop() as boolean
    }
    // this.getData() 拿掉
  }
  
  // 获取购物袋列表
  async getData() {
    // 用户已登录的情况下,才获取购物袋列表
    const userInfo = auth.getUser()
    if (userInfo.token) {
      // 显示加载框
      this.dialog.open()
      const res = await cart.list()
      // map 把每个普通对象转换成 CartGoodsModel 对象,更新对象时图片不会闪动
      this.cartList = res.data.result.map(item => new CartGoodsModel(item))
      // 隐藏加载框
      this.dialog.close()
    }
  }
  
  // ...省略
  build() {
    Column() {
      // ...
    }
    .height('100%')
    .width('100%')
    .backgroundColor($r('[basic].color.under'))
    .onVisibleAreaChange([0, 1], (isVisible) => {
      if (isVisible) {
        this.getData()
      }
    })
  }
}

git 记录

Cart-更新购物车列表

1.8. 个数更新

接口文档

需求:

  1. 修改【个数】时
  2. 更新服务器,更新本地
  3. 更新购物车个数

核心步骤:

  1. 实现Count组件的个数渲染及禁用外观
  2. 累加及递减逻辑:
    1. 修改服务器数据(*)
    2. 更新本地数据
    3. 更新个数
    4. 伴随 loading 效果
  1. 选中:
    1. 修改服务器数据(*)
    2. 更新本地数据
import { auth } from '../../../../Index'
import { RequestAxios } from '../utils/Request'
import { CartGoods } from '../viewmodel'


// 导出 页面中 会用到
export interface UpdateParams {
  selected?: boolean
  count?: number
}

interface AddParams {
  skuId: string
  count: number
}

interface CountResult {
  count: number
}

export const CartKey: string = 'cartCount'

export class Cart {
  // 添加
  add(data: AddParams) {
    return RequestAxios.post<null>('/member/cart', data)
  }

  // 获取个数
  async count() {
    let count = 0
    if (auth.getUser()
      .token) {
      const res = await RequestAxios.get<CountResult>('/member/cart/count')
      count = res.data.result.count
    }
    AppStorage.setOrCreate(CartKey, count)
  }

  // 获取列表
  async list() {
    return RequestAxios.get<CartGoods[]>('/member/cart')
  }

  // 更新商品
  async update(skuId: string, data: UpdateParams) {
    return RequestAxios.put<CartGoods>(`/member/cart/${skuId}`, data)
  }
}

export const cart = new Cart()
// 其他略
async updateCart(params: UpdateParams) {
  await cart.update(this.cart.skuId, params)
  // 如果是更新个数 不传递时为 undefined
  if (params.count) {
    this.cart.count = params.count
    cart.count()
  }
}


// 计数器
Counter() {
  Text(this.cart.count.toString())
}
.enableDec(this.cart.count > 1) // 控制减号是否可用
.enableInc(this.cart.count < this.cart.stock) // 控制加号是否可用
.onInc(() => {
  this.updateCart({ count: this.cart.count + 1 })
})
.onDec(() => {
  this.updateCart({ count: this.cart.count - 1 })
})

git 记录

Cart-个数及选中状态更新

1.9. MkCheckBox组件

checkBox

原生的 CheckBox 使用起来有一些不便,咱们来:

  1. 试试这个不便之处
  2. 然后自己实现个组件,替换到 购物车页面的 【列表项】和【全选】

如果不用双向绑定那么 选中状态 和 绑定的状态变量就没有关联

@Entry
@Component
struct Page06 {
  @State isChecked: boolean = false

  build() {
    Column() {
      Text(JSON.stringify(this.isChecked))
      Checkbox()
        .select(this.isChecked)
        .onClick(() => {
          this.isChecked = true
        })
    }
    .height('100%')
    .width('100%')
    .backgroundColor(Color.Orange)
    .padding(40)
  }
}

基础模版

@ComponentV2
export struct MkCheckBox {
  build() {
    // $r('app.media.ic_public_check_filled')选中  $r('app.media.ic_public_check') 未选中
    Image($r('app.media.ic_public_check_filled'))
      .width(18)
      // $r('app.color.red') 选中 $r('app.color.gray') 默认
      .fillColor($r('app.color.red'))
      .aspectRatio(1)
      .margin(2)
  }
}
// 其他略 记得导出
export { MkCheckBox } from './src/main/ets/components/MkCheckBox'
// 添加全选 其他略
MkCheckBox({
})
Text('全选')
  .fontSize(14)
  .fontColor($r('[basic].color.black'))
  .margin({ right: 20 })




@ComponentV2
struct CartItemComp {
  @Param
  cart: CartGoodsModel = new CartGoodsModel({} as CartGoods)

  async updateCart(params: UpdateParams) {
    await cart.update(this.cart.skuId, params)
    if (params.selected !== undefined) {
      this.cart.selected = params.selected
    }
    if (params.count) {
      this.cart.count = params.count
      cart.count()
    }
  }

  build() {
    Row({ space: 10 }) {
      // 添加单选 其他略
      MkCheckBox({
      })
      Image(this.cart.picture)
        .width(80)
        .height(80)
    }
    .width('100%')
    .height(100)
    .padding(10)
    .border({ width: { bottom: 0.5 }, color: '#e4e4e4' })
    .backgroundColor($r('[basic].color.white'))
  }
}

属性名

类型

说明

默认值

checked

boolean

是否选中

false

boxSize

number

大小

18

onClickBox

()=>void

点击组件

()=>{}

@ComponentV2
export struct MkCheckBox {
  @Param 
  checked: boolean = false
  @Param 
  boxSize: number = 18
  @Event
  onClickBox: () => void = () => {
  }

  build() {
    Image(this.checked ? $r('app.media.ic_public_check_filled') : $r('app.media.ic_public_check'))
      .width(this.boxSize)
      .fillColor(this.checked ? $r('app.color.red') : $r('app.color.gray'))
      .aspectRatio(1)
      .margin(2)
      .onClick(() => this.onClickBox())
  }
}

git记录

Cart-MkCheckBox组件封装

1.10. 选中状态更新

接口文档

需求:

  1. 修改【选中状态】时
  2. 更新服务器,更新本地

核心步骤:

  1. 点击选中:
    1. 修改服务器数据(*)
    2. 更新本地数据
@ComponentV2
struct CartItemComp {
  @Param
  cart: CartGoodsModel = new CartGoodsModel({} as CartGoods)

  async updateCart(params: UpdateParams) {
    await cart.update(this.cart.skuId, params)
    if (params.selected !== undefined) {
      this.cart.selected = params.selected
    }
    if (params.count) {
      this.cart.count = params.count
      cart.count()
    }
  }

  build() {
    Row({ space: 10 }) {
      MkCheckBox({
        checked: this.cart.selected, 
        onClickBox: () => {
          this.updateCart({ selected: !this.cart.selected })
        }
      })
      Image(this.cart.picture)
        .width(80)
        .height(80)
      Column({ space: 8 }) {
        Text(this.cart.name)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .maxLines(1)
          .fontSize(14)
          .fontColor($r('[basic].color.black'))
          .width('100%')
        Row() {
          Text(this.cart.attrsText)
            .textOverflow({ overflow: TextOverflow.Ellipsis })
            .maxLines(1)
            .fontSize(12)
            .fontColor($r('[basic].color.text'))
            .layoutWeight(1)
          Image($r('sys.media.ohos_ic_public_arrow_down'))
            .fillColor($r('[basic].color.gray'))
            .width(16)
            .height(16)
        }
        .padding({ left: 6, right: 4 })
        .width(100)
        .height(24)
        .backgroundColor($r('[basic].color.under'))
        .borderRadius(2)

        Row() {
          Text(`¥${this.cart.price}`)
            .fontSize(14)
            .fontWeight(500)
          Counter() {
            Text(this.cart.count.toString())
          }
          .enableDec(this.cart.count > 0)
          .onInc(() => {
            this.updateCart({ count: this.cart.count + 1 })
          })
          .onDec(() => {
            this.updateCart({ count: this.cart.count - 1 })
          })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
    .height(100)
    .padding(10)
    .border({ width: { bottom: 0.5 }, color: '#e4e4e4' })
    .backgroundColor($r('[basic].color.white'))
  }
}

git记录
Cart-选中状态更新

1.11. 全选

接口文档

Array.prototype.every() - JavaScript | MDN

需求:

  1. 点击列表项目,同步更新全选状态
  2. 点击全选,批量设置每一项的选中状态

核心步骤:

  1. 计算全选状态并绑定(方法):
    1. 返回每一项是否被选中(selected==true)(every)
    2. 点击子组件(CheckBox)通过回调函数通知父组件(强制刷新页面)
  1. 抽取 api
  2. 点击全选:
    1. 获取本地选中状态
    2. 调用接口更新服务器
    3. 设置列表项的选中状态
import { auth } from '../../../../Index'
import { RequestAxios } from '../utils/Request'
import { CartGoods } from '../viewmodel'


interface UpdateParams {
  selected?: boolean
  count?: number
}

interface AddParams {
  skuId: string
  count: number
}

interface CountResult {
  count: number
}

interface CheckAllParams {
  selected: boolean
}

export const CartKey: string = 'cartCount'

export class Cart {
  // 添加
  add(data: AddParams) {
    return RequestAxios.post<null>('/member/cart', data)
  }

  // 获取个数
  async count() {
    let count = 0
    if (auth.getUser()
      .token) {
      const res = await RequestAxios.get<CountResult>('/member/cart/count')
      count = res.data.result.count
    }
    AppStorage.setOrCreate(CartKey, count)
  }

  // 获取列表
   list() {
    return RequestAxios.get<CartGoods[]>('/member/cart')
  }

  // 更新商品
   update(skuid: string, data: UpdateParams) {
    return RequestAxios.put<CartGoods>(`/member/cart/${skuid}`, data)
  }

  // 全选
   checkAll(data: CheckAllParams) {
    return RequestAxios.put<null>('/member/cart/selected', data)
  }
}

export const cart = new Cart()

// 全选
MkCheckBox({
    checked: this.cartList.every(v => v.selected),
    onClickBox: async () => {
      const isCheckedAll = this.cartList.every(v => v.selected)
      await cart.checkAll({ selected: !isCheckedAll })
      this.cartList.forEach(v => v.selected = !isCheckedAll)
    }
  })

git 记录

Cart-全选

1.12. 总价格

链接

需求:

  1. 根据选中状态,个数,单价计算总价格

核心步骤:

  1. 实现方法:
    1. 筛选-》累加-》小数点 2 位-》转字符串
  1. 组件中使用该方法
// 定义计算总价的函数
/**
   * 总价格
   * */
  totalCount() {
    return this.cartList.filter(v => v.selected)
      .reduce((acc, cur) => acc + cur.price * cur.count, 0)
      .toFixed()
      .toString()
  }


Text('合计:')
  .fontSize(14)
  .fontColor($r('[basic].color.black'))
  .margin({ right: 2 })
Text(this.totalCount())
  .fontSize(16)
  .fontWeight(500)
  .fontColor($r('[basic].color.red'))
  .layoutWeight(1)

git 记录

Cart-总价格

1.13. 删除

接口文档

需求:

  1. 点击删除,删除购物车数据

核心步骤:

  1. 抽取接口(坑,delete 请求方法第二个参数和之前的不同
  2. 点击删除调用接口,删除服务器数据
  3. 更新个数
  4. 删除本地
  5. 伴随 loading 效果
export class RequestAxios {
  // get -> params -> { params: {} }
  // T 响应的内容 的类型!!
  static get<T>(url: string, params?: object): Promise<T> {
    return axiosInstance.get<null, T>(url, { params })
  }

  static getPlus<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
    return axiosInstance.get<null, T>(url, config)
  }

  // post -> data -> { data: {} }
  static post<T>(url: string, data?: object): Promise<T> {
    return axiosInstance.post<null, T>(url, data)
  }

  static delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
    return axiosInstance.delete<null, T>(url, config)
  }

  static put<T>(url: string, data?: object): Promise<T> {
    return axiosInstance.put<null, T>(url, data)
  }
}
import { Log } from '@abner/log'
import { auth, Logger } from '../../../../Index'
import { RequestAxios } from '../utils/Request'
import { CartGoods } from '../viewmodel'


interface UpdateParams {
  selected?: boolean
  count?: number
}

interface AddParams {
  skuId: string
  count: number
}

interface CountResult {
  count: number
}

interface CheckAllParams {
  selected: boolean
}

// 删除的参数
interface DeleteParams {
  ids: string[]
}

export const CartKey: string = 'cartCount'

export class Cart {
  // 添加
  add(data: AddParams) {
    return RequestAxios.post<null>('/member/cart', data)
  }

  // 获取个数
  async count() {
    let count = 0
    if (auth.getUser()
      .token) {
      const res = await RequestAxios.get<CountResult>('/member/cart/count')
      count = res.data.result.count
    }
    AppStorage.setOrCreate(CartKey, count)
  }

  // 获取列表
  async list() {
    return RequestAxios.get<CartGoods[]>('/member/cart')
  }

  // 更新商品
  async update(skuid: string, data: UpdateParams) {
    return RequestAxios.put<CartGoods>(`/member/cart/${skuid}`, data)
  }

  // 全选
  async checkAll(data: CheckAllParams) {
    return RequestAxios.put<null>('/member/cart/selected', data)
  }

  // 删除
  async remove(data: DeleteParams) {
    return RequestAxios.delete<null>('/member/cart', { data: data })
  }
}

export const cart = new Cart()
// 其他略
ForEach(
      this.cartList,
      (item: CartGoodsModel, index: number) => {
        ListItem() {
          CartItemComp({
            cart: item
          })
        }
        .backgroundColor($r('[basic].color.under'))
        .padding({ left: 8, right: 8 })
        .transition({ type: TransitionType.Delete, opacity: 0 })
        .swipeAction({
          end: this.DeleteBuilder(async () => {
            AlertDialog.show({
              title: '温馨提示',
              message: '确定删除该商品吗?',
              buttons: [
                {
                  value: '取消', fontColor: $r('[basic].color.gray'), action: () => {
                }
                },
                {
                  value: '确定', fontColor: $r('[basic].color.black'), action: async () => {
                  // 服务器端删除,根据 skuId 删除商品
                  await cart.remove({ ids: [item.skuId] })
                  // 本地删除
                  this.cartList.splice(index, 1)
                  // 更新购物袋数量
                  await cart.count()
                }
                },
              ]
            })
          })
        })
      }
    )

git 记录

Cart-删除购物袋商品

2. bug-ForEach 的keyGenerator参数

ForEach 键值规则

ForEach的第三个参数如果不给,那么默认值

bug 复现

  1. 保证购物车有数据,测试交互效果
  2. 切换到其他tab,再切换购物车,测试交互效果
  3. 修改选中状态,修改个数,无法触发【全选】 和 【价格】的重新计算
import { promptAction } from '@kit.ArkUI'
import { JSON } from '@kit.ArkTS'

@ObservedV2
class Dog {
  id: number
  @Trace
  name: string
  @Trace
  checked: boolean

  constructor(id: number, name: string) {
    this.id = id
    this.name = name
    this.checked = false
  }
}

@Entry
@Component
struct Parent {
  @State
  simpleList: Array<Dog> = [
    new Dog(1, '1狗'),
    new Dog(2, '2狗'),
    new Dog(3, '3狗'),
  ];

  isCheckedAll() {
    console.log('计算是否全部选中')
    return this.simpleList.every(v => v.checked)
  }

  build() {
    Row() {
      Column() {
        Button('打印数据')
          .margin(10)
          .onClick(() => {
            promptAction.showToast({
              message: JSON.stringify(this.simpleList)
            })
          })

        Text('点击替换成相同的数据')
          .fontSize(24)
          .fontColor(Color.Red)
          .onClick(() => {
            this.simpleList = this.simpleList.map(v => {
              return new Dog(v.id, v.name)
            })
          })

        Text(this.isCheckedAll() ? '全部选中' : '未全选')
          .fontSize(24)
          .fontColor(Color.Blue)
          .margin(10)

        ForEach(this.simpleList, (item: Dog, index: number) => {
          ChildItem({ item: item })
            .margin({ top: 20 })
        }, (item: Dog, index: number) => {
          return JSON.stringify(item) + ' ' + index
        })
      }
      .justifyContent(FlexAlign.Center)
      .width('100%')
      .height('100%')
    }
    .height('100%')
    .backgroundColor(0xF1F3F5)
  }
}

@ComponentV2
struct ChildItem {
  @Param item: Dog = new Dog(0, '')

  aboutToAppear(): void {
    console.log('aboutToAppear' + this.item.id)
  }

  build() {
    Row() {
      Text(this.item.checked ? '√' : 'x')
        .fontSize(20)
        .margin({ right: 10 })
        .onClick(() => {
          this.item.checked = !this.item.checked
        })
      Text(this.item.id + '|' + this.item.name)
        .fontSize(20)
        .onClick(() => {
          this.item.name += '1'
        })
    }

  }
}

结论:

  1. 默认的键值生成逻辑,在使用相同数据覆盖的时候不会触发子组件的重新生成
  2. 子组件交互时修改的依旧是上一份数据,但是数据源已经更改所以无法触发 UI 更新

解决方案:

  1. 自定义 keyGenerator 函数,希望子组件全部重新创建时,使生成结果和上一次不同
  2. 可以使用时间戳,随机数,累加的数。。核心就是保证 循环的 key 发生改变

优化代码

import { promptAction } from '@kit.ArkUI'
import { JSON } from '@kit.ArkTS'
import { data } from '@kit.TelephonyKit'

@ObservedV2
class Dog {
  id: number
  @Trace
  name: string
  @Trace
  checked: boolean

  constructor(id: number, name: string) {
    this.id = id
    this.name = name
    this.checked = false
  }
}

@Entry
@Component
struct Parent {
  @State
  simpleList: Array<Dog> = [
    new Dog(1, '1狗'),
    new Dog(2, '2狗'),
    new Dog(3, '3狗'),
  ];
  num: number = 0

  isCheckedAll() {
    console.log('计算是否全部选中')
    return this.simpleList.every(v => v.checked)
  }

  build() {
    Row() {
      Column() {
        Button('打印数据')
          .margin(10)
          .onClick(() => {
            promptAction.showToast({
              message: JSON.stringify(this.simpleList)
            })
          })

        Text('点击替换成相同的数据')
          .fontSize(24)
          .fontColor(Color.Red)
          .onClick(() => {
            this.num = Date.now()
            // 基于原始数据 生成新数据
            this.simpleList = this.simpleList.map(v => {
              return new Dog(v.id, v.name)
            })
          })

        Text(this.isCheckedAll() ? '全部选中' : '未全选')
          .fontSize(24)
          .fontColor(Color.Blue)
          .margin(10)

        ForEach(this.simpleList, (item: Dog, index: number) => {
          ChildItem({ item: item })
            .margin({ top: 20 })
        }, (item: Dog, index: number) => {
          console.log('ForEach' + item.id)
          return JSON.stringify(item)+this.num
        })
      }
      .justifyContent(FlexAlign.Center)
      .width('100%')
      .height('100%')
    }
    .height('100%')
    .backgroundColor(0xF1F3F5)
  }
}

@ComponentV2
struct ChildItem {
  @Param item: Dog = new Dog(0, '')

  aboutToAppear(): void {
    console.log('aboutToAppear' + this.item.id)
  }

  build() {
    Row() {
      Text(this.item.checked ? '√' : 'x')
        .fontSize(20)
        .margin({ right: 10 })
        .onClick(() => {
          this.item.checked = !this.item.checked
        })
      Text(this.item.id + '|' + this.item.name)
        .fontSize(20)
        .onClick(() => {
          this.item.name += '1'
        })
    }

  }
}
// 额外添加到随机数中
refreshNum: number = 0

/**
 * 获取数据
 * */
async getData() {
  if (!auth.getUser()
    .token) {
    return
  }
  const res = await cart.list()
  this.cartList = res.map((item) => new CartGoodsModel(item))
  this.refreshNum = Date.now()
}

  ForEach(
    this.cartList,
    (item: CartGoodsModel, index: number) => {
          // 略
    },
    (item: CartGoodsModel, index: number) => {
      return JSON.stringify(item) + this.refreshNum
    }
  )

3. 自定义弹窗优化

3.1. 弹窗错误优化

错误信息
目前使用的弹窗会有错误提示,错误原因是:

  • 103302: 内容节点对应自定义弹窗已存在
  • 103303:无法找到内容节点对应的自定义弹窗

咱们来优化一下弹框的逻辑

  1. 多次请求只有第一次弹框
  2. 多次请求最后一次结束的时候关闭弹框

// 其他略

let count =0

// 添加请求拦截器
axiosInstance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
  // 如果用户信息中有 token 就在请求头中携带 token
  const user = auth.getUser()
  if (user.token) {
    config.headers.Authorization = `Bearer ${user.token}`
  }
  
  // 对请求数据做点什么
  if (count === 0) {
    PromptActionClass.openDialog()
  }
  count++
  return config;
}, (error: AxiosError) => {
  // 对请求错误做些什么
  return Promise.reject(error);
});


interface ErrorType {
  message: string
  msg: string
  code: string
}

// 添加响应拦截器
axiosInstance.interceptors.response.use((response: AxiosResponse) => {
  // 对响应数据做点什么
  count--
  if (count <= 0) {
    PromptActionClass.closeDialog()
  }
  
  return response.data.result
}, (error: AxiosError<ErrorType>) => {
  // 只要 http 状态码 不在 200-299 以内 就会进到这个异常
  // 400 参数错误 (用户名密码登录 输入错误密码)
  // Logger.info(error.response?.status)
  if (error.response?.status === 400) {
    // Logger.info( error.response.data as object)
    promptAction.showToast({
      message: error.response.data.message
    })
  } else if (error.response?.status === 401) {
    // 删除 token
    auth.removeUser()
    // 提示用户
    promptAction.showToast({
      message: error.response.data.message
    })
    // 去登陆
    pathStack.pushPathByName('LoginView', null)
  }
  count--
  if (count <= 0) {
    PromptActionClass.closeDialog()
  }

  // 401 登录过期 (给错误的 token)
  // 对响应错误做点什么
  return Promise.reject(error);
});

git 记录

fix-优化全局弹框错误提示

http://www.dtcms.com/a/109030.html

相关文章:

  • Sentinel[超详细讲解]-7 -之 -熔断降级[异常比例阈值]
  • 万字重谈C++——类和对象篇
  • JAVA并发编程高级--深入解析 Java ReentrantLock:非公平锁与公平锁的实现原理
  • 【零基础入门unity游戏开发——2D篇】2D 游戏场景地形编辑器——TileMap的使用介绍
  • 虚拟电商-话费充值业务(六)话费充值业务回调补偿
  • MINIQMT学习课程Day3
  • Enovia许可配置和优化
  • seaweedfs分布式文件系统
  • RAC磁盘头损坏问题处理
  • 特征金字塔网络(FPN)详解
  • 【易订货-注册/登录安全分析报告】
  • Oracle触发器使用(二):伪记录和系统触发器
  • 构建个人专属知识库文件的RAG的大模型应用
  • BUUCTF-web刷题篇(9)
  • idea插件(自用)
  • video标签播放mp4格式视频只有声音没有图像的问题
  • NVIDIA显卡
  • 2.3 路径问题专题:剑指 Offer 47. 礼物的最大价值
  • Apollo配置中心登陆页面添加验证码
  • OpenCV销毁窗口
  • 浅谈软件成分分析 (SCA) 在企业开发安全建设中的落地思路
  • 数据库--SQL
  • Pytorch深度学习框架60天进阶学习计划 - 第34天:自动化模型调优
  • 维拉工时自定义字段:赋能项目数据的深度洞察 | 上新预告
  • React-router v7 第一章(安装)
  • JDBC常用的接口
  • coding ability 展开第八幕(位运算——基础篇)超详细!!!!
  • Spring Boot 集成 Redis 对哈希数据的详细操作示例,涵盖不同结构类型(基础类型、对象、嵌套结构)的完整代码及注释
  • PyQt6实例_A股日数据维护工具_使用
  • OpenCV 引擎:驱动实时应用开发的科技狂飙