一、项目初期:明确“为什么做”与“做什么”

开发前的核心是“找准定位”,避免盲目写代码。这一阶段我主要做了两件事:需求拆解和目标明确。

1.1 需求来源与思考

日常就餐时发现,传统人工点餐存在“排队久、沟通易出错、结算麻烦”等问题。结合课程作业要求,我确定开发“自助点餐小程序”,核心需求是“让用户能自主完成从选菜到下单的全流程”,同时满足餐厅“减少人工成本”的潜在需求。

这里我特意避开了复杂功能(如支付对接、会员系统),因为课程作业的核心是“功能完整、逻辑清晰”,先保证基础流程跑通,再考虑扩展——这是开发新手很重要的“取舍思路”。

1.2 核心功能确定

基于需求,我提炼出3个“必须实现”的核心功能,形成最小可行产品,确保开发聚焦:

1、菜单分类展示:按热菜、凉菜等分类呈现菜品,用户能快速定位想吃的菜,支持点击看详情;

2、快速选餐操作:详情页能调份数,购物车信息实时同步,不用反复跳转;

3、一键下单闭环:购物车能算总价,提交后有订单反馈,清空购物车支持重新点餐。

这三个功能覆盖了“浏览-选择-结算”全流程符合用户的核心使用场景。

二、技术准备:确定“用什么工具”与“搭什么框架”

技术选型的核心是“匹配需求+适合自己”,作为课程作业,我优先选择学习成本低、官方支持完善的技术栈。

2.1 开发工具与环境

选择内容 具体选择 思考与原因
开发工具 DevEcoStudio 4.0+ 课程教学推荐工具,对HarmonyOS小程序支持完善,自带代码提示、实时预览,新手易上手
开发框架 HarmonyOS API Version 11 版本稳定,组件丰富(如List、ForEach),能满足页面布局和交互需求
数据存储 HarmonyOS 本地缓存(StorageLink) 无需搭建服务器,适合课程作业;响应式特性支持购物车数据实时同步

2.2 项目结构设计

为了让代码后期好维护,我采用“页面-模型-工具”的分层结构,避免所有代码堆在一个文件里:

1、pages文件夹:存放4个核心页面(Index.ets首页、DetailPage.ets详情页、CartPage.ets购物车页,PaySuccessPage.ets提交成功页);

2、model文件夹:存放数据模型(Dish.ts定义菜品和分类、CartRepo.ts管理购物车逻辑);

3、media文件夹:存放菜品图片等资源文件。

三、核心功能实现

我按照“用户使用流程”来开发功能,即“首页分类展示→详情页选餐→购物车结算”,这样每完成一个功能就能看到实际效果,更有成就感。

3.1、项目结构

创建文件夹及文件

3.2、首页浏览

3.2.1 思考:如何让“分类”和“菜品”对应上?

如果直接写“热菜:鱼香肉丝、番茄炒蛋”,代码会很混乱。我先定义“规则”,再填数据:

1、定义分类规则:用枚举类型(Category)固定分类,这样分类名称不会写错,也方便后续判断。直接复制以下代码到Dish.ts中即可使用:
Dish.ts中定义分类枚举
enum Category {
  HOT = '热菜',
  COLD = '凉菜',
  STAPLE = '主食',
  DRINK = '饮品',
  SNACK = '小吃'
}

2、定义菜品规则:用接口(DishItem)规定每个菜品的“属性”,就像给菜品办“身份证”,确保数据规范。补充代码如下:
Dish.ts中定义菜品接口
interface DishItem {
  id: number;         // 菜品唯一编号,避免重复
  name: string;       // 菜品名称
  price: number;      // 菜品单价
  desc: string;       // 菜品描述
  category: Category; // 所属分类,关联上面的枚举
  image: ResourceStr; // 菜品图片资源
}

3、填充菜品数据:在首页的getDishesByTab()方法里,把所有菜品写成数组,每个菜品都对应一个分类。示例:
{ id:6, name:'鱼香肉丝', price:12, desc:'酸甜微辣,下饭神器',,

category:Category.HOT, image:$r('app.media.yuxiang') },

其中$r('app.media.yuxiang')是图片在media文件夹中的资源路径。

3.2.2 代码实现:分类切换与菜品过滤

1、分类标签栏:用ForEach循环遍历Category枚举,生成“热菜、凉菜”等标签,点击标签时把当前选中的分类存到@State currTab里(默认是热菜);

2、菜品列表:用List组件展示菜品,通过getDishesByTab()方法“过滤”菜品——遍历所有菜品,只留下category等于currTab的菜品,实现“点热菜只显示热菜”;

3、底部购物车栏:用@StorageLink('CartTotal')和@StorageLink('CartCount')关联购物车数据,页面加载时(aboutToAppear生命周期)调用CartRepo的方法获取初始总价和份数,实现数据同步。

前边已将创建的Index.est就是首页界面。

效果图:

代码参考如下:

@Entry
@Component
export struct Index {
  @State currTab: Category = Category.HOT;
  // 响应式购物车
  @StorageLink('CartTotal') cartTotal: number = 0;
  @StorageLink('CartCount') cartCount: number = 0;

  private TABS: Category[] = [
    Category.HOT, Category.COLD, Category.STAPLE, Category.DRINK, Category.SNACK
  ];

  aboutToAppear() {
    this.cartTotal = CartRepo.totalPrice();
    this.cartCount = CartRepo.totalCount();
  }

  build() {
    Stack() {
      Column() {
        Row() {
          ForEach(this.TABS, (tab: Category) => {
            Text(tab)
              .height(40)
              .layoutWeight(1)
              .textAlign(TextAlign.Center)
              .fontSize(16)
              .fontWeight(this.currTab === tab ? FontWeight.Bold : FontWeight.Regular)
              .fontColor(this.currTab === tab ? '#FF6200' : '#66000000')
              .onClick(() => this.currTab = tab)
          }, (tab: Category) => tab)
        }
        .width('100%')
        .backgroundColor('#FFFFFF')

        List({ space: 10 }) {
          ForEach(this.getDishesByTab(), (item: DishItem) => {
            ListItem() {
              Row() {
                Image(item.image)
                  .width(60)
                  .height(60)
                  .borderRadius(8)
                  .margin({ right: 12 })

                Column({ space: 4 }) {
                  Text(item.name).fontSize(18).fontWeight(FontWeight.Medium)
                  Text(`¥${item.price.toFixed(1)}`).fontSize(16).fontColor('#FF6200')
                  Text(item.desc).fontSize(14).fontColor('#99000000').maxLines(2)
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Start)

                Button('选')
                  .width(40)
                  .height(32)
                  .onClick(() => {
                    router.pushUrl({ url: 'pages/DetailPage', params: { dish: item } });
                  })
              }
              .width('100%')
              .padding(12)
              .backgroundColor('#FFFFFF')
              .borderRadius(8)
            }
          }, (item: DishItem) => item.id.toString())
        }
        .width('95%')
        .layoutWeight(1)
        .margin({ top: 8 })
      }
      .width('100%')
      .height('100%')
      .backgroundColor('#F2F2F2')

      /* 底部橙色合计条 + 购物车图标圆钮 */
      Column() {
        Row() {
          Text(`共 ${this.cartCount} 份  ¥${this.cartTotal.toFixed(1)}`)
            .fontSize(16).fontColor(Color.White).fontWeight(FontWeight.Bold)
        }
        .width('100%').height(48).backgroundColor('#FF6200').padding({ left: 16 })
      }
      .width('100%').position({ x: 0, y: '95%' })
      Button() {
        Image($r('app.media.cart'))   // 放你自己的 24×24 透明 png
          .width(24)
          .height(24)
          .fillColor(Color.White)   // 图标染白
      }
      .type(ButtonType.Circle)
      .width(48).height(48)
      .position({ x: '85%', y: '95%' })
      .backgroundColor('#FF6200')
      .onClick(() => {
        router.pushUrl({ url: 'pages/CartPage' });
      })
    }
  }
  
}

3.3、详情页面

详情页面就是菜品展示、介绍菜品,并且提供加减菜品加入购物车选项

3.3.1 思考:如何让“选的份数”同步到购物车?

详情页的核心是“用户选多少份,购物车就记多少份”,我用“页面传参+购物车仓库”实现。

3.3.2 代码实现:从传参到加购的全流程

1、接收菜品数据(步骤化操作):① 首页传参:给“选”按钮绑定点击事件,用router.pushUrl携带菜品数据,代码:

Button('选')
  .onClick(() => {
    router.pushUrl({ url: 'pages/DetailPage', params: { dish: item } });
  })


② 详情页接收:在aboutToAppear生命周期中获取参数,代码:
 

aboutToAppear() {
  const p = router.getParams() as Record<string, DishItem>;
  this.dish = p.dish; // 把接收的菜品数据赋值给页面变量
}

2、份数调整(核心逻辑注释):用@State qty记录当前份数,默认1。

“-/+”按钮逻辑代码附注释,新手可直接复用:

// 减少份数
Button('-').onClick(() => {
  if (this.qty > 1) {
    this.qty--; // 份数大于1时直接减1
  } else {
    CartRepo.reduce(this.dish, 1); // 份数为1时,调用方法移除菜品
    router.back(); // 返回首页
  }
})
// 增加份数
Button('+').onClick(() => this.qty++)

3、加入购物车(一句话调用):点击按钮时调用CartRepo的add方法,把“菜品+份数”存到购物车,代码:

Button('加入购物车')
  .onClick(() => {
    CartRepo.add(this.dish, this.qty); // 核心调用,交给购物车管理
    router.back(); // 加购后返回首页
  })


此时首页的StorageLink会自动同步数据,无需额外写更新代码。

效果图:

代码参考:

import router from '@ohos.router';
import Dish, { Category } from '../model/Dish';
import { CartRepo } from '../model/CartRepo';

@Entry
@Component
export struct DetailPage {
  private dish: Dish = new Dish(0, '', 0, '', '', Category.HOT);
  @State qty: number = 1;

  aboutToAppear() {
    const p = router.getParams() as Record<string, Dish>;
    this.dish = p.dish;
  }

  build() {
    Column({ space: 16 }) {
      Image(this.dish.image)
        .width('100%')
        .height(200)
        .objectFit(ImageFit.Cover)
        .borderRadius(12)

      Text(this.dish.name).fontSize(22).fontWeight(FontWeight.Bold)
      Text(`¥${this.dish.price.toFixed(1)}`).fontSize(18).fontColor('#FF6200')
      Text(this.dish.desc).fontSize(14).fontColor('#99000000')

      Row({ space: 20 }) {
        Button('-')
          .width(40).height(40)
          .onClick(() => {
            if (this.qty > 1) {
              this.qty--;
            } else {
              // 允许减到 0,自动移除并返回
              CartRepo.reduce(this.dish, 1);   // 移除 1 份
              router.back();
            }
          })
        Text(this.qty.toString()).fontSize(18).width(60).textAlign(TextAlign.Center)
        Button('+').width(40).height(40).onClick(() => this.qty++)
      }

      Button('加入购物车')
        .width('90%')
        .height(48)
        .onClick(() => {
          CartRepo.add(this.dish, this.qty);
          router.back();
        })
    }
    .width('100%')
    .height('100%')
    .padding(16)
    .backgroundColor('#F2F2F2')
  }
}

3.4、购物车

购物车提供清空购物车功能和清除单个菜品的功能,然后提交订单,回跳转页面提示“提交成功!”并附有订单号和返回首页。

3.4.1 思考:如何实现“提交订单后清空购物车”?

购物车的核心是“算总价、生成订单、清空数据”,我把这些逻辑都放在CartRepo里,让购物车页只负责“展示和触发操作”:

3.4.2 代码实现:核心逻辑拆解

展示已选菜品:从CartRepo获取已选菜品列表,用List组件展示,遍历菜品时计算每道菜的小计(单价×数量);

计算总价:调用CartRepo.totalPrice()方法,遍历已选菜品累加总价,不用自己写循环,降低出错率;

提交订单:点击“提交订单”时,生成订单号(格式:DD+日期+随机数,如DD20251208123456),弹出提示框展示订单号;然后调用CartRepo.clear()清空购物车数据,StorageLink同步更新,首页购物车栏恢复为0,最后跳转回首页。

效果图:

参考代码:

  build() {
    Column() {
      Row() {
        Text('购物车').fontSize(20).fontWeight(FontWeight.Bold)
        Blank()
        Button('清空')
          .fontSize(14)
          .onClick(() => this.clearAll())
      }
      .width('100%')
      .padding(12)

      if (this.items.length === 0) {
        Text('购物车是空的')
          .fontSize(16)
          .fontColor('#999999')
          .width('100%')
          .textAlign(TextAlign.Center)
          .layoutWeight(1)
      } else {
        List({ space: 8 }) {
          ForEach(this.items, (it: CartItem) => {
            ListItem() {
              Row() {
                Image(it.dish.image)
                  .width(60).height(60).borderRadius(8)

                Column({ space: 4 }) {
                  Text(it.dish.name).fontSize(16).fontWeight(FontWeight.Medium)
                  Text(`¥${it.dish.price.toFixed(1)}`).fontSize(14).fontColor('#FF6200')
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Start)
                .margin({ left: 12 })

                // 一键删除图标
                Image($r('app.media.delete'))
                  .width(24).height(24)
                  .onClick(() => this.removeItem(it))


              }
              .width('100%')
              .padding(10)
              .backgroundColor('#FFFFFF')
              .borderRadius(8)
            }
          }, (it: CartItem) => it.dish.id.toString())
        }
        .width('95%')
        .layoutWeight(1)
      }

      Row() {
        Text('合计:').fontSize(16)
        Text(`¥${this.total.toFixed(1)}`).fontSize(20).fontColor('#FF6200').fontWeight(FontWeight.Bold)
      }
      .width('95%')
      .justifyContent(FlexAlign.End)
      .margin({ bottom: 8 })

      // ==== 确认支付 → 支付成功页(只改这里)====
      Button('提交订单')
        .width('90%')
        .height(48)
        .onClick(() => {
          if (this.items.length === 0) {
            promptAction.showToast({ message: '购物车是空的' });
            return;
          }
          const orderNo = 'T' + Date.now().toString().slice(-6);
          promptAction.showToast({ message: `订单号:${orderNo}` });
          router.pushUrl({ url: 'pages/PaySuccessPage', params: { orderNo: orderNo } });
        })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F2F2F2')
  }

3.5、提交成功跳转页

提交订单后,跳转页面并提示“提交成功!”并附有订单号和返回首页。

效果图:

参考代码:

export struct PaySuccessPage {
  private orderNo: string = '';

  aboutToAppear() {
    const p = router.getParams() as Record<string, string>;
    this.orderNo = p.orderNo;
    // 存支付成功状态(可扩展)
    AppStorage.Set('payStatus_' + this.orderNo, 'success');
  }

  build() {
    Column({ space: 20 }) {
      Image($r('sys.media.ohos_ic_public_more'))
        .width(80).height(80).fillColor('#00C853')
      Text('提交成功!').fontSize(24).fontWeight(FontWeight.Bold).fontColor('#00C853')
      Text(`订单号:${this.orderNo}`).fontSize(14).fontColor('#666666')

      Button('返回首页')
        .width('60%')
        .height(48)
        .onClick(() => {
          // 1. 先清购物车数据
          CartRepo.clear();          // ← 关键:把内存里的购物车清零

          // 2. 再清空页面栈并回到首页
          router.clear();
          router.pushUrl({ url: 'pages/Index' });
        })
    }
    .width('100%')
    .height('100%')
    .padding(16)
    .backgroundColor('#F2F2F2')
  }
}

3.6、项目资源

因为是点餐小程序,所以我们会用到大量的图片资源,都放在media下。

四、关键问题与解决方法

开发中也会遇到很多的小问题,不过没关系,下面来一一解决。

遇到的问题 错误原因 解决方法
首页购物车数据不实时更新 用了普通变量存总价,数据变了页面不会刷新 改用@StorageLink响应式变量,数据变化时页面自动重新渲染
分类切换后菜品重复显示 ForEach没有加“唯一标识”,系统不知道怎么区分菜品 给ForEach加第二个参数(item => item.id.toString()),用菜品id作为唯一标识
详情页返回后,首页分类重置为热菜 currTab用了@State,页面跳转后状态被重置 把currTab改用@StorageProp存储,页面间状态能保留

五、开发总结与思考

5.1 项目成果

完成了“分类展示-选餐-下单”的完整流程,实现了3个核心页面、5个菜品分类、30+菜品数据,购物车数据实时同步,订单提交后自动清空,满足课程作业的功能要求。

5.2 开发收获

1、思路比代码重要:初期没做需求拆解就写代码,改了3次才理清逻辑;后来先画流程再开发,效率提升一倍;

2、分层结构很关键:数据和逻辑分开后,改菜品价格只动Dish.ts,改购物车规则只动CartRepo.ts,维护方便;

3、善用官方文档:StorageLink和router的用法都是查HarmonyOS官方文档学会的,新手不用怕,官方示例很详细。

5.3 后续优化方向

如果后续完善,可以增加:① 支付接口对接(调用微信/支付宝支付);② 订单历史查询(把订单号存到本地缓存)

整个开发过程就像“搭积木”:先搭好分层结构的“架子”,再用分类、菜品的“数据规则”做“积木块”,最后用router(跳转)、StorageLink(同步)把“积木”串成完整流程。作为课程作业,核心是“逻辑清晰、功能完整”;作为新手入门,本文档的分层思想、问题解决方法都可直接复用。如果你在复刻中遇到某步报错,可对照“第四部分 关键问题”排查,或留言交流~

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐