引言

鸿蒙操作系统(HarmonyOS)作为华为自主研发的分布式操作系统,其核心理念在于通过统一的开发框架实现跨设备的应用体验。在 HarmonyOS 6.1.1 版本中,ArkTS 声明式 UI 框架已经演进为一个高度完善的开发范式,它以 TypeScript 为语言基础,引入了 @Component@Entry@State@Builder 等装饰器系统,使开发者能够以纯声明式的方式描述 UI 结构和交互逻辑。与传统的命令式 UI 编程不同,ArkTS 的声明式范式让开发者只需关注"UI 应该是什么样子",而无需手动操作 DOM 节点——当状态数据发生变化时,框架会自动计算 UI 差异并执行最小化刷新。

在基于 HarmonyOS API 24 的 ArkTS 应用中,状态管理是连接数据模型与 UI 渲染的核心纽带。@State 装饰器声明的变量具有响应式特性,当变量值发生变化时,所有引用该变量的 UI 节点都会自动更新。这种数据驱动视图的模式极大地简化了状态同步的复杂度,开发者无需手动调用 setStateforceUpdate 等方法,只需修改变量本身即可触发 UI 刷新。同时,ArkTS 的类型系统通过 interface 接口定义确保了数据的类型安全,编译器在构建阶段就能捕获类型不匹配的错误,提升了代码的可靠性。

本文将深入分析一个名为"轻食沙拉工坊"的餐饮健康类应用的完整技术实现。该应用以牛油果绿 #16A34A 与柠檬黄 #FACC15 为主题色,底色为清新的农场绿 #F6FBF4,包含点餐、营养、周计划、门店、我的五个底部 Tab。应用集成了完整的购物车系统(支持规格选择、数量调整、热卡计算)、BMI 身体质量指数计算器、七日饮食计划 CRUD 管理、门店排队取号系统、营养柱状图数据可视化等丰富功能。通过对该应用的逐层拆解,读者将深入理解 ArkTS 在组件化设计、状态管理、弹框交互、数据可视化以及业务逻辑实现等方面的技术细节。

一、接口定义层分析

轻食沙拉工坊应用共定义了 8 个接口,覆盖了商品、购物车、营养、计划、门店、菜单等全部业务数据模型。

1.1 商品与购物车接口

interface SaladGoods {
  id: number
  name: string
  cat: string
  kcal: number
  protein: number
  price: number
  originPrice: number
  color: string
  tag: string
  sold: number
  stock: number
}

interface CartRow {
  id: number
  name: string
  spec: string
  price: number
  num: number
}

在这里插入图片描述

SaladGoods 接口定义了轻食商品的数据模型。cat 字段存储商品分类(招牌碗、轻食卷、低卡饭、能量饮品、健康小食),用于点餐 Tab 中的分类筛选;kcal 字段存储热量值(千卡),这是轻食类应用最核心的数据维度,在商品卡片左侧以大号字体突出显示;protein 字段存储蛋白质含量(克),用于商品详情和营养档案展示;tag 字段存储商品标签(如"店长推荐"、“低 GI”、“高蛋白”),以小号标签形式展示在商品名称旁;color 字段存储商品卡片的背景色,使用了柔和的农场色系(如 #A7D7A0 浅绿、#F9C78A 暖黄、#A8D8D8 薄荷蓝等)。

CartRow 接口定义了购物车行的数据结构。spec 字段存储规格名称(如"大碗+溏心蛋"、“标准杯”),与商品本身的价格分离——同一商品的不同规格可能产生不同的价格。num 字段存储购买数量,支持在购物车中动态增减。

1.2 营养与计划接口

interface NutriItem {
  id: number
  name: string
  value: number
  max: number
  unit: string
  color: string
  food: string
}

interface PlanCell {
  id: number
  day: string
  meal: string
  name: string
  kcal: number
  done: boolean
}

在这里插入图片描述

NutriItem 接口定义了营养素的数据模型。value 字段存储已摄入量,max 字段存储每日推荐上限,两者配合生成营养进度条的填充比例。unit 字段存储计量单位(克),color 字段存储该营养素的主题色——蛋白质用绿色 #16A34A、碳水用黄色 #FACC15、脂肪用橙色 #F97316、膳食纤维用蓝色 #0EA5E9,这种颜色编码使用户能够快速区分不同营养素。food 字段存储主要食物来源,在营养详情弹框中展示。

PlanCell 接口定义了周计划中每一餐的数据模型。day 字段存储星期(周一至周日),meal 字段存储餐段(早餐、午餐、晚餐、加餐),done 字段是一个布尔值,标记该餐是否已完成打卡。这个字段直接驱动计划卡片的视觉状态——已完成餐次的文字变灰、背景变浅、打卡按钮变为"已吃"文字。

1.3 门店与菜单接口

interface ShopRow {
  id: number
  name: string
  dist: string
  queue: number
  score: string
  tag: string
  color: string
  hours: string
}

interface WeekBar {
  day: string
  kcal: number
}

interface MineMenu {
  id: number
  icon: string
  name: string
  desc: string
  color: string
}

interface TabMetaS {
  name: string
  icon: string
}

在这里插入图片描述

ShopRow 接口定义了门店数据模型。dist 字段存储距离信息(如"850m"、“1.2km”),以字符串形式存储便于直接展示;queue 字段存储当前排队人数,当值为 0 时显示"无需排队",否则显示"排队 N 桌";score 字段存储评分(如"4.9"),在门店卡片左侧以大号白字突出显示;hours 字段存储营业时间区间;color 字段存储门店主题色,不同门店使用不同颜色以增强视觉区分度。

WeekBar 接口定义了周热量柱状图的数据单元,day 存储星期标签,kcal 存储当日总热量摄入。MineMenu 接口定义了"我的"页面菜单项的数据模型,icon 字段使用 emoji 图标,desc 字段存储菜单项的描述文本。TabMetaS 接口定义了底部导航栏标签的元数据。

二、全局常量与枚举分析

2.1 分类标签与商品数据

const CAT_CHIPS: string[] = ['招牌碗', '轻食卷', '低卡饭', '能量饮品', '健康小食']

const SALAD_GOODS: SaladGoods[] = [
  { id: 1, name: '牛油果鸡胸谷物碗', cat: '招牌碗', kcal: 420, protein: 32, price: 32, originPrice: 45, color: '#A7D7A0', tag: '店长推荐', sold: 2861, stock: 18 },
  { id: 2, name: '藜麦三文鱼波奇碗', cat: '招牌碗', kcal: 385, protein: 28, price: 39, originPrice: 52, color: '#F9C78A', tag: '低 GI', sold: 1932, stock: 12 },
  { id: 3, name: '凯撒鸡肉卷饼', cat: '轻食卷', kcal: 340, protein: 22, price: 26, originPrice: 34, color: '#F7E8A4', tag: '高蛋白', sold: 3240, stock: 25 },
  { id: 4, name: '金枪鱼全麦卷', cat: '轻食卷', kcal: 310, protein: 24, price: 28, originPrice: 38, color: '#A8D8D8', tag: '控卡', sold: 1560, stock: 30 },
  { id: 5, name: '紫薯鸡丝低卡饭', cat: '低卡饭', kcal: 360, protein: 26, price: 25, originPrice: 32, color: '#D8B4D8', tag: '饱腹', sold: 2103, stock: 16 },
  { id: 6, name: '糙米牛肉能量饭', cat: '低卡饭', kcal: 480, protein: 35, price: 36, originPrice: 48, color: '#E8A0A0', tag: '健身增肌', sold: 1744, stock: 9 },
  { id: 7, name: '羽衣甘蓝苹果汁', cat: '能量饮品', kcal: 120, protein: 4, price: 18, originPrice: 24, color: '#9BD49B', tag: '轻断食', sold: 4102, stock: 40 },
  { id: 8, name: '冷萃燕麦拿铁', cat: '能量饮品', kcal: 150, protein: 6, price: 22, originPrice: 28, color: '#D9C1A3', tag: '0 植脂末', sold: 3021, stock: 35 },
  { id: 9, name: '无糖希腊酸奶杯', cat: '健康小食', kcal: 160, protein: 15, price: 15, originPrice: 20, color: '#F4E6D0', tag: '0 蔗糖', sold: 2560, stock: 22 },
  { id: 10, name: '烤鹰嘴豆脆脆', cat: '健康小食', kcal: 140, protein: 8, price: 12, originPrice: 16, color: '#E8C97E', tag: '解馋', sold: 3890, stock: 50 }
]

在这里插入图片描述

CAT_CHIPS 常量数组定义了五个商品分类标签,这些标签将在点餐 Tab 的顶部以横向滚动条的形式展示,用户点击不同分类可以筛选对应类别的商品。

SALAD_GOODS 常量数组包含十件商品,覆盖了五个分类。热量值从 120kcal(羽衣甘蓝苹果汁)到 480kcal(糙米牛肉能量饭)不等,蛋白质含量从 4g 到 35g。每件商品的 color 字段使用了柔和的莫兰迪色系,这些颜色将作为商品卡片左侧热量色块的背景色,在视觉上形成丰富的色彩层次。商品标签(tag 字段)涵盖了减脂、增肌、控卡、轻断食等不同饮食场景的需求,帮助用户快速找到适合自己目标的产品。

2.2 营养、计划与门店数据

const NUTRI_ITEMS: NutriItem[] = [
  { id: 1, name: '蛋白质', value: 68, max: 100, unit: 'g', color: '#16A34A', food: '鸡胸肉 / 希腊酸奶' },
  { id: 2, name: '碳水', value: 120, max: 200, unit: 'g', color: '#FACC15', food: '藜麦 / 紫薯 / 糙米' },
  { id: 3, name: '脂肪', value: 42, max: 60, unit: 'g', color: '#F97316', food: '牛油果 / 三文鱼' },
  { id: 4, name: '膳食纤维', value: 18, max: 30, unit: 'g', color: '#0EA5E9', food: '羽衣甘蓝 / 苹果' }
]

const PLAN_CELLS: PlanCell[] = [
  { id: 1, day: '周一', meal: '午餐', name: '牛油果鸡胸谷物碗', kcal: 420, done: true },
  { id: 2, day: '周一', meal: '晚餐', name: '凯撒鸡肉卷饼', kcal: 340, done: true },
  { id: 3, day: '周二', meal: '午餐', name: '藜麦三文鱼波奇碗', kcal: 385, done: true },
  { id: 4, day: '周二', meal: '晚餐', name: '紫薯鸡丝低卡饭', kcal: 360, done: false },
  { id: 5, day: '周三', meal: '午餐', name: '糙米牛肉能量饭', kcal: 480, done: false },
  // ... 更多计划项
]

const SHOP_ROWS: ShopRow[] = [
  { id: 1, name: '轻食工坊·滨江天街店', dist: '850m', queue: 3, score: '4.9', tag: '地铁口 / 自提柜', color: '#16A34A', hours: '10:00-21:30' },
  { id: 2, name: '轻食工坊·软件园店', dist: '1.2km', queue: 8, score: '4.8', tag: '排队快 / 免配送费', color: '#FACC15', hours: '09:30-20:00' },
  { id: 3, name: '轻食工坊·大学城店', dist: '2.4km', queue: 0, score: '4.7', tag: '学生认证 9 折', color: '#0EA5E9', hours: '10:00-22:00' },
  { id: 4, name: '轻食工坊·奥体中心店', dist: '3.8km', queue: 5, score: '4.9', tag: '健身党聚集地', color: '#F97316', hours: '07:00-21:00' },
  { id: 5, name: '轻食工坊·老城巷子店', dist: '4.6km', queue: 2, score: '4.6', tag: '复古小院 / 可堂食', color: '#8B5CF6', hours: '11:00-20:30' }
]

const WEEK_BARS: WeekBar[] = [
  { day: '周一', kcal: 760 },
  { day: '周二', kcal: 745 },
  { day: '周三', kcal: 820 },
  { day: '周四', kcal: 690 },
  { day: '周五', kcal: 725 },
  { day: '周六', kcal: 1080 },
  { day: '周日', kcal: 500 }
]

在这里插入图片描述

NUTRI_ITEMS 定义了四种营养素的摄入数据。蛋白质已摄入 68g(上限 100g),碳水 120g(上限 200g),脂肪 42g(上限 60g),膳食纤维 18g(上限 30g)。每种营养素配有独立的主题色和食物来源描述,这些数据将驱动营养 Tab 中的进度条可视化。

PLAN_CELLS 定义了十四餐的周计划,覆盖周一至周日的午餐和晚餐。前三餐的 done 字段为 true(已完成打卡),其余为 false。这种初始状态设计展示了"部分完成"的周计划场景,用户可以继续打卡未完成的餐次。

SHOP_ROWS 定义了五家门店数据。queue 字段的值从 0 到 8 不等——大学城店排队为 0(无需排队),软件园店排队为 8(较忙),这种差异化的排队数据为门店选择提供了决策依据。每家门店的 color 字段使用了不同的主题色,在门店卡片的评分色块中作为背景色。

WEEK_BARS 定义了七日热量数据。周一至周五的热量在 690-820kcal 之间波动(达标范围内),周六飙升至 1080kcal(超标,可能是放纵餐),周日降至 500kcal(轻断食日)。这种数据分布将驱动热量柱状图的双色渲染——达标日使用绿色柱体,超标日使用橙色柱体。

2.3 菜单与导航常量

const MINE_MENUS: MineMenu[] = [
  { id: 1, icon: '🎫', name: '优惠券', desc: '3 张可用', color: '#FACC15' },
  { id: 2, icon: '📍', name: '收货地址', desc: '2 个', color: '#16A34A' },
  { id: 3, icon: '🏆', name: '轻食成就', desc: '减脂 21 天', color: '#F97316' },
  { id: 4, icon: '⭐', name: '我的收藏', desc: '12 款轻食', color: '#0EA5E9' },
  { id: 5, icon: '🧾', name: '饮食周报', desc: '每周一推送', color: '#8B5CF6' },
  { id: 6, icon: '⚙️', name: '偏好设置', desc: '忌口 / 过敏原', color: '#64748B' }
]

const MAIN_TABS_S: TabMetaS[] = [
  { name: '点餐', icon: '🥗' },
  { name: '营养', icon: '📊' },
  { name: '周计划', icon: '📅' },
  { name: '门店', icon: '🏪' },
  { name: '我的', icon: '👤' }
]

在这里插入图片描述

MINE_MENUS 定义了六个菜单项,每项包含 emoji 图标、名称、描述和主题色。MAIN_TABS_S 定义了五个底部导航标签,注意该应用只有五个 Tab(不同于其他应用的六个 Tab),反映了轻食类应用精简的功能模块设计。

三、全局纯函数分析

3.1 热量柱状图高度计算函数

function kcalBarH(k: number): number {
  if (k >= 1000) {
    return 92
  }
  if (k >= 850) {
    return 78
  }
  if (k >= 700) {
    return 64
  }
  if (k >= 550) {
    return 50
  }
  return 36
}

kcalBarH 函数接收一个热量值参数 k,返回柱状图中对应柱子的高度(vp)。函数使用五个分段阈值将热量值映射到离散高度:大于等于 1000kcal 时高度为 92vp,850-999kcal 为 78vp,700-849kcal 为 64vp,550-699kcal 为 50vp,低于 550kcal 为 36vp。这种分段映射确保了不同热量级别的柱子在视觉上有足够的区分度,同时避免了极端值(如 500kcal 和 1080kcal)之间的柱高差异过大。

3.2 营养进度条宽度计算函数

function nutriBarW(v: number, m: number): number {
  if (m <= 0) {
    return 10
  }
  if (v >= m) {
    return 100
  }
  return Math.round(v * 100 / m)
}

nutriBarW 函数接收已摄入量 v 和每日上限 m 两个参数,返回进度条的填充百分比。函数首先处理除零保护——当 m 小于等于 0 时返回最小值 10,避免运行时错误。当摄入量超过上限时返回 100(满格)。正常情况下使用 Math.round(v * 100 / m) 计算百分比并四舍五入。这个函数被营养 Tab 中的四种营养素进度条调用,根据各自的 valuemax 字段动态计算填充宽度。

3.3 全局 @Builder 弹框遮罩

@Builder
function modalOverlay(onClose: () => void) {
  Column()
    .width('100%')
    .height('100%')
    .backgroundColor('rgba(22,40,25,0.55)')
    .onClick(() => {
      onClose()
    })
}

modalOverlay 是全局弹框遮罩构建函数,背景色使用了 rgba(22,40,25,0.55)——一种深绿色的半透明颜色,alpha 值为 0.55。与谷子集市应用的深蓝灰遮罩不同,这里选择了绿色调的遮罩以匹配轻食主题。该 @Builder 函数在应用的全部五个 Tab 中被复用,涵盖规格选择、购物车、领券中心、营养详情、BMI 计算器、营养师建议、添加餐次、编辑餐次、删除确认、换着吃、排队取号、门店详情、评价门店、周边地图、余额充值、收货地址、会员卡、偏好设置和搜索等近二十种弹框场景。

四、各 Tab 组件深度分析

4.1 点餐 Tab(OrderTab)

点餐 Tab 是应用的核心功能模块,集成了商品浏览、分类筛选、规格选择、购物车管理等完整的点餐流程。

状态变量与购物车方法
@Component
export struct OrderTab {
  @State catIdx: number = 0
  @State showBuy: boolean = false
  @State showCart: boolean = false
  @State showCoupon: boolean = false
  @State showDetail: boolean = false
  @State showTopping: boolean = false
  @State specIdx: number = 0
  @State topIdx: number = 0
  @State buyNum: number = 1
  @State selGoods: SaladGoods = SALAD_GOODS[0]
  @State cartList: CartRow[] = [
    { id: 1, name: '牛油果鸡胸谷物碗', spec: '大碗+溏心蛋', price: 38, num: 1 },
    { id: 2, name: '羽衣甘蓝苹果汁', spec: '标准杯', price: 18, num: 2 }
  ]

  addToCart() {
    const specName: string[] = ['小碗 320g', '大碗 450g', '套餐+饮品']
    const priceArr: number[] = [this.selGoods.price, this.selGoods.price + 8, this.selGoods.price + 18]
    let found = -1
    for (let i = 0; i < this.cartList.length; i++) {
      if (this.cartList[i].id === this.selGoods.id) {
        found = i
      }
    }
    const r: CartRow[] = []
    for (let i = 0; i < this.cartList.length; i++) {
      r.push(this.cartList[i])
    }
    if (found >= 0) {
      const row: CartRow = {
        id: this.cartList[found].id,
        name: this.cartList[found].name,
        spec: specName[this.specIdx],
        price: priceArr[this.specIdx],
        num: this.cartList[found].num + this.buyNum
      }
      r[found] = row
    } else {
      r.push({
        id: this.selGoods.id,
        name: this.selGoods.name,
        spec: specName[this.specIdx],
        price: priceArr[this.specIdx],
        num: this.buyNum
      })
    }
    this.cartList = r
    this.showBuy = false
    this.showCart = true
  }

  cartTotal(): number {
    let t = 0
    for (let i = 0; i < this.cartList.length; i++) {
      t += this.cartList[i].price * this.cartList[i].num
    }
    return t
  }

  cartKcal(): number {
    let t = 0
    for (let i = 0; i < this.cartList.length; i++) {
      t += Math.round(this.cartList[i].price * 3.5)
    }
    return t
  }

在这里插入图片描述

addToCart 方法是购物车系统的核心逻辑。方法首先根据 specIdx 索引从规格名称数组和价格数组中获取对应的规格名称和价格——小碗不加价、大碗加 8 元、套餐加 18 元。然后遍历购物车列表查找是否已存在相同 id 的商品,如果存在则更新规格和数量(累加 buyNum),如果不存在则创建新的购物车行。最终将新数组赋值给 cartList 状态变量,关闭规格选择弹框并自动打开购物车弹框。

cartTotal 方法遍历购物车列表累加每行的 price * num,返回总金额。cartKcal 方法使用 price * 3.5 的近似公式估算热量摄入,这个公式基于"每元价格约对应 3.5 千卡热量"的经验估算,虽然不是精确的营养计算,但为用户提供了快速的热量参考。

商品列表与分类筛选
Scroll() {
  Row({ space: 8 }) {
    ForEach(CAT_CHIPS, (c: string, i: number) => {
      Text(c)
        .fontSize(12)
        .fontWeight(this.catIdx === i ? FontWeight.Bold : FontWeight.Normal)
        .fontColor(this.catIdx === i ? '#FFFFFF' : '#3F6212')
        .padding({ left: 14, right: 14, top: 7, bottom: 7 })
        .borderRadius(16)
        .backgroundColor(this.catIdx === i ? '#16A34A' : '#E7F4DC')
        .onClick(() => {
          this.catIdx = i
        })
    }, (c: string) => c)
  }
  .padding({ left: 2, right: 2 })
}
.scrollable(ScrollDirection.Horizontal)
.width('100%')

ForEach(SALAD_GOODS, (g: SaladGoods) => {
  if (g.cat === CAT_CHIPS[this.catIdx]) {
    Row({ space: 12 }) {
      Column({ space: 4 }) {
        Text(g.kcal + '')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
        Text('kcal')
          .fontSize(9)
          .fontColor('#E3F5DC')
      }
      .width(64)
      .height(84)
      .borderRadius(12)
      .backgroundColor(g.color)
      .justifyContent(FlexAlign.Center)
      .shadow({ radius: 6, color: 'rgba(22,163,74,0.2)', offsetX: 0, offsetY: 3 })

      Column({ space: 5 }) {
        Row({ space: 6 }) {
          Text(g.name)
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor('#1A2E1A')
            .maxLines(1)
            .textOverflow({ overflow: TextOverflow.Ellipsis })
            .layoutWeight(1)
            .onClick(() => {
              this.selGoods = g
              this.showDetail = true
            })
          Text(g.tag)
            .fontSize(9)
            .fontColor('#16A34A')
            .padding({ left: 6, right: 6, top: 2, bottom: 2 })
            .borderRadius(8)
            .backgroundColor('#DCFCE7')
        }
        .width('100%')

        Text('蛋白质 ' + g.protein + 'g · 已售 ' + g.sold + ' · 库存 ' + g.stock)
          .fontSize(10)
          .fontColor('#7CA982')
          .width('100%')

        Row({ space: 8 }) {
          Column({ space: 0 }) {
            Text('¥' + g.price)
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#16A34A')
            Text('¥' + g.originPrice)
              .fontSize(9)
              .fontColor('#B9CDB9')
              .decoration({ type: TextDecorationType.LineThrough })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)

          Text('+ 选规格')
            .fontSize(11)
            .fontColor('#FFFFFF')
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .borderRadius(13)
            .backgroundColor('#16A34A')
            .onClick(() => {
              this.selGoods = g
              this.specIdx = 0
              this.buyNum = 1
              this.showBuy = true
            })
        }
        .width('100%')
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
    }
    .width('100%')
    .padding(12)
    .borderRadius(14)
    .backgroundColor('#FFFFFF')
    .shadow({ radius: 5, color: 'rgba(26,46,26,0.06)', offsetX: 0, offsetY: 2 })
  }
}, (g: SaladGoods) => (g.id.toString() + this.catIdx.toString()))

在这里插入图片描述

分类筛选条使用横向 Scroll 容器包裹 ForEach 生成的标签按钮。选中的分类标签使用绿色背景白字加粗,未选中标签使用浅绿色 #E7F4DC 背景深绿色文字。

商品列表使用 ForEach 遍历 SALAD_GOODS 数组,但在内部使用 if (g.cat === CAT_CHIPS[this.catIdx]) 条件判断来筛选当前分类的商品。这种"全量遍历+条件渲染"的方式确保了 ForEach 的键值稳定性(键值包含 catIdx),同时实现了分类筛选效果。每个商品卡片左侧的 64x84vp 色块以大号白色字体展示热量值,这种设计使热量信息成为视觉焦点,符合轻食应用的用户关注点。

值得注意的是,ForEach 的键值函数使用了 (g.id.toString() + this.catIdx.toString()) 的组合键——将商品 ID 和当前分类索引拼接为唯一键。这种设计确保了当用户切换分类时,ForEach 能够正确识别列表项的变化并执行高效的 diff 更新。

4.2 营养 Tab(NutriTab)

营养 Tab 展示了用户的营养摄入情况和本周热量走势,并提供了 BMI 计算器工具。

BMI 计算器
@State inputH: string = ''
@State inputW: string = ''
@State bmiVal: string = ''

calcBmi() {
  const h: number = Number(this.inputH === '' ? '170' : this.inputH) / 100
  const w: number = Number(this.inputW === '' ? '60' : this.inputW)
  const v: number = w / (h * h)
  if (v < 18.5) {
    this.bmiVal = v.toFixed(1) + ' 偏瘦'
  } else if (v < 24) {
    this.bmiVal = v.toFixed(1) + ' 标准'
  } else if (v < 28) {
    this.bmiVal = v.toFixed(1) + ' 偏胖'
  } else {
    this.bmiVal = v.toFixed(1) + ' 肥胖'
  }
}

calcBmi 方法实现了 BMI(Body Mass Index,身体质量指数)的计算逻辑。方法首先将身高从厘米转换为米(除以 100),然后使用公式 体重 / 身高^2 计算 BMI 值。当用户未输入身高或体重时,使用默认值 170cm 和 60kg 进行计算。计算结果通过四段式条件判断分类——小于 18.5 为"偏瘦",18.5-23.9 为"标准",24-27.9 为"偏胖",大于等于 28 为"肥胖"。结果使用 toFixed(1) 保留一位小数,并拼接对应的分类文字后赋值给 bmiVal 状态变量,触发结果文本的条件渲染。

营养进度条
ForEach(NUTRI_ITEMS, (n: NutriItem) => {
  Column({ space: 6 }) {
    Row({ space: 6 }) {
      Text(n.name)
        .fontSize(12)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1A2E1A')
      Text(n.value + '/' + n.max + n.unit)
        .fontSize(10)
        .fontColor('#7CA982')
        .layoutWeight(1)
      Text(nutriBarW(n.value, n.max) + '%')
        .fontSize(10)
        .fontWeight(FontWeight.Bold)
        .fontColor(n.color)
    }
    .width('100%')

    Column({ space: 0 }) {
      Column()
        .height(8)
        .borderRadius(4)
        .width(nutriBarW(n.value, n.max) + '%')
        .linearGradient({
          angle: 0,
          colors: [[n.color, 0], [n.color, 1]]
        })
    }
    .width('100%')
    .height(8)
    .borderRadius(4)
    .backgroundColor('#EEF6EA')
  }
  .width('100%')
  .padding(10)
  .borderRadius(12)
  .backgroundColor('#F6FBF4')
  .onClick(() => {
    this.selNutri = n
    this.showItem = true
  })
}, (n: NutriItem) => n.id.toString())

营养进度条由两层 Column 组合实现——外层 Column 作为轨道(背景色 #EEF6EA,高度 8vp,圆角 4vp),内层 Column 作为填充条(宽度通过 nutriBarW(n.value, n.max) 计算的百分比,使用 n.color 作为渐变色)。进度条上方显示了营养素名称、已摄入量/上限和百分比,三者在同一行内使用 layoutWeight 实现弹性布局。点击进度条卡片会打开营养详情弹框,展示已摄入量、每日上限、剩余额度和主要食物来源。

热量柱状图
Row({ space: 0 }) {
  ForEach(WEEK_BARS, (w: WeekBar) => {
    Column({ space: 5 }) {
      Text(w.kcal + '')
        .fontSize(8)
        .fontColor('#7CA982')
      Column()
        .width(18)
        .height(kcalBarH(w.kcal))
        .borderRadius(6)
        .linearGradient({
          angle: 180,
          colors: w.kcal > 1000 ? [['#F97316', 0], ['#FDBA74', 1]] : [['#16A34A', 0], ['#86D99A', 1]]
        })
      Text(w.day)
        .fontSize(9)
        .fontColor('#3F6212')
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Center)
    .onClick(() => {
      this.showTip = true
    })
  }, (w: WeekBar) => w.day)
}
.width('100%')
.height(140)
.justifyContent(FlexAlign.End)

热量柱状图通过 ForEach 遍历 WEEK_BARS 数组生成七根柱子,每根柱子使用 layoutWeight(1) 等分容器宽度。柱体高度通过全局函数 kcalBarH(w.kcal) 计算,柱体颜色根据热量阈值动态切换——超过 1000kcal 时使用橙色渐变(#F97316#FDBA74),达标时使用绿色渐变(#16A34A#86D99A)。容器使用 justifyContent(FlexAlign.End) 使柱子底部对齐,上方留出空间显示热量数值标签。

4.3 周计划 Tab(PlanTab)

周计划 Tab 实现了七日饮食计划的完整 CRUD 管理功能。

@Component
export struct PlanTab {
  @State planList: PlanCell[] = PLAN_CELLS
  @State showAdd: boolean = false
  @State showEdit: boolean = false
  @State showDel: boolean = false
  @State showSwap: boolean = false
  @State editIdx: number = -1
  @State delIdx: number = -1
  @State swapIdx: number = -1
  @State inputDay: string = '周一'
  @State inputMeal: string = '午餐'
  @State inputName: string = ''
  @State inputKcal: string = ''
  @State dayIdx: number = 0
  @State mealIdx: number = 0

  toggleDone(idx: number) {
    const r: PlanCell[] = []
    for (let i = 0; i < this.planList.length; i++) {
      if (i === idx) {
        r.push({
          id: this.planList[i].id,
          day: this.planList[i].day,
          meal: this.planList[i].meal,
          name: this.planList[i].name,
          kcal: this.planList[i].kcal,
          done: !this.planList[i].done
        })
      } else {
        r.push(this.planList[i])
      }
    }
    this.planList = r
  }

  swapPlan() {
    const r: PlanCell[] = []
    for (let i = 0; i < this.planList.length; i++) {
      if (i === this.swapIdx) {
        r.push({
          id: this.planList[i].id,
          day: this.planList[i].day,
          meal: this.planList[i].meal,
          name: '羽衣甘蓝苹果汁 + 鹰嘴豆脆脆',
          kcal: 260,
          done: this.planList[i].done
        })
      } else {
        r.push(this.planList[i])
      }
    }
    this.planList = r
    this.showSwap = false
  }

toggleDone 方法是周计划的核心交互方法,用于切换餐次的打卡状态。方法采用不可变更新模式——创建新数组,遍历原数组复制元素,在目标索引处创建一个 done 字段取反的新对象。这种设计确保了 @State planList 的引用变化能够被框架检测到,从而触发计划列表的 UI 刷新。

swapPlan 方法实现了"换着吃"功能——将指定餐次的餐品替换为预设的轻断食组合(羽衣甘蓝苹果汁 + 鹰嘴豆脆脆,260kcal)。这种快捷替换功能为用户提供了饮食多样性选择,无需手动编辑即可快速调整计划。

计划列表中每个卡片包含星期标签、餐段标签、竖条指示器、餐品名称、热量值和操作按钮。已完成餐次的卡片使用浅绿色背景 #F6FBF4,未完成的使用白色背景,竖条指示器从柠檬黄 #FACC15 变为浅灰色 #D6E8D0。打卡按钮在未完成状态显示"打卡"(绿色背景),完成后显示"已吃"(灰色背景),点击任一区域均可切换状态。

4.4 门店 Tab(ShopTab)

门店 Tab 展示了周边门店信息,支持排队取号、门店详情查看和评价功能。

takeNumber() {
  const prefix: string[] = ['A', 'B', 'C', 'D', 'E']
  this.queueNo = prefix[this.takeIdx] + '0' + (this.selShop.queue + 17)
  this.showQueue = false
}

takeNumber 方法是排队取号的核心逻辑。方法根据 takeIdx(就餐人数索引)从前缀数组中取对应字母(1人=A、2人=B、3-4人=C、5人+=D),然后拼接当前排队人数加 17 得到号码序号(加 17 是因为已有排队人数),最终生成如"A020"的排队号码。号码生成后关闭排队弹框,排队号码卡片会出现在门店列表下方。

门店列表中每个卡片左侧的 52x52vp 评分色块使用门店主题色作为背景,以大号白字展示评分。排队信息使用条件渲染——当 queue > 0 时显示"排队 N 桌"(橙色),否则显示"无需排队"(绿色),这种颜色编码帮助用户快速识别门店繁忙程度。

4.5 我的 Tab(SMineTab)

我的 Tab 展示了用户信息、钱包余额、轻食报告、功能菜单等内容。

@Component
export struct SMineTab {
  @State showRecharge: boolean = false
  @State showAddr: boolean = false
  @State showVip: boolean = false
  @State showSetting: boolean = false
  @State amountIdx: number = 1

用户信息卡片使用了从深绿到牛油果绿再到柠檬黄的三色渐变(['#14532D', 0], ['#16A34A', 0.55], ['#FACC15', 1]),角度为 140 度。这种三色渐变在视觉上从深到浅、从冷到暖过渡,象征着从坚持到收获的过程。卡片中的头像使用了半透明白色背景配合柠檬黄边框,用户名旁的"减脂 21 天"标签使用柠檬黄背景深色文字。

余额充值弹框提供了三档充值选项——50 元(无赠送)、100 元(送 10 元券)、200 元(送 25 元券)。选中档位使用不同颜色突出显示:50 元和 200 元使用绿色背景,100 元使用黄色背景(推荐档位)。这种差异化视觉设计引导用户选择推荐档位。

菜单列表使用 ForEach 遍历 MINE_MENUS 数组生成六个菜单项,每项包含 emoji 图标、名称、描述和箭头。菜单项的 onClick 事件根据 m.id 的值打开不同的弹框——id 为 1 时打开充值弹框,id 为 2 时打开地址弹框,id 为 6 时打开设置弹框。这种基于 id 的条件路由是菜单交互的常见模式。

五、主入口页面分析

@Entry
@Component
struct SPage {
  @State activeTab: number = 0
  @State showSearch: boolean = false
  @State searchKw: string = ''

  build() {
    Column() {
      Column() {
        Row({ space: 10 }) {
          Text('轻食工坊')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')

          Row({ space: 6 }) {
            Text('🔍')
              .fontSize(13)
            Text('搜索轻食 / 饮品 / 门店')
              .fontSize(11)
              .fontColor('#BBF7D0')
              .layoutWeight(1)
          }
          .layoutWeight(1)
          .height(32)
          .padding({ left: 12, right: 12 })
          .borderRadius(16)
          .backgroundColor('rgba(255,255,255,0.22)')
          .onClick(() => {
            this.showSearch = true
          })

          Text('🛵')
            .fontSize(17)
        }
        .width('100%')
        .padding({ left: 14, right: 14, top: 10, bottom: 10 })
      }
      .width('100%')
      .linearGradient({
        angle: 120,
        colors: [['#14532D', 0], ['#16A34A', 0.65], ['#4CC57E', 1]]
      })

      Column() {
        if (this.activeTab === 0) {
          OrderTab()
        } else if (this.activeTab === 1) {
          NutriTab()
        } else if (this.activeTab === 2) {
          PlanTab()
        } else if (this.activeTab === 3) {
          ShopTab()
        } else {
          SMineTab()
        }
      }
      .layoutWeight(1)
      .width('100%')

      Row({ space: 0 }) {
        ForEach(MAIN_TABS_S, (t: TabMetaS, i: number) => {
          Column({ space: 3 }) {
            Text(t.icon)
              .fontSize(17)
            Text(t.name)
              .fontSize(10)
              .fontWeight(this.activeTab === i ? FontWeight.Bold : FontWeight.Normal)
              .fontColor(this.activeTab === i ? '#16A34A' : '#A3BFA3')
            if (this.activeTab === i) {
              Column()
                .width(16)
                .height(3)
                .borderRadius(2)
                .backgroundColor('#FACC15')
            } else {
              Column()
                .width(16)
                .height(3)
                .borderRadius(2)
                .backgroundColor('rgba(0,0,0,0)')
            }
          }
          .layoutWeight(1)
          .padding({ top: 8, bottom: 8 })
          .alignItems(HorizontalAlign.Center)
          .onClick(() => {
            this.activeTab = i
          })
        }, (t: TabMetaS) => t.name)
      }
      .width('100%')
      .backgroundColor('#FFFFFF')

SPage 组件的页面结构从上到下分为三层:顶部导航栏、内容区和底部导航栏。

顶部导航栏使用 120 度三色渐变(深绿 #14532D 到牛油果绿 #16A34A 再到浅绿 #4CC57E),包含应用标题"轻食工坊"、搜索入口和配送图标。搜索入口使用半透明白色背景的胶囊形按钮。

内容区使用 if-else if-else 条件链根据 activeTab 渲染对应的 Tab 组件,五个 Tab 分别对应 OrderTabNutriTabPlanTabShopTabSMineTab

底部导航栏使用 ForEach 遍历 MAIN_TABS_S 生成五个标签。选中标签的图标下方的指示条使用柠檬黄 #FACC15,未选中标签的指示条使用透明色 rgba(0,0,0,0)——这种设计保持了指示条的空间占位(高度始终为 3vp),避免了选中/取消选中时标签内容的位置跳动。

六、流程图

6.1 应用架构流程图

数据层

Tab 组件层

导航层

主入口层

SPage @Entry

顶部导航栏
搜索/配送

底部 Tab 导航栏
5个标签

OrderTab
点餐

NutriTab
营养

PlanTab
周计划

ShopTab
门店

SMineTab
我的

接口定义
8个 interface

全局常量
GOODS/NUTRI/PLAN/SHOP

全局函数
kcalBarH/nutriBarW

@Builder
modalOverlay

6.2 弹框交互流程图

选规格

购物车

领券

营养详情

BMI计算

添加餐次

编辑餐次

排队取号

门店详情

评价门店

余额充值

会员卡

选择规格

调整数量

加入购物车

点击遮罩/关闭

提交操作

用户操作

点击触发按钮

showBuy = true

showCart = true

showCoupon = true

showItem = true

showBmi = true

showAdd = true

showEdit = true

showQueue = true

showShop = true

showRate = true

showRecharge = true

showVip = true

渲染 modalOverlay 遮罩

渲染弹框内容

用户操作

specIdx 更新

buyNum 更新

addToCart 方法

商品已存在?

合并数量+更新规格

创建新行

cartList = 新数组

showBuy = false

showCart = true

状态变量 = false

执行业务逻辑

移除弹框 UI

返回页面

七、技术对比表格

技术维度 实现方案 设计特点 优势分析 适用场景
UI 声明范式 ArkTS 声明式 UI 数据驱动视图,@State 变量变更自动触发 UI 刷新 无需手动操作 DOM,代码简洁可维护 所有 ArkTS 应用
组件化架构 @Component + @Entry 五个独立 Tab 组件 + 一个主入口组件 职责分离,功能模块独立 多功能模块应用
购物车系统 不可变数组更新 + 规格价格映射 addToCart 方法支持合并同商品、规格选择、价格计算 精确的购物车状态管理,自动弹出购物车 电商/餐饮点餐
BMI 计算 Number 转换 + 公式计算 + 四段分类 身高体重输入 -> BMI 值 -> 分类标签 实时计算,默认值兜底,四段式健康评估 健康类工具
营养可视化 nutriBarW 函数 + 条件渐变进度条 百分比宽度计算 + 营养素主题色渐变 轻量级进度条,无需图表库,颜色编码区分 营养摄入展示
热量柱状图 kcalBarH 函数 + 双色条件渐变 分段高度映射 + 达标/超标颜色切换 零依赖图表,直观的热量趋势展示 周热量走势
CRUD 模式 不可变数组更新 创建新数组 -> 遍历 -> 条件替换 -> 赋值 确保状态变化被检测,触发 UI 刷新 周计划管理
弹框交互 布尔状态 + @Builder modalOverlay 近20种弹框统一遮罩+条件渲染 交互模式统一,遮罩层复用 表单输入、确认操作
分类筛选 ForEach + if 条件判断 全量遍历+条件渲染+组合键值 键值稳定,diff 高效,筛选实时 商品分类浏览
渐变主题 linearGradient 三色渐变 深绿->牛油果绿->柠檬黄 140度渐变 品牌色彩体系,从冷到暖的视觉过渡 用户卡片、横幅
排队系统 字母前缀 + 序号拼接 takeNumber 方法生成排队号 简洁的号码生成逻辑,支持不同人数 门店排队取号
条件指示条 透明色占位 + 主题色填充 选中/未选中均渲染指示条,颜色不同 避免选中切换时的位置跳动 底部导航栏

安装DevEco Studio程序

在这里插入图片描述
选择目标安装目录:

在这里插入图片描述
设置环境变量,但是需要重启一下:

在这里插入图片描述
新建一个空白模板:

在这里插入图片描述
设置API为24的模板项目:
在这里插入图片描述
初始化项目,自动下载相关依赖:

在这里插入图片描述


完整代码:

// 主题:牛油果绿 #16A34A × 柠檬黄 #FACC15,底色 #F6FBF4,清新农场风
// 5 个底部 Tab:点餐 / 营养 / 周计划 / 门店 / 我的

interface SaladGoods {
  id: number
  name: string
  cat: string
  kcal: number
  protein: number
  price: number
  originPrice: number
  color: string
  tag: string
  sold: number
  stock: number
}

interface CartRow {
  id: number
  name: string
  spec: string
  price: number
  num: number
}

interface NutriItem {
  id: number
  name: string
  value: number
  max: number
  unit: string
  color: string
  food: string
}

interface PlanCell {
  id: number
  day: string
  meal: string
  name: string
  kcal: number
  done: boolean
}

interface ShopRow {
  id: number
  name: string
  dist: string
  queue: number
  score: string
  tag: string
  color: string
  hours: string
}

interface WeekBar {
  day: string
  kcal: number
}

interface MineMenu {
  id: number
  icon: string
  name: string
  desc: string
  color: string
}

interface TabMetaS {
  name: string
  icon: string
}

const CAT_CHIPS: string[] = ['招牌碗', '轻食卷', '低卡饭', '能量饮品', '健康小食']

const SALAD_GOODS: SaladGoods[] = [
  { id: 1, name: '牛油果鸡胸谷物碗', cat: '招牌碗', kcal: 420, protein: 32, price: 32, originPrice: 45, color: '#A7D7A0', tag: '店长推荐', sold: 2861, stock: 18 },
  { id: 2, name: '藜麦三文鱼波奇碗', cat: '招牌碗', kcal: 385, protein: 28, price: 39, originPrice: 52, color: '#F9C78A', tag: '低 GI', sold: 1932, stock: 12 },
  { id: 3, name: '凯撒鸡肉卷饼', cat: '轻食卷', kcal: 340, protein: 22, price: 26, originPrice: 34, color: '#F7E8A4', tag: '高蛋白', sold: 3240, stock: 25 },
  { id: 4, name: '金枪鱼全麦卷', cat: '轻食卷', kcal: 310, protein: 24, price: 28, originPrice: 38, color: '#A8D8D8', tag: '控卡', sold: 1560, stock: 30 },
  { id: 5, name: '紫薯鸡丝低卡饭', cat: '低卡饭', kcal: 360, protein: 26, price: 25, originPrice: 32, color: '#D8B4D8', tag: '饱腹', sold: 2103, stock: 16 },
  { id: 6, name: '糙米牛肉能量饭', cat: '低卡饭', kcal: 480, protein: 35, price: 36, originPrice: 48, color: '#E8A0A0', tag: '健身增肌', sold: 1744, stock: 9 },
  { id: 7, name: '羽衣甘蓝苹果汁', cat: '能量饮品', kcal: 120, protein: 4, price: 18, originPrice: 24, color: '#9BD49B', tag: '轻断食', sold: 4102, stock: 40 },
  { id: 8, name: '冷萃燕麦拿铁', cat: '能量饮品', kcal: 150, protein: 6, price: 22, originPrice: 28, color: '#D9C1A3', tag: '0 植脂末', sold: 3021, stock: 35 },
  { id: 9, name: '无糖希腊酸奶杯', cat: '健康小食', kcal: 160, protein: 15, price: 15, originPrice: 20, color: '#F4E6D0', tag: '0 蔗糖', sold: 2560, stock: 22 },
  { id: 10, name: '烤鹰嘴豆脆脆', cat: '健康小食', kcal: 140, protein: 8, price: 12, originPrice: 16, color: '#E8C97E', tag: '解馋', sold: 3890, stock: 50 }
]

const NUTRI_ITEMS: NutriItem[] = [
  { id: 1, name: '蛋白质', value: 68, max: 100, unit: 'g', color: '#16A34A', food: '鸡胸肉 / 希腊酸奶' },
  { id: 2, name: '碳水', value: 120, max: 200, unit: 'g', color: '#FACC15', food: '藜麦 / 紫薯 / 糙米' },
  { id: 3, name: '脂肪', value: 42, max: 60, unit: 'g', color: '#F97316', food: '牛油果 / 三文鱼' },
  { id: 4, name: '膳食纤维', value: 18, max: 30, unit: 'g', color: '#0EA5E9', food: '羽衣甘蓝 / 苹果' }
]

const PLAN_CELLS: PlanCell[] = [
  { id: 1, day: '周一', meal: '午餐', name: '牛油果鸡胸谷物碗', kcal: 420, done: true },
  { id: 2, day: '周一', meal: '晚餐', name: '凯撒鸡肉卷饼', kcal: 340, done: true },
  { id: 3, day: '周二', meal: '午餐', name: '藜麦三文鱼波奇碗', kcal: 385, done: true },
  { id: 4, day: '周二', meal: '晚餐', name: '紫薯鸡丝低卡饭', kcal: 360, done: false },
  { id: 5, day: '周三', meal: '午餐', name: '糙米牛肉能量饭', kcal: 480, done: false },
  { id: 6, day: '周三', meal: '晚餐', name: '金枪鱼全麦卷', kcal: 310, done: false },
  { id: 7, day: '周四', meal: '午餐', name: '牛油果鸡胸谷物碗', kcal: 420, done: false },
  { id: 8, day: '周四', meal: '晚餐', name: '无糖希腊酸奶杯', kcal: 160, done: false },
  { id: 9, day: '周五', meal: '午餐', name: '藜麦三文鱼波奇碗', kcal: 385, done: false },
  { id: 10, day: '周五', meal: '晚餐', name: '凯撒鸡肉卷饼', kcal: 340, done: false },
  { id: 11, day: '周六', meal: '午餐', name: '放纵餐·自由搭配', kcal: 600, done: false },
  { id: 12, day: '周六', meal: '晚餐', name: '羽衣甘蓝苹果汁', kcal: 120, done: false },
  { id: 13, day: '周日', meal: '午餐', name: '紫薯鸡丝低卡饭', kcal: 360, done: false },
  { id: 14, day: '周日', meal: '晚餐', name: '烤鹰嘴豆脆脆', kcal: 140, done: false }
]

const SHOP_ROWS: ShopRow[] = [
  { id: 1, name: '轻食工坊·滨江天街店', dist: '850m', queue: 3, score: '4.9', tag: '地铁口 / 自提柜', color: '#16A34A', hours: '10:00-21:30' },
  { id: 2, name: '轻食工坊·软件园店', dist: '1.2km', queue: 8, score: '4.8', tag: '排队快 / 免配送费', color: '#FACC15', hours: '09:30-20:00' },
  { id: 3, name: '轻食工坊·大学城店', dist: '2.4km', queue: 0, score: '4.7', tag: '学生认证 9 折', color: '#0EA5E9', hours: '10:00-22:00' },
  { id: 4, name: '轻食工坊·奥体中心店', dist: '3.8km', queue: 5, score: '4.9', tag: '健身党聚集地', color: '#F97316', hours: '07:00-21:00' },
  { id: 5, name: '轻食工坊·老城巷子店', dist: '4.6km', queue: 2, score: '4.6', tag: '复古小院 / 可堂食', color: '#8B5CF6', hours: '11:00-20:30' }
]

const WEEK_BARS: WeekBar[] = [
  { day: '周一', kcal: 760 },
  { day: '周二', kcal: 745 },
  { day: '周三', kcal: 820 },
  { day: '周四', kcal: 690 },
  { day: '周五', kcal: 725 },
  { day: '周六', kcal: 1080 },
  { day: '周日', kcal: 500 }
]

const MINE_MENUS: MineMenu[] = [
  { id: 1, icon: '🎫', name: '优惠券', desc: '3 张可用', color: '#FACC15' },
  { id: 2, icon: '📍', name: '收货地址', desc: '2 个', color: '#16A34A' },
  { id: 3, icon: '🏆', name: '轻食成就', desc: '减脂 21 天', color: '#F97316' },
  { id: 4, icon: '⭐', name: '我的收藏', desc: '12 款轻食', color: '#0EA5E9' },
  { id: 5, icon: '🧾', name: '饮食周报', desc: '每周一推送', color: '#8B5CF6' },
  { id: 6, icon: '⚙️', name: '偏好设置', desc: '忌口 / 过敏原', color: '#64748B' }
]

function kcalBarH(k: number): number {
  if (k >= 1000) {
    return 92
  }
  if (k >= 850) {
    return 78
  }
  if (k >= 700) {
    return 64
  }
  if (k >= 550) {
    return 50
  }
  return 36
}

function nutriBarW(v: number, m: number): number {
  if (m <= 0) {
    return 10
  }
  if (v >= m) {
    return 100
  }
  return Math.round(v * 100 / m)
}

@Builder
function modalOverlay(onClose: () => void) {
  Column()
    .width('100%')
    .height('100%')
    .backgroundColor('rgba(22,40,25,0.55)')
    .onClick(() => {
      onClose()
    })
}

// ==================== Tab 1 点餐 ====================
@Component
export struct OrderTab {
  @State catIdx: number = 0
  @State showBuy: boolean = false
  @State showCart: boolean = false
  @State showCoupon: boolean = false
  @State showDetail: boolean = false
  @State showTopping: boolean = false
  @State specIdx: number = 0
  @State topIdx: number = 0
  @State buyNum: number = 1
  @State selGoods: SaladGoods = SALAD_GOODS[0]
  @State cartList: CartRow[] = [
    { id: 1, name: '牛油果鸡胸谷物碗', spec: '大碗+溏心蛋', price: 38, num: 1 },
    { id: 2, name: '羽衣甘蓝苹果汁', spec: '标准杯', price: 18, num: 2 }
  ]

  addToCart() {
    const specName: string[] = ['小碗 320g', '大碗 450g', '套餐+饮品']
    const priceArr: number[] = [this.selGoods.price, this.selGoods.price + 8, this.selGoods.price + 18]
    const exist: number = -1
    let found = exist
    for (let i = 0; i < this.cartList.length; i++) {
      if (this.cartList[i].id === this.selGoods.id) {
        found = i
      }
    }
    const r: CartRow[] = []
    for (let i = 0; i < this.cartList.length; i++) {
      r.push(this.cartList[i])
    }
    if (found >= 0) {
      const row: CartRow = {
        id: this.cartList[found].id,
        name: this.cartList[found].name,
        spec: specName[this.specIdx],
        price: priceArr[this.specIdx],
        num: this.cartList[found].num + this.buyNum
      }
      r[found] = row
    } else {
      r.push({
        id: this.selGoods.id,
        name: this.selGoods.name,
        spec: specName[this.specIdx],
        price: priceArr[this.specIdx],
        num: this.buyNum
      })
    }
    this.cartList = r
    this.showBuy = false
    this.showCart = true
  }

  changeNum(idx: number, delta: number) {
    const r: CartRow[] = []
    for (let i = 0; i < this.cartList.length; i++) {
      if (i === idx) {
        const next: number = this.cartList[i].num + delta
        if (next > 0) {
          r.push({
            id: this.cartList[i].id,
            name: this.cartList[i].name,
            spec: this.cartList[i].spec,
            price: this.cartList[i].price,
            num: next
          })
        }
      } else {
        r.push(this.cartList[i])
      }
    }
    this.cartList = r
  }

  cartTotal(): number {
    let t = 0
    for (let i = 0; i < this.cartList.length; i++) {
      t += this.cartList[i].price * this.cartList[i].num
    }
    return t
  }

  cartKcal(): number {
    let t = 0
    for (let i = 0; i < this.cartList.length; i++) {
      t += Math.round(this.cartList[i].price * 3.5)
    }
    return t
  }

  build() {
    Scroll() {
      Column({ space: 12 }) {
        Column({ space: 10 }) {
          Row({ space: 10 }) {
            Column({ space: 3 }) {
              Text('今日轻食季')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('低卡碗第二份半价 · 满 39 免配送')
                .fontSize(11)
                .fontColor('#DCF5D3')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Text('领券')
              .fontSize(12)
              .fontColor('#16A34A')
              .padding({ left: 14, right: 14, top: 7, bottom: 7 })
              .borderRadius(14)
              .backgroundColor('#FFFFFF')
              .onClick(() => {
                this.showCoupon = true
              })
          }
          .width('100%')

          Row({ space: 8 }) {
            Column({ space: 2 }) {
              Text('新客立减 ¥8')
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor('#16A34A')
              Text('不限门槛')
                .fontSize(9)
                .fontColor('#DCF5D3')
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
            .padding({ top: 8, bottom: 8 })
            .borderRadius(10)
            .backgroundColor('rgba(255,255,255,0.25)')

            Column({ space: 2 }) {
              Text('会员 88 折')
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor('#16A34A')
              Text('开通即享')
                .fontSize(9)
                .fontColor('#DCF5D3')
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
            .padding({ top: 8, bottom: 8 })
            .borderRadius(10)
            .backgroundColor('rgba(255,255,255,0.25)')

            Column({ space: 2 }) {
              Text('自提 95 折')
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor('#16A34A')
              Text('门店取餐')
                .fontSize(9)
                .fontColor('#DCF5D3')
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
            .padding({ top: 8, bottom: 8 })
            .borderRadius(10)
            .backgroundColor('rgba(255,255,255,0.25)')
          }
          .width('100%')
        }
        .padding(14)
        .borderRadius(16)
        .linearGradient({
          angle: 135,
          colors: [['#16A34A', 0], ['#4CC57E', 1]]
        })
        .shadow({ radius: 12, color: 'rgba(22,163,74,0.28)', offsetX: 0, offsetY: 5 })
        .width('100%')

        Scroll() {
          Row({ space: 8 }) {
            ForEach(CAT_CHIPS, (c: string, i: number) => {
              Text(c)
                .fontSize(12)
                .fontWeight(this.catIdx === i ? FontWeight.Bold : FontWeight.Normal)
                .fontColor(this.catIdx === i ? '#FFFFFF' : '#3F6212')
                .padding({ left: 14, right: 14, top: 7, bottom: 7 })
                .borderRadius(16)
                .backgroundColor(this.catIdx === i ? '#16A34A' : '#E7F4DC')
                .onClick(() => {
                  this.catIdx = i
                })
            }, (c: string) => c)
          }
          .padding({ left: 2, right: 2 })
        }
        .scrollable(ScrollDirection.Horizontal)
        .width('100%')

        ForEach(SALAD_GOODS, (g: SaladGoods) => {
          if (g.cat === CAT_CHIPS[this.catIdx]) {
            Row({ space: 12 }) {
              Column({ space: 4 }) {
                Text(g.kcal + '')
                  .fontSize(17)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#FFFFFF')
                Text('kcal')
                  .fontSize(9)
                  .fontColor('#E3F5DC')
              }
              .width(64)
              .height(84)
              .borderRadius(12)
              .backgroundColor(g.color)
              .justifyContent(FlexAlign.Center)
              .shadow({ radius: 6, color: 'rgba(22,163,74,0.2)', offsetX: 0, offsetY: 3 })

              Column({ space: 5 }) {
                Row({ space: 6 }) {
                  Text(g.name)
                    .fontSize(14)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#1A2E1A')
                    .maxLines(1)
                    .textOverflow({ overflow: TextOverflow.Ellipsis })
                    .layoutWeight(1)
                    .onClick(() => {
                      this.selGoods = g
                      this.showDetail = true
                    })

                  Text(g.tag)
                    .fontSize(9)
                    .fontColor('#16A34A')
                    .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                    .borderRadius(8)
                    .backgroundColor('#DCFCE7')
                }
                .width('100%')

                Text('蛋白质 ' + g.protein + 'g · 已售 ' + g.sold + ' · 库存 ' + g.stock)
                  .fontSize(10)
                  .fontColor('#7CA982')
                  .width('100%')

                Row({ space: 8 }) {
                  Column({ space: 0 }) {
                    Text('¥' + g.price)
                      .fontSize(16)
                      .fontWeight(FontWeight.Bold)
                      .fontColor('#16A34A')
                    Text('¥' + g.originPrice)
                      .fontSize(9)
                      .fontColor('#B9CDB9')
                      .decoration({ type: TextDecorationType.LineThrough })
                  }
                  .alignItems(HorizontalAlign.Start)
                  .layoutWeight(1)

                  Text('+ 选规格')
                    .fontSize(11)
                    .fontColor('#FFFFFF')
                    .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                    .borderRadius(13)
                    .backgroundColor('#16A34A')
                    .onClick(() => {
                      this.selGoods = g
                      this.specIdx = 0
                      this.buyNum = 1
                      this.showBuy = true
                    })
                }
                .width('100%')
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
            }
            .width('100%')
            .padding(12)
            .borderRadius(14)
            .backgroundColor('#FFFFFF')
            .shadow({ radius: 5, color: 'rgba(26,46,26,0.06)', offsetX: 0, offsetY: 2 })
          }
        }, (g: SaladGoods) => (g.id.toString() + this.catIdx.toString()))

        Column({ space: 10 }) {
          Row({ space: 8 }) {
            Text('🥗 搭配师推荐')
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1A2E1A')
              .layoutWeight(1)
            Text('换一批')
              .fontSize(11)
              .fontColor('#16A34A')
              .onClick(() => {
                this.showTopping = true
              })
          }
          .width('100%')

          Row({ space: 8 }) {
            Text('加溏心蛋 +3g 蛋白')
              .fontSize(10)
              .fontColor('#3F6212')
              .padding({ left: 10, right: 10, top: 5, bottom: 5 })
              .borderRadius(10)
              .backgroundColor('#E7F4DC')
              .layoutWeight(1)
              .textAlign(TextAlign.Center)

            Text('换油醋汁 -40kcal')
              .fontSize(10)
              .fontColor('#854D0E')
              .padding({ left: 10, right: 10, top: 5, bottom: 5 })
              .borderRadius(10)
              .backgroundColor('#FEF9C3')
              .layoutWeight(1)
              .textAlign(TextAlign.Center)

            Text('加奇亚籽 +纤维')
              .fontSize(10)
              .fontColor('#0C4A6E')
              .padding({ left: 10, right: 10, top: 5, bottom: 5 })
              .borderRadius(10)
              .backgroundColor('#E0F2FE')
              .layoutWeight(1)
              .textAlign(TextAlign.Center)
          }
          .width('100%')
        }
        .width('100%')
        .padding(14)
        .borderRadius(14)
        .backgroundColor('#FFFFFF')
        .border({ width: 1, color: '#E2EEDD' })

        if (this.showBuy) {
          modalOverlay(() => {
            this.showBuy = false
          })
          Column({ space: 14 }) {
            Row() {
              Text('选择规格')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#1A2E1A')
              Row()
                .layoutWeight(1)
              Text('×')
                .fontSize(18)
                .fontColor('#9CA3AF')
                .onClick(() => {
                  this.showBuy = false
                })
            }
            .width('100%')

            Row({ space: 10 }) {
              Column()
                .width(60)
                .height(76)
                .borderRadius(10)
                .backgroundColor(this.selGoods.color)

              Column({ space: 4 }) {
                Text(this.selGoods.name)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#1A2E1A')
                Text(this.selGoods.kcal + ' kcal · 蛋白质 ' + this.selGoods.protein + 'g')
                  .fontSize(11)
                  .fontColor('#7CA982')
                Text('现做现配 · 30 分钟达')
                  .fontSize(10)
                  .fontColor('#16A34A')
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
            }
            .width('100%')

            Column({ space: 8 }) {
              Text('分量')
                .fontSize(12)
                .fontColor('#3F6212')
                .width('100%')

              Row({ space: 8 }) {
                Text('小碗 320g')
                  .fontSize(12)
                  .fontColor(this.specIdx === 0 ? '#FFFFFF' : '#3F6212')
                  .padding({ left: 14, right: 14, top: 7, bottom: 7 })
                  .borderRadius(12)
                  .backgroundColor(this.specIdx === 0 ? '#16A34A' : '#E7F4DC')
                  .onClick(() => {
                    this.specIdx = 0
                  })

                Text('大碗 450g +¥8')
                  .fontSize(12)
                  .fontColor(this.specIdx === 1 ? '#FFFFFF' : '#3F6212')
                  .padding({ left: 14, right: 14, top: 7, bottom: 7 })
                  .borderRadius(12)
                  .backgroundColor(this.specIdx === 1 ? '#16A34A' : '#E7F4DC')
                  .onClick(() => {
                    this.specIdx = 1
                  })

                Text('套餐+饮品 +¥18')
                  .fontSize(12)
                  .fontColor(this.specIdx === 2 ? '#FFFFFF' : '#3F6212')
                  .padding({ left: 14, right: 14, top: 7, bottom: 7 })
                  .borderRadius(12)
                  .backgroundColor(this.specIdx === 2 ? '#16A34A' : '#E7F4DC')
                  .onClick(() => {
                    this.specIdx = 2
                  })
              }
              .width('100%')
            }
            .width('100%')

            Row() {
              Text('数量')
                .fontSize(13)
                .fontColor('#3F6212')
              Row()
                .layoutWeight(1)
              Row({ space: 0 }) {
                Text('−')
                  .fontSize(16)
                  .fontColor(this.buyNum <= 1 ? '#D6E8D0' : '#16A34A')
                  .padding(8)
                  .onClick(() => {
                    if (this.buyNum > 1) {
                      this.buyNum--
                    }
                  })
                Text(this.buyNum.toString())
                  .fontSize(13)
                  .fontColor('#1A2E1A')
                  .padding({ left: 12, right: 12 })
                Text('+')
                  .fontSize(16)
                  .fontColor(this.buyNum >= 5 ? '#D6E8D0' : '#16A34A')
                  .padding(8)
                  .onClick(() => {
                    if (this.buyNum < 5) {
                      this.buyNum++
                    }
                  })
              }
              .borderRadius(8)
              .backgroundColor('#E7F4DC')
            }
            .width('100%')

            Row() {
              Column({ space: 1 }) {
                Text('合计')
                  .fontSize(11)
                  .fontColor('#7CA982')
                Text('¥' + (this.selGoods.price + this.specIdx * 10 - this.specIdx * this.specIdx * 2) * this.buyNum)
                  .fontSize(20)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#16A34A')
              }
              .alignItems(HorizontalAlign.Start)

              Row()
                .layoutWeight(1)

              Text('加入购物车')
                .fontSize(13)
                .fontColor('#1A2E1A')
                .padding({ left: 20, right: 20, top: 9, bottom: 9 })
                .borderRadius(18)
                .linearGradient({
                  angle: 90,
                  colors: [['#FACC15', 0.0], ['#FDE68A', 1.0]]
                })
                .onClick(() => {
                  this.addToCart()
                })
            }
            .width('100%')
          }
          .padding(16)
          .borderRadius(16)
          .backgroundColor('#FFFFFF')
          .width('92%')
        }

        if (this.showCart) {
          modalOverlay(() => {
            this.showCart = false
          })
          Column({ space: 12 }) {
            Row() {
              Text('购物车 · ' + this.cartList.length + ' 件')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#1A2E1A')
              Row()
                .layoutWeight(1)
              Text('×')
                .fontSize(18)
                .fontColor('#9CA3AF')
                .onClick(() => {
                  this.showCart = false
                })
            }
            .width('100%')

            ForEach(this.cartList, (c: CartRow, idx: number) => {
              Row({ space: 10 }) {
                Column()
                  .width(6)
                  .height(44)
                  .borderRadius(3)
                  .backgroundColor('#FACC15')

                Column({ space: 3 }) {
                  Text(c.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#1A2E1A')
                  Text(c.spec + ' · ¥' + c.price)
                    .fontSize(10)
                    .fontColor('#7CA982')
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)

                Row({ space: 6 }) {
                  Text('−')
                    .fontSize(14)
                    .fontColor('#16A34A')
                    .padding(5)
                    .onClick(() => {
                      this.changeNum(idx, -1)
                    })
                  Text(c.num.toString())
                    .fontSize(12)
                    .fontColor('#1A2E1A')
                    .padding({ left: 6, right: 6 })
                  Text('+')
                    .fontSize(14)
                    .fontColor('#16A34A')
                    .padding(5)
                    .onClick(() => {
                      this.changeNum(idx, 1)
                    })
                }
                .borderRadius(8)
                .backgroundColor('#E7F4DC')
              }
              .width('100%')
              .padding(8)
              .borderRadius(10)
              .backgroundColor('#F6FBF4')
            }, (c: CartRow) => (c.id.toString() + c.num.toString()))

            Row({ space: 10 }) {
              Column({ space: 2 }) {
                Text('预计摄入')
                  .fontSize(10)
                  .fontColor('#7CA982')
                Text(this.cartKcal() + ' kcal')
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#F97316')
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)

              Column({ space: 2 }) {
                Text('合计')
                  .fontSize(10)
                  .fontColor('#7CA982')
                Text('¥' + this.cartTotal())
                  .fontSize(16)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#16A34A')
              }
              .alignItems(HorizontalAlign.End)

              Text('去结算')
                .fontSize(13)
                .fontColor('#FFFFFF')
                .padding({ left: 18, right: 18, top: 9, bottom: 9 })
                .borderRadius(18)
                .backgroundColor('#16A34A')
                .onClick(() => {
                  this.showCart = false
                })
            }
            .width('100%')
            .padding({ top: 8, bottom: 8 })
          }
          .padding(16)
          .borderRadius(16)
          .backgroundColor('#FFFFFF')
          .width('92%')
          .constraintSize({ maxHeight: '80%' })
        }

        if (this.showCoupon) {
          modalOverlay(() => {
            this.showCoupon = false
          })
          Column({ space: 10 }) {
            Text('领券中心')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1A2E1A')
              .width('100%')

            Row({ space: 10 }) {
              Column({ space: 4 }) {
                Text('¥8')
                  .fontSize(22)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#16A34A')
                Text('新客无门槛')
                  .fontSize(10)
                  .fontColor('#7CA982')
              }
              .width(90)
              .padding({ top: 12, bottom: 12 })
              .borderRadius(12)
              .backgroundColor('#DCFCE7')
              .alignItems(HorizontalAlign.Center)

              Column({ space: 3 }) {
                Text('新客立减券')
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#1A2E1A')
                Text('全品类可用 · 有效期 7 天')
                  .fontSize(10)
                  .fontColor('#7CA982')
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)

              Text('领取')
                .fontSize(11)
                .fontColor('#FFFFFF')
                .padding({ left: 12, right: 12, top: 5, bottom: 5 })
                .borderRadius(12)
                .backgroundColor('#16A34A')
                .onClick(() => {
                  this.showCoupon = false
                })
            }
            .width('100%')
            .padding(10)
            .borderRadius(12)
            .backgroundColor('#F6FBF4')

            Row({ space: 10 }) {
              Column({ space: 4 }) {
                Text('5折')
                  .fontSize(20)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#854D0E')
                Text('第二件适用')
                  .fontSize(10)
                  .fontColor('#A16207')
              }
              .width(90)
              .padding({ top: 12, bottom: 12 })
              .borderRadius(12)
              .backgroundColor('#FEF9C3')
              .alignItems(HorizontalAlign.Center)

              Column({ space: 3 }) {
                Text('低卡碗半价券')
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#1A2E1A')
                Text('招牌碗 / 轻食卷可用 · 每日限 1 张')
                  .fontSize(10)
                  .fontColor('#7CA982')
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)

              Text('领取')
                .fontSize(11)
                .fontColor('#854D0E')
                .padding({ left: 12, right: 12, top: 5, bottom: 5 })
                .borderRadius(12)
                .backgroundColor('#FACC15')
                .onClick(() => {
                  this.showCoupon = false
                })
            }
            .width('100%')
            .padding(10)
            .borderRadius(12)
            .backgroundColor('#FFFBEB')

            Row({ space: 10 }) {
              Column({ space: 4 }) {
                Text('免运费')
                  .fontSize(16)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#0C4A6E')
                Text('3 次')
                  .fontSize(10)
                  .fontColor('#0369A1')
              }
              .width(90)
              .padding({ top: 12, bottom: 12 })
              .borderRadius(12)
              .backgroundColor('#E0F2FE')
              .alignItems(HorizontalAlign.Center)

              Column({ space: 3 }) {
                Text('免配送费卡')
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#1A2E1A')
                Text('3km 内可用 · 周末通用')
                  .fontSize(10)
                  .fontColor('#7CA982')
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)

              Text('领取')
                .fontSize(11)
                .fontColor('#FFFFFF')
                .padding({ left: 12, right: 12, top: 5, bottom: 5 })
                .borderRadius(12)
                .backgroundColor('#0EA5E9')
                .onClick(() => {
                  this.showCoupon = false
                })
            }
            .width('100%')
            .padding(10)
            .borderRadius(12)
            .backgroundColor('#F0F9FF')

            Text('知道了')
              .fontSize(13)
              .fontColor('#FFFFFF')
              .padding({ left: 20, right: 20, top: 8, bottom: 8 })
              .borderRadius(16)
              .backgroundColor('#16A34A')
              .width('100%')
              .textAlign(TextAlign.Center)
              .onClick(() => {
                this.showCoupon = false
              })
          }
          .padding(16)
          .borderRadius(16)
          .backgroundColor('#FFFFFF')
          .width('92%')
          .constraintSize({ maxHeight: '80%' })
        }

        if (this.showDetail) {
          modalOverlay(() => {
            this.showDetail = false
          })
          Column({ space: 12 }) {
            Text('营养档案')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1A2E1A')
              .width('100%')

            Column({ space: 4 })
              .width('100%')
              .height(90)
              .borderRadius(12)
              .backgroundColor(this.selGoods.color)

            Text(this.selGoods.name)
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1A2E1A')
              .width('100%')

            Row({ space: 8 }) {
              Column({ space: 2 }) {
                Text(this.selGoods.kcal + '')
                  .fontSize(16)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#F97316')
                Text('热量 kcal')
                  .fontSize(9)
                  .fontColor('#7CA982')
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)

              Column({ space: 2 }) {
                Text(this.selGoods.protein + 'g')
                  .fontSize(16)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#16A34A')
                Text('蛋白质')
                  .fontSize(9)
                  .fontColor('#7CA982')
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)

              Column({ space: 2 }) {
                Text('低')
                  .fontSize(16)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#0EA5E9')
                Text('GI 值')
                  .fontSize(9)
                  .fontColor('#7CA982')
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)

              Column({ space: 2 }) {
                Text('无')
                  .fontSize(16)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#8B5CF6')
                Text('蔗糖')
                  .fontSize(9)
                  .fontColor('#7CA982')
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
            }
            .width('100%')
            .padding({ top: 10, bottom: 10 })
            .borderRadius(12)
            .backgroundColor('#F6FBF4')

            Text('适合:减脂期午餐 / 健身后 30 分钟补给 / 晚餐轻断食')
              .fontSize(11)
              .fontColor('#7CA982')
              .width('100%')

            Text('立即搭配')
              .fontSize(13)
              .fontColor('#1A2E1A')
              .padding({ left: 20, right: 20, top: 9, bottom: 9 })
              .borderRadius(16)
              .linearGradient({
                angle: 90,
                colors: [['#FACC15', 0.0], ['#FDE68A', 1.0]]
              })
              .width('100%')
              .textAlign(TextAlign.Center)
              .onClick(() => {
                this.showDetail = false
                this.specIdx = 0
                this.buyNum = 1
                this.showBuy = true
              })
          }
          .padding(16)
          .borderRadius(16)
          .backgroundColor('#FFFFFF')
          .width('92%')
          .constraintSize({ maxHeight: '80%' })
        }

        if (this.showTopping) {
          modalOverlay(() => {
            this.showTopping = false
          })
          Column({ space: 12 }) {
            Text('自由加料')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1A2E1A')
              .width('100%')

            Flex({ wrap: FlexWrap.Wrap, direction: FlexDirection.Row }) {
              Text('溏心蛋 +¥3')
                .fontSize(11)
                .fontColor(this.topIdx === 0 ? '#FFFFFF' : '#3F6212')
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .borderRadius(11)
                .backgroundColor(this.topIdx === 0 ? '#16A34A' : '#E7F4DC')
                .margin(4)
                .onClick(() => {
                  this.topIdx = 0
                })

              Text('鸡胸肉 +¥5')
                .fontSize(11)
                .fontColor(this.topIdx === 1 ? '#FFFFFF' : '#3F6212')
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .borderRadius(11)
                .backgroundColor(this.topIdx === 1 ? '#16A34A' : '#E7F4DC')
                .margin(4)
                .onClick(() => {
                  this.topIdx = 1
                })

              Text('牛油果 +¥6')
                .fontSize(11)
                .fontColor(this.topIdx === 2 ? '#FFFFFF' : '#3F6212')
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .borderRadius(11)
                .backgroundColor(this.topIdx === 2 ? '#16A34A' : '#E7F4DC')
                .margin(4)
                .onClick(() => {
                  this.topIdx = 2
                })

              Text('奇亚籽 +¥2')
                .fontSize(11)
                .fontColor(this.topIdx === 3 ? '#FFFFFF' : '#3F6212')
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .borderRadius(11)
                .backgroundColor(this.topIdx === 3 ? '#16A34A' : '#E7F4DC')
                .margin(4)
                .onClick(() => {
                  this.topIdx = 3
                })

              Text('油醋汁替换')
                .fontSize(11)
                .fontColor(this.topIdx === 4 ? '#FFFFFF' : '#854D0E')
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .borderRadius(11)
                .backgroundColor(this.topIdx === 4 ? '#FACC15' : '#FEF9C3')
                .margin(4)
                .onClick(() => {
                  this.topIdx = 4
                })
            }
            .width('100%')

            Text('加料最多可叠加 3 种,热量会同步计入营养档案')
              .fontSize(10)
              .fontColor('#7CA982')
              .width('100%')

            Row({ space: 10 }) {
              Text('先不加')
                .fontSize(13)
                .fontColor('#64748B')
                .padding({ left: 20, right: 20, top: 8, bottom: 8 })
                .borderRadius(16)
                .backgroundColor('#F1F5F9')
                .layoutWeight(1)
                .textAlign(TextAlign.Center)
                .onClick(() => {
                  this.showTopping = false
                })

              Text('确认加料')
                .fontSize(13)
                .fontColor('#FFFFFF')
                .padding({ left: 20, right: 20, top: 8, bottom: 8 })
                .borderRadius(16)
                .backgroundColor('#16A34A')
                .layoutWeight(1)
                .textAlign(TextAlign.Center)
                .onClick(() => {
                  this.showTopping = false
                })
            }
            .width('100%')
          }
          .padding(16)
          .borderRadius(16)
          .backgroundColor('#FFFFFF')
          .width('90%')
        }
      }
      .padding(12)
    }
    .backgroundColor('#F6FBF4')
    .layoutWeight(1)
  }
}

// ==================== Tab 2 营养 ====================
@Component
export struct NutriTab {
  @State showItem: boolean = false
  @State showBmi: boolean = false
  @State showTip: boolean = false
  @State selNutri: NutriItem = NUTRI_ITEMS[0]
  @State inputH: string = ''
  @State inputW: string = ''
  @State bmiVal: string = ''

  calcBmi() {
    const h: number = Number(this.inputH === '' ? '170' : this.inputH) / 100
    const w: number = Number(this.inputW === '' ? '60' : this.inputW)
    const v: number = w / (h * h)
    if (v < 18.5) {
      this.bmiVal = v.toFixed(1) + ' 偏瘦'
    } else if (v < 24) {
      this.bmiVal = v.toFixed(1) + ' 标准'
    } else if (v < 28) {
      this.bmiVal = v.toFixed(1) + ' 偏胖'
    } else {
      this.bmiVal = v.toFixed(1) + ' 肥胖'
    }
  }

  build() {
    Scroll() {
      Column({ space: 12 }) {
        Column({ space: 12 }) {
          Row({ space: 8 }) {
            Column({ space: 3 }) {
              Text('1,672')
                .fontSize(24)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('今日已摄入 kcal')
                .fontSize(10)
                .fontColor('#DCF5D3')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Text('BMI 计算器')
              .fontSize(12)
              .fontColor('#16A34A')
              .padding({ left: 12, right: 12, top: 7, bottom: 7 })
              .borderRadius(14)
              .backgroundColor('#FFFFFF')
              .onClick(() => {
                this.showBmi = true
              })
          }
          .width('100%')

          Row({ space: 0 }) {
            Column({ space: 2 }) {
              Text('69%')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FACC15')
              Text('目标完成度')
                .fontSize(9)
                .fontColor('#DCF5D3')
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)

            Column({ space: 2 }) {
              Text('3')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('已打卡餐次')
                .fontSize(9)
                .fontColor('#DCF5D3')
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)

            Column({ space: 2 }) {
              Text('-0.8kg')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('本周体重变化')
                .fontSize(9)
                .fontColor('#DCF5D3')
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
          }
          .width('100%')
          .padding({ top: 10, bottom: 10 })
          .borderRadius(12)
          .backgroundColor('rgba(255,255,255,0.18)')
        }
        .padding(14)
        .borderRadius(16)
        .linearGradient({
          angle: 135,
          colors: [['#16A34A', 0], ['#65B36B', 1]]
        })
        .shadow({ radius: 12, color: 'rgba(22,163,74,0.25)', offsetX: 0, offsetY: 5 })
        .width('100%')

        Column({ space: 14 }) {
          Text('今日营养结构')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor('#1A2E1A')
            .width('100%')

          ForEach(NUTRI_ITEMS, (n: NutriItem) => {
            Column({ space: 6 }) {
              Row({ space: 6 }) {
                Text(n.name)
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#1A2E1A')
                Text(n.value + '/' + n.max + n.unit)
                  .fontSize(10)
                  .fontColor('#7CA982')
                  .layoutWeight(1)
                Text(nutriBarW(n.value, n.max) + '%')
                  .fontSize(10)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(n.color)
              }
              .width('100%')

              Column({ space: 0 }) {
                Column()
                  .height(8)
                  .borderRadius(4)
                  .width(nutriBarW(n.value, n.max) + '%')
                  .linearGradient({
                    angle: 0,
                    colors: [[n.color, 0], [n.color, 1]]
                  })
              }
              .width('100%')
              .height(8)
              .borderRadius(4)
              .backgroundColor('#EEF6EA')
            }
            .width('100%')
            .padding(10)
            .borderRadius(12)
            .backgroundColor('#F6FBF4')
            .onClick(() => {
              this.selNutri = n
              this.showItem = true
            })
          }, (n: NutriItem) => n.id.toString())
        }
        .width('100%')
        .padding(14)
        .borderRadius(14)
        .backgroundColor('#FFFFFF')
        .shadow({ radius: 4, color: 'rgba(26,46,26,0.05)', offsetX: 0, offsetY: 2 })

        Column({ space: 12 }) {
          Text('本周热量柱状图(kcal)')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor('#1A2E1A')
            .width('100%')

          Row({ space: 0 }) {
            ForEach(WEEK_BARS, (w: WeekBar) => {
              Column({ space: 5 }) {
                Text(w.kcal + '')
                  .fontSize(8)
                  .fontColor('#7CA982')
                Column()
                  .width(18)
                  .height(kcalBarH(w.kcal))
                  .borderRadius(6)
                  .linearGradient({
                    angle: 180,
                    colors: w.kcal > 1000 ? [['#F97316', 0], ['#FDBA74', 1]] : [['#16A34A', 0], ['#86D99A', 1]]
                  })
                Text(w.day)
                  .fontSize(9)
                  .fontColor('#3F6212')
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
              .onClick(() => {
                this.showTip = true
              })
            }, (w: WeekBar) => w.day)
          }
          .width('100%')
          .height(140)
          .justifyContent(FlexAlign.End)

          Row({ space: 6 }) {
            Column()
              .width(10)
              .height(10)
              .borderRadius(3)
              .backgroundColor('#16A34A')
            Text('达标 ≤1000')
              .fontSize(9)
              .fontColor('#7CA982')
            Column()
              .width(10)
              .height(10)
              .borderRadius(3)
              .backgroundColor('#F97316')
              .margin({ left: 8 })
            Text('超标 >1000')
              .fontSize(9)
              .fontColor('#7CA982')
            Row()
              .layoutWeight(1)
            Text('查看建议')
              .fontSize(10)
              .fontColor('#16A34A')
              .onClick(() => {
                this.showTip = true
              })
          }
          .width('100%')
        }
        .width('100%')
        .padding(14)
        .borderRadius(14)
        .backgroundColor('#FFFFFF')

        Column({ space: 10 }) {
          Text('饮食黑名单(本周)')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor('#1A2E1A')
            .width('100%')

          Row({ space: 8 }) {
            Text('奶茶 x3')
              .fontSize(11)
              .fontColor('#9A3412')
              .padding({ left: 10, right: 10, top: 5, bottom: 5 })
              .borderRadius(10)
              .backgroundColor('#FFEDD5')

            Text('油炸 x1')
              .fontSize(11)
              .fontColor('#9A3412')
              .padding({ left: 10, right: 10, top: 5, bottom: 5 })
              .borderRadius(10)
              .backgroundColor('#FFEDD5')

            Text('蛋糕 x2')
              .fontSize(11)
              .fontColor('#9A3412')
              .padding({ left: 10, right: 10, top: 5, bottom: 5 })
              .borderRadius(10)
              .backgroundColor('#FFEDD5')

            Text('烧烤 x1')
              .fontSize(11)
              .fontColor('#9A3412')
              .padding({ left: 10, right: 10, top: 5, bottom: 5 })
              .borderRadius(10)
              .backgroundColor('#FFEDD5')
          }
          .width('100%')

          Text('已比上周减少 2 次,继续坚持可解锁「自律青铜」徽章')
            .fontSize(10)
            .fontColor('#7CA982')
            .width('100%')
        }
        .width('100%')
        .padding(14)
        .borderRadius(14)
        .backgroundColor('#FFFFFF')
        .border({ width: 1, color: '#E2EEDD' })

        if (this.showItem) {
          modalOverlay(() => {
            this.showItem = false
          })
          Column({ space: 12 }) {
            Text(this.selNutri.name + '详情')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1A2E1A')
              .width('100%')

            Row({ space: 8 }) {
              Column({ space: 2 }) {
                Text(this.selNutri.value + this.selNutri.unit)
                  .fontSize(18)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(this.selNutri.color)
                Text('已摄入')
                  .fontSize(9)
                  .fontColor('#7CA982')
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)

              Column({ space: 2 }) {
                Text(this.selNutri.max + this.selNutri.unit)
                  .fontSize(18)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#3F6212')
                Text('每日上限')
                  .fontSize(9)
                  .fontColor('#7CA982')
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)

              Column({ space: 2 }) {
                Text((this.selNutri.max - this.selNutri.value) + this.selNutri.unit)
                  .fontSize(18)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#0EA5E9')
                Text('剩余额度')
                  .fontSize(9)
                  .fontColor('#7CA982')
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
            }
            .width('100%')
            .padding({ top: 10, bottom: 10 })
            .borderRadius(12)
            .backgroundColor('#F6FBF4')

            Text('主要来源:' + this.selNutri.food)
              .fontSize(12)
              .fontColor('#3F6212')
              .width('100%')

            Text('补充建议:晚餐可再增加一份优质蛋白,避免睡前 2 小时进食。')
              .fontSize(11)
              .fontColor('#7CA982')
              .lineHeight(17)
              .width('100%')

            Text('关闭')
              .fontSize(13)
              .fontColor('#FFFFFF')
              .padding({ left: 20, right: 20, top: 8, bottom: 8 })
              .borderRadius(16)
              .backgroundColor('#16A34A')
              .width('100%')
              .textAlign(TextAlign.Center)
              .onClick(() => {
                this.showItem = false
              })
          }
          .padding(16)
          .borderRadius(16)
          .backgroundColor('#FFFFFF')
        
          .width('100%')

          Text('取消')
            .fontSize(13)
            .fontColor('#16A34A')
            .padding({ left: 20, right: 20, top: 8, bottom: 8 })
            .borderRadius(16)
            .backgroundColor('#DCFCE7')
            .width('100%')
            .textAlign(TextAlign.Center)
            .onClick(() => {
              this.showSearch = false
            })
        }
        .padding(16)
        .borderRadius(16)
        .backgroundColor('#FFFFFF')
        .width('92%')
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F6FBF4')
  }
}


在这里插入图片描述

八、总结

通过对轻食沙拉工坊应用的深度技术解析,我们可以清晰地看到基于 HarmonyOS 6.1.1 的 ArkTS 声明式 UI 框架在餐饮健康类应用中的强大表现力。该应用以五个 Tab 组件为功能骨架——点餐 Tab 提供完整的商品浏览与购物车流程,营养 Tab 展示营养摄入可视化与 BMI 计算工具,周计划 Tab 管理七日饮食计划的 CRUD 全流程,门店 Tab 提供门店信息与排队取号功能,我的 Tab 展示用户数据与设置入口。每个 Tab 组件都拥有独立的状态空间和完整的业务逻辑闭环,组件之间通过主入口页面的条件渲染进行切换,实现了功能模块的完全解耦。

在购物车系统的实现中,addToCart 方法展示了 ArkTS 状态管理的最佳实践。方法通过不可变数组更新模式确保状态变化被框架检测——先创建新数组、遍历复制旧数据、根据商品是否已存在进行合并或新增、最后将新数组赋值给 cartList 状态变量。规格选择通过 specIdx 索引从预定义的规格名称和价格数组中获取对应值,实现了规格与价格的联动。cartTotalcartKcal 方法分别计算购物车总金额和估算热量摄入,其中热量估算使用 price * 3.5 的经验公式,为用户提供了快速的热量参考。BMI 计算器通过 calcBmi 方法实现了身体质量指数的实时计算,使用 Number 函数进行类型转换、toFixed(1) 格式化结果、四段式条件判断分类健康等级,展示了 ArkTS 中数值计算的典型模式。

在数据可视化方面,营养 Tab 的进度条和热量柱状图展示了两种不同的轻量级图表实现方案。营养进度条通过 nutriBarW 全局函数计算填充百分比,使用嵌套 Column 实现轨道与填充条的分层效果,每种营养素使用独立的主题色渐变。热量柱状图通过 kcalBarH 全局函数计算柱体高度,使用条件渐变切换达标(绿色)与超标(橙色)的视觉状态。这两种图表实现完全不依赖第三方库,仅使用 ArkTS 原生的布局组件和样式属性即可完成,体现了声明式 UI 框架在数据可视化方面的灵活性。周计划 Tab 的 toggleDone 方法通过不可变更新模式切换餐次打卡状态,swapPlan 方法实现了快捷的餐品替换功能,这些 CRUD 操作全面采用了"创建新数组 -> 条件替换 -> 重新赋值"的模式,确保了状态变化能够可靠地触发 UI 局部刷新。整体来看,该应用的技术实现充分展现了 HarmonyOS ArkTS 在声明式 UI 开发、组件化架构设计、响应式状态管理以及轻量级数据可视化等方面的综合能力。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐