一、技术前言

在移动端应用开发领域,外卖配送平台是最具代表性的多业务场景聚合型应用之一。它不仅需要承载商家浏览、菜单展示、订单追踪、购物车管理、优惠券核销、骑手定位、用户评价、收藏管理等多个业务模块,更要求在不同模块之间实现流畅的切换体验和统一的设计语言。本文将以一款基于 HarmonyOS ArkUI 框架开发的外卖配送平台 Demo 应用——"闪电蜂"为例,从架构设计、数据建模、组件拆分、状态管理、动画编排等多个维度,深入剖析其完整的技术实现方案。
在这里插入图片描述

ArkUI 是华为 HarmonyOS 生态下的声明式 UI 框架,其核心设计理念与 React 和 Flutter 有诸多相似之处,但同时具备自身独特的组件化机制和状态管理能力。在这款外卖配送应用中,开发者充分利用了 ArkUI 的 @Entry@Component@Builder@State@Observed 等装饰器,构建了一个结构清晰、职责分明、可维护性强的多 Tab 页面架构。

从技术选型的角度来看,这款应用采用了以下关键技术决策:

第一,双行 Tab 栏设计。 底部导航栏采用两行、每行四个 Tab 的布局方式(4+4),总共容纳 8 个业务模块。这种设计在有限的屏幕空间内最大限度地扩展了导航入口,同时每个 Tab 都配有 Emoji 图标和文字标签,降低了用户的学习成本。选中的 Tab 通过背景色变化和文字高亮来提供清晰的视觉反馈,其核心实现依赖于 @State selTab 状态变量与条件渲染的组合。

第二,声明式条件渲染。 所有 Tab 的内容区域共享同一个 Scroll 容器,通过 if/else if 条件链根据 selTab 的值决定当前显示哪个 Tab 的 @Builder 构建函数。这种设计避免了多个页面实例同时存在带来的内存开销,每次只渲染当前激活的 Tab 内容,实现了按需渲染的性能优化。

第三,响应式数据驱动。 所有列表数据(商家列表、菜品列表、订单列表、购物车列表、优惠券列表、骑手列表、评价列表、收藏列表)均通过 @State 装饰器声明,当数据发生变化时,UI 会自动重新渲染。这种响应式数据绑定机制极大地简化了数据与视图之间的同步逻辑。

第四,统一的视觉设计语言。 应用定义了一套完整的 ColorPalette 色彩体系,包含 22 个颜色常量,涵盖了背景色、卡片色、标题色、副文本色、三级文本色、品牌色、功能色、线条色、选中色、遮罩色、透明色等。所有组件统一引用这些颜色常量,确保了视觉风格的一致性,也便于主题切换。

第五,丰富的动画编排。 应用中使用了多种动画效果,包括呼吸动画(breath 状态驱动的透明度交替)、无限循环动画(animation 属性的 iterations: -1playMode: PlayMode.Alternate 组合)、线性渐变(linearGradient)等,为静态页面增添了生动的视觉层次。

下面,我们将从代码的第一行开始,逐段深入分析这个外卖配送平台的完整实现。


二、整体架构流程图

弹框层

底部导航

内容区域_Scroll

头部区域

数据层

状态管理

入口组件

Page789
@Entry @Component

selTab: number

breath: boolean

showAdd / showEdit / showDel

8 个列表数据
@State 装饰

ColorPalette 色彩体系

8 个数据模型
@Observed

8 组模拟数据
mock 列表

2 个工具函数
orderStatusColor / starStr

headerDeliver
定位栏 + 搜索栏 + 分类宫格 + 闪购条

shopTab
商家双列卡片

menuTab
菜单图标清单

orderTab
订单状态时间轴

cartTab
购物车数量列表

couponTab
优惠券虚线大卡

riderTab
骑手追踪卡

reviewTab
评价评论卡

favTab
收藏清单行

orderChart
月度订单柱状图

tabBar
两行 4+4 Tab

addModal
新增下单

editModal
编辑记录

delModal
确认删除

从上述架构流程图可以清晰地看到,整个应用以 Page789 组件为核心枢纽,向下连接了数据层、头部区域、内容区域、底部导航和弹框层五个子系统。状态管理通过 @State 装饰器实现响应式绑定,数据层通过 @Observed 装饰器提供数据模型,而工具函数则负责将业务逻辑映射为视觉表现。


三、文件头部注释与设计意图

// ============================================================
// 789 闪电蜂 · 外卖配送平台(现代行业:外卖配送)
// 头部样式:购物外卖风(搜索栏 + 分类宫格 + 闪购倒计时条)无动画
// 布局风格:8 个 tab 每个布局完全不同
//   商家=双列卡片 / 菜单=图标清单 / 订单=状态时间轴 / 购物车=数量列表
//   优惠券=虚线大卡 / 骑手=追踪卡 / 评价=评论卡 / 收藏=清单行
// 底部 8 tab 两行(4+4)
// 主题:橙红 + 暖黄 + 火力红
// ============================================================

这段文件头部注释非常重要,它扮演了"设计文档"的角色。在团队协作中,这样的注释能够帮助开发者快速理解整个文件的设计意图和结构布局。我们来逐行解读:

“闪电蜂 · 外卖配送平台”:给出了应用名称和所属行业领域,明确了业务边界。这对于后续的功能扩展和需求变更具有重要的参考价值。

“头部样式:购物外卖风”:指出头部区域的设计风格,包含搜索栏、分类宫格和闪购倒计时条三个核心组件。"无动画"的标注说明头部区域采用静态展示,以保持简洁和性能。

“8 个 tab 每个布局完全不同”:这是整个应用最核心的设计理念——每个 Tab 都采用独一无二的布局方式,而不是简单的列表复用。这种设计虽然增加了开发工作量,但能带来更好的用户体验,因为每个业务场景都有最适合其数据特征的展示方式。具体来说:商家 Tab 采用双列卡片布局,适合展示店铺封面和评分信息;菜单 Tab 采用图标清单,适合展示菜品名称、价格和销量;订单 Tab 采用状态时间轴,适合展示订单的流转过程;购物车 Tab 采用数量列表,支持商品数量的增减操作;优惠券 Tab 采用虚线大卡,左边金额右边详情;骑手 Tab 采用追踪卡,包含进度条和位置信息;评价 Tab 采用评论卡,包含用户头像、评分和正文;收藏 Tab 采用清单行,简洁展示收藏的商家。

“底部 8 tab 两行(4+4)”:明确底部导航的布局方式,这是适配较多 Tab 数量的一种常见方案。

“主题:橙红 + 暖黄 + 火力红”:给出了色彩主题方向,为整个色彩体系的设计提供了基调。外卖行业通常采用暖色系来激发食欲和传递温暖感。


四、色彩体系设计(ColorPalette 接口与 COLORS 常量)

4.1 接口定义

interface ColorPalette {
  bg: string;
  card: string;
  dark: string;
  darkL: string;
  title: string;
  sub: string;
  text3: string;
  orange: string;
  orangeD: string;
  red: string;
  redD: string;
  yellow: string;
  green: string;
  blue: string;
  purple: string;
  gold: string;
  line: string;
  tabOn: string;
  mask: string;
  transparent: string;
  tagBg: string;
}

ColorPalette 接口定义了应用所需的全部颜色变量,共计 22 个字段。这种设计方式的优势在于:

类型安全:TypeScript 接口确保了所有颜色引用都经过编译时检查,避免了拼写错误导致的运行时问题。如果开发者在代码中误写了 COLORS.backgroud,TypeScript 编译器会立即报错。

语义化命名:每个颜色变量都有明确的语义含义,而不是直接使用色值。例如 title 表示标题文字颜色,sub 表示副文本颜色,text3 表示三级文本颜色,orange 表示品牌主色,orangeD 表示品牌深色变体。这种命名方式让代码的可读性大大提升。

可扩展性:如果未来需要支持深色模式或多主题切换,只需要替换 COLORS 常量对象中的色值,而不需要修改任何组件代码。

4.2 具体色值

const COLORS: ColorPalette = {
  bg: '#1A0E08',
  card: '#281A12',
  dark: '#32200F',
  darkL: '#3E2A18',
  title: '#FFF8E7',
  sub: '#C4A878',
  text3: '#806848',
  orange: '#FF8F00',
  orangeD: '#E65100',
  red: '#F44336',
  redD: '#C62828',
  yellow: '#FFC107',
  green: '#4CAF50',
  blue: '#2196F3',
  purple: '#9C27B0',
  gold: '#FFD54F',
  line: '#3A2818',
  tabOn: '#FF8F00',
  mask: 'rgba(0,0,0,0.6)',
  transparent: 'rgba(0,0,0,0)',
  tagBg: 'rgba(255,143,0,0.14)'
};

这款应用采用了深色主题设计,整体色调以深棕/暗色为基底,搭配暖橙色和金黄色作为点缀。我们来逐一分析每个颜色变量的设计意图:

背景层级

  • bg: '#1A0E08'——最深层的页面背景色,接近黑色但带有暖棕色调,营造温馨的暗色氛围。
  • card: '#281A12'——卡片背景色,比页面背景稍亮,形成层次区分。
  • dark: '#32200F'——头部区域背景色,用于区分头部和内容区域。
  • darkL: '#3E2A18'——更亮的深色,用于强调区域(如闪购条、购物车合计栏)。

文字层级

  • title: '#FFF8E7'——标题文字色,暖白色,在深色背景上具有最高的可读性。
  • sub: '#C4A878'——副文本色,暖金色调,用于次要信息。
  • text3: '#806848'——三级文本色,更暗的金棕色,用于最不重要的辅助信息。

品牌色与功能色

  • orange: '#FF8F00'——品牌主色,用于 Tab 选中态、价格标签、操作按钮等核心交互元素。
  • orangeD: '#E65100'——品牌深色,用于图标背景、强调元素。
  • red: '#F44336'——功能红色,用于删除按钮、倒计时、促销标签。
  • redD: '#C62828'——深红色,用于 HOT 标签背景。
  • yellow: '#FFC107'——暖黄色,用于折扣标签、优惠券类型。
  • green: '#4CAF50'——功能绿色,用于已完成状态、配送时间。
  • blue: '#2196F3'——功能蓝色,用于链接和辅助信息。
  • purple: '#9C27B0'——功能紫色,用于评价头像的多样配色。
  • gold: '#FFD54F'——金黄色,用于星级评分。

特殊色

  • line: '#3A2818'——分割线色,比背景稍亮但保持低调。
  • tabOn: '#FF8F00'——Tab 选中色,与品牌色一致。
  • mask: 'rgba(0,0,0,0.6)'——弹框遮罩色,半透明黑色。
  • transparent: 'rgba(0,0,0,0)'——完全透明,用于时间轴末端的隐藏连接线。
  • tagBg: 'rgba(255,143,0,0.14)'——标签背景色,半透明橙色,用于菜品分类标签。

五、Tab 元数据与辅助数据

5.1 Tab 定义

interface TabMeta {
  icon: string;
  label: string;
}

const TAB_LIST: TabMeta[] = [
  { icon: '🏪', label: '商家' },
  { icon: '🍜', label: '菜单' },
  { icon: '📋', label: '订单' },
  { icon: '🛒', label: '购物车' },
  { icon: '🎫', label: '优惠券' },
  { icon: '🛵', label: '骑手' },
  { icon: '⭐', label: '评价' },
  { icon: '❤️', label: '收藏' }
];

TabMeta 接口定义了每个 Tab 的数据结构,仅包含 icon(Emoji 图标)和 label(文字标签)两个字段。在 ArkUI 中,Emoji 可以直接作为文本渲染,这大大简化了图标的管理——不需要引入额外的图标库或图片资源。

TAB_LIST 数组按顺序定义了 8 个 Tab,索引 0-7 分别对应商家、菜单、订单、购物车、优惠券、骑手、评价、收藏。这个顺序决定了底部导航栏的排列次序。

5.2 行分组索引

const ROW1_IDX: number[] = [0, 1, 2, 3];
const ROW2_IDX: number[] = [4, 5, 6, 7];

由于底部导航栏采用两行布局,需要将 8 个 Tab 分为两组。ROW1_IDX 包含前四个 Tab 的索引(商家、菜单、订单、购物车),ROW2_IDX 包含后四个 Tab 的索引(优惠券、骑手、评价、收藏)。这种分组方式将高频使用的 Tab 放在第一行,相对低频的放在第二行,符合用户的操作习惯。

5.3 图表数据

const MONTH_IDX: number[] = [0, 1, 2, 3, 4, 5];
const MONTH_NAME: string[] = ['2月', '3月', '4月', '5月', '6月', '7月'];
const ORDER_IDX: number[] = [18, 22, 26, 31, 28, 35];

这三组数据用于绘制月度订单柱状图。MONTH_IDX 提供循环索引,MONTH_NAME 提供月份标签,ORDER_IDX 提供每月订单数量。柱状图的高度由 ORDER_IDX[m] * 1.5 计算得出,使得不同月份的订单量差异在视觉上得到清晰呈现。
在这里插入图片描述

5.4 分类数据

const CAT_IDX: number[] = [0, 1, 2, 3, 4, 5, 6, 7];
const CAT_ICON: string[] = ['🍔', '🍜', '🍰', '🧋', '🍗', '🥗', '🍱', '🔥'];
const CAT_NAME: string[] = ['快餐', '面食', '甜品', '饮品', '炸鸡', '轻食', '便当', '热榜'];

这三个数组分别提供了分类宫格的索引、图标和名称。它们通过相同的索引进行关联:CAT_ICON[0](🍔)对应 CAT_NAME[0](快餐),以此类推。这种平行数组的数据组织方式在小型数据集上足够简洁,但在大型项目中建议使用结构化对象数组。

5.5 空数组(未来扩展预留)

const DOT_IDX: number[] = [0, 1, 2, 3, 4];

DOT_IDX 是一个简单的索引数组,在当前页面中未被直接使用,但在其他类似页面中(如健康趋势图)被用于绘制数据点。这表明该代码库具有一定的模板化特征,不同页面之间共享相似的数据结构。


六、数据模型层(8 个 @Observed 类)

数据模型是整个应用的数据基础。这款应用定义了 8 个数据模型类,每个都使用 @Observed 装饰器标记,确保当模型实例的属性发生变化时,依赖该数据的 UI 组件能够自动更新。

6.1 ShopItem —— 商家模型

@Observed
export class ShopItem {
  name: string;
  tag: string;
  rating: number;
  sales: number;
  delivery: string;
  fee: number;
  minOrder: number;
  constructor(name: string, tag: string, rating: number, sales: number, delivery: string, fee: number, minOrder: number) {
    this.name = name; this.tag = tag; this.rating = rating; this.sales = sales; this.delivery = delivery; this.fee = fee; this.minOrder = minOrder;
  }
}

ShopItem 类定义了外卖商家的核心属性,共 7 个字段:

  • name:商家名称,如"老王快餐"、“川香面馆”。这是商家的唯一标识,在 ForEach 渲染中用作 key 的一部分。
  • tag:商家分类标签,如"快餐·汉堡"、“面食·米粉”。采用"一级分类·二级分类"的格式,提供了丰富的分类信息。
  • rating:评分,number 类型,如 4.8。在 UI 中通过 toFixed(1) 格式化为一位小数。
  • sales:月销量,整数类型,如 3620。用于展示商家的受欢迎程度。
  • delivery:预计配送时间,如"25分钟"。这是一个字符串而非数字,因为配送时间通常是一个范围估计。
  • fee:配送费,如 3 元。在 UI 中显示为"配送 ¥3"。
  • minOrder:起送金额,如 15 元。用户订单金额低于此值时无法下单。

@Observed 装饰器是 ArkUI 中实现深度响应式的关键机制。当一个被 @Observed 标记的类实例的属性发生变化时,所有引用该实例的 @State@Prop 变量都会触发 UI 更新。需要注意的是,@Observed 只能观察类实例的顶层属性变化,对于嵌套对象的深层属性变化则需要额外的处理。

export 关键字表明这个类可以被其他模块导入使用,这是 TypeScript/ArkTS 模块化开发的基础。
在这里插入图片描述

6.2 DishItem —— 菜品模型

@Observed
export class DishItem {
  name: string;
  price: number;
  cat: string;
  desc: string;
  sold: number;
  constructor(name: string, price: number, cat: string, desc: string, sold: number) {
    this.name = name; this.price = price; this.cat = cat; this.desc = desc; this.sold = sold;
  }
}

DishItem 类定义了菜品的核心属性,共 5 个字段:

  • name:菜品名称,如"招牌牛肉面"。
  • price:价格,数字类型,如 28。在 UI 中显示为"¥28"。
  • cat:菜品分类,如"面食"、“快餐”。用于在菜单中展示菜品所属类别。
  • desc:菜品描述,如"手工拉面+牛腱肉"。简要说明菜品的主要成分和特色。
  • sold:月销量,如 1260。用于展示菜品的受欢迎程度。
    在这里插入图片描述

6.3 OrderItem —— 订单模型

@Observed
export class OrderItem {
  shop: string;
  dish: string;
  date: string;
  total: number;
  status: string;
  constructor(shop: string, dish: string, date: string, total: number, status: string) {
    this.shop = shop; this.dish = dish; this.date = date; this.total = total; this.status = status;
  }
}

OrderItem 类定义了订单的核心属性,共 5 个字段:

  • shop:商家名称,如"老王快餐"。
  • dish:菜品及数量,如"炸鸡腿汉堡套餐 x1"。
  • date:下单日期时间,如"08-20 12:15"。
  • total:订单总金额,如 35。
  • status:订单状态,如"配送中"、“已完成”、“已取消”。这是订单模型中最重要的字段,它决定了订单在时间轴上的显示颜色和操作按钮。

在这里插入图片描述

6.4 CartItem —— 购物车模型

@Observed
export class CartItem {
  name: string;
  price: number;
  qty: number;
  shop: string;
  constructor(name: string, price: number, qty: number, shop: string) {
    this.name = name; this.price = price; this.qty = qty; this.shop = shop;
  }
}

CartItem 类定义了购物车商品的属性,共 4 个字段:

  • name:商品名称。
  • price:单价。
  • qty:数量。这是购物车模型的关键字段,与 price 共同参与总价计算。
  • shop:所属商家。在外卖场景中,不同商家的商品不能合并下单,因此需要记录商家信息。
    在这里插入图片描述

6.5 CouponItem —— 优惠券模型

@Observed
export class CouponItem {
  amount: number;
  condition: string;
  desc: string;
  expire: string;
  type: string;
  constructor(amount: number, condition: string, desc: string, expire: string, type: string) {
    this.amount = amount; this.condition = condition; this.desc = desc; this.expire = expire; this.type = type;
  }
}

CouponItem 类定义了优惠券的属性,共 5 个字段:

  • amount:优惠金额,如 15 元。
  • condition:使用条件,如"满 30 可用"、“无门槛”。
  • desc:优惠券描述,如"新人专享券"。
  • expire:有效期,如"08-31 到期"。
  • type:优惠券类型,如"新人"、“午餐”、“会员”。

6.6 RiderItem —— 骑手模型

@Observed
export class RiderItem {
  name: string;
  distance: string;
  eta: string;
  progress: number;
  constructor(name: string, distance: string, eta: string, progress: number) {
    this.name = name; this.distance = distance; this.eta = eta; this.progress = progress;
  }
}

RiderItem 类定义了骑手的位置和配送进度,共 4 个字段:

  • name:骑手姓名,如"张师傅"。
  • distance:距离,如"1.2km"。
  • eta:预计到达时间,如"8分钟"。
  • progress:配送进度百分比,如 65。这个值直接用于渲染进度条的宽度,实现配送进度的可视化展示。

6.7 ReviewItem —— 评价模型

@Observed
export class ReviewItem {
  user: string;
  rating: number;
  content: string;
  dish: string;
  date: string;
  constructor(user: string, rating: number, content: string, dish: string, date: string) {
    this.user = user; this.rating = rating; this.content = content; this.dish = dish; this.date = date;
  }
}

ReviewItem 类定义了用户评价,共 5 个字段:

  • user:用户名,如"美食家小李"。用户名的首字符用于生成头像占位符。
  • rating:评分,1-5 的数字。通过 starStr() 函数转换为星星符号。
  • content:评价正文,完整的文字评价内容。
  • dish:评价的菜品,如"招牌牛肉面"。
  • date:评价日期,如"08-19"。

6.8 FavItem —— 收藏模型

@Observed
export class FavItem {
  name: string;
  tag: string;
  rating: number;
  sales: number;
  delivery: string;
  constructor(name: string, tag: string, rating: number, sales: number, delivery: string) {
    this.name = name; this.tag = tag; this.rating = rating; this.sales = sales; this.delivery = delivery;
  }
}

FavItem 类的结构与 ShopItem 类似,但去掉了 feeminOrder 字段,因为收藏列表不需要展示配送费和起送价。这种"按需裁剪"的模型设计避免了对商家模型的过度依赖,使得各个业务模块的数据模型保持独立。


七、模拟数据(Mock Data)

7.1 商家模拟数据

const mockShopList: ShopItem[] = [
  new ShopItem('老王快餐', '快餐·汉堡', 4.8, 3620, '25分钟', 3, 15),
  new ShopItem('川香面馆', '面食·米粉', 4.6, 2890, '30分钟', 2, 12),
  // ... 共 12 条
];

商家模拟数据包含 12 个商家,覆盖了快餐、面食、甜品、饮品、炸鸡、轻食、便当、川菜、日料、西餐、烧烤、粥品等多种品类。每个商家都有不同的评分(4.4-4.9)、销量(1500-5200)、配送时间(20-40分钟)、配送费(1-6元)和起送价(8-30元),这些差异化数据使得 UI 展示更加真实。

7.2 菜品模拟数据

const mockDishList: DishItem[] = [
  new DishItem('招牌牛肉面', 28, '面食', '手工拉面+牛腱肉', 1260),
  // ... 共 12 条
];

菜品模拟数据同样包含 12 条,每条关联到不同的分类,并提供了描述和销量信息。价格范围从 12 元(皮蛋瘦肉粥)到 58 元(玛格丽特披萨),覆盖了不同消费层次。

7.3 订单模拟数据

const mockOrderList: OrderItem[] = [
  new OrderItem('老王快餐', '炸鸡腿汉堡套餐 x1', '08-20 12:15', 35, '配送中'),
  // ... 共 10 条
];

订单模拟数据包含 10 条记录,状态覆盖了"配送中"、“已完成"和"已取消"三种情况。大部分订单为"已完成"状态,只有第一条为"配送中”,第六条为"已取消",这种分布符合真实场景中的订单状态比例。

7.4 购物车模拟数据

const mockCartList: CartItem[] = [
  new CartItem('招牌牛肉面', 28, 1, '川香面馆'),
  new CartItem('蜜汁鸡翅', 26, 1, '炸鸡研究所'),
  new CartItem('珍珠奶茶(大杯)', 16, 2, '茶百道'),
  new CartItem('草莓蛋糕', 38, 1, '甜蜜时光'),
  new CartItem('烤羊肉串(10串)', 30, 1, '深夜烧烤')
];

购物车模拟数据包含 5 件商品,来自不同的商家。其中珍珠奶茶数量为 2,其他均为 1。总价 = 28×1 + 26×1 + 16×2 + 38×1 + 30×1 = 154 元。这个数据在购物车 Tab 的合计栏中通过 reduce 函数动态计算。

7.5 优惠券模拟数据

const mockCouponList: CouponItem[] = [
  new CouponItem(15, '满 30 可用', '新人专享券', '08-31 到期', '新人'),
  // ... 共 8 条
];

优惠券模拟数据包含 8 张券,金额从 5 元到 30 元不等,类型覆盖了新人、午餐、晚餐、配送、下午茶、周末、会员、早餐。使用条件有"无门槛"、"满 15/20/25/30/50/80 可用"等不同档次。

7.6 骑手模拟数据

const mockRiderList: RiderItem[] = [
  new RiderItem('张师傅', '1.2km', '8分钟', 65),
  // ... 共 5 条
];

骑手模拟数据包含 5 位骑手,距离从 0.3km 到 3.0km,预计到达时间从 2 分钟到 18 分钟,配送进度从 20% 到 95%。进度条的颜色根据索引奇偶性在橙色和红色之间交替。

7.7 评价模拟数据

const mockReviewList: ReviewItem[] = [
  new ReviewItem('美食家小李', 5, '牛肉面非常好吃,汤头浓郁,面条筋道!', '招牌牛肉面', '08-19'),
  // ... 共 10 条
];

评价模拟数据包含 10 条评价,评分以 5 星为主(7 条),4 星为辅(3 条),模拟了真实外卖平台中好评占多数的分布。评价内容具体且口语化,增强了真实感。

7.8 收藏模拟数据

const mockFavList: FavItem[] = [
  new FavItem('老王快餐', '快餐·汉堡', 4.8, 3620, '25分钟'),
  // ... 共 10 条
];

收藏模拟数据包含 10 家商家,与商家列表有大量重叠,这符合用户"浏览后收藏"的行为模式。


八、工具函数

8.1 订单状态颜色映射

function orderStatusColor(s: string): string {
  if (s === '配送中') {
    return COLORS.orange;
  }
  if (s === '已完成') {
    return COLORS.green;
  }
  return COLORS.text3;
}

这个工具函数实现了订单状态到颜色的映射:**“配送中"返回橙色(COLORS.orange),表示正在进行中的积极状态;“已完成”**返回绿色(COLORS.green),表示成功结束的状态;其他状态(如"已取消”)返回三级文本色(COLORS.text3),表示非活跃状态。这种颜色语义化设计让用户无需阅读文字即可快速判断订单状态。

8.2 星级转字符串

function starStr(r: number): string {
  if (r >= 5) {
    return '⭐⭐⭐⭐⭐';
  }
  if (r >= 4) {
    return '⭐⭐⭐⭐';
  }
  if (r >= 3) {
    return '⭐⭐⭐';
  }
  if (r >= 2) {
    return '⭐⭐';
  }
  return '⭐';
}

这个函数将数字评分(1-5)转换为可视化的星星符号字符串。采用的是"向下取整"策略——评分 4.8 显示为 4 颗星,评分 4.0 也显示为 4 颗星。这种简单的映射方式适用于快速原型开发,但在生产环境中通常需要支持半星显示。


九、组件主体结构(Page789)

9.1 状态声明

@Entry
@Component
struct Page789 {
  @State selTab: number = 0;
  @State breath: boolean = false;
  @State showAdd: boolean = false;
  @State showEdit: boolean = false;
  @State showDel: boolean = false;
  @State editIdx: number = 0;
  @State delIdx: number = 0;
  @State shopList: ShopItem[] = mockShopList;
  @State dishList: DishItem[] = mockDishList;
  @State orderList: OrderItem[] = mockOrderList;
  @State cartList: CartItem[] = mockCartList;
  @State couponList: CouponItem[] = mockCouponList;
  @State riderList: RiderItem[] = mockRiderList;
  @State reviewList: ReviewItem[] = mockReviewList;
  @State favList: FavItem[] = mockFavList;

@Entry 装饰器标记 Page789 为页面入口组件,这意味着它可以作为独立页面被路由系统加载。@Component 装饰器标记其为可复用的自定义组件。

@State 装饰器是 ArkUI 响应式系统的核心。被 @State 标记的变量一旦发生变化,所有依赖该变量的 UI 组件都会自动重新渲染。这里声明了 15 个状态变量,可以分为以下几类:

导航状态selTab 控制当前选中的 Tab 索引,初始值为 0(商家 Tab)。

动画状态breath 是一个布尔型开关,通过定时器每隔 600ms 翻转一次,驱动呼吸动画效果。

弹框状态showAdd(新增弹框)、showEdit(编辑弹框)、showDel(删除弹框)三个布尔变量控制弹框的显示与隐藏。同时,editIdxdelIdx 记录了当前操作的数据索引。

数据状态:8 个列表变量分别存储了不同业务模块的数据,初始值来自对应的 mock 数据。这些变量被标记为 @State,意味着当列表数据发生变化时(如删除、编辑),UI 会自动更新。

9.2 生命周期

aboutToAppear() {
  setInterval(() => { this.breath = !this.breath; }, 600);
}

aboutToAppear() 是 ArkUI 组件的生命周期回调,在组件即将显示时调用。这里启动了一个定时器,每隔 600ms 翻转 breath 变量。这个定时器驱动的呼吸动画被应用于多个 UI 元素(如月度订单柱状图下方的文字图标),通过 opacity 属性在 1.0 和 0.5 之间交替,产生"呼吸"的视觉效果。

需要注意的是,代码中使用了 setInterval 返回的定时器 ID 被隐式忽略。在生产环境中,建议在 aboutToDisappear() 生命周期中调用 clearInterval() 来清理定时器,防止内存泄漏。

9.3 build() 方法

build() {
  Column() {
    this.headerDeliver()
    Divider().strokeWidth(1).color(COLORS.line)
    Scroll() {
      Column() {
        if (this.selTab === 0) {
          this.shopTab()
        } else if (this.selTab === 1) {
          this.menuTab()
        } else if (this.selTab === 2) {
          this.orderTab()
        } else if (this.selTab === 3) {
          this.cartTab()
        } else if (this.selTab === 4) {
          this.couponTab()
        } else if (this.selTab === 5) {
          this.riderTab()
        } else if (this.selTab === 6) {
          this.reviewTab()
        } else {
          this.favTab()
        }
        this.orderChart()
      }.width('100%').padding({ left: 10, right: 10, top: 10, bottom: 100 })
    }.constraintSize({ maxHeight: '60%' }).scrollBar(BarState.Off)
    this.tabBar()
    if (this.showAdd) {
      this.addModal()
    }
    if (this.showEdit) {
      this.editModal()
    }
    if (this.showDel) {
      this.delModal()
    }
  }.width('100%').height('100%').backgroundColor(COLORS.bg)
}

build() 方法是每个 ArkUI 组件的核心,它定义了组件的 UI 结构。这个方法的返回类型是 void,但内部通过声明式语法构建了整个页面的 UI 树。

顶层容器:最外层是 Column 组件,设置为 100% 宽高和页面背景色。Column 是一种垂直布局容器,其子组件按从上到下的顺序排列。

头部区域this.headerDeliver() 调用 @Builder 构建函数,渲染外卖配送平台的头部区域(定位栏、搜索栏、分类宫格、闪购倒计时条)。

分割线Divider 组件在头部和内容区域之间绘制一条 1px 的分割线,颜色使用 COLORS.line

内容区域Scroll 组件包裹了一个 Column 容器,其中包含条件渲染的 Tab 内容和月度订单柱状图。constraintSize({ maxHeight: '60%' }) 限制了内容区域的最大高度为屏幕的 60%,留出底部空间给 Tab 栏。scrollBar(BarState.Off) 隐藏了滚动条,保持界面简洁。paddingbottom: 100 为底部 Tab 栏留出了足够的空间。

条件渲染:使用 if/else if 链根据 selTab 的值决定渲染哪个 Tab 的 @Builder 函数。这种模式确保每次只有一个 Tab 的内容被渲染,避免了不必要的 DOM 节点创建。orderChart() 在所有 Tab 下方渲染月度订单柱状图,作为公共展示区域。

底部导航this.tabBar() 渲染底部两行 Tab 导航栏。

弹框层:三个弹框(新增、编辑、删除)通过条件渲染控制显示。它们渲染在 Column 的最上层,通过 zIndexposition 实现覆盖效果。


十、头部区域详解(headerDeliver)

@Builder
headerDeliver() {
  Column({ space: 12 }) {
    // 顶栏 + 搜索
    Row() {
      Text('📍 定位:科技园').fontSize(10).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
      Column().layoutWeight(1)
      Text('🔔').fontSize(13).fontColor(COLORS.sub).margin({ right: 10 })
      Text('👤').fontSize(13).fontColor(COLORS.sub)
    }.width('100%').padding({ left: 14, right: 14, top: 12 })
    // ...
  }
}

@Builder 装饰器用于定义构建函数,它是一种可复用的 UI 构建单元。与 @Component 不同,@Builder 函数不具备独立的生命周期和状态管理能力,但语法更简洁,适合用于构建可复用的 UI 片段。

头部区域由四个子区域组成,我们来逐一分析:

10.1 顶栏

Row() {
  Text('📍 定位:科技园').fontSize(10).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
  Column().layoutWeight(1)
  Text('🔔').fontSize(13).fontColor(COLORS.sub).margin({ right: 10 })
  Text('👤').fontSize(13).fontColor(COLORS.sub)
}.width('100%').padding({ left: 14, right: 14, top: 12 })

顶栏是一个水平排列的 Row 组件,包含三个元素:

  • 左侧显示当前定位地址"科技园",使用 fontWeight(FontWeight.Bold) 加粗显示。
  • 中间使用 Column().layoutWeight(1) 作为弹性空白区域,将右侧元素推到最右边。layoutWeight(1) 相当于 CSS Flexbox 中的 flex: 1,它会占据所有剩余空间。
  • 右侧显示通知铃铛🔔和用户头像👤两个图标,使用 Emoji 作为图标。

10.2 搜索栏

Row({ space: 8 }) {
  Text('🔍').fontSize(13).fontColor(COLORS.sub)
  Text('搜索商家、菜品').fontSize(12).fontColor(COLORS.text3)
  Column().layoutWeight(1)
  Text('🔍').fontSize(13).fontColor(COLORS.orange)
}
.width('100%').padding({ left: 14, right: 14, top: 8, bottom: 8 })
.backgroundColor(COLORS.card).borderRadius(10)
.margin({ left: 14, right: 14 })

搜索栏采用卡片样式设计,背景色为 COLORS.card,圆角 10px,左右各有 14px 的外边距。内部使用 Row 水平排列:左侧搜索图标、中间占位文本"搜索商家、菜品"、右侧橙色的搜索按钮。space: 8 参数设置了子元素之间的间距为 8px。

10.3 分类宫格

Flex({ wrap: FlexWrap.Wrap }) {
  ForEach(CAT_IDX, (c: number) => {
    Column({ space: 4 }) {
      Text(CAT_ICON[c]).fontSize(22)
      Text(CAT_NAME[c]).fontSize(8).fontColor(COLORS.sub)
    }
    .width('23%').margin({ top: 4, bottom: 4 })
    .padding({ top: 8, bottom: 8 })
    .alignItems(HorizontalAlign.Center)
    .onClick(() => { this.selTab = 0; })
  }, (c: number) => 'ci' + c)
}
.width('100%').padding({ left: 14, right: 14 })

分类宫格使用 Flex 组件实现弹性换行布局,wrap: FlexWrap.Wrap 允许子元素在超出容器宽度时自动换行。每个分类项占 23% 的宽度(4 个一行,留出少量间隙),点击后跳转到商家 Tab。

ForEach 是 ArkUI 中的列表渲染组件,它的三个参数分别是:数据源(CAT_IDX)、渲染函数(接收每个元素和索引)、键值生成函数('ci' + c)。键值生成函数对于列表性能至关重要——它帮助框架识别哪些元素发生了变化,从而实现高效的增量更新。

10.4 闪购倒计时条

Row({ space: 10 }) {
  Text('⚡ 限时闪购').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
  Text('5折起').fontSize(9).fontColor(COLORS.redD)
    .padding({ left: 6, right: 6, top: 1, bottom: 1 })
    .backgroundColor(COLORS.yellow).borderRadius(6)
  Column().layoutWeight(1)
  Text('距结束').fontSize(8).fontColor(COLORS.sub)
  Text('02:18:36').fontSize(11).fontColor(COLORS.red).fontWeight(FontWeight.Bold)
  Column() {
    Text('抢购').fontSize(10).fontColor(COLORS.title)
  }
  .padding({ left: 10, right: 10, top: 3, bottom: 3 })
  .backgroundColor(COLORS.red).borderRadius(9)
  .onClick(() => { this.showAdd = true; })
}
.width('100%').padding({ left: 14, right: 14, top: 8, bottom: 8 })
.backgroundColor(COLORS.darkL).borderRadius(10)
.margin({ left: 14, right: 14 })

闪购倒计时条是一个营销性质的组件,包含以下元素:

  • 闪电图标和"限时闪购"标题,使用加粗字体。
  • "5折起"标签,黄色背景上的深红色文字,形成强烈的视觉对比。
  • 倒计时"02:18:36",使用红色加粗显示,营造紧迫感。
  • "抢购"按钮,红色背景白色文字,点击触发新增弹框。

整个头部区域以 COLORS.dark 为背景色,形成与内容区域的视觉分离。


十一、Tab0:商家(双列卡片)详解

@Builder
shopTab() {
  Column({ space: 8 }) {
    Row() {
      Text('🏪 精选商家 · ' + this.shopList.length + ' 家').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
      Column().layoutWeight(1)
      Text('距离优先').fontSize(10).fontColor(COLORS.sub)
    }.width('100%').padding({ left: 4 })
    Flex({ wrap: FlexWrap.Wrap }) {
      ForEach(this.shopList, (item: ShopItem, idx: number) => {
        Column({ space: 6 }) {
          Stack({ alignContent: Alignment.TopEnd }) {
            Column()
              .width('100%').height(56)
              .borderRadius(10)
              .linearGradient({
                angle: 145,
                colors: [[idx % 3 === 0 ? COLORS.orange : idx % 3 === 1 ? COLORS.red : COLORS.yellow, 0.0], [COLORS.darkL, 1.0]]
              })
            Text(idx < 3 ? 'HOT' : '').fontSize(7).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
              .padding({ left: 5, right: 5, top: 1, bottom: 1 })
              .backgroundColor(COLORS.redD).borderRadius(5)
              .margin(4)
              .animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
          }
          .width('100%')
          Text(item.name).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
          Text(item.tag).fontSize(8).fontColor(COLORS.sub)
            .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
          Row({ space: 6 }) {
            Text('⭐' + item.rating.toFixed(1)).fontSize(9).fontColor(COLORS.gold)
            Text('· ' + item.sales + ' 单').fontSize(8).fontColor(COLORS.sub)
          }
          Row({ space: 6 }) {
            Text('🛵' + item.delivery).fontSize(8).fontColor(COLORS.green)
            Text('· 配送 ¥' + item.fee).fontSize(8).fontColor(COLORS.sub)
            Column().layoutWeight(1)
            Text('起送 ¥' + item.minOrder).fontSize(8).fontColor(COLORS.text3)
          }.width('100%')
        }
        .width('48%').margin(4).padding(10)
        .backgroundColor(COLORS.card).borderRadius(12)
        .onClick(() => { this.editIdx = idx; this.showEdit = true; })
      }, (item: ShopItem) => 'sp' + item.name)
    }.width('100%')
  }.width('100%')
}

商家 Tab 是默认显示的 Tab,也是用户最常浏览的页面。它采用双列卡片布局,我们来逐层分析其设计:

11.1 标题栏

Row() {
  Text('🏪 精选商家 · ' + this.shopList.length + ' 家').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
  Column().layoutWeight(1)
  Text('距离优先').fontSize(10).fontColor(COLORS.sub)
}.width('100%').padding({ left: 4 })

标题栏展示了商家总数(动态计算自 this.shopList.length),右侧显示排序方式"距离优先"。

11.2 卡片封面(Stack 叠加)

Stack({ alignContent: Alignment.TopEnd }) {
  Column()
    .width('100%').height(56)
    .borderRadius(10)
    .linearGradient({
      angle: 145,
      colors: [[idx % 3 === 0 ? COLORS.orange : idx % 3 === 1 ? COLORS.red : COLORS.yellow, 0.0], [COLORS.darkL, 1.0]]
    })
  Text(idx < 3 ? 'HOT' : '').fontSize(7).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
    .padding({ left: 5, right: 5, top: 1, bottom: 1 })
    .backgroundColor(COLORS.redD).borderRadius(5)
    .margin(4)
    .animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
}
.width('100%')

Stack 组件实现了图层叠加效果。底层是一个 56px 高的色块,使用 linearGradient 线性渐变填充。渐变的起始色根据索引 idx % 3 的余数在橙色、红色、黄色之间轮换,结束色统一为 COLORS.darkL。渐变角度 145 度,从左上到右下。

顶层是一个"HOT"标签,仅在前三个商家(idx < 3)上显示。标签带有无限循环的交替动画,产生闪烁效果以吸引用户注意。

11.3 商家信息

Text(item.name).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
  .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text(item.tag).fontSize(8).fontColor(COLORS.sub)
  .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })

商家名称和分类标签都设置了 maxLines(1)textOverflow,确保超长文本被截断并显示省略号。这是移动端 UI 开发中的常见实践,防止文本溢出破坏布局。

11.4 评分销量与配送信息

Row({ space: 6 }) {
  Text('⭐' + item.rating.toFixed(1)).fontSize(9).fontColor(COLORS.gold)
  Text('· ' + item.sales + ' 单').fontSize(8).fontColor(COLORS.sub)
}
Row({ space: 6 }) {
  Text('🛵' + item.delivery).fontSize(8).fontColor(COLORS.green)
  Text('· 配送 ¥' + item.fee).fontSize(8).fontColor(COLORS.sub)
  Column().layoutWeight(1)
  Text('起送 ¥' + item.minOrder).fontSize(8).fontColor(COLORS.text3)
}.width('100%')

两行信息分别展示评分/销量和配送时间/配送费/起送价。评分使用金黄色 (COLORS.gold),配送时间使用绿色 (COLORS.green),配送费使用副文本色,起送价使用三级文本色。这种分层设色让用户能够快速区分不同信息的重要性。

11.5 卡片容器

.width('48%').margin(4).padding(10)
.backgroundColor(COLORS.card).borderRadius(12)
.onClick(() => { this.editIdx = idx; this.showEdit = true; })

每个卡片占 48% 的宽度(两个卡片 + 间隙 ≈ 100%),卡片背景色为 COLORS.card,圆角 12px。点击卡片会设置 editIdx 并打开编辑弹框。


十二、Tab1:菜单(图标清单)详解

@Builder
menuTab() {
  Column({ space: 8 }) {
    Text('🍜 热门菜品 · ' + this.dishList.length + ' 道').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
      .width('100%').padding({ left: 4 })
    ForEach(this.dishList, (item: DishItem, idx: number) => {
      Row({ space: 12 }) {
        Column() {
          Text(idx % 4 === 0 ? '🍜' : idx % 4 === 1 ? '🍔' : idx % 4 === 2 ? '🍰' : '🧋').fontSize(28)
        }
        .width(56).height(56).borderRadius(12)
        .backgroundColor(idx % 3 === 0 ? COLORS.orangeD : idx % 3 === 1 ? COLORS.redD : COLORS.darkL)
        .justifyContent(FlexAlign.Center)
        .animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
        // ... 中间信息区
        Column({ space: 4 }) {
          Text('¥' + item.price).fontSize(14).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
          Column() {
            Text('+ 加入').fontSize(9).fontColor(COLORS.title)
          }
          .padding({ left: 10, right: 10, top: 3, bottom: 3 })
          .backgroundColor(COLORS.orange).borderRadius(9)
          .onClick(() => { this.showAdd = true; })
        }
        .alignItems(HorizontalAlign.End)
      }
      .width('100%').padding(10)
      .backgroundColor(COLORS.card).borderRadius(12)
      .onClick(() => { this.editIdx = idx; this.showEdit = true; })
    }, (item: DishItem) => 'dh' + item.name)
  }.width('100%')
}

菜单 Tab 采用横向列表布局,每个菜品占据一整行。我们来分析其关键设计元素:

12.1 左侧图标区

Column() {
  Text(idx % 4 === 0 ? '🍜' : idx % 4 === 1 ? '🍔' : idx % 4 === 2 ? '🍰' : '🧋').fontSize(28)
}
.width(56).height(56).borderRadius(12)
.backgroundColor(idx % 3 === 0 ? COLORS.orangeD : idx % 3 === 1 ? COLORS.redD : COLORS.darkL)
.justifyContent(FlexAlign.Center)
.animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })

左侧是一个 56×56 的圆角方块,内部显示菜品 Emoji 图标。图标根据 idx % 4 在四种 Emoji 之间轮换。背景色根据 idx % 3 在三种颜色之间轮换。整个图标区域带有呼吸动画,使静态的图标列表增添了动感。

12.2 中间信息区

Column({ space: 4 }) {
  Text(item.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
  Text(item.desc).fontSize(8).fontColor(COLORS.sub)
    .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
  Row({ space: 6 }) {
    Text('月售 ' + item.sold).fontSize(8).fontColor(COLORS.text3)
    Text(item.cat).fontSize(8).fontColor(COLORS.orange)
      .padding({ left: 6, right: 6, top: 1, bottom: 1 })
      .backgroundColor(COLORS.tagBg).borderRadius(8)
  }
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)

中间区域使用 layoutWeight(1) 占据剩余空间,内容左对齐。三层信息垂直排列:菜品名(加粗)、描述(单行省略)、月销量和分类标签(分类标签使用半透明橙色背景)。

12.3 右侧操作区

Column({ space: 4 }) {
  Text('¥' + item.price).fontSize(14).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
  Column() {
    Text('+ 加入').fontSize(9).fontColor(COLORS.title)
  }
  .padding({ left: 10, right: 10, top: 3, bottom: 3 })
  .backgroundColor(COLORS.orange).borderRadius(9)
  .onClick(() => { this.showAdd = true; })
}
.alignItems(HorizontalAlign.End)

右侧显示价格和"加入"按钮,内容右对齐。价格使用品牌橙色加粗显示,按钮使用橙色背景白色文字,点击触发新增弹框。


十三、Tab2:订单(状态时间轴)详解

@Builder
orderTab() {
  Column({ space: 0 }) {
    Row() {
      Text('📋 订单记录 · ' + this.orderList.length + ' 单').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
      Column().layoutWeight(1)
      Text('全部').fontSize(10).fontColor(COLORS.sub)
    }.width('100%').padding({ left: 4, bottom: 8 })
    ForEach(this.orderList, (item: OrderItem, idx: number) => {
      Row({ space: 10 }) {
        Column({ space: 4 }) {
          Column()
            .width(12).height(12).borderRadius(6)
            .backgroundColor(orderStatusColor(item.status))
            .animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
          Column()
            .width(2).layoutWeight(1)
            .backgroundColor(idx === this.orderList.length - 1 ? COLORS.transparent : COLORS.line)
        }
        .width(14).height(78)
        // ... 订单详情
      }
    })
  }
}

订单 Tab 是最具视觉特色的 Tab 之一,采用时间轴布局。我们来深入分析其实现细节:

13.1 时间轴节点

Column({ space: 4 }) {
  Column()
    .width(12).height(12).borderRadius(6)
    .backgroundColor(orderStatusColor(item.status))
    .animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
  Column()
    .width(2).layoutWeight(1)
    .backgroundColor(idx === this.orderList.length - 1 ? COLORS.transparent : COLORS.line)
}
.width(14).height(78)

时间轴由两部分组成:圆点节点(12×12 的圆形)和连接线(2px 宽的竖线)。圆点的颜色由 orderStatusColor() 函数根据订单状态动态决定——"配送中"为橙色,"已完成"为绿色,"已取消"为灰色。圆点带有呼吸动画,使得当前状态的订单更加醒目。

连接线使用 layoutWeight(1) 填充剩余高度,颜色为 COLORS.line。关键在于最后一条订单的连接线颜色被设置为 COLORS.transparent(完全透明),避免时间轴在列表末尾无意义地延伸。

13.2 订单详情

Column({ space: 4 }) {
  Row() {
    Text(item.shop).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
    Column().layoutWeight(1)
    Text(item.status).fontSize(9).fontColor(orderStatusColor(item.status)).fontWeight(FontWeight.Bold)
  }.width('100%')
  Text(item.dish).fontSize(9).fontColor(COLORS.sub)
  Row() {
    Text(item.date).fontSize(9).fontColor(COLORS.text3)
    Column().layoutWeight(1)
    Text('¥' + item.total).fontSize(11).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
  }.width('100%')
  Row() {
    if (item.status === '配送中') {
      Text('催单').fontSize(9).fontColor(COLORS.title)
        .padding({ left: 8, right: 8, top: 3, bottom: 3 })
        .backgroundColor(COLORS.orange).borderRadius(9)
        .onClick(() => { this.showAdd = true; })
    }
    if (item.status === '已完成') {
      Text('再来一单').fontSize(9).fontColor(COLORS.title)
        .padding({ left: 8, right: 8, top: 3, bottom: 3 })
        .backgroundColor(COLORS.green).borderRadius(9)
        .onClick(() => { this.showAdd = true; })
    }
    Column().layoutWeight(1)
    Text('删除').fontSize(9).fontColor(COLORS.red)
      .onClick(() => { this.delIdx = idx; this.showDel = true; })
  }.width('100%')
}

订单详情区域展示了四层信息:

  1. 商家名称 + 订单状态(两端对齐)
  2. 菜品名称
  3. 下单日期 + 订单金额(两端对齐)
  4. 操作按钮:根据状态显示不同按钮——"配送中"显示"催单"按钮,"已完成"显示"再来一单"按钮,所有订单都显示"删除"按钮

这种"状态驱动 UI"的模式是声明式框架的核心优势——不需要手动切换按钮的显示/隐藏,只需根据状态条件渲染对应的按钮即可。


十四、Tab3:购物车(数量列表)详解

@Builder
cartTab() {
  Column({ space: 8 }) {
    Row() {
      Text('🛒 购物车 · ' + this.cartList.length + ' 件').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
      Column().layoutWeight(1)
      Text('清空').fontSize(10).fontColor(COLORS.red)
        .onClick(() => { this.delIdx = 0; this.showDel = true; })
    }.width('100%').padding({ left: 4 })
    ForEach(this.cartList, (item: CartItem, idx: number) => {
      Row({ space: 12 }) {
        Column() { Text('🍽️').fontSize(22) }
        .width(44).height(44).borderRadius(10)
        .backgroundColor(COLORS.darkL).justifyContent(FlexAlign.Center)
        Column({ space: 4 }) {
          Text(item.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
          Text(item.shop).fontSize(8).fontColor(COLORS.sub)
          Text('¥' + item.price).fontSize(11).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Start)
        // 数量控制
        Row({ space: 8 }) {
          Column() { Text('−').fontSize(14).fontColor(COLORS.sub) }
          .width(24).height(24).borderRadius(12)
          .backgroundColor(COLORS.darkL).justifyContent(FlexAlign.Center)
          .onClick(() => { this.editIdx = idx; this.showEdit = true; })
          Text(item.qty.toString()).fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
          Column() { Text('+').fontSize(14).fontColor(COLORS.title) }
          .width(24).height(24).borderRadius(12)
          .backgroundColor(COLORS.orange).justifyContent(FlexAlign.Center)
          .onClick(() => { this.showAdd = true; })
        }.alignItems(VerticalAlign.Center)
      }
      .width('100%').padding(10)
      .backgroundColor(COLORS.card).borderRadius(12)
    }, (item: CartItem) => 'ct' + item.name + item.shop)
    // 合计
    Row() {
      Text('合计').fontSize(12).fontColor(COLORS.sub)
      Column().layoutWeight(1)
      Text('¥' + this.cartList.reduce((sum: number, c: CartItem) => sum + c.price * c.qty, 0))
        .fontSize(20).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
      Column() {
        Text('去结算').fontSize(13).fontColor(COLORS.title)
      }
      .padding({ left: 20, right: 20, top: 8, bottom: 8 })
      .backgroundColor(COLORS.red).borderRadius(10)
      .onClick(() => { this.showAdd = true; })
    }
    .width('100%').padding(12)
    .backgroundColor(COLORS.darkL).borderRadius(12)
  }.width('100%')
}

购物车 Tab 的核心功能是管理用户选购的商品,我们来分析其关键设计:

14.1 数量控制器

Row({ space: 8 }) {
  Column() { Text('−').fontSize(14).fontColor(COLORS.sub) }
  .width(24).height(24).borderRadius(12)
  .backgroundColor(COLORS.darkL).justifyContent(FlexAlign.Center)
  .onClick(() => { this.editIdx = idx; this.showEdit = true; })
  Text(item.qty.toString()).fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
  Column() { Text('+').fontSize(14).fontColor(COLORS.title) }
  .width(24).height(24).borderRadius(12)
  .backgroundColor(COLORS.orange).justifyContent(FlexAlign.Center)
  .onClick(() => { this.showAdd = true; })
}

数量控制器由三个元素组成:减号按钮(圆形,深色背景)、当前数量(加粗居中)、加号按钮(圆形,橙色背景)。“−"按钮触发编辑弹框,”+"按钮触发新增弹框。两个按钮都是 24×24 的圆形,使用 borderRadius(12) 实现完美圆角。

14.2 合计栏

Row() {
  Text('合计').fontSize(12).fontColor(COLORS.sub)
  Column().layoutWeight(1)
  Text('¥' + this.cartList.reduce((sum: number, c: CartItem) => sum + c.price * c.qty, 0))
    .fontSize(20).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
  Column() {
    Text('去结算').fontSize(13).fontColor(COLORS.title)
  }
  .padding({ left: 20, right: 20, top: 8, bottom: 8 })
  .backgroundColor(COLORS.red).borderRadius(10)
  .onClick(() => { this.showAdd = true; })
}

合计栏使用 reduce 函数动态计算总价:sum + c.price * c.qty。对于购物车数据(招牌牛肉面 28×1 + 蜜汁鸡翅 26×1 + 珍珠奶茶 16×2 + 草莓蛋糕 38×1 + 烤羊肉串 30×1 = 154),总价显示为 ¥154。总价使用 20px 的大字号橙色加粗显示,非常醒目。"去结算"按钮使用红色背景,与总价形成视觉关联。


十五、Tab4:优惠券(虚线大卡)详解

@Builder
couponTab() {
  Column({ space: 8 }) {
    Text('🎫 优惠券 · ' + this.couponList.length + ' 张可用').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
      .width('100%').padding({ left: 4 })
    ForEach(this.couponList, (item: CouponItem, idx: number) => {
      Row({ space: 0 }) {
        // 左侧金额
        Column({ space: 2 }) {
          Text('¥' + item.amount).fontSize(22).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
          Text(item.type).fontSize(8).fontColor(COLORS.gold)
        }
        .width(90).height(76)
        .backgroundColor(COLORS.darkL).borderRadius(12)
        .justifyContent(FlexAlign.Center)
        // 右侧信息
        Column({ space: 4 }) {
          Text(item.desc).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
          Text(item.condition).fontSize(9).fontColor(COLORS.sub)
          Row({ space: 6 }) {
            Text('📅 ' + item.expire).fontSize(8).fontColor(COLORS.text3)
            Column().layoutWeight(1)
            Column() {
              Text('立即使用').fontSize(9).fontColor(COLORS.title)
            }
            .padding({ left: 10, right: 10, top: 3, bottom: 3 })
            .backgroundColor(COLORS.orange).borderRadius(9)
            .onClick(() => { this.showAdd = true; })
          }.width('100%')
        }
        .layoutWeight(1).height(76).padding(10)
        .backgroundColor(idx % 2 === 0 ? COLORS.card : COLORS.dark)
        .borderRadius(12)
      }
      .width('100%').margin({ top: 4 })
      .borderRadius(12)
      .clip(true)
      .onClick(() => { this.editIdx = idx; this.showEdit = true; })
    }, (item: CouponItem) => 'cp' + item.desc + item.expire)
  }.width('100%')
}

优惠券卡片采用左右分栏设计,我们来分析其核心设计要点:

15.1 左侧金额区

Column({ space: 2 }) {
  Text('¥' + item.amount).fontSize(22).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
  Text(item.type).fontSize(8).fontColor(COLORS.gold)
}
.width(90).height(76)
.backgroundColor(COLORS.darkL).borderRadius(12)
.justifyContent(FlexAlign.Center)

左侧区域固定宽度 90px,高度 76px,使用深色背景。22px 大字号显示优惠金额,金色小字显示券类型。justifyContent(FlexAlign.Center) 使内容在垂直和水平方向上都居中。

15.2 右侧信息区

Column({ space: 4 }) {
  Text(item.desc).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
  Text(item.condition).fontSize(9).fontColor(COLORS.sub)
  Row({ space: 6 }) {
    Text('📅 ' + item.expire).fontSize(8).fontColor(COLORS.text3)
    Column().layoutWeight(1)
    Column() {
      Text('立即使用').fontSize(9).fontColor(COLORS.title)
    }
    .padding({ left: 10, right: 10, top: 3, bottom: 3 })
    .backgroundColor(COLORS.orange).borderRadius(9)
    .onClick(() => { this.showAdd = true; })
  }.width('100%')
}
.layoutWeight(1).height(76).padding(10)
.backgroundColor(idx % 2 === 0 ? COLORS.card : COLORS.dark)
.borderRadius(12)

右侧区域占据剩余宽度,显示优惠券描述、使用条件和有效期,底部有一个"立即使用"按钮。背景色根据索引奇偶性在 COLORS.cardCOLORS.dark 之间交替,增加了视觉层次感。

15.3 圆角与裁剪

.width('100%').margin({ top: 4 })
.borderRadius(12)
.clip(true)

外层的 clip(true) 属性确保子元素不会超出圆角边界,这是实现"虚线大卡"效果的关键——左右两侧虽然视觉上分离,但被裁剪在同一个圆角容器内,形成统一的卡片外观。


十六、Tab5:骑手(追踪卡)详解

@Builder
riderTab() {
  Column({ space: 8 }) {
    Text('🛵 骑手追踪 · 实时位置').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
      .width('100%').padding({ left: 4 })
    ForEach(this.riderList, (item: RiderItem, idx: number) => {
      Column({ space: 8 }) {
        Row({ space: 12 }) {
          Stack({ alignContent: Alignment.BottomEnd }) {
            Column() {
              Text('🛵').fontSize(26)
            }
            .width(48).height(48).borderRadius(24)
            .backgroundColor(idx % 3 === 0 ? COLORS.orangeD : idx % 3 === 1 ? COLORS.redD : COLORS.darkL)
            .justifyContent(FlexAlign.Center)
            .animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
            Column()
              .width(12).height(12).borderRadius(6)
              .backgroundColor(COLORS.green)
          }
          .width(48).height(48)
          Column({ space: 4 }) {
            Text(item.name).fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Text('距您 ' + item.distance + ' · 预计 ' + item.eta + ' 到达').fontSize(9).fontColor(COLORS.sub)
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Start)
          Text(item.progress + '%').fontSize(16).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
        }.width('100%')
        // 进度条
        Row() {
          Column()
            .width(item.progress + '%').height(6).borderRadius(3)
            .backgroundColor(idx % 2 === 0 ? COLORS.orange : COLORS.red)
        }
        .width('100%').height(6).borderRadius(3).backgroundColor(COLORS.darkL)
        Row({ space: 8 }) {
          Text('📍 商家已取餐').fontSize(8).fontColor(COLORS.green)
          Column().layoutWeight(1)
          Text('📞 联系骑手').fontSize(9).fontColor(COLORS.title)
            .padding({ left: 10, right: 10, top: 3, bottom: 3 })
            .backgroundColor(COLORS.orange).borderRadius(9)
            .onClick(() => { this.showAdd = true; })
        }.width('100%')
      }
      .width('100%').padding(12)
      .backgroundColor(COLORS.card).borderRadius(12)
      .onClick(() => { this.editIdx = idx; this.showEdit = true; })
    })
  }
}

骑手 Tab 的设计重点在于配送进度的可视化展示,我们来深入分析:

16.1 骑手头像(Stack 叠加)

Stack({ alignContent: Alignment.BottomEnd }) {
  Column() {
    Text('🛵').fontSize(26)
  }
  .width(48).height(48).borderRadius(24)
  .backgroundColor(idx % 3 === 0 ? COLORS.orangeD : idx % 3 === 1 ? COLORS.redD : COLORS.darkL)
  .justifyContent(FlexAlign.Center)
  .animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
  Column()
    .width(12).height(12).borderRadius(6)
    .backgroundColor(COLORS.green)
}

Stack 组件将骑手头像(圆形、48×48)和在线状态指示点(右下角、12×12 绿色圆点)叠加在一起。alignContent: Alignment.BottomEnd 将指示点定位在右下角,模拟了常见聊天应用中的在线状态标识。头像带有呼吸动画,表示骑手正在移动中。

16.2 进度条

Row() {
  Column()
    .width(item.progress + '%').height(6).borderRadius(3)
    .backgroundColor(idx % 2 === 0 ? COLORS.orange : COLORS.red)
}
.width('100%').height(6).borderRadius(3).backgroundColor(COLORS.darkL)

进度条的实现非常巧妙:外层 Row 作为进度条的背景轨道(深色、6px 高、圆角),内层 Column 的宽度为 item.progress + '%',实现了百分比宽度的进度填充。例如 progress: 65 意味着进度条填充了 65% 的宽度。进度条颜色根据索引奇偶性在橙色和红色之间交替。

16.3 进度百分比

Text(item.progress + '%').fontSize(16).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)

右侧显示精确的进度百分比,16px 大字号橙色加粗,与进度条形成呼应。


十七、Tab6:评价(评论卡)详解

@Builder
reviewTab() {
  Column({ space: 8 }) {
    Text('⭐ 用户评价 · ' + this.reviewList.length + ' 条').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
      .width('100%').padding({ left: 4 })
    ForEach(this.reviewList, (item: ReviewItem, idx: number) => {
      Column({ space: 8 }) {
        Row({ space: 10 }) {
          Column() {
            Text(item.user.charAt(0)).fontSize(18).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
          }
          .width(40).height(40).borderRadius(20)
          .backgroundColor(idx % 4 === 0 ? COLORS.orangeD : idx % 4 === 1 ? COLORS.redD : idx % 4 === 2 ? COLORS.purple : COLORS.blue)
          .justifyContent(FlexAlign.Center)
          Column({ space: 2 }) {
            Text(item.user).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Text(starStr(item.rating)).fontSize(9).fontColor(COLORS.gold)
              .animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Start)
          Text(item.date).fontSize(8).fontColor(COLORS.text3)
        }.width('100%')
        Text(item.content).fontSize(11).fontColor(COLORS.sub)
        Row({ space: 6 }) {
          Text('🍽️ ' + item.dish).fontSize(8).fontColor(COLORS.orange)
            .padding({ left: 6, right: 6, top: 1, bottom: 1 })
            .backgroundColor(COLORS.tagBg).borderRadius(8)
          Column().layoutWeight(1)
          Text('👍 有用').fontSize(9).fontColor(COLORS.sub)
            .onClick(() => { this.showAdd = true; })
        }.width('100%')
      }
      .width('100%').padding(10)
      .backgroundColor(COLORS.card).borderRadius(12)
      .onClick(() => { this.editIdx = idx; this.showEdit = true; })
    })
  }
}

评价 Tab 模拟了真实外卖平台的评价系统,我们来分析其设计亮点:

17.1 用户头像(首字符占位符)

Column() {
  Text(item.user.charAt(0)).fontSize(18).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
}
.width(40).height(40).borderRadius(20)
.backgroundColor(idx % 4 === 0 ? COLORS.orangeD : idx % 4 === 1 ? COLORS.redD : idx % 4 === 2 ? COLORS.purple : COLORS.blue)
.justifyContent(FlexAlign.Center)

由于没有真实的头像图片,这里使用用户名首字符作为头像占位符。item.user.charAt(0) 提取用户名的第一个字符(如"美食家小李"→"美"),显示在 40×40 的圆形色块中。背景色根据 idx % 4 在四种颜色之间轮换,确保相邻用户的头像颜色不同。

17.2 星级显示

Text(starStr(item.rating)).fontSize(9).fontColor(COLORS.gold)
  .animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })

星级通过 starStr() 函数将数字评分转换为星星 Emoji 字符串,使用金黄色显示,并带有呼吸动画,使星级评价更具视觉吸引力。

17.3 评价内容与菜品标签

Text(item.content).fontSize(11).fontColor(COLORS.sub)
Row({ space: 6 }) {
  Text('🍽️ ' + item.dish).fontSize(8).fontColor(COLORS.orange)
    .padding({ left: 6, right: 6, top: 1, bottom: 1 })
    .backgroundColor(COLORS.tagBg).borderRadius(8)
  Column().layoutWeight(1)
  Text('👍 有用').fontSize(9).fontColor(COLORS.sub)
    .onClick(() => { this.showAdd = true; })
}

评价正文使用 11px 副文本色,下方展示关联的菜品标签(橙色文字、半透明橙色背景)和"有用"按钮(模拟点赞功能)。


十八、Tab7:收藏(清单行)详解

@Builder
favTab() {
  Column({ space: 8 }) {
    Text('❤️ 收藏商家 · ' + this.favList.length + ' 家').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
      .width('100%').padding({ left: 4 })
    ForEach(this.favList, (item: FavItem, idx: number) => {
      Row({ space: 12 }) {
        Column() { Text('🏪').fontSize(22) }
        .width(44).height(44).borderRadius(12)
        .backgroundColor(idx % 3 === 0 ? COLORS.orangeD : idx % 3 === 1 ? COLORS.redD : COLORS.darkL)
        .justifyContent(FlexAlign.Center)
        Column({ space: 4 }) {
          Text(item.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
          Row({ space: 6 }) {
            Text('⭐' + item.rating.toFixed(1)).fontSize(9).fontColor(COLORS.gold)
            Text('· ' + item.sales + ' 单').fontSize(8).fontColor(COLORS.sub)
          }
          Row({ space: 6 }) {
            Text(item.tag).fontSize(8).fontColor(COLORS.orange)
              .padding({ left: 6, right: 6, top: 1, bottom: 1 })
              .backgroundColor(COLORS.tagBg).borderRadius(8)
            Text('🛵' + item.delivery).fontSize(8).fontColor(COLORS.green)
          }.width('100%')
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Start)
        Column() {
          Text('点餐').fontSize(9).fontColor(COLORS.title)
        }
        .padding({ left: 10, right: 10, top: 5, bottom: 5 })
        .backgroundColor(COLORS.orange).borderRadius(9)
        .onClick(() => { this.showAdd = true; })
      }
      .width('100%').padding(10)
      .backgroundColor(COLORS.card).borderRadius(12)
      .onClick(() => { this.editIdx = idx; this.showEdit = true; })
    })
    Row({ space: 10 }) {
      Column() {
        Text('添加收藏').fontSize(11).fontColor(COLORS.title)
      }
      .layoutWeight(1).height(36)
      .backgroundColor(COLORS.orange).borderRadius(9)
      .justifyContent(FlexAlign.Center)
      .onClick(() => { this.showAdd = true; })
      Column() {
        Text('清空收藏').fontSize(11).fontColor(COLORS.red)
      }
      .layoutWeight(1).height(36)
      .border({ width: 1, color: COLORS.red }).borderRadius(9)
      .justifyContent(FlexAlign.Center)
      .onClick(() => { this.delIdx = 0; this.showDel = true; })
    }.width('100%').margin({ top: 6 })
  }.width('100%')
}

收藏 Tab 的布局与商家 Tab 类似,但采用了单列清单行而非双列卡片。底部的两个操作按钮是亮点:

18.1 底部操作按钮

Row({ space: 10 }) {
  Column() {
    Text('添加收藏').fontSize(11).fontColor(COLORS.title)
  }
  .layoutWeight(1).height(36)
  .backgroundColor(COLORS.orange).borderRadius(9)
  .justifyContent(FlexAlign.Center)
  .onClick(() => { this.showAdd = true; })
  Column() {
    Text('清空收藏').fontSize(11).fontColor(COLORS.red)
  }
  .layoutWeight(1).height(36)
  .border({ width: 1, color: COLORS.red }).borderRadius(9)
  .justifyContent(FlexAlign.Center)
  .onClick(() => { this.delIdx = 0; this.showDel = true; })
}

两个按钮并排排列,各占 50% 宽度。"添加收藏"按钮使用橙色实心背景,"清空收藏"按钮使用红色边框(border 而非 backgroundColor),实现了"主操作"和"危险操作"的视觉区分。


十九、月度订单柱状图详解

@Builder
orderChart() {
  Column({ space: 8 }) {
    Row() {
      Text('月度订单量').fontSize(14).fontColor(COLORS.title).layoutWeight(1)
      Text('单').fontSize(11).fontColor(COLORS.text3)
    }.width('100%')
    Row({ space: 4 }) {
      ForEach(MONTH_IDX, (m: number) => {
        Column({ space: 4 }) {
          Text(ORDER_IDX[m].toString())
            .fontSize(9).fontColor(COLORS.sub)
          Column()
            .width(24)
            .height(ORDER_IDX[m] * 1.5)
            .backgroundColor(m % 2 === 0 ? COLORS.orange : COLORS.red)
            .borderRadius(4)
          Text(MONTH_NAME[m])
            .fontSize(9).fontColor(COLORS.text3)
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Center)
      }, (m: number) => 'oc' + m)
    }
    .width('100%').height(70)
    .alignItems(VerticalAlign.Bottom)
    Row({ space: 8 }) {
      Text('🛵').fontSize(12)
        .opacity(this.breath ? 1.0 : 0.5)
        .animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
      Text('本月 35 单 · 累计消费 ¥1,280 · 闪电会员').fontSize(9).fontColor(COLORS.sub)
    }
    .width('100%').justifyContent(FlexAlign.Center)
  }
  .width('100%').padding(12)
  .backgroundColor(COLORS.card).borderRadius(10)
  .margin({ top: 10 })
}

月度订单柱状图是一个纯 CSS 实现的简易图表,不依赖任何第三方图表库。我们来分析其实现原理:

19.1 柱状图本体

Row({ space: 4 }) {
  ForEach(MONTH_IDX, (m: number) => {
    Column({ space: 4 }) {
      Text(ORDER_IDX[m].toString()).fontSize(9).fontColor(COLORS.sub)
      Column()
        .width(24).height(ORDER_IDX[m] * 1.5)
        .backgroundColor(m % 2 === 0 ? COLORS.orange : COLORS.red)
        .borderRadius(4)
      Text(MONTH_NAME[m]).fontSize(9).fontColor(COLORS.text3)
    }
    .layoutWeight(1).alignItems(HorizontalAlign.Center)
  })
}
.width('100%').height(70)
.alignItems(VerticalAlign.Bottom)

每个柱子由三部分组成:数值标签(顶部)、柱子(中间,高度 = 订单数 × 1.5)、月份标签(底部)。关键属性是外层的 alignItems(VerticalAlign.Bottom),它确保所有柱子从底部对齐,高度差异在顶部体现,符合柱状图的标准呈现方式。

柱子颜色根据月份索引奇偶性在橙色和红色之间交替,数据为 [18, 22, 26, 31, 28, 35],高度分别为 27、33、39、46.5、42、52.5 像素(乘以 1.5)。

19.2 呼吸动画文字

Text('🛵').fontSize(12)
  .opacity(this.breath ? 1.0 : 0.5)
  .animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })

图表下方的文字图标使用 this.breath 状态驱动的透明度动画,在 1.0 和 0.5 之间交替变化,产生"呼吸"效果。animation 属性的 iterations: -1 表示无限循环,playMode: PlayMode.Alternate 表示来回交替。


二十、底部 Tab 栏详解

@Builder
tabBar() {
  Column({ space: 4 }) {
    Row() {
      ForEach(ROW1_IDX, (i: number) => {
        Column({ space: 2 }) {
          Text(TAB_LIST[i].icon).fontSize(13).fontColor(this.selTab === i ? COLORS.title : COLORS.sub)
          Text(TAB_LIST[i].label).fontSize(8).fontColor(this.selTab === i ? COLORS.title : COLORS.sub)
        }
        .layoutWeight(1).height(42)
        .backgroundColor(this.selTab === i ? COLORS.orange : COLORS.card)
        .borderRadius(8)
        .justifyContent(FlexAlign.Center)
        .onClick(() => { this.selTab = i; })
      }, (i: number) => 'r1' + i)
    }.width('100%')
    Row() {
      ForEach(ROW2_IDX, (i: number) => {
        Column({ space: 2 }) {
          Text(TAB_LIST[i].icon).fontSize(13).fontColor(this.selTab === i ? COLORS.title : COLORS.sub)
          Text(TAB_LIST[i].label).fontSize(8).fontColor(this.selTab === i ? COLORS.title : COLORS.sub)
        }
        .layoutWeight(1).height(42)
        .backgroundColor(this.selTab === i ? COLORS.orange : COLORS.card)
        .borderRadius(8)
        .justifyContent(FlexAlign.Center)
        .onClick(() => { this.selTab = i; })
      }, (i: number) => 'r2' + i)
    }.width('100%')
  }
  .width('100%').padding({ left: 8, right: 8, top: 5, bottom: 6 })
  .backgroundColor(COLORS.card)
}

底部 Tab 栏采用两行布局,每行 4 个 Tab。我们来分析其交互设计:

20.1 选中态与非选中态

Text(TAB_LIST[i].icon).fontSize(13).fontColor(this.selTab === i ? COLORS.title : COLORS.sub)
Text(TAB_LIST[i].label).fontSize(8).fontColor(this.selTab === i ? COLORS.title : COLORS.sub)

每个 Tab 的图标和文字颜色根据 this.selTab === i 条件动态切换:选中时使用 COLORS.title(暖白色),未选中时使用 COLORS.sub(金棕色)。这种颜色对比让用户能够一眼识别当前所在的 Tab。

20.2 背景色切换

.backgroundColor(this.selTab === i ? COLORS.orange : COLORS.card)

选中 Tab 的背景色切换为 COLORS.orange(品牌橙色),未选中 Tab 为 COLORS.card(卡片色)。这种背景色变化提供了比纯文字颜色变化更强的视觉反馈,符合移动端设计的"可发现性"原则。

20.3 点击事件

.onClick(() => { this.selTab = i; })

点击 Tab 时,只需将 selTab 设置为对应的索引值。由于 selTab@State 变量,它的变化会自动触发 UI 重新渲染——Tab 栏的选中态更新,同时 build() 方法中的条件渲染会切换到对应的 Tab 内容。


二十一、弹框系统详解

21.1 遮罩层

@Builder
modalOverlay(onClose: () => void) {
  Column()
    .width('100%')
    .height('100%')
    .backgroundColor(COLORS.mask)
    .onClick(() => { onClose(); })
}

modalOverlay 是一个接收回调函数的 @Builder 构建函数,渲染一个全屏的半透明遮罩层。点击遮罩层时调用传入的 onClose 回调关闭弹框。这种设计将遮罩的关闭逻辑与弹框的具体内容解耦,提高了复用性。

21.2 新增弹框

@Builder
addModal() {
  Stack({ alignContent: Alignment.Center }) {
    this.modalOverlay(() => { this.showAdd = false; })
    Column({ space: 12 }) {
      Text('🛒 加入购物车 / 下单').fontSize(16).fontColor(COLORS.title)
      TextInput({ placeholder: '菜品名称' })
        .fontSize(13).fontColor(COLORS.title).placeholderColor(COLORS.text3)
        .backgroundColor(COLORS.dark).borderRadius(8)
      TextInput({ placeholder: '数量' })
        .fontSize(13).fontColor(COLORS.title).placeholderColor(COLORS.text3)
        .backgroundColor(COLORS.dark).borderRadius(8)
      TextInput({ placeholder: '备注(少辣/不要葱等)' })
        .fontSize(13).fontColor(COLORS.title).placeholderColor(COLORS.text3)
        .backgroundColor(COLORS.dark).borderRadius(8)
      Row({ space: 10 }) {
        Column() {
          Text('确定下单').fontSize(13).fontColor(COLORS.title)
        }
        .layoutWeight(1).height(38)
        .backgroundColor(COLORS.orange).borderRadius(8)
        .justifyContent(FlexAlign.Center)
        .onClick(() => { this.showAdd = false; })
        Column() {
          Text('取消').fontSize(13).fontColor(COLORS.sub)
        }
        .layoutWeight(1).height(38)
        .backgroundColor(COLORS.darkL).borderRadius(8)
        .justifyContent(FlexAlign.Center)
        .onClick(() => { this.showAdd = false; })
      }.width('100%')
    }
    .width('80%').padding(20)
    .backgroundColor(COLORS.card).borderRadius(12)
    .constraintSize({ maxHeight: '80%' })
  }
  .width('100%').height('100%')
  .position({ x: 0, y: 0 })
  .zIndex(999)
  .backgroundColor(COLORS.transparent)
}

新增弹框使用 Stack 组件实现居中定位。Stack 内部包含两层:底层是遮罩层(点击关闭),上层是弹框内容。弹框内容包含三个 TextInput 输入框(菜品名称、数量、备注)和两个按钮(确定下单、取消)。

弹框的定位使用了三个关键属性:

  • position({ x: 0, y: 0 }):绝对定位到页面左上角
  • zIndex(999):确保弹框显示在所有内容之上
  • backgroundColor(COLORS.transparent):外层容器透明,让遮罩层的颜色生效

21.3 编辑弹框与删除弹框

编辑弹框和删除弹框的结构与新增弹框类似,区别在于:

  • 编辑弹框:显示"编辑第 N 条"的标题,包含名称和备注两个输入框
  • 删除弹框:显示"确认删除"的标题,包含警告文案和确认/取消按钮,确认按钮使用红色背景

这三个弹框的触发条件在 build() 方法中通过 if 条件控制:

if (this.showAdd) { this.addModal() }
if (this.showEdit) { this.editModal() }
if (this.showDel) { this.delModal() }

二十二、技术亮点总结

22.1 架构设计层面

  1. 组件化拆分:每个 Tab 的功能通过 @Builder 构建函数封装,每个弹框也独立封装,实现了高内聚低耦合的组件架构。这种设计使得每个业务模块可以独立开发、测试和维护。

  2. 声明式条件渲染:通过 if/else if 链和 @State 变量实现 Tab 切换,代码逻辑清晰,无需手动管理 DOM 的显示/隐藏。

  3. 单一数据源:所有状态通过 @State 变量管理,UI 完全由状态驱动。当数据发生变化时,框架自动计算最小更新范围,高效地重新渲染受影响的组件。

22.2 视觉设计层面

  1. 深色主题 + 暖色点缀:整体采用深棕/暗色基底,搭配暖橙色和金黄色作为品牌色和功能色,营造出温馨、诱人的视觉氛围,符合外卖行业的品牌调性。

  2. 色彩语义化:不同颜色承载不同的语义——橙色表示品牌/积极状态,红色表示警告/促销,绿色表示成功/完成,金色表示评分/优惠。这种色彩语义化设计降低了用户的认知负担。

  3. 动画编排:呼吸动画、交替动画、无限循环动画等多种动画效果组合使用,提升了页面的生动性和交互反馈的及时性。

22.3 数据建模层面

  1. 按需建模:每个业务模块拥有独立的数据模型(如 ShopItem 用于商家展示,FavItem 用于收藏展示),避免了"一个模型适配所有场景"带来的字段冗余。

  2. @Observed 响应式:所有数据模型类都使用 @Observed 装饰器,确保属性变化能自动触发 UI 更新。

  3. 丰富的模拟数据:12 条商家、12 条菜品、10 条订单、5 条购物车、8 条优惠券、5 条骑手、10 条评价、10 条收藏——数据量充足,覆盖了各种状态和边界情况。


二十三、页面功能对比总览表

维度 商家 Tab 菜单 Tab 订单 Tab 购物车 Tab 优惠券 Tab 骑手 Tab 评价 Tab 收藏 Tab
布局方式 双列卡片 图标清单行 状态时间轴 数量列表行 左右分栏大卡 追踪卡片 评论卡片 清单行
数据模型 ShopItem DishItem OrderItem CartItem CouponItem RiderItem ReviewItem FavItem
字段数 7 5 5 4 5 4 5 5
核心操作 查看详情 加入购物车 催单/再来一单/删除 增减数量/结算 立即使用 联系骑手 点赞 点餐/添加/清空
动画效果 HOT标签闪烁、封面渐变 图标呼吸、背景色轮换 状态圆点呼吸 背景色交替 头像呼吸、进度条 星星呼吸、头像色轮换
颜色主题 橙/红/黄渐变 橙/红/暗色图标 橙/绿/灰状态 橙色操作 橙/暗色交替 橙/红进度 多色头像 橙/红操作
数据量 12 条 12 条 10 条 5 件 8 张 5 位 10 条 10 家
特殊组件 linearGradient tagBg 标签 时间轴连线 数量控制器、合计栏 左右分栏 进度条、Stack 叠加 首字符头像、starStr 底部操作栏

二十四、总结与展望

通过对"闪电蜂"外卖配送平台源码的逐段深入分析,我们可以看到这款应用在 ArkUI 框架下展现了以下核心技术能力:

首先,声明式 UI 开发范式的高效性。 整个页面的 UI 结构完全由声明式代码定义,开发者只需描述"UI 应该是什么样子",而无需关心"如何一步步构建 UI"。当状态发生变化时,框架自动处理 UI 更新,大大减少了手动 DOM 操作带来的复杂性和出错概率。

其次,组件化架构的可维护性。 8 个 Tab 的内容各自封装为独立的 @Builder 函数,弹框系统也独立封装。这种架构使得每个业务模块可以独立迭代,互不干扰。当需要修改某个 Tab 的布局时,只需定位到对应的 @Builder 函数,不会影响其他 Tab。

再次,色彩系统的设计规范性。 ColorPalette 接口定义了 22 个语义化的颜色变量,所有组件统一引用。这种设计不仅保证了视觉一致性,也为未来的主题切换(如深色/浅色模式)提供了基础设施。

最后,数据驱动的响应式架构。 通过 @State@Observed 装饰器,数据与视图之间建立了自动同步机制。当用户添加商品到购物车、删除订单、修改收藏时,UI 会自动更新,无需手动调用刷新方法。

从工程实践的角度来看,这款应用还有一些可以优化的方向:

  1. 定时器清理:在 aboutToDisappear() 生命周期中清理 setInterval 定时器,防止内存泄漏。
  2. 弹框状态管理:三个弹框状态变量(showAddshowEditshowDel)可以统一为一个枚举状态,减少变量数量。
  3. 数据持久化:当前的模拟数据在页面刷新后会重置,实际应用中需要接入持久化存储(如 Preferences 或关系型数据库)。
  4. 列表性能优化:对于长列表(如 12 条以上的数据),可以使用 LazyForEach 替代 ForEach 实现懒加载,提升滚动性能。
  5. 可访问性:添加适当的无障碍标签(accessibilityText),提升应用对不同用户群体的友好度。

总的来说,"闪电蜂"外卖配送平台是一个结构清晰、设计规范的 ArkUI 多 Tab 应用范例。它展示了如何在 HarmonyOS 生态下构建一个功能完整、视觉统一、交互流畅的移动端应用,对于学习 ArkUI 声明式开发和组件化架构具有很好的参考价值。

附录:DevEco Studio 创建新项目与查看 SDK 版本

本章节演示如何使用 DevEco Studio 创建一个 HarmonyOS 新项目,并查看当前 IDE 已安装的 SDK 版本,适合作为其他技术博文的补充操作指南。


一、创建新项目

1.1 进入欢迎界面

启动 DevEco Studio 后,首先看到的是欢迎界面。左侧导航栏默认选中 “项目”,右侧提供三个主要入口:

  • 新建项目:从头创建新项目
  • 打开项目:打开本地已有项目
  • 克隆仓库:从 Git 等版本控制拉取代码

点击 “新建项目” 按钮,进入项目创建向导。

在这里插入图片描述

1.2 选择项目模板

在弹出的"新建项目"对话框中,左侧分类标签提供了两种项目类型:

类型 说明
应用(Application) 开发标准的 HarmonyOS 应用,具备完整的 Ability 生命周期
元服务(Atomic Service) 开发轻量级的原子化服务,无需安装即可使用

选择 “应用” 标签后,右侧展示多种模板。对于大多数场景,推荐选择 “Empty Ability” —— 这是一个最基础的入门模板,仅包含 Hello World 功能,适合从零开始构建应用。

在这里插入图片描述

1.3 配置项目信息

点击 “下一步” 后,进入项目配置界面,需要填写以下核心参数:

配置项 示例值 说明
项目名称(Project name) rollboat 应用的项目名称,建议使用英文命名
包名(Bundle name) com.rollboat.myapplication 应用唯一标识,采用反向域名格式
保存路径(Save location) D:\CodeFactory\rollboat 项目本地存储路径,避免使用中文和空格
兼容 SDK(Compatible SDK) 6.1.1(24) 目标 HarmonyOS API 版本,点击"查看参考"可了解各版本差异
模块名称(Module name) entry 主模块名称,默认 entry 为应用入口模块
设备类型(Device types) ☑ Phone 勾选目标设备:Phone / Tablet / 2in1 / Car / Wearable / TV

右侧预览区会实时展示当前模板的默认效果 —— 一个居中显示的 “Hello World” 文本。

在这里插入图片描述

1.4 完成创建

确认配置无误后,点击右下角 “完成” 按钮,IDE 将自动执行以下操作:

  1. 生成项目骨架(Stage 模型目录结构)
  2. 执行 ohpm install 安装依赖
  3. 运行 Hvigor 构建初始化(Build Init

构建日志中显示 “退出代码为 0” 表示项目初始化成功。

在这里插入图片描述

1.5 项目结构概览

创建完成后,左侧项目面板展示的是标准的 Stage 模型 目录结构:

rollboat/
├── .hvigor/                   # Hvigor 构建工具缓存
├── .idea/                     # IDE 配置文件
├── AppScope/                  # 应用级全局配置
│   └── app.json5
├── entry/                     # 主模块(入口模块)
│   ├── src/main/ets/
│   │   ├── entryability/      # Ability 生命周期管理
│   │   │   └── EntryAbility.ets
│   │   └── pages/             # UI 页面
│   │       └── Index.ets      # 首页(默认 Hello World)
│   ├── src/main/resources/    # 资源文件
│   ├── module.json5           # 模块配置
│   └── build-profile.json5    # 构建配置
├── oh_modules/                # OHPM 依赖包
├── build-profile.json5        # 工程构建配置
├── hvigorfile.ts              # Hvigor 构建脚本
└── oh-package.json5           # 包管理配置

核心文件 Index.ets 的默认代码如下,采用 ArkTS 声明式 UI 语法:

@Entry
@Component
struct Index {
  @State message: string = 'Hello World';

  build() {
    RelativeContainer() {
      Text(this.message)
        .id('HelloWorld')
        .fontSize($r('app.float.page_text_font_size'))
        .fontWeight(FontWeight.Bold)
        .alignRules({
          center: { anchor: '__container__', align: VerticalAlign.Center },
          middle: { anchor: '__container__', align: HorizontalAlign.Center }
        })
        .onClick(() => {
          this.message = 'Welcome';
        })
    }
    .height('100%')
    .width('100%')
  }
}
关键语法 作用
@Entry 标记为页面入口,可用于路由跳转
@Component 声明为自定义组件
@State 状态变量,数据变更时自动触发 UI 刷新
RelativeContainer 相对布局容器,替代传统线性布局
.onClick() 点击事件,此处点击后文本变为 “Welcome”

打开右侧 Previewer(预览器),选择 Phone 设备,即可实时预览 Hello World 效果,无需连接真机或启动模拟器。

在这里插入图片描述


二、查看 SDK 版本

2.1 查看 HarmonyOS SDK

DevEco Studio 安装时已内置 HarmonyOS SDK,无需单独下载。通过以下路径查看:

文件 → 设置 → HarmonyOS SDK(或快捷键 Ctrl + Alt + S 搜索 “HarmonyOS SDK”)

在设置面板中,可以看到当前已安装的 SDK 版本信息:

名称 阶段 状态
HarmonyOS 6.1.1 Release ✅ 已安装

界面顶部提示:“HarmonyOS SDK 已经包含在 IDE,无需单独安装”,省去了手动配置 SDK 的繁琐步骤。

在这里插入图片描述

2.2 查看 ArkUI-X SDK(跨平台扩展)

如果项目需要将 ArkUI 框架扩展到多个 OS 平台(Android / iOS / OpenHarmony),还需要配置 ArkUI-X SDK。路径如下:

文件 → 设置 → 语言和框架 → ArkUI-X

在这里可以查看已安装和可选的 ArkUI-X SDK 版本:

版本 SDK 版本号 阶段 状态
API Version 24 6.1.1.100 Release ✅ 已安装
API Version 23 6.1.0.28 Beta1 未安装
API Version 22 6.0.2.112 Release 未安装

安装路径示例:D:\DevTools\ArkUI-X\sdk

说明:ArkUI-X 允许开发者使用一套 ArkTS 主代码,同时构建多平台应用。如果仅开发 HarmonyOS 原生应用,无需额外安装 ArkUI-X SDK。

在这里插入图片描述


三、小结

步骤 操作 关键点
创建项目 欢迎页 → 新建项目 → 选择 Empty Ability 模板 → 配置项目信息 → 完成 使用 Stage 模型 + ArkTS 语言
查看 SDK 设置 → HarmonyOS SDK SDK 已内置,无需手动安装
跨平台扩展 设置 → ArkUI-X 根据需要安装对应 API 版本

至此,DevEco Studio 的项目创建与 SDK 环境确认全部完成,可以开始 HarmonyOS 应用的功能开发。


本文基于 DevEco Studio 6.1.1 Release 版本编写,不同版本界面可能存在细微差异。

Logo

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

更多推荐