鸿蒙-4. 美团外卖

阶段案例-美团外卖

商品页 购物车

1. 页面结构-入口页面

pages/Index.ets
1import { Cart } from '../components/Cart'
2import { Footer } from '../components/Footer'
3import { MenuWrapper } from '../components/MenuWrapper'
4import { Nav } from '../components/Nav'
5
6@Entry
7@Component
8struct Index {
9
10  @State
11  showCart: boolean = false
12
13  build() {
14    Stack({ alignContent: Alignment.Bottom }) {
15      Column() {
16        Nav()
17        MenuWrapper()
18      }
19      .width('100%')
20      .height('100%')
21      if (this.showCart) {
22        Cart()
23      }
24      Footer({ showCart: $showCart })
25    }
26  }
27}

2. 页面结构-底部组件

components/Footer.ets
1@Component
2export struct Footer {
3  @Link
4  showCart: boolean
5
6  build() {
7    Row() {
8      Row() {
9        Badge({
10          value: '0',
11          position: BadgePosition.Right,
12          style: { badgeSize: 18 }
13        }) {
14          Image('https://zqran.gitee.io/images/waimai/cart-2.png')
15            .width(47)
16            .height(69)
17            .position({ y: -19 })
18        }
19        .width(50)
20        .height(50)
21        .margin({ left: 25, right: 10 })
22        .onClick(() => {
23          this.showCart = !this.showCart
24        })
25
26        Column() {
27          Text() {
28            Span('¥')
29              .fontColor('#fff')
30              .fontSize(12)
31            Span('0.00')
32              .fontColor('#fff')
33              .fontSize(24)
34          }
35
36          Text('预估另需配送费 ¥5')
37            .fontSize(12)
38            .fontColor('#999')
39        }
40        .layoutWeight(1)
41        .alignItems(HorizontalAlign.Start)
42
43        Text('去结算')
44          .backgroundColor('#f8c74e')
45          .alignSelf(ItemAlign.Stretch)
46          .padding(15)
47          .borderRadius({
48            topRight: 25,
49            bottomRight: 25
50          })
51      }
52      .height(50)
53      .layoutWeight(1)
54      .backgroundColor('#222426')
55      .borderRadius(25)
56    }
57    .padding(15)
58    .height(80)
59  }
60}

3. 页面结构-导航组件

components/Nav.ets
1@Component
2export struct Nav {
3  @Builder
4  NavItem(active: boolean, title: string, subTitle?: string) {
5    Column() {
6      Text() {
7        Span(title)
8        if (subTitle) {
9          Span(' ' + subTitle)
10            .fontSize(10)
11            .fontColor(active ? '#000' : '#666')
12        }
13      }.layoutWeight(1)
14      .fontColor(active ? '#000' : '#666')
15      .fontWeight(active ? FontWeight.Bold : FontWeight.Normal)
16
17      Text()
18        .height(1)
19        .width(20)
20        .margin({ left: 6 })
21        .backgroundColor(active ? '#fa0' : 'transparent')
22    }
23    .width(73)
24    .alignItems(HorizontalAlign.Start)
25    .padding({ top: 3 })
26  }
27
28  build() {
29    Row() {
30      this.NavItem(true, '点菜')
31      this.NavItem(false, '评价', '1796')
32      this.NavItem(false, '商家')
33
34      Row() {
35        Image($r('app.media.ic_public_input_search'))
36          .width(14)
37          .aspectRatio(1)
38          .fillColor('#999')
39        Text('请输入菜品名称')
40          .fontSize(12)
41          .fontColor('#999')
42      }
43      .backgroundColor('#eee')
44      .height(25)
45      .borderRadius(13)
46      .padding({ left: 5, right: 5 })
47      .layoutWeight(1)
48    }
49    .padding({ left: 15, right: 15 })
50    .height(40)
51    .border({ width: { bottom: 0.5 }, color: '#e4e4e4' })
52  }
53}

4. 页面结构-商品菜单和商品列表

components/MenuWrapper.ets
1import { MenuWrapperItem } from './MenuWrapperItem'
2
3@Component
4export struct MenuWrapper {
5  list: string[] = ['一人套餐', '特色烧烤', '杂粮主食']
6  @State
7  activeIndex: number = 0
8
9  build() {
10    Row() {
11      Column() {
12        ForEach(this.list, (item: string, index: number) => {
13          Text(item)
14            .height(50)
15            .width('100%')
16            .textAlign(TextAlign.Center)
17            .fontSize(14)
18            .backgroundColor(this.activeIndex === index ? '#fff' : '#f5f5f5')
19            .onClick(() => {
20              this.activeIndex = index
21            })
22        })
23      }
24      .width(90)
25
26      List() {
27        ListItem(){
28          MenuWrapperItem()
29        }
30      }
31      .layoutWeight(1)
32      .height('100%')
33      .backgroundColor('#fff')
34    }
35    .layoutWeight(1)
36    .alignItems(VerticalAlign.Top)
37    .backgroundColor('#f5f5f5')
38  }
39}
components/MenuWrapperItem.ets
1import { CalcBtn } from './CalcBtn'
2
3@Component
4export struct MenuWrapperItem {
5  build() {
6    Row() {
7      Image('https://zqran.gitee.io/images/waimai/8078956697.jpg')
8        .width(90)
9        .aspectRatio(1)
10      Column({ space: 5 }) {
11        Text('小份酸汤莜面鱼鱼+肉夹馍套餐')
12          .textOverflow({
13            overflow: TextOverflow.Ellipsis,
14          })
15          .maxLines(2)
16          .fontWeight(600)
17        Text('酸汤莜面鱼鱼,主料:酸汤、莜面 肉夹馍,主料:白皮饼、猪肉')
18          .textOverflow({
19            overflow: TextOverflow.Ellipsis,
20          })
21          .maxLines(1)
22          .fontSize(12)
23          .fontColor('#333')
24        Text('点评网友推荐')
25          .fontSize(10)
26          .backgroundColor('#fff5e2')
27          .fontColor('#ff8000')
28          .padding({ top: 2, bottom: 2, right: 5, left: 5 })
29          .borderRadius(2)
30        Text() {
31          Span('月销售40')
32          Span(' ')
33          Span('好评度100%')
34        }
35        .fontSize(12)
36        .fontColor('#999')
37
38        Row() {
39          Text() {
40            Span('¥ ')
41              .fontColor('#ff8000')
42              .fontSize(10)
43            Span('34.23')
44              .fontColor('#ff8000')
45              .fontWeight(FontWeight.Bold)
46          }
47
48          CalcBtn({ icon: $r('app.media.ic_public_add_filled') })
49        }
50        .justifyContent(FlexAlign.SpaceBetween)
51        .width('100%')
52      }
53      .layoutWeight(1)
54      .alignItems(HorizontalAlign.Start)
55      .padding({ left: 10, right: 10 })
56    }
57    .padding(10)
58    .alignItems(VerticalAlign.Top)
59  }
60}

5. 业务逻辑-渲染商品菜单和列表

1)定义 model 数据模型

models/index.ets
1export class FoodItem {
2  id: number
3  name: string
4  like_ratio_desc: string
5  food_tag_list: string[]
6  price: number
7  picture: string
8  description: string
9  tag: string
10  month_saled: number
11}
12
13export class Category {
14  tag: string
15  name: string
16  foods: FoodItem[]
17}

2)使用 http 发送请求,获取数据

pages/Index.ets
1import http from '@ohos.net.http'
2import { Cart } from '../components/Cart'
3import { Footer } from '../components/Footer'
4import { MenuWrapper } from '../components/MenuWrapper'
5import { Nav } from '../components/Nav'
6import { Category } from '../models'
7
8const req = http.createHttp()
9
10@Entry
11@Component
12struct Index {
13  @State
14  showCart: boolean = false
15
16  @State
17  categoryList: Category[] = []
18
19  aboutToAppear() {
20    req.request('http://172.16.39.26:3000/takeaway')
21      .then(res => {
22        const data = JSON.parse(res.result as string) as Category[]
23        this.categoryList = data
24      })
25      .catch(err => {
26        console.error('MEITU', err.message)
27      })
28  }
29
30  build() {
31    Stack({ alignContent: Alignment.Bottom }) {
32      Column() {
33        Nav()
34        MenuWrapper({ categoryList: $categoryList })
35      }
36      .width('100%')
37      .height('100%')
38
39      if (this.showCart) {
40        Cart()
41      }
42      Footer({ showCart: $showCart })
43    }
44  }
45}

3)传入列表数据给,商品菜单组件,进行渲染

components/MenuWrapper.ets
1import { Category, FoodItem } from '../models'
2import { MenuWrapperItem } from './MenuWrapperItem'
3
4@Component
5export struct MenuWrapper {
6  @Link
7  categoryList: Category[]
8  @State
9  activeIndex: number = 0
10
11  build() {
12    Row() {
13      Column() {
14        ForEach(this.categoryList, (item: Category, index: number) => {
15          Text(item.name)
16            .height(50)
17            .width('100%')
18            .textAlign(TextAlign.Center)
19            .fontSize(14)
20            .backgroundColor(this.activeIndex === index ? '#fff' : '#f5f5f5')
21            .onClick(() => {
22              this.activeIndex = index
23            })
24        })
25      }
26      .width(90)
27
28      List() {
29        ForEach(this.categoryList[this.activeIndex]?.foods, (item: FoodItem) => {
30          ListItem() {
31            MenuWrapperItem({ food: item })
32          }
33        })
34      }
35      .layoutWeight(1)
36      .height('100%')
37      .backgroundColor('#fff')
38    }
39    .layoutWeight(1)
40    .alignItems(VerticalAlign.Top)
41    .backgroundColor('#f5f5f5')
42  }
43}
components/MenuWrapperItem.ets
1import { FoodItem } from '../models'
2import { CalcBtn } from './CalcBtn'
3
4@Component
5export struct MenuWrapperItem {
6
7  @ObjectLink
8  food: FoodItem
9
10  build() {
11    Row() {
12      Image(this.food.picture)
13        .width(90)
14        .aspectRatio(1)
15      Column({ space: 5 }) {
16        Text(this.food.name)
17          .textOverflow({
18            overflow: TextOverflow.Ellipsis,
19          })
20          .maxLines(2)
21          .fontWeight(600)
22        Text(this.food.description)
23          .textOverflow({
24            overflow: TextOverflow.Ellipsis,
25          })
26          .maxLines(1)
27          .fontSize(12)
28          .fontColor('#333')
29        ForEach(this.food.food_tag_list, (tag) => {
30          Text(tag)
31            .fontSize(10)
32            .backgroundColor('#fff5e2')
33            .fontColor('#ff8000')
34            .padding({ top: 2, bottom: 2, right: 5, left: 5 })
35            .borderRadius(2)
36        })
37        Text() {
38          Span('月销售'+this.food.month_saled)
39          Span(' ')
40          Span(this.food.like_ratio_desc)
41        }
42        .fontSize(12)
43        .fontColor('#999')
44
45        Row() {
46          Text() {
47            Span('¥ ')
48              .fontColor('#ff8000')
49              .fontSize(10)
50            Span(this.food.price.toFixed(2))
51              .fontColor('#ff8000')
52              .fontWeight(FontWeight.Bold)
53          }
54
55          CalcBtn({ icon: $r('app.media.ic_public_add_filled') })
56        }
57        .justifyContent(FlexAlign.SpaceBetween)
58        .width('100%')
59      }
60      .layoutWeight(1)
61      .alignItems(HorizontalAlign.Start)
62      .padding({ left: 10, right: 10 })
63    }
64    .padding(10)
65    .alignItems(VerticalAlign.Top)
66  }
67}

6. 页面结构-购物车

  • 抽取计算案例组件
  • 搭建购物车页面
components/CalcBtn.ets
1@Component
2export struct CalcBtn {
3
4  icon: Resource
5
6  plain?: boolean
7
8  build() {
9    Row() {
10      Image(this.icon)
11        .width(10)
12        .aspectRatio(1)
13    }
14    .width(16)
15    .aspectRatio(1)
16    .backgroundColor(this.plain ? '#fff' : '#f8c74e')
17    .border(this.plain ? { width: 0.5 , color: '#f8c74e'}: {})
18    .borderRadius(4)
19    .justifyContent(FlexAlign.Center)
20  }
21}
components/Cart.ets
1import { CartItem } from './CartItem'
2@Component
3export struct Cart {
4  build() {
5    Column(){
6      Column(){
7        Row(){
8          Text('购物车')
9            .fontSize(14)
10          Text('清空购物车')
11            .fontSize(14)
12            .fontColor('#999')
13        }
14        .width('100%')
15        .height(40)
16        .justifyContent(FlexAlign.SpaceBetween)
17        .padding({ left: 15, right: 15 })
18        .border({ width: { bottom: 0.5 }, color: '#e4e4e4' })
19        // 购物车列表
20        List(){
21          ListItem(){
22            CartItem()
23          }
24          ListItem(){
25            CartItem()
26          }
27          ListItem(){
28            CartItem()
29          }
30        }
31        .divider({
32          strokeWidth: 0.5,
33          color: '#e4e4e4'
34        })
35        .padding({ left: 15, right: 15 })
36      }
37      .width('100%')
38      .padding({ bottom: 100 })
39      .backgroundColor('#fff')
40      .borderRadius({
41        topLeft: 16,
42        topRight: 16
43      })
44    }
45    .height('100%')
46    .width('100%')
47    .backgroundColor('rgba(0,0,0,0.5)')
48    .justifyContent(FlexAlign.End)
49  }
50}
components/CartItem.ets
1import { CalcBtn } from './CalcBtn'
2@Component
3export struct CartItem {
4  build() {
5    Row(){
6      Image('https://zqran.gitee.io/images/waimai/7384994864.jpg')
7        .width(60)
8        .aspectRatio(1)
9      Column({ space: 10 }){
10        Text('小份酸汤莜面鱼鱼+肉夹馍套餐')
11          .textOverflow({
12            overflow: TextOverflow.Ellipsis
13          })
14          .maxLines(2)
15        Row(){
16          Text(){
17            Span('¥ ')
18              .fontSize(10)
19              .fontColor('#ff8000')
20            Span('23.23')
21              .fontWeight(600)
22              .fontColor('#ff8000')
23          }
24
25          Row({ space: 10 }){
26            CalcBtn({ icon: $r('app.media.ic_screenshot_line'), plain: true })
27            Text('0')
28              .fontSize(14)
29            CalcBtn({ icon: $r('app.media.ic_public_add_filled') })
30          }
31        }
32        .width('100%')
33        .justifyContent(FlexAlign.SpaceBetween)
34      }
35      .layoutWeight(1)
36      .alignItems(HorizontalAlign.Start)
37      .padding({ left: 10 })
38    }
39    .padding({ top: 15, bottom: 15 })
40    .alignItems(VerticalAlign.Top)
41  }
42}

7. 业务逻辑-加入购物车

1)购物车数据模型

models/index.ets
1export class CartItemModel {
2  id: number
3  name: string
4  price: number
5  picture: string
6  count: number
7}

2)购物车添加逻辑

utils/index.ets
1import { CartItemModel, FoodItem } from '../models'
2
3export const CART_KEY = 'CART_KEY'
4
5export const getCart = () => {
6  return JSON.parse(AppStorage.Get(CART_KEY) || '[]') as CartItemModel[]
7}
8
9export const addCart = (food: FoodItem) => {
10  const cart = getCart()
11  const f = cart.find(f => f.id === food.id)
12  if (f) {
13    f.count++
14  } else {
15    const { id, price, picture, name } = food
16    cart.unshift({
17      id,
18      price,
19      picture,
20      name,
21      count: 1
22    })
23  }
24  AppStorage.Set(CART_KEY, JSON.stringify(cart))
25}

3)购物车状态持久化

pages/Index.ets
1import { CART_KEY } from '../utils'
2
3
4PersistentStorage.PersistProp(CART_KEY, '[]')

4)监听购物车数据变化,设置购物车状态,底部组件显示数量和总价

pages/Index.ets
1@StorageProp(CART_KEY)
2  @Watch('onUpdateCart')
3  cartJson: string = '[]'
4  @State
5  cart: CartItem[] = JSON.parse(this.cartJson)
6  onUpdateCart () {
7    this.cart = JSON.parse(this.cartJson)
8  }
pages/Index.ets
1Footer({ showCart: $showCart, cart: $cart })
components/Footer.ets
1@Link
2cart: CartItem[]
3
4  // ...
5          Badge({
6          value: this.cart.reduce((p, c) => p + c.count, 0) + '',
7          })
8
9  // ...
10          Span(this.cart.reduce((p, c) => p + (c.count * c.price * 100) / 100, 0).toFixed(2))
11          .fontColor('#fff')
12          .fontSize(24)

5)添加购物车

components/MenuWrapperItem.ets
1CalcBtn({ icon: $r('app.media.ic_public_add_filled') })
2            .onClick(() => {
3              addCart(this.food)
4              promptAction.showToast({ message: '添加购物车成功' })
5            })

8. 业务逻辑-购物车管理

1)渲染购物车

pages/Index.ets
1if (this.showCart) {
2        Cart({ cart: $cart })
3      }
components/Cart.ets
1@Component
2export struct Cart {
3  @Link
4  cart: CartItemModel[]
5
6  // ...
7
8          List({ space: 30 }) {
9          ForEach(this.cart, (item:CartItemModel) => {
10            ListItem() {
11              CartItemComp({ item })
12            }
13          })
14        }
components/CartItem.ets
1import { CartItemModel } from '../models'
2import { CalcBtn } from './CalcBtn'
3
4@Component
5export struct CartItem {
6
7  @ObjectLink
8  item: CartItemModel
9
10  build() {
11    Row() {
12      Image(this.item.picture)
13        .width(60)
14        .aspectRatio(1)
15        .borderRadius(8)
16      Column({ space: 5 }) {
17        Text(this.item.name)
18          .fontSize(14)
19          .textOverflow({
20            overflow: TextOverflow.Ellipsis
21          })
22          .maxLines(2)
23        Row() {
24          Text() {
25            Span('¥ ')
26              .fontColor('#ff8000')
27              .fontSize(10)
28            Span(this.item.price.toFixed(2))
29              .fontColor('#ff8000')
30              .fontWeight(FontWeight.Bold)
31          }
32
33          Row() {
34            CalcBtn({ icon: $r('app.media.ic_screenshot_line'), plain: true })
35            Text(this.item.count+'')
36              .padding(10)
37              .fontSize(12)
38            CalcBtn({ icon: $r('app.media.ic_public_add_filled') })
39          }
40        }
41        .justifyContent(FlexAlign.SpaceBetween)
42        .width('100%')
43      }
44      .layoutWeight(1)
45      .alignItems(HorizontalAlign.Start)
46      .padding({ left: 10, right: 10 })
47    }
48    .alignItems(VerticalAlign.Top)
49  }
50}

2)购物车数量修改

utils/index.ets
1export const addCart = (food: FoodItem | CartItemModel) => { ... }
2
3export const delCart = (id: number) => {
4  const cart = getCart()
5  const f = cart.find(f => f.id === id)
6  if (f && f.count > 0) {
7    f.count--
8  }
9  AppStorage.Set(CART_KEY, JSON.stringify(cart))
10}
components/CartItem.ets
1CalcBtn({ icon: $r('app.media.ic_screenshot_line'), plain: true })
2              .onClick(() => {
3                delCart(this.item.id)
4              })
5            Text(this.item.count+'')
6              .padding(10)
7              .fontSize(12)
8            CalcBtn({ icon: $r('app.media.ic_public_add_filled') })
9              .onClick(()=>{
10                addCart(this.item)
11              })

3)清空购物车

utils/index.ets
1export const clearCart  = () => {
2  AppStorage.Set(CART_KEY, '[]')
3}
components/Cart.ets
1Text('清空购物车')
2            .fontSize(12)
3            .fontColor('#999')
4            .onClick(() => {
5              clearCart()
6            })