先交代一个背景:这几天我把蜜雪冰城鸿蒙原生版翻来覆去地拆,用 ArkTS 把点单、购物车、门店选择这些核心链路重新走了一遍。这篇是系列第一篇,主要聚焦“从零开始搭一个能跑、能点单、能加购的蜜雪冰城风格 App”,把项目拆解、工程骨架、首页、点单页、购物车一路串完,过程中会把每个关键技术点背后的取舍讲清楚。
为什么选蜜雪冰城这个案例?因为它的业务链路足够典型:首页有品牌展示和活动运营区,点单页有左侧分类、右侧商品列表,商品详情有规格选择(甜度、冰量、加料),再往下是购物车、结算、订单。这一整套交互在电商、餐饮、零售类 App 里几乎一模一样。你把这套流程啃下来,后面做任何同类型商业应用,思路都是通的。
说实话,很多初学者拿 HarmonyOS 练手,做的都是待办事项、计数器、新闻阅读这类 Demo,做完就扔,因为根本没有“业务闭环”的感觉。蜜雪冰城这个案例的妙处在于:它逼着你处理真实业务才有的问题,比如多个页面怎么共享购物车数据、菜单数据量大了怎么筛选、规格弹层怎么回填、门店定位权限怎么申请。这些才是企业做 App 时真正会遇到的坎。
我先把这篇的内容边界说清楚,免得你期望错位:本篇从环境准备写到购物车闭环,包含完整的工程结构、核心代码片段、组件选型理由、状态管理方案,以及真机调试和上架前要注意的事。内容偏实战,不罗列官方文档,默认你已经会装 DevEco Studio、知道 ArkTS 基础语法。如果纯小白,建议先跑一遍官方“创建第一个页面”的教程再回来看。
1. 先别急着写代码:拿到“蜜雪冰城”这类商业 App,要做哪些业务拆解
很多人写 App 的习惯是打开 DevEco Studio,新建工程,然后就开始堆页面。这个习惯做 Demo 没问题,做商业项目必翻车。因为商业 App 的页面之间是有依赖关系的,比如点单页选完规格要能把数据丢给购物车,购物车要能反向影响商品列表的角标,结算页要读取收货地址和门店信息。这些“跨页面数据流”如果不在动手前设计好,后面就是无穷无尽的补丁。
1.1 一杯柠檬水的完整业务链路
把蜜雪冰城 App 的核心链路拆开看,其实就是一条线:浏览 → 选品 → 确认规格 → 加入购物车 → 去结算 → 支付 → 取餐。这个链路里,最重的是“选规格”和“购物车”这两个环节。
我以前做过一个奶茶品牌的小程序,当时团队最大的失误就是没把“规格”这个抽象层做好。什么叫做“规格”?就是你点一杯柠檬水,会遇到甜度(正常糖、少糖、半糖、无糖)、冰量(正常冰、少冰、去冰、热饮)、加料(珍珠、椰果、冰淇淋)这三组选项。每组选项都影响价格和库存。如果你把规格写死在商品详情页里,购物车就会很痛苦——因为购物车要能区分“一杯正常糖少冰加珍珠的柠檬水”和“一杯无糖去冰不加料的柠檬水”是两款不同的商品。所以数据模型必须在一开始就把“商品”和“商品SKU”分开。
用一句话概括业务拆解的核心:你要知道每个页面会改哪些数据,这些数据会流向哪里,谁负责存储,谁负责展示。画一张数据流图比写一百行代码都值钱。
1.2 页面结构设计:App 的主干与分支
蜜雪冰城这类点单 App 的页面结构并不复杂,我拆成三级:
- 一级:首页、点单、订单、我的,四个底部 Tab,这是 App 的主干。
- 二级:门店选择、商品详情、购物车列表、结算页,这些是从主干分支出去的页面。
- 三级:订单跟踪、优惠券、个人资料,这些是相对独立的功能页。
在 HarmonyOS 里实现这种结构,最直接的方式是“主页面用 Tabs 组件承载四个 Tab,二级页面用 Navigation 或路由跳转”。但这里有个非常常见的坑:很多人把每个 Tab 都做成一个独立的 page 路由,导致底部 Tab 切换时页面重新加载,状态全丢。正确做法是:Tabs 的每个 TabContent 内部放一个独立的组件(比如 IndexPage、OrderPage、MinePage),而不是放一个路由页面。这样切换 Tab 只是切换组件显隐,不会销毁状态。
1.3 数据模型:菜单、商品、购物车怎么建模
数据模型是我每次做项目第一个动手的地方。蜜雪冰城 App 的数据模型可以简化成三层:
- 分类(Category):id、名称、图标。比如“人气推荐”“奶茶”“果茶”“冰淇淋”。
- 商品(Product):id、名称、分类id、基础价格、图片地址、描述。注意商品本身不包含规格,规格是独立集合。
- 商品规格(Sku):id、商品id、规格组合(甜度、冰量、加料)、价格、库存。
购物车的数据结构则要按“店铺维度 + 商品维度”组织。为什么带店铺?因为蜜雪冰城有门店概念,如果你做了多个门店切换,购物车必须按门店区分——你不可能在 A 店加了三杯,跑到 B 店还能一起结算。这个细节特别容易被忽略,等做到多门店就麻烦了。
购物车每个条目长这样:
typescript复制interface CartItem {
skuId: string
productName: string
specs: string[] // ["正常糖", "少冰", "加珍珠"]
quantity: number
unitPrice: number
}
我的建议是先用 TypeScript interface 把数据模型定义好,再写页面。因为 ArkTS 是 TypeScript 的超集,前后端数据模型可以共用一套定义,后面对接服务端时不用来回改。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 开发环境与工程模板:用 DevEco Studio 把项目骨架搭起来
这一节讲环境,但我不打算教你点“下一步”,只讲几个容易踩坑的决策点。开发 HarmonyOS 应用,目前标准工具就是 DevEco Studio,它内置了模拟器、SDK 管理、签名工具和应用市场接入能力。版本选择上,尽量用最新稳定版,不需要纠结,因为鸿蒙工具链迭代很快,新版本会修复旧版本的编译器和调试器问题。
2.1 工程模板怎么选:Empty Ability 还是 Atomic Service
DevEco Studio 新建项目时会让你选模板,常见的有 Empty Ability、List Ability、Atomic Service 等。做蜜雪冰城这种多页面应用,选 Empty Ability 就够了。Atomic Service 是“免安装服务”的形态,适合工具类、卡片类应用,不适合完整点单流程。
还有一个选择是 Stage 模型还是 FA 模型。新项目全部用 Stage 模型,这个不用争。Stage 模型把 UIAbility 作为应用入口,页面通过 router 或 Navigation 管理,生命周期清晰,适合商业应用。FA 模型是旧架构,现在新的 SDK 已经在逐步弱化,就别选它了。
2.2 工程结构:entry 模块与 src/main 里要放什么
一个标准 Stage 模型工程的核心目录结构长这样:
code复制AppScope/
app.json5 // 应用全局配置
entry/
src/main/
ets/
entryability/ // UIAbility,应用入口
pages/ // 页面级组件
components/ // 可复用组件
model/ // 数据模型与模拟数据
resources/
base/
element/ // 颜色、字符串等资源
media/ // 图片资源
module.json5 // 模块配置
重点说下 module.json5,这里面有几个必填项:module.name、module.type、deviceTypes、abilities。其中 deviceTypes 决定你的应用能装在哪类设备上,默认是 phone,如果你想在平板上调试,要加 tablet。另外,模块名要唯一,不然打包会报错。
2.3 第一个页面:从 Hello World 改成框架页
新建工程完后默认代码是一个居中显示的 Text("Hello World")。第一步我会习惯性把入口 UIAbility 里加载的页面改成 IndexPage,再按业务把页面分成 home、order、cart、mine 四个目录。这里有一个关于路由的决策:用官方推荐的 Navigation 还是用 router?
我的结论是:主框架内用 Tabs,跨 Tab 的二级页面用 Navigation。因为 Navigation 支持系统级返回手势、转场动画和路由栈管理,比 router 的能力更完整,而且 ArkUI 新版本对 Navigation 的推荐度更高。
首页框架的代码可以这样搭:
typescript复制@Entry
@Component
struct IndexPage {
@State currentTab: number = 0
build() {
Tabs({ barPosition: BarPosition.End }) {
TabContent() {
HomePage()
}.tabBar(this.tabBuilder(0, '首页'))
TabContent() {
OrderPage()
}.tabBar(this.tabBuilder(1, '点单'))
TabContent() {
CartPage()
}.tabBar(this.tabBuilder(2, '购物车'))
TabContent() {
MinePage()
}.tabBar(this.tabBuilder(3, '我的'))
}
}
}
这里注意:Tabs 的 barPosition 设为 End,图标栏在底部。每个 TabContent 内部挂的是自定义组件,不是路由页面。如果你在 TabContent 里用 router.pushUrl 跳二级页,没问题;但如果你在里面再放一个 Navigation,就会嵌套多个路由栈,返回手势会混乱,别这么干。
3. 首页的运营位与信息流:Swiper 轮播、WaterFlow 瀑布流和卡片布局
首页是用户进来第一眼看到的东西,蜜雪冰城首页主要由几个区域组成:顶部搜索栏、品牌区/活动横幅、金刚区(分类图标)、新品推荐瀑布流。这个布局在 ArkUI 里做起来并不难,难点在于组件选型和列表性能。
3.1 顶部栏和活动横幅:Swiper 实现轮播图
顶部搜索栏用 Row + TextInput 就能搭,我一般会在里面加一个点击事件,跳转到搜索页,而不是真的去写一个搜索框的完整逻辑——因为业务上搜索页是独立页面。
活动横幅轮播是首页的视觉重点。ArkUI 提供了 Swiper 组件,天然支持左右滑动切换、自动播放、循环播放和指示器。
typescript复制Swiper() {
ForEach(this.banners, (banner: Banner) => {
Image(banner.imageUrl)
.width('100%')
.height(160)
.borderRadius(12)
})
}
.autoPlay(true)
.interval(4000)
.loop(true)
.indicator(true)
.index(this.currentBannerIndex)
要注意的是,Swiper 的指示器默认样式比较朴素,如果你想做底部居中的小圆点,用自带 indicator 就行;如果你想在图片右下角显示“1/5”这种文字指示器,需要把 indicator 关掉,自己用 Row 叠加一个 Text 上去。这种小细节就是商业应用和 Demo 的差别。
3.2 金刚区图标:用 Grid 做宫格布局
金刚区就是那一排圆形图标入口,蜜雪冰城里大概有“点单”“外送”“会员”“优惠券”等入口。实现方式很简单,用 Grid 或者 Row + Flex 都能做。我推荐 Grid,因为它的列数控制方便,数据驱动渲染也干净:
typescript复制Grid() {
ForEach(this.quickEntries, (entry: QuickEntry) => {
GridItem() {
Column() {
Image(entry.icon).width(48).height(48)
Text(entry.name).fontSize(12).margin({ top: 4 })
}
}
})
}
.columnsTemplate('1fr 1fr 1fr 1fr 1fr')
.rowsGap(12)
.columnsGap(12)
这个区域没什么难点,纯 UI 组装,但要注意图片资源不要用本地 static 资源写死,最好走网络 URL,后面换活动入口不用发版。开发调试阶段没有真实接口,可以用本地占位图先顶着。
3.3 新品推荐流:WaterFlow 实现错落卡片
瀑布流是首页最有“内容社区”味道的区域。ArkUI 的 WaterFlow 组件能按列排列高度不一的卡片,非常适合做商品推荐流。
typescript复制WaterFlow() {
ForEach(this.recommendProducts, (product: Product) => {
FlowItem() {
Column() {
Image(product.imageUrl)
.width('100%')
.height(product.imageHeight) // 每个商品图片高度不同
Text(product.name)
Text(`¥${product.price}`)
}
}
})
}
.columnsTemplate('1fr 1fr')
.columnsGap(10)
这里有个性能点:WaterFlow 是懒加载的,但如果你直接给每个 FlowItem 塞大量复杂子组件,滚动时还是会掉帧。我的做法是:瀑布流卡片里的图片用 Image 的懒加载策略,并给 FlowItem 固定一个估算高度,避免图片加载时布局反复计算。另外,如果你的数据量超过 50 条,强烈建议改用 LazyForEach 来渲染,它会在组件滑出可视区时自动回收,内存表现天差地别。
4. 点单页的“菜单树”:分类侧边栏、商品列表与本地筛选
点单页是整个 App 业务最重的页面。蜜雪冰城的点单页左侧是分类竖排列表,右侧是对应分类下的商品卡片。这个交互在餐饮类 App 里太常见了,但它对组件联动的要求很高:点击左侧分类,右侧要跳到对应分组;右侧滚动时,左侧的高亮分类要跟着变。
4.1 SideBarContainer 实现左右联动
ArkUI 里做这种“侧边栏 + 内容区”的结构,最简单的方式是用 SideBarContainer。但说实话,这个组件在真机上偶尔会有滑动冲突,如果你追求稳定,也可以直接用 Row 把左侧和右侧拆成两个独立容器,再手动监听滚动位置来同步高亮。
我的推荐是用 Row 方案,原因有二:一是控制力强,滚动监听和分类高亮逻辑完全自己掌控;二是避免 SideBarContainer 在复杂嵌套中的布局异常,踩过坑的人都知道它有时候会自动弹出一条 miniSideBar 的按钮,非常碍事。
右侧商品列表使用 List 组件,给每个分组设置对应的 header 和 item:
typescript复制List({ space: 12 }) {
ForEach(this.filteredProducts, (group: ProductGroup) => {
ListItemGroup({ header: this.groupHeader(group.name) }) {
ForEach(group.products, (product: Product) => {
ListItem() {
ProductCard({ product: product, onAdd: (p) => this.handleAdd(p) })
}
})
}
})
}
.onScrollIndex((start: number, end: number) => {
// 根据当前滚动位置计算高亮分类
})
这里重点说 onScrollIndex。这个回调会返回当前可视区域内第一个和最后一个 item 的索引。你需要维护一个“每个分组的起始索引数组”,然后根据 start 索引算出当前应该高亮哪个分类。这个逻辑听着简单,但 group 里面只有 header 没有数据条目的情况很容易崩,所以分组过滤时要把空分类过滤掉,否则索引对不上。
4.2 商品卡片:尺寸、价格与加购按钮
右侧商品卡片我一般用一张横版卡片:左边商品图(约 80vp 见方),右边两行文字(名称、描述),右下角一个“+”按钮。这个卡片在 ArkUI 里就是 Row + Column 的嵌套。
卡片做出来不难,但有几个像素级细节会影响体验:图片圆角建议统一 8vp,卡片整体背景用白色,文字主标题 16fp、副标题 12fp,颜色用 0.9 的黑色和 0.5 的灰色,价格用主题红加粗。还有,加购按钮的点击区域要做大一点,至少 36vp,不然用户老是点不到,尤其真机上手指粗。
4.3 搜索与本地筛选:不用后端也能体验流畅
点单页顶部一般有一个搜索框。在没有后端接口的开发阶段,我在 model 层写了一个轻量的本地检索方法:把商品名称、描述、分类名称拼成一个字符串,用 includes 做关键字匹配。这个方案在数据量几百条以内完全够用,而且过滤逻辑是同步的,输入即出结果,体验比走网络接口还流畅。
typescript复制function searchProducts(keyword: string, products: Product[]): Product[] {
if (!keyword.trim()) {
return products
}
return products.filter((item) => {
const text = `${item.name} ${item.description} ${item.categoryName}`
return text.includes(keyword)
})
}
一个容易被忽略的坑:ForEach 的 keyGenerator 要写对。如果你直接用数组索引当 key,那过滤后列表重排时,组件复用会错乱,可能出现“图片和文字不匹配”的灵异现象。正确做法是用商品 id 生成 key,例如 (item: Product) => item.id。
5. 商品详情与购物车的状态流转:规格弹层、加购与角标联动
点单页做完了,用户点“+”或者点商品卡片,会进入商品详情页或者弹出规格选择层。蜜雪冰城 App 的做法是底部弹出一个半屏的规格面板,里面展示商品图、名称、价格、规格选项、数量选择器和“加入购物车”按钮。
5.1 规格选择弹层:用 bindSheet 还是自定义半屏
ArkUI 提供了 bindSheet 来快速绑定一个半屏弹层。这个组件用起来很舒服,支持滑动手势关闭、遮罩点击关闭、自定义高度。我在商品详情页直接给根组件绑了一个 sheet,里面放规格选择内容。
typescript复制Button('选择规格')
.bindSheet($$this.showSheet, this.specSheetBuilder(), {
height: 480,
dragBar: true,
showClose: true
})
规格数据绑定怎么做?我给每个规格组定义了一个选项数组,并维护一个“当前选中的规格 key”状态。例如:
typescript复制interface SpecOption {
label: string
value: string
selected: boolean
}
点击某个选项时,把同组其他选项的 selected 置为 false,把当前项置为 true,然后重新计算价格。这个价格变化其实很有趣:基础价格 + 加料加价,加料选项的价格可能是 +2 元,冰淇淋 +3 元,我直接在选中时加上差价再刷新 UI。
5.2 购物车状态管理:@State、@Prop、@Link 怎么分配
购物车是整个 App 状态管理最难的地方。因为点单页要能加购,详情页要能加购,购物车 Tab 要能展示和修改数量,四个页面共享同一份数据。在 HarmonyOS 的 ArkUI 里,跨组件共享状态有几种做法:
- 用 @State + 回调函数一层层传。
- 用 @Observed + @ObjectLink 观察深层对象变化。
- 用 AppStorage 做全局状态存储。
- 用 @Provide + @Consume 做跨层级依赖注入。
我个人的实践结论是:购物车这个场景,用 AppStorage 最直接。因为它天然全局唯一,不需要层层传参,而且支持持久化(用 PersistentStorage 可以做到 App 重启后购物车还在)。但注意:不要把所有数据都塞进 AppStorage,它适合存“需要跨页面共享的业务状态”,比如购物车、用户信息、门店信息。纯页面内部 UI 状态(比如弹层开关、选中态)继续用 @State。
我在 model 层定义了一个 CartStore,封装了 addItem、removeItem、updateQuantity、clear 四个方法,方法内部操作 AppStorage 里的购物车数组。页面里通过 AppStorage.get<CartItem[]>('cart') 读取,并调用方法修改。这样修改后,所有绑定该 key 的组件会自动刷新。
代码示意:
typescript复制// 初始化
AppStorage.setOrCreate('cart', [] as CartItem[])
function addToCart(item: CartItem) {
const cart = AppStorage.get<CartItem[]>('cart') ?? []
const existed = cart.find(it => it.skuId === item.skuId)
if (existed) {
existed.quantity += item.quantity
} else {
cart.push(item)
}
AppStorage.setOrCreate('cart', cart)
}
这个方案有个坑:AppStorage 里存的是对象数组,如果你直接修改数组里某个对象的属性,UI 不一定刷新。所以切记,每次修改数组后都调用一次 AppStorage.setOrCreate 重新赋值,哪怕数组是同一个引用。也可以先把数组展开成一个新数组,强制触发依赖更新。
5.3 购物车 Tab:列表展示、数量加减与合计金额
购物车页面本身不复杂,一个 List 展示商品条目,每个条目有名称、规格、单价、数量加减器,底部一个合计金额和“去结算”按钮。
数量加减器我封装成了一个小组件,内部用 @Link 绑定一个 CartItem 的 quantity 属性,这样父组件的数组变化才能同步到子组件。注意 @Link 只能绑定 @State 或 AppStorage 里的数据,不能直接传一个普通对象字段。所以我的做法是:购物车 List 的每一项用 @Link cartItem: CartItem,然后父组件渲染时把 AppStorage.get('cart') 里对应项传进去。
这里再强调一个容易踩的坑:当你对购物车数组做 splice 删除或 push 操作后,ForEach 的 key 必须稳定,否则会出现删错项或动画错乱。我给购物车条目生成 key 用的是 skuId,因为同一商品不同规格是不同 skuId,这样能保证每个条目身份唯一。
合计金额的计算我直接放在购物车页面里,reduce 一遍购物车数组,累加 quantity * unitPrice,同时计算总件数。这个逻辑可以单独抽出成一个纯函数,方便测试,也方便以后加“满减优惠”扩展。
6. 鸿蒙原生 App 落地前后:位置服务、真机调试与上架前检查
很多人在模拟器上跑通就以为完事了,实际上真机调试和上架才是噩梦的开始。这一节我把遇到过的典型问题列出来,帮你少走弯路。
6.1 位置服务与门店选择:权限申请的正确姿势
蜜雪冰城点单首页通常会请求定位权限,用来推荐附近门店。HarmonyOS 里申请定位权限要分两步:先在 module.json5 里声明 ohos.permission.LOCATION,再在代码里通过 geoLocationManager 调用。
只声明权限是不够的,应用运行时还需要动态请求授权。HarmonyOS 的授权弹窗由系统统一管理,开发者只需要调用 requestPermissionsFromUser 触发。这个 API 在 API 9 以后的签名是:
typescript复制import { abilityAccessCtrl, common } from '@kit.AbilityKit'
const atManager = abilityAccessCtrl.createAtManager()
const permissions: Array<Permissions> = ['ohos.permission.LOCATION']
atManager.requestPermissionsFromUser(this.getContext() as common.UIAbilityContext, permissions)
这里有个体验上的细节:不要在页面加载时就立刻弹权限框,用户会觉得莫名其妙。正确做法是先进入门店选择页,下拉刷新门店列表时再请求定位权限,配合一句提示语“需要获取位置以推荐附近门店”,授权通过率会高很多。另外,模拟器上定位 API 经常拿到空数据,这属于正常现象,最好在代码里写一个 fallback:拿不到定位就默认展示一个城市门店列表。
6.2 真机调试:HDC 连接与常见失败排查
真机调试是开发阶段逃不掉的环节。DevEco Studio 支持通过 HDC(HarmonyOS Device Connector)连接设备,无线调试在 API 11 及以上也支持了,但实话实说,无线调试在部分机型上不稳定,我大多数时候还是用 USB 有线连接,稳定第一。
常见的连接失败原因有三个:一是手机没有开启“开发者模式”和“USB 调试”,这个在设置里连续点击版本号即可开启;二是电脑缺少 HDC 驱动,表现为设备管理器里有未知设备;三是 DevEco Studio 的自动检测没刷新,手动在 Terminal 里跑 hdc list targets 能看到设备。
bash复制hdc list targets
如果能看到设备但没有授权,手机会弹一个“允许 USB 调试”的对话框,记得点允许。然后 DevEco Studio 里就能看到设备了,直接点击 Run 按钮会安装调试包并启动应用。
一个很容易被忽视的点是:真机调试时应用签名默认用的是 debug 证书,这个证书只能在调试设备上安装。你想装到别人手机上,必须生成 release 签名。这一块 DevEco Studio 有自动签名功能,但前提是你登录了华为账号,并且手机和电脑在同一个开发者账号下。
6.3 上架前要过的几道关:签名、隐私声明与审核材料
项目做到可以上架了,就要走应用市场审核。第一步是签名,release 签名的 keystore 文件要妥善保存,丢了就再也无法更新应用。建议至少备份到两个不同的地方,比如本地磁盘和加密网盘。
第二步是隐私声明。应用涉及定位、账号、支付等敏感权限,需要在应用市场填写隐私政策链接,同时在 App 内部也要有可访问的隐私政策入口。HarmonyOS 的审核对权限申请的说明卡得很严,你在代码里申请了定位权限,就必须在隐私声明里写明用途,比如“用于附近门店推荐和配送地址填写”。
第三步是材料准备,包括应用图标、截图、描述、分类信息。截图要求一般是至少 4 张,分辨率要适配主流机型。这一步没什么技术含量,但经常会有人因为截图尺寸不对被打回。
我可以负责任地说,上架审核最耗时的往往不是代码问题,而是这些资质和材料问题。开发阶段早点把隐私声明、权限用途说明准备好,后面审核会顺利很多。
写在最后:关于这套实战,我自己的几点体会
这套蜜雪冰城 App 的复刻实战做完之后,我最大的体会是:HarmonyOS 开发真正难的,不是 ArkTS 语法或者某个组件不会用,而是“业务状态怎么组织”。你看网上大量教程,都在教 Swiper 怎么用、WaterFlow 怎么写,但几乎没人告诉你购物车数据应该放 AppStorage 还是 @State,分类联动滚动到底应该监听什么回调。而这些,恰恰是商业应用开发中的核心问题。
我个人建议你拿到一个案例项目后,先花 30% 的时间做数据模型和状态设计,再花 30% 的时间搭页面骨架,最后才去填充 UI 细节。这个顺序别搞反。否则你代码写了两千行,突然发现购物车要在四个页面共享,改起来会非常痛苦。
如果你按这篇的思路把首页、点单页、购物车跑通,下一篇我会接着拆结算流程和订单状态管理,包括优惠券计算、配送费规则、订单列表的状态机流转。这些内容是点单 App 里最容易写乱的地方,到时候我会把代码直接贴出来给你看。
