【江鸟中原】自助点餐小程序
本文介绍了一个基于HarmonyOS开发的自助点餐小程序项目。
一、项目初期:明确“为什么做”与“做什么”
开发前的核心是“找准定位”,避免盲目写代码。这一阶段我主要做了两件事:需求拆解和目标明确。
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(同步)把“积木”串成完整流程。作为课程作业,核心是“逻辑清晰、功能完整”;作为新手入门,本文档的分层思想、问题解决方法都可直接复用。如果你在复刻中遇到某步报错,可对照“第四部分 关键问题”排查,或留言交流~
更多推荐



所有评论(0)