引言

健康饮食已成为现代人生活方式的重要组成,轻食沙拉以其低卡路里、高蛋白、营养均衡的特点受到广泛关注。然而,传统的轻食外卖平台往往只关注点餐和配送环节,缺乏对用户营养摄入的持续追踪和科学的饮食计划管理。本应用以 HarmonyOS 6.1.1 声明式 UI 范式为技术基础,构建了一套集点餐、营养分析、周计划管理、门店自提和会员体系于一体的轻食沙拉工坊平台,实现了从"吃一顿"到"管一周"的饮食管理升级。

从技术架构层面来看,本应用采用了 ArkTS 声明式组件体系,主入口组件 SPage 通过 @Entry 装饰器注册为应用根节点,内部通过 activeTab 状态变量驱动五个子组件的条件渲染。五个 Tab 组件分别为 OrderTab(点餐)、NutriTab(营养)、PlanTab(周计划)、ShopTab(门店)和 SMineTab(我的),每个组件内部维护着各自独立的 @State 状态集合和业务方法。应用的视觉主题采用牛油果绿 #16A34A 搭配柠檬黄 #FACC15 的双主色方案,底色为 #F6FBF4 的淡绿色,营造出清新自然的农场氛围。这种配色不仅符合健康饮食的品牌调性,还通过两种主色的功能区分——绿色用于价格和操作按钮、黄色用于营养和提醒标识——建立了清晰的视觉语义系统。

在业务设计层面,应用的亮点在于将购物车、营养追踪和周计划三大功能深度整合。点餐模块的购物车不仅计算总价,还通过 cartKcal() 方法估算总热量摄入,让用户在点餐时就能感知卡路里影响。营养模块提供了蛋白质、碳水、脂肪、膳食纤维四大营养素的实时进度条和每日上限对比,并内置了 BMI 计算器小工具。周计划模块支持七日餐单的添加、编辑、删除和替换推荐操作,通过打卡机制追踪完成度,每个餐次卡片可点击切换"已吃/未吃"状态。门店模块的特色在于排队取号功能——通过 takeNumber() 方法生成排队号码,结合预计等待时间(排队人数 x 4 分钟)为用户提供决策参考。这些功能的有机结合,使得该应用不仅是一个外卖工具,更是一个完整的健康饮食管理助手。
在这里插入图片描述

逐段代码分析

一、数据模型与类型定义层

在这里插入图片描述

应用定义了一系列覆盖轻食餐饮全领域的接口类型,从商品到购物车、从营养到计划、从门店到会员菜单,构建了完整的数据模型体系。

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
}

上述接口定义体现了轻食餐饮管理的数据维度。SaladGoods 接口是该应用最核心的商品数据结构,其中 kcalprotein 字段将营养信息直接嵌入商品数据中,使得点餐时可以直接展示热量和蛋白质含量。cat 字段表示品类(招牌碗、轻食卷、低卡饭、能量饮品、健康小食),tag 字段存储营销标签(如"店长推荐"“低 GI”“高蛋白"等)。CartRow 接口中的 spec 字段记录了用户选择的规格信息(如"大碗+溏心蛋”)。NutriItem 接口中的 valuemax 配对使用表示已摄入量和每日上限,color 字段为每个营养素分配独立的标识色,food 字段记录主要食物来源。PlanCell 接口中的 done 布尔字段是打卡机制的核心状态。ShopRow 接口中的 queue 字段记录当前排队人数,hours 字段存储营业时间。

二、静态数据与品类配置

在这里插入图片描述

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 }
]

CAT_CHIPS 是一个简单的字符串数组,定义了五个品类筛选标签。SALAD_GOODS 包含 10 款商品,覆盖了全部五个品类,每款商品都有独特的背景色值用于卡片图片占位区。值得注意的是热量的分布范围——从 120kcal 的羽衣甘蓝苹果汁到 480kcal 的糙米牛肉能量饭,跨度达到 4 倍,满足了从轻断食到健身增肌的不同需求。NUTRI_ITEMS 定义了四大营养素及其当前摄入量与每日上限,每个营养素分配了独立的颜色标识——蛋白质绿色、碳水黄色、脂肪橙色、膳食纤维蓝色。PLAN_CELLS 是周计划的核心数据,包含 14 条餐次记录(7 天 x 2 餐),其中前 3 条已标记 done: true 表示已完成打卡,周六午餐特别设置了"放纵餐·自由搭配"600kcal,体现了科学的饮食计划灵活性。

三、工具函数与公共 Builder

在这里插入图片描述

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()
    })
}

kcalBarH 函数根据热量值返回柱状图高度,将热量分为五档(1000+、850+、700+、550+、其他),分别映射为 92、78、64、50、36 像素高度。nutriBarW 函数计算营养进度条宽度百分比,使用 Math.round(v * 100 / m) 公式将已摄入量除以上限并转换为百分比,同时处理了两条边界情况——上限为零时返回最小宽度 10%、已摄入超过上限时返回最大宽度 100%。modalOverlay 遮罩使用 rgba(22,40,25,0.55) 的深绿色半透明背景,与应用的绿色主题色调保持一致。

四、点餐 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]
    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
  }

OrderTab 组件拥有 10 个 @State 变量,管理着品类筛选、5 个弹窗状态、规格选择、加料选择、购买数量、选中商品和购物车列表。addToCart 方法是该组件的核心业务逻辑——它首先根据 specIdx 从规格名称数组和价格数组中获取对应的规格名和价格(小碗原价、大碗加 8 元、套餐加 18 元),然后遍历购物车查找是否已存在同 ID 商品。如果存在,更新该行的规格、价格和数量(累加 buyNum);如果不存在,追加新行。这种"合并相同商品"的逻辑是购物车的标准行为。changeNum 方法处理购物车内数量的加减,当数量减到 0 以下时自动从列表中移除该商品。cartTotalcartKcal 两个方法分别计算购物车总价和总热量——总热量使用 price * 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 容器包裹 Row 布局,选中状态通过 catIdx 与当前索引比较来决定样式——选中时白字绿底加粗,未选中时深绿字浅绿底。商品列表的渲染使用了一个巧妙的条件过滤模式——在 ForEach 内部使用 if (g.cat === CAT_CHIPS[this.catIdx]) 条件判断,只有品类匹配当前选中索引的商品才会渲染。ForEach 的 key 函数使用 g.id.toString() + this.catIdx.toString() 将商品 ID 和品类索引组合为唯一键,确保品类切换时列表能正确重新渲染。商品卡片的左侧色块以大号字体显示热量数值(如"420"),下方标注"kcal"单位,让用户在浏览时第一时间获取最关键的营养信息。点击商品名称打开营养详情弹窗,点击"+ 选规格"打开规格选择弹窗。

六、规格选择弹窗与购物车弹窗

在这里插入图片描述

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() {
      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%')
}

规格选择弹窗提供了三种分量选择——小碗 320g(原价)、大碗 450g(加 8 元)、套餐+饮品(加 18 元)。合计金额的计算公式 (this.selGoods.price + this.specIdx * 10 - this.specIdx * this.specIdx * 2) * this.buyNum 使用了一个数学表达式来模拟规格差价——当 specIdx 为 0 时加价为 0,为 1 时加价为 10-2=8,为 2 时加价为 20-8=12,与规格标签上标注的加价金额基本吻合。"加入购物车"按钮使用了从黄色到浅黄色的渐变背景,与点餐模块的绿色主色调形成互补对比,通过颜色差异突出购物车操作的特殊性。

七、营养 Tab——NutriTab 组件与 BMI 计算器

营养 Tab 是该应用的差异化功能模块,提供营养结构可视化、热量柱状图、饮食黑名单和 BMI 计算器。

@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%')

calcBmi 方法是该组件的计算核心——它将身高从厘米转换为米,然后使用标准 BMI 公式 体重 / (身高 x 身高) 计算结果,并通过四个区间判断输出带有评价标签的结果字符串。当用户未输入身高或体重时,使用 170cm 和 60kg 作为默认值兜底。头部区域展示了今日已摄入热量(1,672 kcal)、目标完成度(69%,使用黄色突出)、已打卡餐次和本周体重变化(-0.8kg,负号表示减重成功)。头部背景使用绿色渐变并带有半透明白色统计栏,目标完成度使用黄色 #FACC15 与绿色形成鲜明对比。

八、营养结构进度条与热量柱状图

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())

营养结构区域为每个营养素渲染一个进度条卡片。进度条的宽度通过 nutriBarW(n.value, n.max) 函数计算,返回值为百分比字符串。进度条使用双层结构——外层 Column 作为轨道(背景色 #EEF6EA),内层 Column 作为填充条(背景使用 n.color 的纯色渐变)。点击营养素卡片打开详情弹窗,展示已摄入量、每日上限、剩余额度和主要食物来源。本周热量柱状图使用 ForEach(WEEK_BARS, ...) 渲染七根柱子,柱子高度由 kcalBarH(w.kcal) 函数决定,颜色根据热量是否超过 1000 进行分支——超过 1000 的使用橙色渐变(#F97316#FDBA74),达标的使用绿色渐变(#16A34A#86D99A),图例区域清晰标注了"达标 <=1000"和"超标 >1000"的阈值说明。

九、周计划 Tab——PlanTab 组件与打卡机制

周计划 Tab 是该应用业务逻辑最丰富的模块,支持餐次的添加、编辑、删除、替换和打卡操作。

@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

  doneCount(): number {
    let n = 0
    for (let i = 0; i < this.planList.length; i++) {
      if (this.planList[i].done) {
        n++
      }
    }
    return n
  }

  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
  }

  addPlan() {
    const dayArr: string[] = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
    const mealArr: string[] = ['早餐', '午餐', '晚餐', '加餐']
    const r: PlanCell[] = []
    for (let i = 0; i < this.planList.length; i++) {
      r.push(this.planList[i])
    }
    r.push({
      id: this.planList.length + 1,
      day: dayArr[this.dayIdx],
      meal: mealArr[this.mealIdx],
      name: this.inputName === '' ? '自由搭配轻食' : this.inputName,
      kcal: Number(this.inputKcal === '' ? '400' : this.inputKcal),
      done: false
    })
    this.planList = r
    this.showAdd = false
    this.inputName = ''
    this.inputKcal = ''
  }

  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
  }

PlanTab 组件拥有 12 个 @State 变量,管理着 4 个弹窗、3 个操作索引、2 个选择索引和 2 个输入框。doneCount 方法遍历计划列表统计已完成餐次数,用于头部进度展示。toggleDone 方法是打卡功能的核心——它通过不可变更新模式将指定索引的 done 字段取反。addPlan 方法支持七日和四餐段的选择,当用户未输入餐品名称时默认填充"自由搭配轻食",未输入热量时默认 400kcal。swapPlan 方法将指定餐次替换为预设的"轻断食组合"(果蔬汁+鹰嘴豆,260kcal),同时保留原有的 done 状态。这种替换推荐功能为用户提供了健康的替代方案,无需手动搜索即可获得营养搭配建议。

十、餐次卡片渲染与打卡交互

ForEach(this.planList, (p: PlanCell, idx: number) => {
  Row({ space: 10 }) {
    Column({ space: 3 }) {
      Text(p.day)
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor('#16A34A')
      Text(p.meal)
        .fontSize(10)
        .fontColor('#7CA982')
    }
    .width(44)
    .alignItems(HorizontalAlign.Center)

    Column()
      .width(4)
      .height(40)
      .borderRadius(2)
      .backgroundColor(p.done ? '#FACC15' : '#D6E8D0')

    Column({ space: 3 }) {
      Text(p.name)
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(p.done ? '#7CA982' : '#1A2E1A')
        .maxLines(1)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
        .layoutWeight(1)
      Text(p.kcal + ' kcal')
        .fontSize(10)
        .fontColor(p.done ? '#A3BFA3' : '#F97316')
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    .onClick(() => {
      this.toggleDone(idx)
    })

    Text(p.done ? '已吃' : '打卡')
      .fontSize(10)
      .fontColor(p.done ? '#A3BFA3' : '#FFFFFF')
      .padding({ left: 10, right: 10, top: 5, bottom: 5 })
      .borderRadius(11)
      .backgroundColor(p.done ? '#EEF6EA' : '#16A34A')
      .onClick(() => {
        this.toggleDone(idx)
      })

    Column({ space: 4 }) {
      Text('⋯')
        .fontSize(14)
        .fontColor('#7CA982')
    }
    .onClick(() => {
      this.swapIdx = idx
      this.showSwap = true
    })
  }
  .width('100%')
  .padding(10)
  .borderRadius(12)
  .backgroundColor(p.done ? '#F6FBF4' : '#FFFFFF')
  .shadow({ radius: 3, color: 'rgba(26,46,26,0.05)', offsetX: 0, offsetY: 1 })

  if (idx === 1 || idx === 4 || idx === 8 || idx === 11) {
    Row({ space: 8 }) {
      Text('编辑')
        .fontSize(10)
        .fontColor('#16A34A')
        .padding({ left: 10, right: 10, top: 4, bottom: 4 })
        .borderRadius(10)
        .backgroundColor('#DCFCE7')
        .onClick(() => {
          this.editIdx = idx
          this.inputName = ''
          this.inputKcal = ''
          this.showEdit = true
        })

      Text('替换推荐')
        .fontSize(10)
        .fontColor('#854D0E')
        .padding({ left: 10, right: 10, top: 4, bottom: 4 })
        .borderRadius(10)
        .backgroundColor('#FEF9C3')
        .onClick(() => {
          this.swapIdx = idx
          this.showSwap = true
        })

      Text('删除')
        .fontSize(10)
        .fontColor('#B91C1C')
        .padding({ left: 10, right: 10, top: 4, bottom: 4 })
        .borderRadius(10)
        .backgroundColor('#FEE2E2')
        .onClick(() => {
          this.delIdx = idx
          this.showDel = true
        })
    }
    .width('100%')
    .padding({ left: 58, right: 4, top: 2 })
  }
}, (p: PlanCell) => p.id.toString())

餐次卡片的渲染充分体现了条件样式的设计理念。竖线颜色根据 p.done 状态在黄色(#FACC15,已完成)和浅绿色(#D6E8D0,未完成)之间切换。餐品名称在已完成状态下变为浅灰色(#7CA982),未完成时为深绿色(#1A2E1A)。热量数值在已完成时变为灰色,未完成时显示橙色(#F97316)以吸引注意。打卡按钮的文字在"已吃"和"打卡"之间切换,背景色也从灰绿变为绿色。卡片整体背景色也根据完成状态变化——已完成时使用淡绿色背景,未完成时使用白色。在特定索引位置(1、4、8、11),卡片下方会展开一行操作按钮(编辑、替换推荐、删除),这种间隔展开的操作区设计避免了每张卡片都显示操作按钮导致的视觉冗余。

十一、门店 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 0-3 对应 1 人/2 人/3-4 人/5 人+)从前缀数组中获取字母标识(A-E),然后将当前排队人数加 17 作为序号,拼接出类似"A020"的排队号码。这种号码设计既包含了人数信息(通过前缀字母),又反映了排队顺序(通过数字),简洁实用。

十二、我的 Tab——SMineTab 与主页面路由

我的 Tab 展示用户信息、消费报告、充值、会员卡和偏好设置等功能。主页面 SPage 负责整体布局编排和 Tab 路由。

@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')
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F6FBF4')
  }
}

主页面 SPage 的头部导航栏使用了 120 度三段式绿色渐变(从深绿 #14532D 到主色绿 #16A34A 到浅绿 #4CC57E),搜索栏的占位文字使用浅绿色(#BBF7D0),与整体绿色主题协调。底部导航栏的选中状态指示条使用黄色(#FACC15),与主色绿色形成双主色呼应。我的 Tab 中的头部个人卡片使用了更复杂的三色渐变(从 #14532D#16A34A#FACC15,140 度角),将深绿、主色绿和柠檬黄三色完美融合,视觉上体现了"从健康到愉悦"的品牌理念。充值弹窗提供了三档充值选项(50 元无赠送、100 元送 10 元券、200 元送 25 元券),选中状态通过颜色区分——100 元档使用黄色背景突出推荐。偏好设置弹窗展示了忌口、过敏原、酱料偏好和餐后提醒四项设置,过敏原使用红色(#DC2626)警示色标注,体现了对食品安全的高度重视。

整体业务流程图

0

1

2

3

4

选规格

营养详情

加料

领券

编辑

替换

删除

取号

详情

评价

地图

充值

地址

会员

设置

应用启动 SPage

头部导航栏 三段绿色渐变

activeTab 选择

OrderTab 点餐

NutriTab 营养

PlanTab 周计划

ShopTab 门店

SMineTab 我的

品类筛选条

商品列表 条件过滤

操作选择

规格弹窗 specIdx

营养档案弹窗

自由加料弹窗

addToCart 合并/新增

购物车弹窗

cartTotal 总价

cartKcal 估算热量

领券中心弹窗

今日摄入概览

四大营养素进度条

nutriBarW 百分比计算

点击查看详情

营养详情弹窗

本周热量柱状图

kcalBarH 高度计算

超标? >1000

橙色渐变柱

绿色渐变柱

BMI计算器

calcBmi w/h²

四区间判断

偏瘦/标准/偏胖/肥胖

饮食黑名单

营养师建议弹窗

打卡进度条

七日餐单列表

点击打卡

toggleDone done取反

⋯操作

编辑弹窗 editPlan

换着吃弹窗 swapPlan

删除弹窗 removePlan

添加餐次弹窗 addPlan

门店列表

操作选择

排队弹窗 takeNumber

门店详情弹窗

星级评价弹窗

周边门店示意

生成排队号 A0xx

个人信息卡 三色渐变

本月轻食报告

操作选择

充值弹窗 三档

地址管理弹窗

会员卡弹窗 季卡/年卡

偏好设置弹窗

搜索弹窗

猜你想搜标签流

取消关闭

技术点对比表格

技术维度点餐Tab (OrderTab)营养Tab (NutriTab)周计划Tab (PlanTab)门店Tab (ShopTab)我的Tab (SMineTab)
核心数据结构SaladGoods/CartRowNutriItem/WeekBarPlanCellShopRowMineMenu/WalletCard
状态变量数量10个7个12个7个5个
弹窗数量5个(规格/购物车/领券/详情/加料)3个(营养详情/BMI/建议)4个(添加/编辑/删除/替换)4个(排队/详情/评价/地图)4个(充值/地址/会员/设置)
计算方法addToCart/changeNum/cartTotal/cartKcalcalcBmidoneCount/toggleDone/addPlan/editPlan/removePlan/swapPlantakeNumber
数据可视化热量色块营养进度条+热量柱状图打卡进度点阵评分色块+排队号消费报告柱状图
交互特色购物车合并/热量估算BMI四区间判断打卡状态切换/替换推荐排队号生成三档充值选择
列表布局Row水平卡片+条件过滤ForEach进度条+柱状图ForEach打卡卡片ForEach门店卡片ForEach菜单行
渐变效果135度绿色渐变135度绿色渐变135度深绿到主色绿白色卡片140度三色渐变

安装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('90%')
        }

        if (this.showBmi) {
          modalOverlay(() => {
            this.showBmi = false
          })
          Column({ space: 12 }) {
            Text('BMI 小工具')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1A2E1A')
              .width('100%')

            Row({ space: 10 }) {
              TextInput({ placeholder: '身高 cm', text: this.inputH })
                .fontSize(12)
                .height(38)
                .borderRadius(10)
                .backgroundColor('#E7F4DC')
                .layoutWeight(1)
                .onChange((v: string) => {
                  this.inputH = v
                })

              TextInput({ placeholder: '体重 kg', text: this.inputW })
                .fontSize(12)
                .height(38)
                .borderRadius(10)
                .backgroundColor('#E7F4DC')
                .layoutWeight(1)
                .onChange((v: string) => {
                  this.inputW = v
                })
            }
            .width('100%')

            if (this.bmiVal !== '') {
              Text('你的 BMI:' + this.bmiVal)
                .fontSize(15)
                .fontWeight(FontWeight.Bold)
                .fontColor('#16A34A')
                .width('100%')
                .textAlign(TextAlign.Center)
                .padding({ top: 8, bottom: 8 })
                .borderRadius(10)
                .backgroundColor('#FEF9C3')
            }

            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.inputH = ''
                  this.inputW = ''
                  this.bmiVal = ''
                })

              Text('计算')
                .fontSize(13)
                .fontColor('#1A2E1A')
                .padding({ left: 20, right: 20, top: 8, bottom: 8 })
                .borderRadius(16)
                .backgroundColor('#FACC15')
                .layoutWeight(1)
                .textAlign(TextAlign.Center)
                .onClick(() => {
                  this.calcBmi()
                })
            }
            .width('100%')
          }
          .padding(16)
          .borderRadius(16)
          .backgroundColor('#FFFFFF')
          .width('88%')
        }

        if (this.showTip) {
          modalOverlay(() => {
            this.showTip = false
          })
          Column({ space: 10 }) {
            Text('营养师建议')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1A2E1A')
              .width('100%')

            Column({ space: 4 }) {
              Text('① 周六热量超标 80kcal')
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor('#F97316')
              Text('放纵餐尽量安排在中午,晚餐改为轻断食饮品')
                .fontSize(11)
                .fontColor('#7CA982')
            }
            .alignItems(HorizontalAlign.Start)
            .width('100%')
            .padding(10)
            .borderRadius(10)
            .backgroundColor('#FFF7ED')

            Column({ space: 4 }) {
              Text('② 蛋白质缺口 32g')
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor('#16A34A')
              Text('下午加餐无糖希腊酸奶杯 + 烤鹰嘴豆即可补齐')
                .fontSize(11)
                .fontColor('#7CA982')
            }
            .alignItems(HorizontalAlign.Start)
            .width('100%')
            .padding(10)
            .borderRadius(10)
            .backgroundColor('#F0FDF4')

            Column({ space: 4 }) {
              Text('③ 膳食纤维偏低')
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor('#0EA5E9')
              Text('把白米饭换成藜麦或紫薯,纤维摄入可翻倍')
                .fontSize(11)
                .fontColor('#7CA982')
            }
            .alignItems(HorizontalAlign.Start)
            .width('100%')
            .padding(10)
            .borderRadius(10)
            .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.showTip = false
              })
          }
          .padding(16)
          .borderRadius(16)
          .backgroundColor('#FFFFFF')
          .width('90%')
        }
      }
      .padding(12)
    }
    .backgroundColor('#F6FBF4')
    .layoutWeight(1)
  }
}

// ==================== Tab 3 周计划 ====================
@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

  doneCount(): number {
    let n = 0
    for (let i = 0; i < this.planList.length; i++) {
      if (this.planList[i].done) {
        n++
      }
    }
    return n
  }

  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
  }

  addPlan() {
    const dayArr: string[] = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
    const mealArr: string[] = ['早餐', '午餐', '晚餐', '加餐']
    const r: PlanCell[] = []
    for (let i = 0; i < this.planList.length; i++) {
      r.push(this.planList[i])
    }
    r.push({
      id: this.planList.length + 1,
      day: dayArr[this.dayIdx],
      meal: mealArr[this.mealIdx],
      name: this.inputName === '' ? '自由搭配轻食' : this.inputName,
      kcal: Number(this.inputKcal === '' ? '400' : this.inputKcal),
      done: false
    })
    this.planList = r
    this.showAdd = false
    this.inputName = ''
    this.inputKcal = ''
  }

  editPlan() {
    const r: PlanCell[] = []
    for (let i = 0; i < this.planList.length; i++) {
      if (i === this.editIdx) {
        r.push({
          id: this.planList[i].id,
          day: this.planList[i].day,
          meal: this.planList[i].meal,
          name: this.inputName === '' ? this.planList[i].name : this.inputName,
          kcal: Number(this.inputKcal === '' ? this.planList[i].kcal : this.inputKcal),
          done: this.planList[i].done
        })
      } else {
        r.push(this.planList[i])
      }
    }
    this.planList = r
    this.showEdit = false
  }

  removePlan() {
    const r: PlanCell[] = []
    for (let i = 0; i < this.planList.length; i++) {
      if (i !== this.delIdx) {
        r.push(this.planList[i])
      }
    }
    this.planList = r
    this.showDel = false
  }

  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
  }

  build() {
    Scroll() {
      Column({ space: 12 }) {
        Column({ space: 10 }) {
          Row({ space: 8 }) {
            Column({ space: 3 }) {
              Text(this.doneCount() + '/' + this.planList.length)
                .fontSize(22)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('本周已完成餐次')
                .fontSize(10)
                .fontColor('#DCF5D3')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Text('+ 添加餐次')
              .fontSize(12)
              .fontColor('#16A34A')
              .padding({ left: 12, right: 12, top: 7, bottom: 7 })
              .borderRadius(14)
              .backgroundColor('#FFFFFF')
              .onClick(() => {
                this.dayIdx = 0
                this.mealIdx = 0
                this.inputName = ''
                this.inputKcal = ''
                this.showAdd = true
              })
          }
          .width('100%')

          Row({ space: 4 }) {
            ForEach(this.planList, (p: PlanCell) => {
              Column()
                .width(18)
                .height(18)
                .borderRadius(5)
                .backgroundColor(p.done ? '#FACC15' : 'rgba(255,255,255,0.3)')
            }, (p: PlanCell) => (p.id.toString() + p.done.toString()))
          }
          .width('100%')
        }
        .padding(14)
        .borderRadius(16)
        .linearGradient({
          angle: 135,
          colors: [['#14532D', 0], ['#16A34A', 1]]
        })
        .shadow({ radius: 10, color: 'rgba(20,83,45,0.25)', offsetX: 0, offsetY: 4 })
        .width('100%')

        Column({ space: 12 }) {
          Row({ space: 6 }) {
            Text('七日餐单')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1A2E1A')
              .layoutWeight(1)
            Text('点击卡片打卡')
              .fontSize(10)
              .fontColor('#7CA982')
          }
          .width('100%')

          ForEach(this.planList, (p: PlanCell, idx: number) => {
            Row({ space: 10 }) {
              Column({ space: 3 }) {
                Text(p.day)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#16A34A')
                Text(p.meal)
                  .fontSize(10)
                  .fontColor('#7CA982')
              }
              .width(44)
              .alignItems(HorizontalAlign.Center)

              Column()
                .width(4)
                .height(40)
                .borderRadius(2)
                .backgroundColor(p.done ? '#FACC15' : '#D6E8D0')

              Column({ space: 3 }) {
                Text(p.name)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(p.done ? '#7CA982' : '#1A2E1A')
                  .maxLines(1)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })
                  .layoutWeight(1)
                Text(p.kcal + ' kcal')
                  .fontSize(10)
                  .fontColor(p.done ? '#A3BFA3' : '#F97316')
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              .onClick(() => {
                this.toggleDone(idx)
              })

              Text(p.done ? '已吃' : '打卡')
                .fontSize(10)
                .fontColor(p.done ? '#A3BFA3' : '#FFFFFF')
                .padding({ left: 10, right: 10, top: 5, bottom: 5 })
                .borderRadius(11)
                .backgroundColor(p.done ? '#EEF6EA' : '#16A34A')
                .onClick(() => {
                  this.toggleDone(idx)
                })

              Column({ space: 4 }) {
                Text('⋯')
                  .fontSize(14)
                  .fontColor('#7CA982')
              }
              .onClick(() => {
                this.swapIdx = idx
                this.showSwap = true
              })
            }
            .width('100%')
            .padding(10)
            .borderRadius(12)
            .backgroundColor(p.done ? '#F6FBF4' : '#FFFFFF')
            .shadow({ radius: 3, color: 'rgba(26,46,26,0.05)', offsetX: 0, offsetY: 1 })

     



总结

在这里插入图片描述

本文对基于 HarmonyOS 6.1.1 的轻食沙拉工坊应用进行了全面的代码级深度解析。该应用以 ArkTS 声明式 UI 为核心框架,通过五大功能模块构建了从点餐到营养管理、从周计划到门店自提的完整健康饮食管理平台。应用在数据层通过 SaladGoodsNutriItemPlanCell 等强类型接口定义了涵盖商品、营养、计划等领域的完整数据模型;在视图层大量运用了 ForEachFlexStacklinearGradient 等声明式组件能力实现了营养进度条、热量柱状图、打卡卡片等丰富的可视化效果;在业务层通过 addToCartcalcBmitoggleDonetakeNumber 等方法实现了购物车合并、BMI 计算、打卡切换、排队取号等核心业务逻辑。

从技术亮点角度来总结,该应用展现了几个突出的工程实践。首先是购物车的智能合并机制——addToCart 方法在添加商品时会先遍历购物车查找同 ID 商品,如果存在则更新数量而非重复添加,同时根据规格索引动态计算价格差,实现了"同品不同规格"的精确管理。其次是营养数据的动态计算——nutriBarW 函数通过 Math.round(v * 100 / m) 公式实时计算进度条宽度,kcalBarH 函数根据热量阈值返回不同高度的柱子,实现了数据到视觉的自动映射。再次是不可变状态更新模式的一贯使用——所有列表的增删改查操作都采用"创建新数组-遍历复制-条件修改-整体赋值"的模式,保证了 ArkTS 声明式框架的响应式更新能够正确触发。最后是条件样式的广泛运用——周计划打卡卡片的竖线颜色、文字颜色、按钮文字、背景色全部根据 done 状态动态变化,通过多维度的视觉反馈让用户清晰感知打卡状态。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐