1. 购物车
1. 购物车
咱们购物车基于 V2 装饰器进行开发,底气来源于
自定义组件混用场景指导
1.1. 素材整合
observedv2和Trace
- 数据模型和页面
// 其他略
// 购物车
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
}
}
- 静态页面
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'))
}
}
- 在 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装饰器
先来搞定购物车中间部分的显示效果,通过组件来完成
核心步骤:
- 创建组件并暴露
- 根据文档实现功能
- 页面中导入并测试使用
-
- 已登录,无商品,点击去第一个 Tab(下一节做)
- 未登录,点击去登录页
基础模版
@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进行线程间通信
需求及分析:
- 已登录,购物车没有商品的时候,点击切换 tab 到第一个【首页】
- feature/cart -> products/phone 可以通过??? 来实现通讯
- products/phone 如何控制 tab 切换
核心步骤:
- 增加 emmit 的事件 id 常量,并导出 (方便管理)
- phone/index 注册事件,接收参数
- feature/cart 触发事件,传递数据
- 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. 加入购物车
接口文档
需求:
- 详情页点击添加到购物车
- 更新服务器数据
核心步骤:
- 抽取 api,丢到 common
- 页面实现交互逻辑
-
- token 校验-》sku 校验-》提交-》api调用-》
- 通过一个类管理所有购物车接口,因为有一整套的 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()
- 导出
// 导出购物车封装购物车请求 API 的类,这个类只需导出一次即可
export { cart } from './src/main/ets/apis/cart'
- 整合业务,在商品详情页调用 加入购物车 接口
// 加入购物车
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. 详情页面个数更新
需求:
- 获取最新个数(通过接口)
- 更新购物车个数(考虑其他界面获取)
核心步骤:
- 抽取 api:
-
- 有 Token
-
-
- 获取数据,具体的值
-
-
- 没 Token
-
-
- 格式为 0
-
-
- 更新到AppStorage
- 组件中获取并渲染
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. 首页更新数量
需求:
- 首页增加角标
- 获取个数并渲染
- 如果是首次打开页面,获取个数(已登录的情况)
核心步骤:
- 增加 Badge 组件
- 通过 AppStorage 获取数据并渲染
- 生命周期钩子中获取数据
- 更新首页购物车数量
@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. 登录和登出更新数量
登录和登出:
- 登录更新:
-
- 账号密码登录,华为登录之后,保存用户信息,同时更新购物车数量
- 登出更新:
-
- 设置页,退出登录时,删除用户信息,同时更新购物车数量
- cart.count() --> 0
- 保存用户信息、删除用户信息时,都要更新购物车数量
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. 跳转购物车页
需求:
- 点击购物车图标,打开购物袋页面
- tabs打开时,没有返回按钮
- 跳转打开时,显示返回按钮
注意:
- 之前购物袋只是 Tabs 渲染的组件,并不是页面,没有注册页面路由。
核心步骤:
- feature/CartView
-
- 增加 Builder 入口函数 和 NavDestination 组件
- 配置路由表 route_map.json
- 配置 module.json5
- 详情页,点击跳转即可
-
- 优化:详情页跳转的时候【显示返回按钮】,并可以点击【返回上一页】
- 通过携带参数实现效果
- 增加 Builder 入口函数 和 NavDestination 组件
// 1.1 路由入口函数
@Builder
function CartViewBuilder() {
// 1.2 子路由组件
NavDestination() {
CartView()
}
.hideTitleBar(true)
}
@Component
export struct CartView {
}
- 新建路由表文件,并配置路由表
{
"routerMap": [
{
"name": "CartView",
"pageSourceFile": "src/main/ets/views/CartView.ets",
"buildFunction": "CartViewBuilder"
}
]
}
- 配置 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"
}
}
- 页面跳转,并传参
// 购物袋角标
Badge({
count: this.cartCount,
style: {},
position: { x: 30, y: 4 }
}) {
Image($r('[basic].media.ic_public_cart'))
.iconButton()
.onClick(() => {
pathStack.pushPathByName('CartView', true)
})
}
- 获取参数
@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. 购物车列表渲染
接口文档
需求:
- 获取购物车数据并渲染
核心步骤:
- 抽取接口
- 获取数据之后通过 new 转对象(嵌套数据更新)
- 这个过程需要在 2 种情况下执行(分两步完成)
-
- 首次进入,有 token 就获取(生命周期钩子)
- 添加商品到购物车中(详情页--》购物车页)
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. 更新购物车列表
组件可见区域变化事件
问题分析:
- 购物车,每次打开重新获取列表数据
-
- 不是页面,只有 aboutToAppear 钩子
- 第一次打开之后就不会被销毁,不会再次触发
- 详情页
-
- 跳转去购物车是新页面,会触发 aboutToAppear 可以刷新
- tab 页
-
- 切换到 购物车 tab 时需要获取列表(不会再次触发aboutToAppear)
思考:如何实现 购物车页面数据刷新?
- 通过 emitter 触发刷新
- 组件可视区域改变时刷新 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. 个数更新
接口文档
需求:
- 修改【个数】时
- 更新服务器,更新本地
- 更新购物车个数
核心步骤:
- 实现Count组件的个数渲染及禁用外观
- 累加及递减逻辑:
-
- 修改服务器数据(*)
- 更新本地数据
- 更新个数
- 伴随 loading 效果
- 选中:
-
- 修改服务器数据(*)
- 更新本地数据
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 使用起来有一些不便,咱们来:
- 试试这个不便之处
- 然后自己实现个组件,替换到 购物车页面的 【列表项】和【全选】
如果不用双向绑定那么 选中状态 和 绑定的状态变量就没有关联
@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. 选中状态更新
接口文档
需求:
- 修改【选中状态】时
- 更新服务器,更新本地
核心步骤:
- 点击选中:
-
- 修改服务器数据(*)
- 更新本地数据
@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
需求:
- 点击列表项目,同步更新全选状态
- 点击全选,批量设置每一项的选中状态
核心步骤:
- 计算全选状态并绑定(方法):
-
- 返回每一项是否被选中(selected==true)(every)
- 点击子组件(CheckBox)通过回调函数通知父组件(强制刷新页面)
- 抽取 api
- 点击全选:
-
- 获取本地选中状态
- 调用接口更新服务器
- 设置列表项的选中状态
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. 总价格
链接
需求:
- 根据选中状态,个数,单价计算总价格
核心步骤:
- 实现方法:
-
- 筛选-》累加-》小数点 2 位-》转字符串
- 组件中使用该方法
// 定义计算总价的函数
/**
* 总价格
* */
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. 删除
接口文档
需求:
- 点击删除,删除购物车数据
核心步骤:
- 抽取接口(坑,delete 请求方法第二个参数和之前的不同)
- 点击删除调用接口,删除服务器数据
- 更新个数
- 删除本地
- 伴随 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 复现
- 保证购物车有数据,测试交互效果
- 切换到其他tab,再切换购物车,测试交互效果
- 修改选中状态,修改个数,无法触发【全选】 和 【价格】的重新计算
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'
})
}
}
}
结论:
- 默认的键值生成逻辑,在使用相同数据覆盖的时候不会触发子组件的重新生成
- 子组件交互时修改的依旧是上一份数据,但是数据源已经更改所以无法触发 UI 更新
解决方案:
- 自定义 keyGenerator 函数,希望子组件全部重新创建时,使生成结果和上一次不同
- 可以使用时间戳,随机数,累加的数。。核心就是保证 循环的 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:无法找到内容节点对应的自定义弹窗
咱们来优化一下弹框的逻辑
- 多次请求只有第一次弹框
- 多次请求最后一次结束的时候关闭弹框
// 其他略
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-优化全局弹框错误提示