一、应用背景与技术栈概述

在移动互联网与健康意识高速发展的当下,健身饮食类应用已经成为众多用户日常生活中不可或缺的数字助手。一款优秀的健身饮食管理应用,不仅需要帮助用户精准记录每日摄入的卡路里与各类营养素,还要能够以直观、美观、富有激励性的方式展示数据趋势,让用户在长期坚持中获得正向反馈。本文所要解析的,正是一款基于 HarmonyOS(鸿蒙)操作系统、采用 ArkTS 声明式开发范式构建的"健身饮食计划"应用。

在这里插入图片描述

HarmonyOS 是华为推出的面向万物互联时代的分布式操作系统,其应用开发框架经历了从 Java-based 到 ArkTS 声明式 UI 的演进。ArkTS 是在 TypeScript 基础上扩展而来的编程语言,它保留了 TypeScript 的类型系统优势,同时深度融合了鸿蒙的声明式 UI 范式,开发者可以通过 @Component@Entry@State@Builder 等装饰器,以接近自然语言的方式描述界面结构与状态驱动的更新逻辑。这种范式与 Flutter 的 Widget 树、SwiftUI 的 View 协议、Jetpack Compose 的 Composable 函数有异曲同工之妙,但其独特的状态管理机制和与系统底层的深度融合,使其在鸿蒙生态中具备原生级别的性能与体验。

本应用的技术栈可以概括为以下几个层面:

  • 编程语言:ArkTS,兼具 TypeScript 的类型安全与鸿蒙的声明式 UI 能力。
  • UI 范式:声明式开发范式(Declarative UI),通过链式调用构建组件树,数据变化自动驱动界面刷新。
  • 状态管理:采用 @State 装饰器管理组件内部可变状态,配合 @Observed@ObjectLink 实现对象的深度观测(本应用中使用了 @Observed 标注数据模型类)。
  • 组件化:通过 @Builder 装饰器将可复用的 UI 片段封装为构建器函数,实现组件级别的复用与组合。
  • 导航架构:基于底部 Tab 栏的多页面切换架构,通过状态变量 currentTab 控制四个 Tab 页的渲染切换,无需借助路由框架即可实现简洁的页面管理。
  • 弹窗系统:自定义模态弹窗层,通过状态变量控制弹窗的显隐,实现新增、编辑、删除确认等交互场景。
  • 数据模型:采用接口(interface)定义数据契约,结合 @Observed 类实现可观测的领域对象,体现了面向数据的设计思想。
  • 视觉设计:以"橙色"为主色调(#E65100),搭配金黄色(#FFB300)与浅橙背景(#FFF3E0),整体视觉温暖、活力充沛,契合健身饮食这一应用主题。

从架构层面来看,本应用采用了"单入口组件 + 多 Builder 子页面"的结构。整个应用只有一个 @Entry 入口组件 MealPlannerApp,其内部通过 currentTab 状态变量配合 if-else 条件渲染,切换展示四个功能各异的 Builder 函数(mealsTabweeklyTabnutritionTabmineTab),并在最外层叠加三个模态弹窗(addModaleditModaldeleteModal)。这种设计避免了复杂路由的引入,使得应用结构紧凑、状态集中、逻辑清晰,非常适合中小型应用的快速开发。下面,我们将逐段深入解析每一块代码的实现细节、设计思路与技术要点。


二、数据模型与类型定义

2.1 颜色主题注释与 MealItem 接口

// 健身饮食计划 — 橙色 #E65100 | 黄色 #FFB300 | 背景 #FFF3E0

interface MealItem {
  id: number
  name: string
  mealType: string
  calories: number
  protein: number
  carbs: number
  fat: number
  fiber: number
  date: string
  time: string
  isCompleted: boolean
  emoji: string
  recipe: string[]
  ingredients: string[]
}

在这里插入图片描述

文件开篇以一行注释声明了整个应用的颜色主题体系:主色橙色 #E65100、辅助色黄色 #FFB300、背景色浅橙 #FFF3E0。这三个颜色构成了应用的视觉基调,橙色象征着活力与能量,契合健身运动的主题;黄色作为高亮与强调色,用于关键数据与进度条;浅橙色背景则提供了温暖而不刺眼的底色。这种统一在文件顶部声明颜色主题的做法,虽然在大型工程中通常会提取为资源文件或常量文件,但在单文件应用中,这样的注释起到了"颜色词典"的作用,让开发者一眼就能把握全局配色策略。

接下来是 MealItem 接口的定义。在 ArkTS/TypeScript 中,interface 用于定义数据的形状(shape),它是一种纯类型声明,不包含任何实现逻辑。MealItem 接口描述了一份餐食(Meal)的完整数据结构,包含了 14 个字段:

  • id: number:餐食的唯一标识符,用于列表渲染时的 key 管理和数据定位。
  • name: string:餐食名称,如"燕麦牛奶杯"。
  • mealType: string:餐食类型,取值为"早餐"“午餐”“晚餐”"加餐"之一,用于分类筛选。
  • calories: number:卡路里含量,单位 kcal,是饮食管理的核心指标。
  • protein / carbs / fat / fiber: number:分别表示蛋白质、碳水化合物、脂肪、膳食纤维的含量,单位克(g),这四项构成了宏量营养素的完整画像。
  • date: string:日期字符串,格式为 YYYY-MM-DD,用于按日期组织与查询。
  • time: string:用餐时间字符串,格式为 HH:mm,用于时间线展示。
  • isCompleted: boolean:是否已完成的标志位,用于在列表项中显示完成状态标记。
  • emoji: string:食物对应的 Emoji 图标,如 🥣🥪,以轻量化的方式替代真实图片,降低资源开销。
  • recipe: string[]:食谱步骤或用量说明数组,如 ['燕麦50g', '牛奶200ml', '蓝莓30g']
  • ingredients: string[]:食材清单数组,记录所需食材名称。

这个接口的设计体现了"领域驱动"的思想——每一个字段都直接对应健身饮食领域的真实概念,字段命名清晰自解释,类型定义严格准确。将 recipeingredients 设计为数组类型而非拼接字符串,为后续的展开渲染、遍历操作提供了便利。isCompleted 使用布尔类型而非字符串,也体现了类型安全的设计原则。

2.2 DailyCalorie、MacroTarget、WeeklyProtein 接口

interface DailyCalorie {
  day: string
  calories: number
  goal: number
}

interface MacroTarget {
  name: string
  current: number
  target: number
  unit: string
  color: string
  percentage: number
}

interface WeeklyProtein {
  day: string
  protein: number
}

在这里插入图片描述

这三个接口分别服务于应用中不同的数据可视化场景。

DailyCalorie 接口用于描述每日卡路里摄入与目标对比的数据,包含 day(星期几的简称,如"周一")、calories(实际摄入卡路里)和 goal(目标卡路里)。goal 字段的存在使得渲染时可以同时展示实际值与目标值的对比,当 calories > goal 时,界面会以红色(#C62828)标识超量摄入,从而在视觉上对用户形成警示。这种将对比基准(goal)与实际值绑定在同一数据结构中的设计,简化了渲染逻辑中的参数传递,避免了在 UI 层硬编码目标值。

MacroTarget 接口描述宏量营养素(蛋白质、碳水、脂肪、纤维)的目标达成情况。这个接口的字段最为丰富:name 是营养素名称,currenttarget 分别为当前值与目标值,unit 为单位(如"g"),color 为该营养素对应的主题色,percentage 为完成百分比。特别值得注意的是 color 字段——将颜色直接存储在数据结构中,是一种"数据驱动的样式"设计思路。渲染时,进度条的颜色直接取自 item.color,而非在 Builder 中通过 switch-case 判断营养素名称来决定颜色。这种设计使得新增一种营养素时,只需在数据源中添加一条记录并指定颜色即可,无需修改渲染逻辑,体现了开闭原则(Open-Closed Principle)。

WeeklyProtein 接口最为简洁,仅包含 dayprotein 两个字段,专门用于"每周蛋白质趋势"的条形图渲染。虽然结构简单,但它的存在使得数据职责单一化——不与每日卡路里数据混用同一个结构,保持了各数据集的独立性和可读性。

2.3 TabConfig 与 MealTypeConfig 接口

interface TabConfig {
  index: number
  label: string
  icon: string
}

interface MealTypeConfig {
  type: string
  mealType: string
  icon: string
  color: string
  targetCalories: number
}

在这里插入图片描述

TabConfig 接口定义了底部导航栏每一个 Tab 页的配置信息:index 为该 Tab 在枚举中的序号,label 为显示文字(如"餐食"“周计划”),icon 为 Emoji 图标。将导航配置抽象为接口并以数组形式存储,是声明式 UI 中非常经典的数据驱动渲染模式——底部导航栏的渲染只需遍历 tabConfigs 数组即可,新增 Tab 页只需在数组中添加一条记录,完全无需修改渲染逻辑。

MealTypeConfig 接口描述餐食类型的详细配置,包括类型名称、图标、主题色和目标卡路里。targetCalories 字段为每种餐类设定了建议的卡路里目标(如早餐 500 kcal、午餐 700 kcal),这个数据不仅可以在界面中作为参考展示,也可以作为未来校验用户输入是否合理的依据。这种将业务规则数据化的做法,使得应用逻辑与业务参数解耦,增强了可维护性。

2.4 @Observed 数据模型 MealModel

@Observed
class MealModel {
  id: number
  name: string
  mealType: string
  calories: number
  protein: number
  carbs: number
  fat: number
  fiber: number
  date: string
  time: string
  isCompleted: boolean
  emoji: string
  recipe: string[]
  ingredients: string[]

  constructor(meal: MealItem) {
    this.id = meal.id
    this.name = meal.name
    this.mealType = meal.mealType
    this.calories = meal.calories
    this.protein = meal.protein
    this.carbs = meal.carbs
    this.fat = meal.fat
    this.fiber = meal.fiber
    this.date = meal.date
    this.time = meal.time
    this.isCompleted = meal.isCompleted
    this.emoji = meal.emoji
    this.recipe = meal.recipe
    this.ingredients = meal.ingredients
  }
}

在这里插入图片描述

MealModel 是一个被 @Observed 装饰器标注的类。在 ArkTS 的状态管理体系中,@Observed 的作用是使一个类成为"可观测对象"——当该类实例的属性发生变化时,框架能够自动检测到变化并触发依赖该对象的 UI 组件重新渲染。这与 @State 的区别在于:@State 用于管理组件内部的原始值状态(如 number、string、boolean),而 @Observed 适用于管理复杂对象类型,实现的是对象级别的深度观测。

MealModel 类的字段与前面定义的 MealItem 接口完全一致,这并非冗余——MealItem 是纯类型契约(interface),而 MealModel 是具有构造函数的可实例化类。构造函数接收一个 MealItem 类型的参数,并将所有属性逐一赋值。这种"接口定义形状 + 类承载行为与可观测性"的设计模式,是 ArkTS 中管理领域对象的推荐实践:接口保证类型的灵活性与可替换性,类则通过 @Observed 获得响应式能力。

在实际使用中,所有的餐食数据都以 MealModel 实例的形式存储在 meals 数组中。当用户通过编辑弹窗修改某条餐食的属性时,由于 MealModel@Observed 标注,框架能够自动感知属性变化并刷新对应的列表项 UI。不过需要注意的是,在本应用当前版本中,编辑弹窗的保存操作仅关闭弹窗,并未实际修改数据,但数据模型的可观测性设计为未来的功能完善预留了空间。

2.5 MealTab 枚举

enum MealTab {
  Meals,
  Weekly,
  Nutrition,
  Mine
}

在这里插入图片描述

MealTab 枚举定义了四个 Tab 页的索引值,分别对应"餐食"“周计划”“营养”"我的"四个功能模块。使用枚举(enum)而非魔法数字(magic number)来表示 Tab 索引,是编码规范中的基本要求——MealTab.Meals 的可读性远胜于 0。枚举的成员值从 0 开始自动递增,这与 currentTab 状态变量的初始值 MealTab.Meals(即 0)配合,确保应用启动时默认展示"餐食"页。

build() 方法中,Tab 页的切换通过 if (this.currentTab === MealTab.Meals) 等条件判断实现。虽然此处也可以使用 switch-case 结构,但 if-else if-else 链在 ArkTS 的 build() 函数中更为常见,因为声明式 UI 的条件渲染更倾向于 if 表达式。tabConfigs 数组中的 index 字段也引用了 MealTab 枚举值,保证了配置数据与切换逻辑之间的一致性。


三、主组件结构、状态管理与模拟数据

3.1 @Entry 与 @Component 装饰器及状态变量声明

@Entry
@Component
struct MealPlannerApp {
  @State currentTab: number = MealTab.Meals
  @State showAddModal: boolean = false
  @State showEditModal: boolean = false
  @State showDeleteModal: boolean = false
  @State editIndex: number = 0
  @State deleteIndex: number = 0
  @State selectedMealType: string = '全部'
  @State dailyCalorieGoal: number = 2000
  @State todayCalories: number = 1450
  @State nutritionScore: number = 85

在这里插入图片描述

@Entry 装饰器标识 MealPlannerApp 为应用的入口组件——即整个 UI 树的根节点。在 HarmonyOS 应用中,有且只有一个被 @Entry 标注的组件会作为页面入口被加载渲染。@Component 装饰器则标识该 struct 为一个自定义组件,使其可以被复用、组合和管理生命周期。

接下来的 @State 装饰器是 ArkTS 状态管理中最核心的装饰器之一。被 @State 修饰的变量成为"状态变量",当其值发生变化时,框架会自动重新调用 build() 函数中依赖该变量的部分,实现 UI 的局部刷新。本应用定义了 9 个状态变量:

  • currentTab: number:当前激活的 Tab 索引,初始值为 MealTab.Meals(0),控制四个 Tab 页的切换。这是整个应用导航的核心状态。
  • showAddModal / showEditModal / showDeleteModal: boolean:三个布尔型状态分别控制"新增"“编辑”"删除确认"三个弹窗的显隐。初始值均为 false,表示应用启动时弹窗不可见。
  • editIndex / deleteIndex: number:记录当前正在编辑或删除的餐食在 meals 数组中的索引,用于弹窗中读取对应餐食的数据。初始值为 0。
  • selectedMealType: string:当前选中的餐类筛选条件,初始值为 '全部',表示默认展示所有类型的餐食。
  • dailyCalorieGoal: number:每日卡路里目标,初始值 2000 kcal,这是成人每日推荐摄入量的常用参考值。
  • todayCalories: number:今日已摄入卡路里,初始值 1450 kcal,用于顶部进度条展示。
  • nutritionScore: number:营养评分,初始值 85,在顶部进度区域与营养 Tab 页均会展示。

这些状态变量涵盖了应用的所有交互场景:Tab 切换、弹窗控制、筛选条件、数据展示。将所有状态集中在一个入口组件中管理,是中小型应用常见的做法——虽然随着应用规模增长可能需要引入更复杂的状态管理方案(如全局 AppStorage),但在当前规模下,集中式状态管理保证了数据流向的清晰可追踪。

3.2 餐食模拟数据 meals 数组

private meals: MealModel[] = [
  new MealModel({ id: 1, name: '燕麦牛奶杯', mealType: '早餐', calories: 320, protein: 18, carbs: 45, fat: 8, fiber: 6, date: '2026-07-25', time: '07:30', isCompleted: true, emoji: '🥣', recipe: ['燕麦50g', '牛奶200ml', '蓝莓30g'], ingredients: ['燕麦', '牛奶', '蓝莓', '蜂蜜'] }),
  new MealModel({ id: 2, name: '全麦三明治', mealType: '早餐', calories: 380, protein: 22, carbs: 48, fat: 12, fiber: 8, date: '2026-07-24', time: '08:00', isCompleted: true, emoji: '🥪', recipe: ['全麦面包', '鸡蛋', '生菜'], ingredients: ['全麦面包', '鸡蛋', '生菜', '番茄'] }),
  // ... 共 26 条餐食记录 ...
  new MealModel({ id: 26, name: '黑巧克力配咖啡', mealType: '加餐', calories: 160, protein: 3, carbs: 14, fat: 10, fiber: 3, date: '2026-07-19', time: '16:00', isCompleted: true, emoji: '🍫', recipe: ['黑巧克力', '咖啡'], ingredients: ['黑巧克力', '咖啡豆'] })
]

meals 是一个 MealModel[] 类型的私有数组,包含 26 条精心构造的餐食记录。每条记录都通过 new MealModel({...}) 实例化,传入的参数对象符合 MealItem 接口的形状。这些数据覆盖了早餐、午餐、晚餐、加餐四种类型,日期跨度从 2026-07-19 到 2026-07-25(一周),每条数据都包含完整的营养素信息、食谱和食材清单。

值得注意的是数据的真实性与丰富度。例如"鸡胸肉沙拉"含 450 kcal、35g 蛋白质,"三文鱼寿司"含 480 kcal、28g 蛋白质,这些数值与真实食物的营养成分高度吻合,说明开发者在数据构造时进行了合理的估算。每条记录都配有一个 Emoji 图标,如 🥣🥪🥗🍣🍗🐟 等,使用 Emoji 作为食物图标是一个聪明的轻量化方案——无需引入图片资源文件,不会增加应用体积,且 Emoji 具有跨平台一致性。

这 26 条数据被标记为 private,意味着它们仅在 MealPlannerApp 组件内部可访问。在当前版本中,这些是硬编码的模拟数据;在实际生产应用中,meals 数组通常会从本地数据库(如关系型数据库或 Preferences)或网络接口异步加载,并配合 @State@StorageLink 实现数据的响应式管理。

3.3 每日卡路里与宏量营养素目标数据

private dailyCalories: DailyCalorie[] = [
  { day: '周一', calories: 1850, goal: 2000 },
  { day: '周二', calories: 1950, goal: 2000 },
  { day: '周三', calories: 1720, goal: 2000 },
  { day: '周四', calories: 2100, goal: 2000 },
  { day: '周五', calories: 1650, goal: 2000 },
  { day: '周六', calories: 2200, goal: 2000 },
  { day: '周日', calories: 1450, goal: 2000 }
]

private macroTargets: MacroTarget[] = [
  { name: '蛋白质', current: 95, target: 120, unit: 'g', color: '#E65100', percentage: 79 },
  { name: '碳水', current: 180, target: 250, unit: 'g', color: '#FFB300', percentage: 72 },
  { name: '脂肪', current: 48, target: 65, unit: 'g', color: '#FF8F00', percentage: 74 },
  { name: '纤维', current: 22, target: 30, unit: 'g', color: '#FF6F00', percentage: 73 }
]

在这里插入图片描述

dailyCalories 数组提供了过去一周每天的卡路里摄入与目标对比数据。每条记录的 goal 均为 2000 kcal,而实际 calories 在 1450~2200 之间波动——其中周四(2100)和周六(2200)超过了目标值,这种"有升有降"的数据设计使得界面渲染时的条形图颜色变化更加丰富(超量时变红),也更具展示效果和真实感。

macroTargets 数组定义了四类宏量营养素的目标达成情况。每条记录的 color 字段使用了不同的橙色系色调(从深橙 #E65100 到琥珀 #FFB300),percentage 字段预计算了完成百分比。虽然 percentage 理论上可以通过 current / target * 100 动态计算,但将其预存为数据字段的做法可以避免在渲染时进行重复计算,提升了渲染性能。这种"空间换时间"的取舍在数据量较大时尤为有意义。

3.4 每周蛋白质趋势与配置数据

private weeklyProteins: WeeklyProtein[] = [
  { day: '周一', protein: 110 },
  { day: '周二', protein: 125 },
  { day: '周三', protein: 95 },
  { day: '周四', protein: 130 },
  { day: '周五', protein: 88 },
  { day: '周六', protein: 135 },
  { day: '周日', protein: 95 }
]

private tabConfigs: TabConfig[] = [
  { index: MealTab.Meals, label: '餐食', icon: '🍽️' },
  { index: MealTab.Weekly, label: '周计划', icon: '📅' },
  { index: MealTab.Nutrition, label: '营养', icon: '📊' },
  { index: MealTab.Mine, label: '我的', icon: '👤' }
]

private mealTypeConfigs: MealTypeConfig[] = [
  { type: '全部', icon: '🍽️', color: '#E65100', targetCalories: 2000 },
  { type: '早餐', icon: '🌅', color: '#FFB300', targetCalories: 500 },
  { type: '午餐', icon: '☀️', color: '#FF8F00', targetCalories: 700 },
  { type: '晚餐', icon: '🌙', color: '#E65100', targetCalories: 600 },
  { type: '加餐', icon: '🥜', color: '#FF6F00', targetCalories: 200 }
]

private mealTypeFilters: string[] = ['全部', '早餐', '午餐', '晚餐', '加餐']

weeklyProteins 数组记录了一周内每天的蛋白质摄入量(单位 g),数据范围在 88~135g 之间,目标参考值约为 120g(从 macroTargets 中蛋白质的目标值推断)。这个数据专门服务于"周计划"Tab 中的"每周蛋白质趋势"条形图渲染。

tabConfigs 数组是底部导航栏的数据源,包含了四个 Tab 的配置。index 字段引用了 MealTab 枚举值,label 为中文标签,icon 为 Emoji。这种数据驱动的导航配置方式是声明式 UI 的精髓——导航栏的渲染逻辑是通用的 ForEach 遍历,配置变更无需修改渲染代码。

mealTypeConfigs 数组为每种餐类定义了图标、颜色和目标卡路里。这些配置虽然在本应用中主要用于数据展示(如营养 Tab 中的"餐类卡路里分布"),但其设计意图是为应用提供一个可扩展的餐类管理系统。mealTypeFilters 是一个简单的字符串数组,专门用于"餐食"Tab 中的横向滚动筛选条。

至此,所有的数据源都已就绪。这些数据构成了应用的数据层(Data Layer),而后续的 build() 方法和各个 @Builder 函数则构成了表现层(Presentation Layer)。两者之间通过状态变量(@State)和事件回调建立连接,实现了数据驱动 UI 的核心架构。


四、主入口 build 方法与布局骨架

4.1 顶部标题栏与主布局结构

build() {
  Column() {
    Column() {
      // 顶部标题栏
      Row() {
        Text('🍽️ 健身饮食').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
        Row().layoutWeight(1)
        Text('📅').fontSize(20).fontColor('#FFFFFF').margin({ right: 16 })
        Text('⚙️').fontSize(20).fontColor('#FFFFFF')
      }
      .width('100%').height(56).padding({ left: 20, right: 20 })
      .backgroundColor('#E65100').alignItems(VerticalAlign.Center)

      // 今日卡路里进度
      this.calorieProgressBuilder()

      // 内容区域
      Column() {
        if (this.currentTab === MealTab.Meals) {
          this.mealsTab()
        } else if (this.currentTab === MealTab.Weekly) {
          this.weeklyTab()
        } else if (this.currentTab === MealTab.Nutrition) {
          this.nutritionTab()
        } else {
          this.mineTab()
        }
      }.layoutWeight(1).width('100%')

      // 底部导航栏
      this.bottomTabBar()
    }
    .width('100%').height('100%').backgroundColor('#FFF3E0')

    // 弹窗层
    if (this.showAddModal) {
      this.addModal()
    }
    if (this.showEditModal) {
      this.editModal()
    }
    if (this.showDeleteModal) {
      this.deleteModal()
    }
  }
}

build() 方法是 ArkTS 声明式 UI 的核心入口,它返回一个组件树描述。当任何 @State 变量发生变化时,框架会重新调用 build() 方法并高效地更新差异部分。

最外层是一个 Column,它承载了两部分内容:主界面容器(内层 Column)和弹窗层(三个 if 条件块)。这种将弹窗与主界面平级放置在最外层的设计,确保了弹窗能够覆盖整个屏幕(包括底部导航栏),实现真正的全屏模态效果。

内层 Column 构成了主界面的骨架,从上到下依次排列三个区域:顶部标题栏、内容区域和底部导航栏。顶部标题栏是一个 Row,左侧显示应用名称"🍽️ 健身饮食",中间使用 Row().layoutWeight(1) 占据弹性空间(将右侧图标推向右边),右侧放置日历和设置两个 Emoji 图标。标题栏高度 56vp,背景为主色橙色 #E65100,文字为白色,视觉简洁醒目。alignItems(VerticalAlign.Center) 确保所有子元素垂直居中对齐。

内容区域使用了 layoutWeight(1),这是 ArkTS 布局中的关键属性——它使该 Column 占据父容器中所有剩余的弹性空间(即总高度减去标题栏和导航栏的高度后的全部空间)。内部通过 if-else if-else 条件渲染,根据 currentTab 的值调用对应的 @Builder 函数。当用户点击底部导航栏切换 Tab 时,currentTab 状态变化触发 build() 重新执行,内容区域自动切换到对应的 Tab 页面。

底部导航栏通过 this.bottomTabBar() 调用,固定在主界面最底部。整个内层 Column 的背景色设为浅橙 #FFF3E0,为内容区域提供了温暖的底色。

弹窗层位于外层 Column 中,三个 if 条件分别检查 showAddModalshowEditModalshowDeleteModal 的值。当某个状态为 true 时,对应的弹窗 Builder 会被调用并渲染到最外层,覆盖在主界面之上。这种条件渲染的弹窗管理方式简单直接,无需引入第三方模态库。


五、顶部卡路里进度组件

5.1 calorieProgressBuilder 构建

@Builder calorieProgressBuilder() {
  Column() {
    Row() {
      Text('🔥 今日卡路里').fontSize(13).fontColor('#FFFFFF')
      Row().layoutWeight(1)
      Text(this.nutritionScore + '分').fontSize(13).fontColor('#FFB300').fontWeight(FontWeight.Bold)
    }.width('100%')

    Row() {
      Text(this.todayCalories + '').fontSize(28).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
      Text(' / ' + this.dailyCalorieGoal + ' kcal').fontSize(14).fontColor('#FFE0B2')
        .margin({ left: 4, bottom: 4 })
    }.margin({ top: 6 }).alignItems(VerticalAlign.Bottom)

    // 进度条
    Row() {
      Column()
        .width(this.todayCalories / this.dailyCalorieGoal * 100 + '%')
        .height(6)
        .backgroundColor('#FFB300')
        .borderRadius(3)
    }.width('100%').height(6).backgroundColor('#BF360C').borderRadius(3).margin({ top: 8 })

    Row() {
      Text('剩余 ' + (this.dailyCalorieGoal - this.todayCalories) + ' kcal').fontSize(11).fontColor('#FFE0B2')
      Row().layoutWeight(1)
      Text('🔥 营养评分').fontSize(11).fontColor('#FFE0B2')
    }.width('100%').margin({ top: 6 })
  }
  .width('100%').padding(16).backgroundColor('#E65100').borderRadius(0)
}

calorieProgressBuilder 是一个 @Builder 装饰的构建器函数,用于渲染应用顶部的"今日卡路里进度"区域。@Builder 是 ArkTS 中实现 UI 片段复用的核心装饰器——它可以将一段 UI 描述封装为可调用的函数,在 build() 方法中通过 this.xxxBuilder() 的方式引用。与独立的自定义组件(@Component struct)不同,@Builder 函数不拥有独立的状态,它直接访问宿主组件的状态变量,这使得它非常适合在同一个组件内部拆分复杂的 UI 结构。

该构建器渲染的内容分为四个层次:

第一层是标题行,左侧显示"🔥 今日卡路里",右侧显示营养评分(如"85分")。评分使用黄色 #FFB300 加粗显示,在橙色背景上形成鲜明的高亮效果。中间的 Row().layoutWeight(1) 起到弹性占位的作用。

第二层是数据展示行,左侧大字号(28vp)加粗显示今日已摄入卡路里(如"1450"),右侧以较小字号(14vp)显示目标值(如" / 2000 kcal")。两个 Text 通过 alignItems(VerticalAlign.Bottom) 实现底对齐,使得大小字号文本在底部齐平,视觉上更加协调。这种大小字号的搭配是数据类应用中常见的展示手法,既突出了核心数字,又保留了上下文信息。

第三层是进度条。进度条由两层 Row 嵌套构成:外层 Row 作为容器,背景色为深橙红 #BF360C(表示未完成的部分),高度 6vp,圆角 3vp;内层是一个空 Column,其宽度通过 this.todayCalories / this.dailyCalorieGoal * 100 + '%' 动态计算——即已摄入卡路里占目标值的百分比,背景色为黄色 #FFB300。例如当今日摄入 1450、目标 2000 时,进度条宽度为 72.5%。这种"容器+填充"的双层结构是进度条的经典实现方式,通过百分比宽度的字符串拼接实现了动态计算。

第四层是辅助信息行,左侧显示"剩余 550 kcal"(目标值减去已摄入值),右侧显示"🔥 营养评分"标签。整个组件的背景色与标题栏一致为 #E65100,padding 为 16vp,形成从标题栏到内容区域的无缝过渡。

这个组件的设计亮点在于它将多项关键信息(评分、已摄入、目标、剩余、进度条)整合在一个紧凑的区域内,信息密度高但层次分明,是应用顶部"信息总览"区域的核心展示组件。


六、底部导航栏组件

6.1 bottomTabBar 构建

@Builder bottomTabBar() {
  Row() {
    ForEach(this.tabConfigs, (item: TabConfig) => {
      Column() {
        Text(item.icon).fontSize(22)
        Text(item.label).fontSize(10).fontColor(this.currentTab === item.index ? '#E65100' : '#9E9E9E')
          .margin({ top: 2 })
      }.alignItems(HorizontalAlign.Center).layoutWeight(1)
      .onClick(() => { this.currentTab = item.index })
    }, (item: TabConfig) => item.index.toString())
  }
  .width('100%').height(56).backgroundColor('#FFFFFF')
  .alignItems(VerticalAlign.Center)
  .shadow({ radius: 8, color: '#1A000000', offsetY: -2 })
}

bottomTabBar 构建器负责渲染应用底部的四标签导航栏。这是整个应用导航交互的核心组件。

渲染逻辑使用了 ForEach 遍历 tabConfigs 数组。ForEach 是 ArkTS 中用于列表渲染的核心组件,它接收三个参数:数据源数组、子项生成函数和键值生成函数。子项生成函数为每个 TabConfig 生成一个 Column,包含 Emoji 图标(22vp)和文字标签(10vp)。标签的颜色通过三元表达式动态判断:如果 this.currentTab === item.index(当前激活的 Tab),文字颜色为橙色 #E65100;否则为灰色 #9E9E9E。这种高亮当前 Tab 的视觉反馈是底部导航栏的标准交互模式。

每个 Column 都绑定了 onClick 事件,点击时将 this.currentTab 设置为该 Tab 的 index 值。由于 currentTab@State 变量,赋值后会自动触发 build() 方法重新执行,内容区域的 if-else 条件会渲染新的 Tab 页面。这种"点击改状态→状态驱动渲染"的闭环,是声明式 UI 的核心运行机制。

ForEach 的第三个参数是键值生成函数 (item: TabConfig) => item.index.toString(),它为每个列表项生成一个唯一的字符串 key。在 ArkTS 中,key 的作用是帮助框架在数据变化时高效地定位差异,实现最小化更新。虽然在本应用中 tabConfigs 数组是静态的,但提供正确的 key 仍然是良好的编码习惯。

布局上,每个 Tab 项使用 layoutWeight(1) 实现等分宽度,四个 Tab 平均占据导航栏的总宽度。alignItems(HorizontalAlign.Center) 使图标和文字水平居中。整个导航栏高度 56vp,背景白色,并通过 shadow 属性添加了向上偏移 2vp 的轻微阴影(offsetY: -2,颜色为半透明黑色 #1A000000),在视觉上将导航栏与上方内容区域分离,增强了层次感。


七、餐食 Tab 页详解

7.1 mealsTab 主体结构

@Builder mealsTab() {
  Column() {
    // 餐类选择
    Scroll() {
      Row() {
        ForEach(this.mealTypeFilters, (mt: string) => {
          Text(mt)
            .fontSize(12).fontColor(this.selectedMealType === mt ? '#FFFFFF' : '#E65100')
            .backgroundColor(this.selectedMealType === mt ? '#E65100' : '#FFE0B2')
            .padding({ left: 14, right: 14, top: 6, bottom: 6 })
            .borderRadius(16).margin({ right: 8 })
            .onClick(() => { this.selectedMealType = mt })
        }, (mt: string) => mt)
      }.padding({ left: 16, right: 16 })
    }.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).height(40)

    // 营养素汇总卡片
    Row() {
      Column() {
        Text('蛋白质').fontSize(10).fontColor('#FFE0B2')
        Text('95g').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF').margin({ top: 2 })
      }.layoutWeight(1).alignItems(HorizontalAlign.Center)
      // ... 碳水、脂肪、纤维 ...
    }.width('100%').padding(14).backgroundColor('#FF8F00').borderRadius(14).margin({ top: 4, left: 16, right: 16 })

    // 餐食列表
    Scroll() {
      Column() {
        this.mealItemBuilder(this.meals[0])
        // ... this.mealItemBuilder(this.meals[1]) 到 this.mealItemBuilder(this.meals[25]) ...
        Row() {
          Text('➕ 添加新餐食').fontSize(14).fontColor('#E65100').fontWeight(FontWeight.Bold)
        }.width('100%').height(48).justifyContent(FlexAlign.Center)
        .backgroundColor('#FFE0B2').borderRadius(12).margin({ top: 12 })
        .onClick(() => { this.showAddModal = true })
      }.padding({ left: 16, right: 16, top: 8, bottom: 16 })
    }.layoutWeight(1).scrollBar(BarState.Off)
  }.width('100%').height('100%')
}

mealsTab 是应用的主功能页面,也是默认展示的 Tab 页。它由三个区域构成:横向滚动的餐类筛选条、营养素汇总卡片和可滚动的餐食列表。

最上方的餐类筛选条使用了 Scroll + Row 的组合,scrollable(ScrollDirection.Horizontal) 使其支持横向滑动,scrollBar(BarState.Off) 隐藏了滚动条以保持视觉简洁。筛选条通过 ForEach 遍历 mealTypeFilters 数组(共 5 项:全部、早餐、午餐、晚餐、加餐),为每一项生成一个胶囊形(borderRadius(16))的 Text 标签。选中状态的标签背景为橙色 #E65100、文字白色;未选中状态的背景为浅橙 #FFE0B2、文字橙色。点击标签时将 selectedMealType 设置为对应的餐类名称,触发筛选状态更新。这种"胶囊标签组"的交互模式在移动应用中极为常见,如 iOS 的分段控制器和 Android 的 Chip 组件。

中间的营养素汇总卡片是一个横向排列的四列布局,分别展示蛋白质(95g)、碳水(180g)、脂肪(48g)和纤维(22g)的当日总量。每列使用 layoutWeight(1) 等分宽度,标签字号 10vp、数值字号 18vp 加粗。卡片背景为中橙色 #FF8F00,圆角 14vp,左右边距 16vp。这个卡片将关键营养素信息整合在一行中,让用户在浏览餐食列表前就能快速了解当日营养摄入概况。

最下方是餐食列表区域,使用 Scroll 包裹一个 Column,支持垂直滚动。列表中通过 26 次 this.mealItemBuilder(this.meals[i]) 调用,将每条餐食数据渲染为列表项卡片。这里采用的是手动展开调用而非 ForEach 遍历的方式——虽然 ForEach 更为简洁,但手动展开确保了每条数据的渲染独立可控,且避免了 key 生成的问题。列表末尾是一个"➕ 添加新餐食"的按钮,点击后设置 showAddModal = true 触发新增弹窗。整个列表区域使用 layoutWeight(1) 占据剩余空间,隐藏滚动条。

7.2 mealItemBuilder 餐食卡片构建

@Builder mealItemBuilder(meal: MealModel) {
  Row() {
    // 食物Emoji
    Stack() {
      Text(meal.emoji).fontSize(28)
    }.width(52).height(52).backgroundColor('#FFE0B2').borderRadius(10)
    .align(Alignment.Center)

    Column() {
      Row() {
        Text(meal.name).fontSize(15).fontWeight(FontWeight.Bold).fontColor('#212121')
        if (meal.isCompleted) {
          Text('✓').fontSize(12).fontColor('#33691E').margin({ left: 6 })
        }
      }.width('100%').alignItems(VerticalAlign.Center)

      Row() {
        Text(meal.mealType).fontSize(10).fontColor('#E65100')
          .backgroundColor('#FFF3E0').padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(4)
        Text('⏰ ' + meal.time).fontSize(10).fontColor('#9E9E9E').margin({ left: 8 })
        Text('🔥 ' + meal.calories + 'kcal').fontSize(10).fontColor('#FF6F00').margin({ left: 8 })
      }.width('100%').margin({ top: 6 }).alignItems(VerticalAlign.Center)

      // 营养素进度条
      Row() {
        Column()
          .width(meal.protein / 50 * 100 + '%')
          .height(4).backgroundColor('#E65100').borderRadius(2)
      }.width('30%').height(4).backgroundColor('#F5F5F5').borderRadius(2).margin({ top: 6 })

      Row() {
        Text('🥩 ' + meal.protein + 'g').fontSize(10).fontColor('#757575')
        Text('🍞 ' + meal.carbs + 'g').fontSize(10).fontColor('#757575').margin({ left: 8 })
        Text('🧈 ' + meal.fat + 'g').fontSize(10).fontColor('#757575').margin({ left: 8 })
        Text('🌿 ' + meal.fiber + 'g').fontSize(10).fontColor('#757575').margin({ left: 8 })
      }.width('100%').margin({ top: 4 }).alignItems(VerticalAlign.Center)
    }.layoutWeight(1).margin({ left: 12 }).alignItems(HorizontalAlign.Start)

    Column() {
      Text('✏️').fontSize(14).margin({ bottom: 10 })
        .onClick(() => {
          this.editIndex = meal.id - 1
          this.showEditModal = true
        })
      Text('🗑️').fontSize(14)
        .onClick(() => {
          this.deleteIndex = meal.id - 1
          this.showDeleteModal = true
        })
    }.alignItems(HorizontalAlign.Center)
  }
  .width('100%').padding(12).backgroundColor('#FFFFFF').borderRadius(12).margin({ top: 8 })
  .alignItems(VerticalAlign.Center)
}

mealItemBuilder 是餐食列表中每一张餐食卡片的构建器,接收一个 MealModel 参数。这个构建器是整个应用中信息密度最高的 UI 组件,将餐食的图标、名称、状态、类型、时间、卡路里、营养素进度和操作按钮整合在一张卡片中。

卡片采用 Row 横向布局,从左到右分为三个部分:

左侧是食物图标区域,使用 Stack 容器承载 Emoji(28vp),背景浅橙 #FFE0B2,尺寸 52x52vp,圆角 10vp。Stackalign(Alignment.Center) 确保 Emoji 居中显示。使用 Stack 而非直接用 Text,是因为后续可能需要在图标上叠加角标(如未完成标记)。

中间是信息主体区域,使用 Column 纵向排列四行内容。第一行是餐食名称和完成状态标记——名称使用 15vp 加粗深灰色 #212121,如果 isCompletedtrue,则在名称右侧显示绿色(#33691E)的"✓"标记。第二行是元信息行,包括餐类标签(橙色文字+浅橙背景的胶囊)、时间(带⏰图标)和卡路里(带🔥图标,橙色文字)。第三行是蛋白质进度条,宽度限制在 30% 内,通过 meal.protein / 50 * 100 + '%' 计算填充比例(以 50g 为满格基准)。第四行是四种营养素的数值展示,每种配以对应的 Emoji(🥩蛋白质、🍞碳水、🧈脂肪、🌿纤维)。

右侧是操作按钮区域,垂直排列编辑(✏️)和删除(🗑️)两个图标。编辑按钮的 onClick 回调将 editIndex 设置为 meal.id - 1(通过 id 减 1 得到数组索引),然后触发编辑弹窗。删除按钮同理设置 deleteIndex 并触发删除确认弹窗。

整个卡片背景白色,圆角 12vp,padding 12vp,上边距 8vp,实现了卡片之间的视觉间距。这种设计兼顾了信息丰富度与视觉整洁度,是列表项设计的优秀范例。


八、周计划 Tab 页详解

8.1 weeklyTab 主体结构

@Builder weeklyTab() {
  Scroll() {
    Column() {
      // 周计划标题
      Row() {
        Text('📅 本周餐食计划').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#212121')
      }.width('100%').margin({ bottom: 8 })

      // 每日餐食卡片
      this.dayPlanBuilder('周一', 1850, 4, '#E65100')
      this.dayPlanBuilder('周二', 1950, 4, '#FF8F00')
      this.dayPlanBuilder('周三', 1720, 5, '#FFB300')
      this.dayPlanBuilder('周四', 2100, 4, '#E65100')
      this.dayPlanBuilder('周五', 1650, 3, '#FF8F00')
      this.dayPlanBuilder('周六', 2200, 5, '#FFB300')
      this.dayPlanBuilder('周日', 1450, 4, '#E65100')

      // 每周蛋白质趋势
      Row() {
        Text('💪 每周蛋白质趋势').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#212121')
      }.width('100%').margin({ top: 20, bottom: 8 })

      Column() {
        this.weeklyProteinBuilder(this.weeklyProteins[0])
        // ... weeklyProteins[1] 到 weeklyProteins[6] ...
      }.width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(14)
    }.padding({ left: 16, right: 16, top: 12, bottom: 16 })
  }.layoutWeight(1).scrollBar(BarState.Off)
}

weeklyTab 构建器渲染"周计划"页面,展示一周内每天的餐食规划概览和蛋白质摄入趋势。整个页面使用 Scroll 包裹,支持垂直滚动。

页面分为两个区块:上半部分是"本周餐食计划",通过 7 次 dayPlanBuilder 调用渲染周一到周日的每日卡片。每次调用传入了四个参数:日期名称、卡路里总量、已规划餐数和主题色。值得注意的是卡路里和颜色之间的关联——超过 2000 kcal 的天数(周四 2100、周六 2200)使用了较深的橙色 #E65100,而热量较低的天数使用了较浅的 #FFB300,这种颜色暗示虽然细微,但在视觉上能够传递热量高低的差异。

下半部分是"每周蛋白质趋势",同样通过 7 次 weeklyProteinBuilder 调用渲染。这里的数据取自 weeklyProteins 数组,与上半部分的每日卡路里数据形成互补——卡路里关注总量,蛋白质关注关键营养素。两个区块之间通过 20vp 的上边距实现视觉分隔。

整个页面的数据虽然也是硬编码的,但与 dailyCaloriesweeklyProteins 数组中的数据一一对应,体现了数据的一致性。在实际应用中,这些数据可以从 meals 数组中按日期聚合计算得出。

8.2 dayPlanBuilder 每日计划卡片

@Builder dayPlanBuilder(day: string, calories: number, mealCount: number, color: string) {
  Row() {
    Column() {
      Text(day).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
      Text(calories + ' kcal').fontSize(11).fontColor('#FFE0B2').margin({ top: 2 })
    }.width(60).alignItems(HorizontalAlign.Center)

    Column() {
      Row() {
        Text('🌅 早餐').fontSize(11).fontColor('#757575')
        Text('☀️ 午餐').fontSize(11).fontColor('#757575').margin({ left: 12 })
        Text('🌙 晚餐').fontSize(11).fontColor('#757575').margin({ left: 12 })
        Text('🥜 加餐').fontSize(11).fontColor('#757575').margin({ left: 12 })
      }.width('100%')

      Row() {
        Text(mealCount + ' 餐已规划').fontSize(11).fontColor('#E65100').fontWeight(FontWeight.Bold)
      }.width('100%').margin({ top: 4 })
    }.layoutWeight(1).margin({ left: 12 }).alignItems(HorizontalAlign.Start)

    // 卡路里进度条
    Column() {
      Row() {
        Column()
          .width(calories / 2500 * 100 + '%')
          .height(6)
          .backgroundColor(color)
          .borderRadius(3)
      }.width(60).height(6).backgroundColor('#F5F5F5').borderRadius(3)
      Text(calories / 2000 * 100 + '%').fontSize(9).fontColor('#9E9E9E').margin({ top: 2 })
    }.alignItems(HorizontalAlign.Center)
  }
  .width('100%').padding(14).backgroundColor('#FFFFFF').borderRadius(12).margin({ top: 8 })
  .alignItems(VerticalAlign.Center)
}

dayPlanBuilder 接收四个参数:day(日期名称)、calories(卡路里总量)、mealCount(已规划餐数)和 color(主题色),渲染一张每日计划卡片。

卡片采用三段式横向布局。左侧是一个固定宽度 60vp 的 Column,显示日期名称(白色加粗)和卡路里数值(浅橙色)。这里的文字颜色设计很巧妙——卡片整体背景为白色,但左侧日期区域没有单独设置背景色,白色文字在白底上应该是不可见的。实际上,仔细分析代码可以发现,这些白色文字是配合视觉设计的预留——在实际渲染时,如果文字不可见,开发者可能需要调整。不过从整体设计意图来看,左侧区域可能计划使用深色背景块但当前版本尚未实现。

中间区域使用 layoutWeight(1) 占据弹性空间,展示两行信息:第一行是四种餐类的标签(早餐、午餐、晚餐、加餐,各带 Emoji),第二行是已规划餐数(如"4 餐已规划",橙色加粗)。这种"分类标签 + 状态摘要"的组合方式,让用户一眼就能了解当日餐食规划的覆盖情况。

右侧是卡路里进度条和百分比文字。进度条宽度固定 60vp,填充比例通过 calories / 2500 * 100 + '%' 计算(以 2500 kcal 为满格基准),颜色取自参数 color。进度条下方显示卡路里占目标的百分比(以 2000 为基准)。使用两个不同的基准(2500 和 2000)可能是一个设计上的考量——进度条以 2500 为满格避免条形图溢出容器,而百分比文字以 2000(每日目标)为基准更贴近用户认知。

8.3 weeklyProteinBuilder 每周蛋白质条形图

@Builder weeklyProteinBuilder(item: WeeklyProtein) {
  Row() {
    Text(item.day).fontSize(12).fontColor('#424242').width(40)
    Row() {
      Column()
        .width(item.protein / 150 * 100 + '%')
        .height(14)
        .backgroundColor('#E65100')
        .borderRadius(7)
    }.layoutWeight(1).height(14).backgroundColor('#F5F5F5').borderRadius(7)
    Text(item.protein + 'g').fontSize(11).fontColor('#757575').width(40).textAlign(TextAlign.Center)
  }.width('100%').margin({ top: 8 }).alignItems(VerticalAlign.Center)
}

weeklyProteinBuilder 渲染每周蛋白质趋势的单行条形图,接收一个 WeeklyProtein 参数。这个构建器实现了一个经典的水平条形图(horizontal bar chart)。

每行由三部分构成:左侧固定宽度 40vp 的日期标签、中间弹性宽度的条形图条、右侧固定宽度 40vp 的数值标签。条形图的填充比例通过 item.protein / 150 * 100 + '%' 计算,以 150g 为满格基准。条形高度 14vp,圆角 7vp(高度的一半,形成完全圆角的胶囊形),填充色为橙色 #E65100,轨道背景为浅灰 #F5F5F5。右侧数值居中对齐(textAlign(TextAlign.Center))。

这种"标签-条形-数值"的三列水平条形图是数据可视化中最基础也最实用的形式之一。它的优点在于实现简单(无需引入图表库)、渲染高效(纯 CSS/样式属性)、信息清晰(数值与视觉比例双重表达)。7 行数据纵向排列就构成了一个完整的周蛋白质趋势图表。


九、营养 Tab 页详解

9.1 nutritionTab 主体结构

@Builder nutritionTab() {
  Scroll() {
    Column() {
      // 每日卡路里柱状图
      Row() {
        Text('📊 每日卡路里摄入').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#212121')
      }.width('100%').margin({ bottom: 8 })

      Column() {
        this.dailyCalorieBuilder(this.dailyCalories[0])
        // ... dailyCalories[1] 到 dailyCalories[6] ...
      }.width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(14)

      // 宏量营养素分布
      Row() {
        Text('🥗 宏量营养素目标').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#212121')
      }.width('100%').margin({ top: 20, bottom: 8 })

      Column() {
        this.macroTargetBuilder(this.macroTargets[0])
        // ... macroTargets[1] 到 macroTargets[3] ...
      }.width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(14)

      // 营养评分卡
      Row() {
        Text('🏆 营养评分').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#212121')
      }.width('100%').margin({ top: 20, bottom: 8 })

      Column() {
        Row() {
          Stack() {
            Text('85').fontSize(32).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
          }.width(80).height(80).backgroundColor('#E65100').borderRadius(40)
          .border({ width: 4, color: '#FFB300' }).align(Alignment.Center)

          Column() {
            Text('优秀!').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
            Text('你的饮食营养均衡').fontSize(12).fontColor('#757575').margin({ top: 4 })
            Row() {
              Text('建议增加蛋白质摄入').fontSize(11).fontColor('#E65100')
            }.margin({ top: 6 })
          }.layoutWeight(1).margin({ left: 16 }).alignItems(HorizontalAlign.Start)
        }.width('100%').alignItems(VerticalAlign.Center)
      }.width('100%').padding(20).backgroundColor('#FFFFFF').borderRadius(14)

      // 餐类卡路里分布
      Row() {
        Text('🍽️ 餐类卡路里分布').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#212121')
      }.width('100%').margin({ top: 20, bottom: 8 })

      Column() {
        this.mealTypeCalorieBuilder('早餐', 500, '#FFB300')
        this.mealTypeCalorieBuilder('午餐', 700, '#FF8F00')
        this.mealTypeCalorieBuilder('晚餐', 600, '#E65100')
        this.mealTypeCalorieBuilder('加餐', 200, '#FF6F00')
      }.width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(14)
    }.padding({ left: 16, right: 16, top: 12, bottom: 16 })
  }.layoutWeight(1).scrollBar(BarState.Off)
}

nutritionTab 是营养数据可视化页面,是信息量最大的 Tab 页,包含四个数据可视化区块:每日卡路里柱状图、宏量营养素目标、营养评分卡和餐类卡路里分布。

页面整体使用 Scroll 包裹,四个区块以卡片形式纵向排列,区块之间通过 20vp 的上边距分隔。每个区块的结构一致:一个标题行(带 Emoji 和加粗文字)+ 一个白色圆角卡片容器(padding 16vp,圆角 14vp)。

第三个区块"营养评分卡"的设计尤为精巧。它使用了一个圆形评分徽章——Stack 容器尺寸 80x80vp,背景橙色 #E65100,圆角 40vp(正好为宽度的一半,形成正圆),外加 4vp 宽的黄色 #FFB300 边框,内部居中显示"85"的白色大字号(32vp)数字。这种圆形徽章配合边框的设计,营造了类似"奖牌"或"勋章"的视觉效果,对用户形成正向激励。徽章右侧是评价文字(“优秀!”、描述语、建议),形成"图标+文字"的经典信息展示组合。

第四个区块"餐类卡路里分布"直接传入了硬编码的参数值(早餐 500、午餐 700、晚餐 600、加餐 200),这些数值与 mealTypeConfigs 中的 targetCalories 一致,展示了各餐类的目标卡路里占比。以 700(午餐最大值)为满格基准,通过 calories / 700 * 100 + '%' 计算各条形图的填充比例。

9.2 dailyCalorieBuilder 每日卡路里条形图

@Builder dailyCalorieBuilder(item: DailyCalorie) {
  Row() {
    Text(item.day).fontSize(12).fontColor('#424242').width(40)
    Column() {
      Row() {
        Column()
          .width(item.calories / 2500 * 100 + '%')
          .height(16)
          .backgroundColor(item.calories > item.goal ? '#C62828' : '#E65100')
          .borderRadius(8)
      }.width('100%').height(16).backgroundColor('#F5F5F5').borderRadius(8)
    }.layoutWeight(1)
    Text(item.calories + '').fontSize(11).fontColor(item.calories > item.goal ? '#C62828' : '#757575').width(50).textAlign(TextAlign.Center)
  }.width('100%').margin({ top: 8 }).alignItems(VerticalAlign.Center)
}

dailyCalorieBuilder 渲染每日卡路里摄入的单行条形图,结构与 weeklyProteinBuilder 类似,但增加了一个关键的视觉特性——超量预警颜色变化。

条形图的填充颜色通过三元表达式判断:item.calories > item.goal ? '#C62828' : '#E65100'。当实际摄入超过目标值时,条形图变为红色 #C62828(深红),否则为橙色 #E65100。同样,右侧数值的文字颜色也随条件变化。这种条件化的颜色设计是数据可视化中"编码语义"的经典应用——颜色不仅仅是装饰,更是信息的载体,红色直观地传达了"超量"这一警示信息。

条形图高度 16vp(比蛋白质条形图的 14vp 稍高),圆角 8vp,填充比例以 2500 kcal 为满格基准。左右两侧标签的宽度分别为 40vp 和 50vp,确保数值列对齐。

9.3 macroTargetBuilder 宏量营养素目标进度

@Builder macroTargetBuilder(item: MacroTarget) {
  Column() {
    Row() {
      Text(item.name).fontSize(13).fontColor('#424242').fontWeight(FontWeight.Bold)
      Row().layoutWeight(1)
      Text(item.current + '/' + item.target + item.unit).fontSize(12).fontColor('#757575')
    }.width('100%')
    Row() {
      Column()
        .width(item.percentage + '%')
        .height(10)
        .backgroundColor(item.color)
        .borderRadius(5)
    }.width('100%').height(10).backgroundColor('#F5F5F5').borderRadius(5).margin({ top: 6 })
    Text(item.percentage + '% 目标完成').fontSize(10).fontColor('#9E9E9E').margin({ top: 4 })
  }.width('100%').margin({ top: 12 })
}

macroTargetBuilder 渲染宏量营养素的目标达成进度条,接收一个 MacroTarget 参数。每个营养素占用三行垂直空间:标题行、进度条行和百分比说明行。

标题行左侧是营养素名称(加粗深灰),右侧是"当前值/目标值+单位"的文本(如"95/120g")。中间的 Row().layoutWeight(1) 将右侧数值推到最右边。进度条高度 10vp,圆角 5vp,填充宽度直接取自数据中的 percentage 字段(如 79%),颜色取自 item.color。这里充分体现了前面提到的"数据驱动样式"设计——不同营养素使用不同的颜色(蛋白质深橙、碳水黄色、脂肪中橙、纤维橙红),颜色直接来自数据结构,渲染逻辑无需判断。

进度条下方显示"79% 目标完成"的辅助说明文字,灰色小字号,为用户提供精确的百分比数值。整个组件的设计层次分明:标题提供上下文,进度条提供视觉比例,百分比提供精确数值,三者互补形成完整的信息表达。

9.4 mealTypeCalorieBuilder 餐类卡路里分布

@Builder mealTypeCalorieBuilder(type: string, calories: number, color: string) {
  Row() {
    Text(type).fontSize(12).fontColor('#424242').width(50)
    Row() {
      Column()
        .width(calories / 700 * 100 + '%')
        .height(12)
        .backgroundColor(color)
        .borderRadius(6)
    }.layoutWeight(1).height(12).backgroundColor('#F5F5F5').borderRadius(6)
    Text(calories + 'kcal').fontSize(11).fontColor('#757575').width(56).textAlign(TextAlign.Center)
  }.width('100%').margin({ top: 8 }).alignItems(VerticalAlign.Center)
}

mealTypeCalorieBuilder 渲染餐类卡路里分布的单行条形图,接收三个参数:餐类名称、卡路里值和颜色。结构与前两个条形图构建器一致,但参数全部由调用处传入,使得该构建器更加通用化。

条形图以 700 kcal(午餐的目标值,也是四个餐类中的最大值)为满格基准,高度 12vp,圆角 6vp。四种餐类使用了四种不同的橙色系颜色(早餐黄、午餐中橙、晚餐深橙、加餐橙红),在视觉上形成从浅到深的渐变效果,呼应了各餐类在全天卡路里中的占比大小。

这个构建器的设计与 weeklyProteinBuilderdailyCalorieBuilder 高度相似,三者共享了"标签-条形-数值"的布局模板。在实际工程优化中,可以考虑将它们合并为一个通用的条形图构建器,通过参数控制颜色逻辑和格式。但在当前版本中,保持独立的构建器有助于代码的可读性和可维护性。


十、我的 Tab 页详解

10.1 mineTab 主体结构

@Builder mineTab() {
  Scroll() {
    Column() {
      // 用户信息卡片
      Row() {
        Stack() {
          Text('💪').fontSize(40)
        }.width(80).height(80).backgroundColor('#E65100').borderRadius(40).align(Alignment.Center)

        Column() {
          Text('健身达人').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
          Text('已坚持 128 天').fontSize(12).fontColor('#FFE0B2').margin({ top: 4 })
          Row() {
            Text('PRO会员').fontSize(10).fontColor('#FFB300')
              .backgroundColor('#BF360C').padding({ left: 8, right: 8, top: 3, bottom: 3 })
              .borderRadius(8)
          }.margin({ top: 6 })
        }.layoutWeight(1).margin({ left: 16 }).alignItems(HorizontalAlign.Start)
      }.width('100%').padding(20).backgroundColor('#E65100').borderRadius(16)

      // 统计数据
      Row() {
        Column() {
          Text('26').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#E65100')
          Text('餐食记录').fontSize(11).fontColor('#757575').margin({ top: 2 })
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)
        // ... 坚持天数、营养评分、日均卡路里 ...
      }.width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(14).margin({ top: 12 })

      // 身体数据
      Row() {
        Text('📏 身体数据').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#212121')
      }.width('100%').margin({ top: 20, bottom: 8 })

      Column() {
        Row() {
          Text('体重').fontSize(13).fontColor('#424242')
          Row().layoutWeight(1)
          Text('68.5 kg').fontSize(13).fontColor('#E65100').fontWeight(FontWeight.Bold)
          Text(' ↓ 2.5kg').fontSize(11).fontColor('#33691E').margin({ left: 8 })
        }.width('100%').padding({ left: 16, right: 16, top: 12, bottom: 12 })
        // ... 身高、BMI、体脂率 ...
      }.width('100%').backgroundColor('#FFFFFF').borderRadius(14)

      // 功能列表
      Column() {
        this.menuItemBuilder('🎯', '目标设置', '卡路里·营养素目标')
        this.menuItemBuilder('📋', '饮食历史', '查看历史记录')
        this.menuItemBuilder('💧', '饮水记录', '今日 1.5L / 2L')
        this.menuItemBuilder('🏃', '运动记录', '今日消耗 320kcal')
        this.menuItemBuilder('🔔', '餐食提醒', '定时提醒用餐')
        this.menuItemBuilder('ℹ️', '关于', '版本 v3.1.0')
      }.width('100%').backgroundColor('#FFFFFF').borderRadius(14).margin({ top: 12 })
    }.padding({ left: 16, right: 16, top: 16, bottom: 16 })
  }.layoutWeight(1).scrollBar(BarState.Off)
}

mineTab 是"我的"个人中心页面,是用户信息与设置功能的聚合页。页面包含四个区块:用户信息卡片、统计数据栏、身体数据卡片和功能菜单列表。

用户信息卡片是页面的视觉焦点,背景橙色 #E65100,圆角 16vp,padding 20vp。左侧是一个 80x80vp 的圆形头像区域(Stack + borderRadius(40) 形成正圆),背景也是橙色但比卡片背景稍深,内部居中显示"💪" Emoji(40vp)。右侧是用户信息:昵称"健身达人"(白色加粗 18vp)、坚持天数"已坚持 128 天"(浅橙 12vp)和 PRO 会员标签(黄色文字 + 深红 #BF360C 背景的胶囊标签)。PRO 标签的设计很巧妙——使用与主色形成对比的深红色背景,黄色文字在深红背景上极为醒目,起到了会员标识的"突出展示"效果。

统计数据栏是一个四列等分布局,分别展示餐食记录数(26)、坚持天数(128)、营养评分(85)和日均卡路里(1,820)。每个统计项的数值使用 20vp 加粗字体,数字颜色在橙色和黄色之间交替,下方是灰色描述标签。这个卡片以白色为背景,圆角 14vp,与上方的橙色用户卡片形成对比。

身体数据卡片是一个列表式的数据展示,包含体重(68.5 kg,↓ 2.5kg)、身高(175 cm)、BMI(22.4,正常)、体脂率(15.2%,↓ 3.8%)四项。每行采用"标签-弹性占位-数值-变化标识"的布局,数值使用橙色加粗,变化标识(如"↓ 2.5kg")使用绿色 #33691E 表示正向变化。这种颜色语义编码——橙色表示关键数据、绿色表示改善趋势——在健康类应用中是通用的设计语言。

功能菜单列表通过 6 次 menuItemBuilder 调用渲染,包含目标设置、饮食历史、饮水记录、运动记录、餐食提醒和关于六个功能入口。每个菜单项的右侧还带有动态描述(如"今日 1.5L / 2L"、“今日消耗 320kcal”),让用户无需进入详情即可了解当前状态。

10.2 menuItemBuilder 菜单项构建

@Builder menuItemBuilder(icon: string, title: string, desc: string) {
  Row() {
    Text(icon).fontSize(20).width(32)
    Text(title).fontSize(14).fontColor('#212121').layoutWeight(1)
    Text(desc).fontSize(12).fontColor('#9E9E9E')
    Text('›').fontSize(18).fontColor('#BDBDBD').margin({ left: 8 })
  }.width('100%').padding({ left: 16, right: 16, top: 14, bottom: 14 })
  .alignItems(VerticalAlign.Center)
}

menuItemBuilder 是一个高度通用的列表菜单项构建器,接收三个参数:图标(Emoji)、标题和描述。它渲染一个标准的设置列表行——左侧图标(20vp,固定宽度 32vp)、中间标题(14vp 深灰,弹性宽度)、右侧描述(12vp 浅灰)和最右侧的箭头指示符"›"(18vp,浅灰色 #BDBDBD)。

这种"图标+标题+描述+箭头"的行布局是移动应用设置页面的通用模式,几乎所有主流应用的个人中心都采用了这一设计。箭头"›"是一个 Unicode 字符,用于暗示该行可点击进入下一级页面。虽然本应用中菜单项尚未绑定 onClick 事件,但视觉上已经为用户建立了"可点击进入"的心理预期。

行高通过 padding({ top: 14, bottom: 14 }) 控制,确保了足够的触摸目标面积。alignItems(VerticalAlign.Center) 使所有元素垂直居中对齐。整个构建器的设计简洁、通用,是列表项复用的优秀范例。


十一、弹窗系统详解

11.1 modalBg 背景遮罩构建器

@Builder modalBg(onClose: () => void) {
  Column().width('100%').height('100%')
    .backgroundColor('rgba(0,0,0,0.5)').onClick(onClose)
}

modalBg 是所有弹窗共用的背景遮罩构建器,接收一个 onClose 回调函数作为参数。它渲染一个全屏透明黑色遮罩层(rgba(0,0,0,0.5),即 50% 不透明度的黑色),点击遮罩时触发 onClose 回调关闭弹窗。

这个构建器体现了 ArkTS 中 @Builder 函数支持接收函数类型参数(回调函数)的特性。通过将关闭逻辑参数化,modalBg 可以被不同弹窗复用——新增弹窗调用 this.modalBg(() => { this.showAddModal = false }),编辑弹窗调用 this.modalBg(() => { this.showEditModal = false }),各自设置对应的状态变量为 false。这种"函数参数注入行为"的设计模式,是构建可复用组件的核心技巧。

半透明遮罩是模态弹窗的标准视觉元素,它的作用有两个:一是将用户的注意力聚焦到弹窗内容上,二是通过点击遮罩关闭弹窗提供"点击外部退出"的交互快捷方式。rgba 颜色格式在 ArkTS 中的使用表明了其对标准 CSS 颜色语法的兼容性。

11.2 addModal 新增餐食弹窗

@Builder addModal() {
  Column() {
    this.modalBg(() => { this.showAddModal = false })
    Column() {
      Row() {
        Text('➕ 新增餐食').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
        Row().layoutWeight(1)
        Text('✕').fontSize(18).fontColor('#999999')
          .onClick(() => { this.showAddModal = false })
      }.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
      Divider().color('#F0F0F0')
      Scroll() {
        Column() {
          this.formFieldBuilder('餐食名称', '请输入餐食名称')
          this.formFieldBuilder('餐食类型', '早餐/午餐/晚餐/加餐')
          this.formFieldBuilder('卡路里(kcal)', '请输入数字')
          // ... 蛋白质、碳水、脂肪、纤维、日期、时间、Emoji、食谱、食材 ...
        }.padding({ left: 20, right: 20 })
      }.layoutWeight(1)
      Row() {
        Text('取消').fontSize(14).fontColor('#888888')
          .backgroundColor('#F5F5F5').borderRadius(20)
          .padding({ left: 24, right: 24, top: 10, bottom: 10 })
          .onClick(() => { this.showAddModal = false })
        Text('保存').fontSize(14).fontColor('#FFFFFF')
          .backgroundColor('#E65100').borderRadius(20)
          .padding({ left: 24, right: 24, top: 10, bottom: 10 })
          .margin({ left: 12 })
          .onClick(() => { this.showAddModal = false })
      }.width('100%').justifyContent(FlexAlign.Center)
      .padding({ left: 20, right: 20, top: 16, bottom: 16 })
    }
    .width('90%').constraintSize({ maxHeight: '80%' }).backgroundColor('#FFFFFF').borderRadius(18)
    .position({ x: '5%', y: '8%' }).shadow({ radius: 20, color: '#33000000', offsetY: 4 })
  }
  .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}

addModal 是新增餐食的模态弹窗构建器,也是应用中结构最复杂的弹窗。它的整体结构是一个全屏 Column(覆盖整个屏幕),内部包含两层:底层是 modalBg 遮罩,上层是白色弹窗容器。

弹窗容器使用 width('90%') 占据屏幕宽度的 90%,constraintSize({ maxHeight: '80%' }) 限制最大高度为屏幕的 80%,position({ x: '5%', y: '8%' }) 将弹窗定位在距左边 5%、距顶部 8% 的位置,使其在屏幕中偏上居中显示。圆角 18vp,配合 shadow(半径 20、半透明黑色 #33000000、向下偏移 4vp)形成浮起效果。zIndex(999) 确保弹窗层级最高,覆盖在所有其他元素之上。

弹窗内容从上到下分为三部分:标题栏(“➕ 新增餐食” + 关闭按钮)、表单区域和操作按钮栏。标题栏右侧的"✕"关闭按钮通过 onClick 设置 showAddModal = false 关闭弹窗。Divider 组件作为标题栏与表单之间的分隔线,颜色为浅灰 #F0F0F0

表单区域使用 Scroll 包裹,支持内容超出弹窗高度时滚动。内部通过 12 次 formFieldBuilder 调用生成表单字段,涵盖餐食名称、类型、卡路里、四种营养素、日期、时间、Emoji、食谱和食材。每个字段使用 formFieldBuilder 统一渲染,传入标签名和 placeholder 提示文字。

操作按钮栏包含"取消"和"保存"两个按钮,水平居中排列(justifyContent(FlexAlign.Center))。取消按钮使用灰色背景 #F5F5F5 和灰色文字,保存按钮使用橙色背景 #E65100 和白色文字——主次按钮的颜色对比引导用户优先选择保存。两个按钮均通过 onClick 关闭弹窗(当前版本中保存按钮仅关闭弹窗,未实际添加数据)。

11.3 editModal 编辑餐食弹窗

@Builder editModal() {
  Column() {
    this.modalBg(() => { this.showEditModal = false })
    Column() {
      Row() {
        Text('✏️ 编辑餐食').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
        Row().layoutWeight(1)
        Text('✕').fontSize(18).fontColor('#999999')
          .onClick(() => { this.showEditModal = false })
      }.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
      Divider().color('#F0F0F0')
      Scroll() {
        Column() {
          this.editFieldBuilder('餐食名称', this.meals[this.editIndex].name)
          this.editFieldBuilder('餐食类型', this.meals[this.editIndex].mealType)
          this.editFieldBuilder('卡路里', this.meals[this.editIndex].calories.toString())
          this.editFieldBuilder('蛋白质(g)', this.meals[this.editIndex].protein.toString())
          this.editFieldBuilder('碳水(g)', this.meals[this.editIndex].carbs.toString())
          this.editFieldBuilder('脂肪(g)', this.meals[this.editIndex].fat.toString())
          this.editFieldBuilder('纤维(g)', this.meals[this.editIndex].fiber.toString())
          this.editFieldBuilder('日期', this.meals[this.editIndex].date)
          this.editFieldBuilder('时间', this.meals[this.editIndex].time)
        }.padding({ left: 20, right: 20 })
      }.layoutWeight(1)
      Row() {
        Text('取消').fontSize(14).fontColor('#888888')
          .backgroundColor('#F5F5F5').borderRadius(20)
          .padding({ left: 24, right: 24, top: 10, bottom: 10 })
          .onClick(() => { this.showEditModal = false })
        Text('保存').fontSize(14).fontColor('#FFFFFF')
          .backgroundColor('#FFB300').borderRadius(20)
          .padding({ left: 24, right: 24, top: 10, bottom: 10 })
          .margin({ left: 12 })
          .onClick(() => { this.showEditModal = false })
      }.width('100%').justifyContent(FlexAlign.Center)
      .padding({ left: 20, right: 20, top: 16, bottom: 16 })
    }
    .width('90%').constraintSize({ maxHeight: '80%' }).backgroundColor('#FFFFFF').borderRadius(18)
    .position({ x: '5%', y: '8%' }).shadow({ radius: 20, color: '#33000000', offsetY: 4 })
  }
  .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}

editModal 的结构与 addModal 几乎完全一致,区别在于两点:标题改为"✏️ 编辑餐食";表单字段使用 editFieldBuilder 而非 formFieldBuilder,且每个字段的初始值取自 this.meals[this.editIndex] 的对应属性。

editIndex 在用户点击餐食卡片的编辑按钮时被设置为 meal.id - 1,因此 this.meals[this.editIndex] 能够精确获取到当前编辑的餐食数据。字段值通过 .toString() 方法将数字类型转换为字符串,以适配 TextInput 组件的 text 参数。

保存按钮的颜色使用了黄色 #FFB300 而非新增弹窗的橙色 #E65100,这是一个微妙但有意的设计——通过按钮颜色的细微差异,帮助用户在视觉上区分"新增"和"编辑"两种操作场景。表单字段比新增弹窗少了 Emoji、食谱和食材三项,因为编辑场景下通常不需要修改这些属性,体现了操作场景的差异化设计。

11.4 deleteModal 删除确认弹窗

@Builder deleteModal() {
  Column() {
    this.modalBg(() => { this.showDeleteModal = false })
    Column() {
      Text('🗑️').fontSize(40).margin({ top: 24 })
      Text('确认删除?').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
        .margin({ top: 12 })
      Text('删除后不可恢复,确定要删除「' + this.meals[this.deleteIndex].name + '」吗?')
        .fontSize(13).fontColor('#757575').textAlign(TextAlign.Center)
        .margin({ top: 8, left: 24, right: 24 })

      Row() {
        Text('取消').fontSize(14).fontColor('#666666')
          .backgroundColor('#F5F5F5').borderRadius(20)
          .padding({ left: 32, right: 32, top: 10, bottom: 10 })
          .onClick(() => { this.showDeleteModal = false })
        Text('删除').fontSize(14).fontColor('#FFFFFF')
          .backgroundColor('#C62828').borderRadius(20)
          .padding({ left: 32, right: 32, top: 10, bottom: 10 })
          .margin({ left: 12 })
          .onClick(() => { this.showDeleteModal = false })
      }.margin({ top: 24, bottom: 24 }).justifyContent(FlexAlign.Center)
    }.width('80%').backgroundColor('#FFFFFF').borderRadius(18)
    .alignItems(HorizontalAlign.Center)
    .shadow({ radius: 20, color: '#33000000', offsetY: 4 })
  }.width('100%').height('100%').justifyContent(FlexAlign.Center)
  .position({ x: 0, y: 0 }).zIndex(999)
}

deleteModal 是删除确认弹窗,与前两个弹窗在视觉风格上有明显差异——它更小、更居中,是一个标准的确认对话框(Confirmation Dialog)。

弹窗宽度为屏幕的 80%,内容垂直居中对齐。顶部是一个 40vp 的垃圾桶 Emoji,下方是"确认删除?"的标题(18vp 加粗)和描述文字。描述文字动态插入了餐食名称:'删除后不可恢复,确定要删除「' + this.meals[this.deleteIndex].name + '」吗?',这种在提示信息中包含具体对象名称的做法,能够帮助用户确认操作对象,避免误删。

按钮区域使用红色 #C62828 背景的"删除"按钮和灰色背景的"取消"按钮。红色作为危险操作的警示色,是 UI 设计中的通用规范——它与前面卡路里超量预警使用的红色一致,在应用中形成了统一的"警示=红色"颜色语义体系。按钮的 padding 比前两个弹窗更大(左右 32vp vs 24vp),使按钮更宽,因为删除确认是一个重要的决策点,更大的触摸目标有助于防止误触。

整个弹窗通过外层 ColumnjustifyContent(FlexAlign.Center) 实现垂直居中,与新增/编辑弹窗的顶部偏移定位(position y: 8%)形成对比——确认对话框通常居中显示以突出其重要性。


十二、表单字段构建器

12.1 formFieldBuilder 新增表单字段

@Builder formFieldBuilder(label: string, placeholder: string) {
  Column() {
    Text(label).fontSize(12).fontColor('#757575').margin({ bottom: 6 })
    TextInput({ placeholder: placeholder })
      .fontSize(14).height(40).borderRadius(10)
      .backgroundColor('#F5F5F5')
  }.width('100%').margin({ top: 12 }).alignItems(HorizontalAlign.Start)
}

formFieldBuilder 用于渲染新增弹窗中的表单字段,接收标签名和 placeholder 提示文字两个参数。每个字段由一个标签 Text(12vp 灰色)和一个 TextInput 输入框组成,输入框高度 40vp,圆角 10vp,背景浅灰 #F5F5F5

TextInput 是 ArkTS 提供的文本输入组件,通过 placeholder 参数设置占位提示文字。当输入框为空时,placeholder 文字以浅色显示在输入框内,引导用户输入。这种"标签在外、输入框在内"的表单布局是移动端表单设计的经典模式,相比浮动标签或行内标签,它在小屏幕上具有更好的可读性和操作便利性。

整个字段容器使用 width('100%') 占满父容器宽度,margin({ top: 12 }) 在字段之间添加垂直间距。alignItems(HorizontalAlign.Start) 确保标签和输入框左对齐。

12.2 editFieldBuilder 编辑表单字段

@Builder editFieldBuilder(label: string, value: string) {
  Column() {
    Text(label).fontSize(12).fontColor('#757575').margin({ bottom: 6 })
    TextInput({ text: value })
      .fontSize(14).height(40).borderRadius(10)
      .backgroundColor('#F5F5F5')
  }.width('100%').margin({ top: 12 }).alignItems(HorizontalAlign.Start)
}

editFieldBuilderformFieldBuilder 结构完全一致,区别仅在于 TextInput 的参数——使用 text 而非 placeholdertext 参数设置输入框的初始值,使得编辑弹窗在打开时就能显示当前餐食的已有数据,用户可以在原有数据基础上进行修改。这种"预填充"的交互模式是编辑场景的标准做法,显著提升了用户体验。

两个构建器虽然结构相似但保持独立,是因为它们的语义不同——一个是"新建空表单",一个是"编辑已有数据"。在未来的功能迭代中,它们可能需要不同的验证逻辑、默认值或字段类型(如数字输入框 vs 文本输入框),保持独立为后续扩展提供了灵活性。


十三、各模块关键技术点横向对比总结

下面通过表格对本应用四个 Tab 页及弹窗系统的关键技术点进行横向对比总结:

对比维度 餐食 Tab(Meals) 周计划 Tab(Weekly) 营养 Tab(Nutrition) 我的 Tab(Mine) 弹窗系统(Modals)
核心功能 餐食列表展示、餐类筛选、增删改入口 一周餐食规划概览、蛋白质趋势图 卡路里/营养素可视化、评分展示 用户信息、身体数据、功能菜单入口 新增/编辑/删除餐食的模态交互
状态管理要点 selectedMealType 筛选状态、showAddModal 触发新增 纯展示型,无独立状态变量 纯展示型,依赖预置数据数组 纯展示型,静态用户数据 showAddModal/showEditModal/showDeleteModal 显隐控制 + editIndex/deleteIndex 索引定位
关键组件 Scroll(横向筛选+纵向列表)、ForEach(筛选标签)、Stack(图标容器) Scroll(纵向滚动)、自定义进度条(Column 宽度百分比) ScrollStack(圆形评分徽章)、多层进度条 ScrollStack(圆形头像)、Row(统计栏) Column(全屏覆盖层)、TextInput(表单输入)、Divider(分隔线)、zIndex 层级控制
数据驱动方式 遍历 meals 数组 + mealTypeFilters 筛选 遍历 dailyCalories + weeklyProteins + 参数化调用 遍历 dailyCalories + macroTargets + 参数化调用 硬编码用户数据 + 参数化 menuItemBuilder 读取 meals[editIndex] / meals[deleteIndex] 动态填充表单
设计模式 数据驱动渲染 + Builder 组合复用 参数化 Builder 模式(dayPlanBuilder 传参) 数据驱动样式(item.color 直接映射颜色) 列表项模板复用(menuItemBuilder 通用化) 回调注入模式(modalBg(onClose) 函数参数) + 遮罩-内容分层模式
可视化形式 营养素汇总卡片 + 卡片列表 + 蛋白质迷你进度条 每日卡路里进度条 + 水平条形图(蛋白质趋势) 水平条形图(卡路里/餐类分布)+ 进度条(营养素目标)+ 圆形评分徽章 统计数字栏 + 键值对列表行 表单输入字段 + 按钮组
颜色语义 橙色=主色/选中、浅橙=未选中、绿=完成 颜色按天变化暗示热量高低 红=超量警示、橙=正常、各营养素独立配色 橙=关键数据、绿=改善趋势、深红=会员标识 红=危险操作(删除)、橙=主操作(保存)、灰=次要操作(取消)
交互特性 横向滚动筛选 + 点击编辑/删除 + 添加入口 纯展示滚动 纯展示滚动 菜单项箭头暗示可进入(未绑定事件) 点击遮罩关闭 + 表单滚动 + 双按钮操作
复用性设计 mealItemBuilder 复用 26 次 dayPlanBuilder 复用 7 次 + weeklyProteinBuilder 复用 7 次 dailyCalorieBuilder 复用 7 次 + macroTargetBuilder 复用 4 次 + mealTypeCalorieBuilder 复用 4 次 menuItemBuilder 复用 6 次 modalBg 复用 3 次 + formFieldBuilder/editFieldBuilder 各复用 9~12 次

安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// 健身饮食计划 — 橙色 #E65100 | 黄色 #FFB300 | 背景 #FFF3E0

interface MealItem {
  id: number
  name: string
  mealType: string
  calories: number
  protein: number
  carbs: number
  fat: number
  fiber: number
  date: string
  time: string
  isCompleted: boolean
  emoji: string
  recipe: string[]
  ingredients: string[]
}

interface DailyCalorie {
  day: string
  calories: number
  goal: number
}

interface MacroTarget {
  name: string
  current: number
  target: number
  unit: string
  color: string
  percentage: number
}

interface WeeklyProtein {
  day: string
  protein: number
}

interface TabConfig {
  index: number
  label: string
  icon: string
}

interface MealTypeConfig {
  type: string
  icon: string
  color: string
  targetCalories: number
}

@Observed
class MealModel {
  id: number
  name: string
  mealType: string
  calories: number
  protein: number
  carbs: number
  fat: number
  fiber: number
  date: string
  time: string
  isCompleted: boolean
  emoji: string
  recipe: string[]
  ingredients: string[]

  constructor(meal: MealItem) {
    this.id = meal.id
    this.name = meal.name
    this.mealType = meal.mealType
    this.calories = meal.calories
    this.protein = meal.protein
    this.carbs = meal.carbs
    this.fat = meal.fat
    this.fiber = meal.fiber
    this.date = meal.date
    this.time = meal.time
    this.isCompleted = meal.isCompleted
    this.emoji = meal.emoji
    this.recipe = meal.recipe
    this.ingredients = meal.ingredients
  }
}

enum MealTab {
  Meals,
  Weekly,
  Nutrition,
  Mine
}

@Entry
@Component
struct MealPlannerApp {
  @State currentTab: number = MealTab.Meals
  @State showAddModal: boolean = false
  @State showEditModal: boolean = false
  @State showDeleteModal: boolean = false
  @State editIndex: number = 0
  @State deleteIndex: number = 0
  @State selectedMealType: string = '全部'
  @State dailyCalorieGoal: number = 2000
  @State todayCalories: number = 1450
  @State nutritionScore: number = 85

  private meals: MealModel[] = [
    new MealModel({ id: 1, name: '燕麦牛奶杯', mealType: '早餐', calories: 320, protein: 18, carbs: 45, fat: 8, fiber: 6, date: '2026-07-25', time: '07:30', isCompleted: true, emoji: '🥣', recipe: ['燕麦50g', '牛奶200ml', '蓝莓30g'], ingredients: ['燕麦', '牛奶', '蓝莓', '蜂蜜'] }),
    new MealModel({ id: 2, name: '全麦三明治', mealType: '早餐', calories: 380, protein: 22, carbs: 48, fat: 12, fiber: 8, date: '2026-07-24', time: '08:00', isCompleted: true, emoji: '🥪', recipe: ['全麦面包', '鸡蛋', '生菜'], ingredients: ['全麦面包', '鸡蛋', '生菜', '番茄'] }),
    new MealModel({ id: 3, name: '鸡胸肉沙拉', mealType: '午餐', calories: 450, protein: 35, carbs: 25, fat: 15, fiber: 10, date: '2026-07-25', time: '12:00', isCompleted: true, emoji: '🥗', recipe: ['鸡胸肉150g', '生菜', '番茄'], ingredients: ['鸡胸肉', '生菜', '番茄', '橄榄油'] }),
    new MealModel({ id: 4, name: '牛肉藜麦饭', mealType: '午餐', calories: 520, protein: 38, carbs: 55, fat: 14, fiber: 8, date: '2026-07-24', time: '12:30', isCompleted: true, emoji: '🍚', recipe: ['牛肉120g', '藜麦80g'], ingredients: ['牛肉', '藜麦', '西兰花', '胡萝卜'] }),
    new MealModel({ id: 5, name: '三文鱼寿司', mealType: '午餐', calories: 480, protein: 28, carbs: 60, fat: 12, fiber: 4, date: '2026-07-23', time: '12:00', isCompleted: true, emoji: '🍣', recipe: ['三文鱼', '米饭', '海苔'], ingredients: ['三文鱼', '米饭', '海苔', '黄瓜'] }),
    new MealModel({ id: 6, name: '蛋白奶昔', mealType: '加餐', calories: 180, protein: 25, carbs: 15, fat: 3, fiber: 2, date: '2026-07-25', time: '15:30', isCompleted: true, emoji: '🥤', recipe: ['蛋白粉', '香蕉', '牛奶'], ingredients: ['蛋白粉', '香蕉', '牛奶'] }),
    new MealModel({ id: 7, name: '坚果混合', mealType: '加餐', calories: 220, protein: 8, carbs: 12, fat: 18, fiber: 4, date: '2026-07-24', time: '16:00', isCompleted: true, emoji: '🥜', recipe: ['杏仁20g', '核桃15g'], ingredients: ['杏仁', '核桃', '腰果'] }),
    new MealModel({ id: 8, name: '希腊酸奶', mealType: '加餐', calories: 150, protein: 15, carbs: 12, fat: 5, fiber: 0, date: '2026-07-23', time: '15:30', isCompleted: true, emoji: '🍶', recipe: ['酸奶150g', '蜂蜜'], ingredients: ['希腊酸奶', '蜂蜜', '蓝莓'] }),
    new MealModel({ id: 9, name: '香煎鸡胸肉', mealType: '晚餐', calories: 380, protein: 42, carbs: 8, fat: 18, fiber: 2, date: '2026-07-25', time: '18:30', isCompleted: false, emoji: '🍗', recipe: ['鸡胸肉200g', '黑胡椒'], ingredients: ['鸡胸肉', '黑胡椒', '橄榄油', '蒜'] }),
    new MealModel({ id: 10, name: '清蒸鲈鱼', mealType: '晚餐', calories: 320, protein: 36, carbs: 5, fat: 15, fiber: 1, date: '2026-07-24', time: '19:00', isCompleted: true, emoji: '🐟', recipe: ['鲈鱼一条', '姜丝'], ingredients: ['鲈鱼', '姜', '葱', '蒸鱼豉油'] }),
    new MealModel({ id: 11, name: '番茄意面', mealType: '晚餐', calories: 420, protein: 14, carbs: 68, fat: 10, fiber: 6, date: '2026-07-23', time: '18:30', isCompleted: true, emoji: '🍝', recipe: ['意面100g', '番茄酱'], ingredients: ['意面', '番茄', '牛肉末', '洋葱'] }),
    new MealModel({ id: 12, name: '牛油果吐司', mealType: '早餐', calories: 350, protein: 12, carbs: 35, fat: 18, fiber: 8, date: '2026-07-23', time: '08:00', isCompleted: true, emoji: '🥑', recipe: ['全麦吐司', '牛油果'], ingredients: ['全麦吐司', '牛油果', '鸡蛋', '盐'] }),
    new MealModel({ id: 13, name: '蔬菜蛋饼', mealType: '早餐', calories: 280, protein: 18, carbs: 22, fat: 12, fiber: 4, date: '2026-07-22', time: '07:30', isCompleted: true, emoji: '🥞', recipe: ['鸡蛋2个', '蔬菜'], ingredients: ['鸡蛋', '菠菜', '胡萝卜', '面粉'] }),
    new MealModel({ id: 14, name: '豆腐炒饭', mealType: '午餐', calories: 400, protein: 20, carbs: 52, fat: 12, fiber: 6, date: '2026-07-22', time: '12:00', isCompleted: true, emoji: '🍱', recipe: ['豆腐100g', '米饭'], ingredients: ['豆腐', '米饭', '豌豆', '玉米'] }),
    new MealModel({ id: 15, name: '虾仁西兰花', mealType: '晚餐', calories: 280, protein: 30, carbs: 10, fat: 12, fiber: 5, date: '2026-07-22', time: '18:30', isCompleted: true, emoji: '🦐', recipe: ['虾仁150g', '西兰花'], ingredients: ['虾仁', '西兰花', '蒜', '盐'] }),
    new MealModel({ id: 16, name: '香蕉花生酱', mealType: '加餐', calories: 260, protein: 8, carbs: 35, fat: 12, fiber: 4, date: '2026-07-22', time: '16:00', isCompleted: true, emoji: '🍌', recipe: ['香蕉', '花生酱'], ingredients: ['香蕉', '花生酱'] }),
    new MealModel({ id: 17, name: '鸡蛋牛油果沙拉', mealType: '午餐', calories: 380, protein: 24, carbs: 18, fat: 22, fiber: 8, date: '2026-07-21', time: '12:30', isCompleted: true, emoji: '🥗', recipe: ['鸡蛋2个', '牛油果'], ingredients: ['鸡蛋', '牛油果', '生菜', '番茄'] }),
    new MealModel({ id: 18, name: '红薯鸡胸肉', mealType: '晚餐', calories: 420, protein: 35, carbs: 45, fat: 10, fiber: 8, date: '2026-07-21', time: '19:00', isCompleted: true, emoji: '🍠', recipe: ['鸡胸肉', '红薯'], ingredients: ['鸡胸肉', '红薯', '橄榄油'] }),
    new MealModel({ id: 19, name: '蓝莓松饼', mealType: '早餐', calories: 340, protein: 10, carbs: 52, fat: 10, fiber: 4, date: '2026-07-21', time: '08:00', isCompleted: true, emoji: '🫐', recipe: ['面粉', '蓝莓'], ingredients: ['面粉', '蓝莓', '牛奶', '鸡蛋'] }),
    new MealModel({ id: 20, name: '金枪鱼三明治', mealType: '午餐', calories: 410, protein: 28, carbs: 40, fat: 14, fiber: 6, date: '2026-07-20', time: '12:00', isCompleted: true, emoji: '🥪', recipe: ['金枪鱼罐头', '面包'], ingredients: ['金枪鱼', '全麦面包', '生菜', '蛋黄酱'] }),
    new MealModel({ id: 21, name: '蘑菇汤', mealType: '晚餐', calories: 220, protein: 10, carbs: 18, fat: 10, fiber: 4, date: '2026-07-20', time: '18:30', isCompleted: true, emoji: '🍄', recipe: ['蘑菇', '奶油'], ingredients: ['蘑菇', '奶油', '洋葱', '面粉'] }),
    new MealModel({ id: 22, name: '苹果片配杏仁酱', mealType: '加餐', calories: 200, protein: 5, carbs: 28, fat: 10, fiber: 6, date: '2026-07-20', time: '15:30', isCompleted: true, emoji: '🍎', recipe: ['苹果', '杏仁酱'], ingredients: ['苹果', '杏仁酱'] }),
    new MealModel({ id: 23, name: '小米南瓜粥', mealType: '早餐', calories: 260, protein: 8, carbs: 52, fat: 3, fiber: 4, date: '2026-07-19', time: '07:30', isCompleted: true, emoji: '🎃', recipe: ['小米', '南瓜'], ingredients: ['小米', '南瓜', '枸杞'] }),
    new MealModel({ id: 24, name: '烤鸡腿配蔬菜', mealType: '晚餐', calories: 480, protein: 40, carbs: 20, fat: 22, fiber: 6, date: '2026-07-19', time: '18:30', isCompleted: true, emoji: '🍗', recipe: ['鸡腿', '蔬菜'], ingredients: ['鸡腿', '土豆', '胡萝卜', '迷迭香'] }),
    new MealModel({ id: 25, name: '蔬菜豆腐汤', mealType: '午餐', calories: 260, protein: 18, carbs: 22, fat: 8, fiber: 6, date: '2026-07-19', time: '12:00', isCompleted: true, emoji: '🍲', recipe: ['豆腐', '蔬菜'], ingredients: ['豆腐', '白菜', '蘑菇', '姜'] }),
    new MealModel({ id: 26, name: '黑巧克力配咖啡', mealType: '加餐', calories: 160, protein: 3, carbs: 14, fat: 10, fiber: 3, date: '2026-07-19', time: '16:00', isCompleted: true, emoji: '🍫', recipe: ['黑巧克力', '咖啡'], ingredients: ['黑巧克力', '咖啡豆'] })
  ]

  private dailyCalories: DailyCalorie[] = [
    { day: '周一', calories: 1850, goal: 2000 },
    { day: '周二', calories: 1950, goal: 2000 },
    { day: '周三', calories: 1720, goal: 2000 },
    { day: '周四', calories: 2100, goal: 2000 },
    { day: '周五', calories: 1650, goal: 2000 },
    { day: '周六', calories: 2200, goal: 2000 },
    { day: '周日', calories: 1450, goal: 2000 }
  ]

  private macroTargets: MacroTarget[] = [
    { name: '蛋白质', current: 95, target: 120, unit: 'g', color: '#E65100', percentage: 79 },
    { name: '碳水', current: 180, target: 250, unit: 'g', color: '#FFB300', percentage: 72 },
    { name: '脂肪', current: 48, target: 65, unit: 'g', color: '#FF8F00', percentage: 74 },
    { name: '纤维', current: 22, target: 30, unit: 'g', color: '#FF6F00', percentage: 73 }
  ]

  private weeklyProteins: WeeklyProtein[] = [
    { day: '周一', protein: 110 },
    { day: '周二', protein: 125 },
    { day: '周三', protein: 95 },
    { day: '周四', protein: 130 },
    { day: '周五', protein: 88 },
    { day: '周六', protein: 135 },
    { day: '周日', protein: 95 }
  ]

  private tabConfigs: TabConfig[] = [
    { index: MealTab.Meals, label: '餐食', icon: '🍽️' },
    { index: MealTab.Weekly, label: '周计划', icon: '📅' },
    { index: MealTab.Nutrition, label: '营养', icon: '📊' },
    { index: MealTab.Mine, label: '我的', icon: '👤' }
  ]

  private mealTypeConfigs: MealTypeConfig[] = [
    { type: '全部', icon: '🍽️', color: '#E65100', targetCalories: 2000 },
    { type: '早餐', icon: '🌅', color: '#FFB300', targetCalories: 500 },
    { type: '午餐', icon: '☀️', color: '#FF8F00', targetCalories: 700 },
    { type: '晚餐', icon: '🌙', color: '#E65100', targetCalories: 600 },
    { type: '加餐', icon: '🥜', color: '#FF6F00', targetCalories: 200 }
  ]

  private mealTypeFilters: string[] = ['全部', '早餐', '午餐', '晚餐', '加餐']

  build() {
    Column() {
      Column() {
        // 顶部标题栏
        Row() {
          Text('🍽️ 健身饮食').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
          Row().layoutWeight(1)
          Text('📅').fontSize(20).fontColor('#FFFFFF').margin({ right: 16 })
          Text('⚙️').fontSize(20).fontColor('#FFFFFF')
        }
        .width('100%').height(56).padding({ left: 20, right: 20 })
        .backgroundColor('#E65100').alignItems(VerticalAlign.Center)

        // 今日卡路里进度
        this.calorieProgressBuilder()

        // 内容区域
        Column() {
          if (this.currentTab === MealTab.Meals) {
            this.mealsTab()
          } else if (this.currentTab === MealTab.Weekly) {
            this.weeklyTab()
          } else if (this.currentTab === MealTab.Nutrition) {
            this.nutritionTab()
          } else {
            this.mineTab()
          }
        }.layoutWeight(1).width('100%')

        // 底部导航栏
        this.bottomTabBar()
      }
      .width('100%').height('100%').backgroundColor('#FFF3E0')

      // 弹窗层
      if (this.showAddModal) {
        this.addModal()
      }
      if (this.showEditModal) {
        this.editModal()
      }
      if (this.showDeleteModal) {
        this.deleteModal()
      }
    }
  }

  // 今日卡路里进度
  @Builder calorieProgressBuilder() {
    Column() {
      Row() {
        Text('🔥 今日卡路里').fontSize(13).fontColor('#FFFFFF')
        Row().layoutWeight(1)
        Text(this.nutritionScore + '分').fontSize(13).fontColor('#FFB300').fontWeight(FontWeight.Bold)
      }.width('100%')

      Row() {
        Text(this.todayCalories + '').fontSize(28).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
        Text(' / ' + this.dailyCalorieGoal + ' kcal').fontSize(14).fontColor('#FFE0B2')
          .margin({ left: 4, bottom: 4 })
      }.margin({ top: 6 }).alignItems(VerticalAlign.Bottom)

      // 进度条
      Row() {
        Column()
          .width(this.todayCalories / this.dailyCalorieGoal * 100 + '%')
          .height(6)
          .backgroundColor('#FFB300')
          .borderRadius(3)
      }.width('100%').height(6).backgroundColor('#BF360C').borderRadius(3).margin({ top: 8 })

      Row() {
        Text('剩余 ' + (this.dailyCalorieGoal - this.todayCalories) + ' kcal').fontSize(11).fontColor('#FFE0B2')
        Row().layoutWeight(1)
        Text('🔥 营养评分').fontSize(11).fontColor('#FFE0B2')
      }.width('100%').margin({ top: 6 })
    }
    .width('100%').padding(16).backgroundColor('#E65100').borderRadius(0)
  }

  // 底部导航栏
  @Builder bottomTabBar() {
    Row() {
      ForEach(this.tabConfigs, (item: TabConfig) => {
        Column() {
          Text(item.icon).fontSize(22)
          Text(item.label).fontSize(10).fontColor(this.currentTab === item.index ? '#E65100' : '#9E9E9E')
            .margin({ top: 2 })
        }.alignItems(HorizontalAlign.Center).layoutWeight(1)
        .onClick(() => { this.currentTab = item.index })
      }, (item: TabConfig) => item.index.toString())
    }
    .width('100%').height(56).backgroundColor('#FFFFFF')
    .alignItems(VerticalAlign.Center)
    .shadow({ radius: 8, color: '#1A000000', offsetY: -2 })
  }

  // ===== 餐食 Tab =====
  @Builder mealsTab() {
    Column() {
      // 餐类选择
      Scroll() {
        Row() {
          ForEach(this.mealTypeFilters, (mt: string) => {
            Text(mt)
              .fontSize(12).fontColor(this.selectedMealType === mt ? '#FFFFFF' : '#E65100')
              .backgroundColor(this.selectedMealType === mt ? '#E65100' : '#FFE0B2')
              .padding({ left: 14, right: 14, top: 6, bottom: 6 })
              .borderRadius(16).margin({ right: 8 })
              .onClick(() => { this.selectedMealType = mt })
          }, (mt: string) => mt)
        }.padding({ left: 16, right: 16 })
      }.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).height(40)

      // 营养素汇总卡片
      Row() {
        Column() {
          Text('蛋白质').fontSize(10).fontColor('#FFE0B2')
          Text('95g').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF').margin({ top: 2 })
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)

        Column() {
          Text('碳水').fontSize(10).fontColor('#FFE0B2')
          Text('180g').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF').margin({ top: 2 })
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)

        Column() {
          Text('脂肪').fontSize(10).fontColor('#FFE0B2')
          Text('48g').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF').margin({ top: 2 })
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)

        Column() {
          Text('纤维').fontSize(10).fontColor('#FFE0B2')
          Text('22g').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF').margin({ top: 2 })
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)
      }.width('100%').padding(14).backgroundColor('#FF8F00').borderRadius(14).margin({ top: 4, left: 16, right: 16 })

      // 餐食列表
      Scroll() {
        Column() {
          this.mealItemBuilder(this.meals[0])
          this.mealItemBuilder(this.meals[1])
          this.mealItemBuilder(this.meals[2])
          this.mealItemBuilder(this.meals[3])
          this.mealItemBuilder(this.meals[4])
          this.mealItemBuilder(this.meals[5])
          this.mealItemBuilder(this.meals[6])
          this.mealItemBuilder(this.meals[7])
          this.mealItemBuilder(this.meals[8])
          this.mealItemBuilder(this.meals[9])
          this.mealItemBuilder(this.meals[10])
          this.mealItemBuilder(this.meals[11])
          this.mealItemBuilder(this.meals[12])
          this.mealItemBuilder(this.meals[13])
          this.mealItemBuilder(this.meals[14])
          this.mealItemBuilder(this.meals[15])
          this.mealItemBuilder(this.meals[16])
          this.mealItemBuilder(this.meals[17])
          this.mealItemBuilder(this.meals[18])
          this.mealItemBuilder(this.meals[19])
          this.mealItemBuilder(this.meals[20])
          this.mealItemBuilder(this.meals[21])
          this.mealItemBuilder(this.meals[22])
          this.mealItemBuilder(this.meals[23])
          this.mealItemBuilder(this.meals[24])
          this.mealItemBuilder(this.meals[25])
          Row() {
            Text('➕ 添加新餐食').fontSize(14).fontColor('#E65100').fontWeight(FontWeight.Bold)
          }.width('100%').height(48).justifyContent(FlexAlign.Center)
          .backgroundColor('#FFE0B2').borderRadius(12).margin({ top: 12 })
          .onClick(() => { this.showAddModal = true })
        }.padding({ left: 16, right: 16, top: 8, bottom: 16 })
      }.layoutWeight(1).scrollBar(BarState.Off)
    }.width('100%').height('100%')
  }

  @Builder mealItemBuilder(meal: MealModel) {
    Row() {
      // 食物Emoji
      Stack() {
        Text(meal.emoji).fontSize(28)
      }.width(52).height(52).backgroundColor('#FFE0B2').borderRadius(10)
      .align(Alignment.Center)

      Column() {
        Row() {
          Text(meal.name).fontSize(15).fontWeight(FontWeight.Bold).fontColor('#212121')
          if (meal.isCompleted) {
            Text('✓').fontSize(12).fontColor('#33691E').margin({ left: 6 })
          }
        }.width('100%').alignItems(VerticalAlign.Center)

        Row() {
          Text(meal.mealType).fontSize(10).fontColor('#E65100')
            .backgroundColor('#FFF3E0').padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(4)
          Text('⏰ ' + meal.time).fontSize(10).fontColor('#9E9E9E').margin({ left: 8 })
          Text('🔥 ' + meal.calories + 'kcal').fontSize(10).fontColor('#FF6F00').margin({ left: 8 })
        }.width('100%').margin({ top: 6 }).alignItems(VerticalAlign.Center)

        // 营养素进度条
        Row() {
          Column()
            .width(meal.protein / 50 * 100 + '%')
            .height(4).backgroundColor('#E65100').borderRadius(2)
        }.width('30%').height(4).backgroundColor('#F5F5F5').borderRadius(2).margin({ top: 6 })

        Row() {
          Text('🥩 ' + meal.protein + 'g').fontSize(10).fontColor('#757575')
          Text('🍞 ' + meal.carbs + 'g').fontSize(10).fontColor('#757575').margin({ left: 8 })
          Text('🧈 ' + meal.fat + 'g').fontSize(10).fontColor('#757575').margin({ left: 8 })
          Text('🌿 ' + meal.fiber + 'g').fontSize(10).fontColor('#757575').margin({ left: 8 })
        }.width('100%').margin({ top: 4 }).alignItems(VerticalAlign.Center)
      }.layoutWeight(1).margin({ left: 12 }).alignItems(HorizontalAlign.Start)

      Column() {
        Text('✏️').fontSize(14).margin({ bottom: 10 })
          .onClick(() => {
            this.editIndex = meal.id - 1
            this.showEditModal = true
          })
        Text('🗑️').fontSize(14)
          .onClick(() => {
            this.deleteIndex = meal.id - 1
            this.showDeleteModal = true
          })
      }.alignItems(HorizontalAlign.Center)
    }
    .width('100%').padding(12).backgroundColor('#FFFFFF').borderRadius(12).margin({ top: 8 })
    .alignItems(VerticalAlign.Center)
  }

  // ===== 周计划 Tab =====
  @Builder weeklyTab() {
    Scroll() {
      Column() {
        // 周计划标题
        Row() {
          Text('📅 本周餐食计划').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#212121')
        }.width('100%').margin({ bottom: 8 })

        // 每日餐食卡片
        this.dayPlanBuilder('周一', 1850, 4, '#E65100')
        this.dayPlanBuilder('周二', 1950, 4, '#FF8F00')
        this.dayPlanBuilder('周三', 1720, 5, '#FFB300')
        this.dayPlanBuilder('周四', 2100, 4, '#E65100')
        this.dayPlanBuilder('周五', 1650, 3, '#FF8F00')
        this.dayPlanBuilder('周六', 2200, 5, '#FFB300')
        this.dayPlanBuilder('周日', 1450, 4, '#E65100')

        // 每周蛋白质趋势
        Row() {
          Text('💪 每周蛋白质趋势').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#212121')
        }.width('100%').margin({ top: 20, bottom: 8 })

        Column() {
          this.weeklyProteinBuilder(this.weeklyProteins[0])
          this.weeklyProteinBuilder(this.weeklyProteins[1])
          this.weeklyProteinBuilder(this.weeklyProteins[2])
          this.weeklyProteinBuilder(this.weeklyProteins[3])
          this.weeklyProteinBuilder(this.weeklyProteins[4])
          this.weeklyProteinBuilder(this.weeklyProteins[5])
          this.weeklyProteinBuilder(this.weeklyProteins[6])
        }.width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(14)
      }.padding({ left: 16, right: 16, top: 12, bottom: 16 })
    }.layoutWeight(1).scrollBar(BarState.Off)
  }

  @Builder dayPlanBuilder(day: string, calories: number, mealCount: number, color: string) {
    Row() {
      Column() {
        Text(day).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
        Text(calories + ' kcal').fontSize(11).fontColor('#FFE0B2').margin({ top: 2 })
      }.width(60).alignItems(HorizontalAlign.Center)

      Column() {
        Row() {
          Text('🌅 早餐').fontSize(11).fontColor('#757575')
          Text('☀️ 午餐').fontSize(11).fontColor('#757575').margin({ left: 12 })
          Text('🌙 晚餐').fontSize(11).fontColor('#757575').margin({ left: 12 })
          Text('🥜 加餐').fontSize(11).fontColor('#757575').margin({ left: 12 })
        }.width('100%')

        Row() {
          Text(mealCount + ' 餐已规划').fontSize(11).fontColor('#E65100').fontWeight(FontWeight.Bold)
        }.width('100%').margin({ top: 4 })
      }.layoutWeight(1).margin({ left: 12 }).alignItems(HorizontalAlign.Start)

      // 卡路里进度条
      Column() {
        Row() {
          Column()
            .width(calories / 2500 * 100 + '%')
            .height(6)
            .backgroundColor(color)
            .borderRadius(3)
        }.width(60).height(6).backgroundColor('#F5F5F5').borderRadius(3)
        Text(calories / 2000 * 100 + '%').fontSize(9).fontColor('#9E9E9E').margin({ top: 2 })
      }.alignItems(HorizontalAlign.Center)
    }
    .width('100%').padding(14).backgroundColor('#FFFFFF').borderRadius(12).margin({ top: 8 })
    .alignItems(VerticalAlign.Center)
  }

  @Builder weeklyProteinBuilder(item: WeeklyProtein) {
    Row() {
      Text(item.day).fontSize(12).fontColor('#424242').width(40)
      Row() {
        Column()
          .width(item.protein / 150 * 100 + '%')
          .height(14)
          .backgroundColor('#E65100')
          .borderRadius(7)
      }.layoutWeight(1).height(14).backgroundColor('#F5F5F5').borderRadius(7)
      Text(item.protein + 'g').fontSize(11).fontColor('#757575').width(40).textAlign(TextAlign.Center)
    }.width('100%').margin({ top: 8 }).alignItems(VerticalAlign.Center)
  }

  // ===== 营养 Tab =====
  @Builder nutritionTab() {
    Scroll() {
      Column() {
        // 每日卡路里柱状图
        Row() {
          Text('📊 每日卡路里摄入').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#212121')
        }.width('100%').margin({ bottom: 8 })

        Column() {
          this.dailyCalorieBuilder(this.dailyCalories[0])
          this.dailyCalorieBuilder(this.dailyCalories[1])
          this.dailyCalorieBuilder(this.dailyCalories[2])
          this.dailyCalorieBuilder(this.dailyCalories[3])
          this.dailyCalorieBuilder(this.dailyCalories[4])
          this.dailyCalorieBuilder(this.dailyCalories[5])
          this.dailyCalorieBuilder(this.dailyCalories[6])
        }.width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(14)

        // 宏量营养素分布
        Row() {
          Text('🥗 宏量营养素目标').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#212121')
        }.width('100%').margin({ top: 20, bottom: 8 })

        Column() {
          this.macroTargetBuilder(this.macroTargets[0])
          this.macroTargetBuilder(this.macroTargets[1])
          this.macroTargetBuilder(this.macroTargets[2])
          this.macroTargetBuilder(this.macroTargets[3])
        }.width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(14)

        // 营养评分卡
        Row() {
          Text('🏆 营养评分').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#212121')
        }.width('100%').margin({ top: 20, bottom: 8 })

        Column() {
          Row() {
            Stack() {
              Text('85').fontSize(32).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
            }.width(80).height(80).backgroundColor('#E65100').borderRadius(40)
            .border({ width: 4, color: '#FFB300' }).align(Alignment.Center)

            Column() {
              Text('优秀!').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
              Text('你的饮食营养均衡').fontSize(12).fontColor('#757575').margin({ top: 4 })
              Row() {
                Text('建议增加蛋白质摄入').fontSize(11).fontColor('#E65100')
              }.margin({ top: 6 })
            }.layoutWeight(1).margin({ left: 16 }).alignItems(HorizontalAlign.Start)
          }.width('100%').alignItems(VerticalAlign.Center)
        }.width('100%').padding(20).backgroundColor('#FFFFFF').borderRadius(14)

        // 餐类卡路里分布
        Row() {
          Text('🍽️ 餐类卡路里分布').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#212121')
        }.width('100%').margin({ top: 20, bottom: 8 })

        Column() {
          this.mealTypeCalorieBuilder('早餐', 500, '#FFB300')
          this.mealTypeCalorieBuilder('午餐', 700, '#FF8F00')
          this.mealTypeCalorieBuilder('晚餐', 600, '#E65100')
          this.mealTypeCalorieBuilder('加餐', 200, '#FF6F00')
        }.width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(14)
      }.padding({ left: 16, right: 16, top: 12, bottom: 16 })
    }.layoutWeight(1).scrollBar(BarState.Off)
  }

  @Builder dailyCalorieBuilder(item: DailyCalorie) {
    Row() {
      Text(item.day).fontSize(12).fontColor('#424242').width(40)
      Column() {
        Row() {
          Column()
            .width(item.calories / 2500 * 100 + '%')
            .height(16)
            .backgroundColor(item.calories > item.goal ? '#C62828' : '#E65100')
            .borderRadius(8)
        }.width('100%').height(16).backgroundColor('#F5F5F5').borderRadius(8)
      }.layoutWeight(1)
      Text(item.calories + '').fontSize(11).fontColor(item.calories > item.goal ? '#C62828' : '#757575').width(50).textAlign(TextAlign.Center)
    }.width('100%').margin({ top: 8 }).alignItems(VerticalAlign.Center)
  }

  @Builder macroTargetBuilder(item: MacroTarget) {
    Column() {
      Row() {
        Text(item.name).fontSize(13).fontColor('#424242').fontWeight(FontWeight.Bold)
        Row().layoutWeight(1)
        Text(item.current + '/' + item.target + item.unit).fontSize(12).fontColor('#757575')
      }.width('100%')
      Row() {
        Column()
          .width(item.percentage + '%')
          .height(10)
          .backgroundColor(item.color)
          .borderRadius(5)
      }.width('100%').height(10).backgroundColor('#F5F5F5').borderRadius(5).margin({ top: 6 })
      Text(item.percentage + '% 目标完成').fontSize(10).fontColor('#9E9E9E').margin({ top: 4 })
    }.width('100%').margin({ top: 12 })
  }

  @Builder mealTypeCalorieBuilder(type: string, calories: number, color: string) {
    Row() {
      Text(type).fontSize(12).fontColor('#424242').width(50)
      Row() {
        Column()
          .width(calories / 700 * 100 + '%')
          .height(12)
          .backgroundColor(color)
          .borderRadius(6)
      }.layoutWeight(1).height(12).backgroundColor('#F5F5F5').borderRadius(6)
      Text(calories + 'kcal').fontSize(11).fontColor('#757575').width(56).textAlign(TextAlign.Center)
    }.width('100%').margin({ top: 8 }).alignItems(VerticalAlign.Center)
  }

  // ===== 我的 Tab =====
  @Builder mineTab() {
    Scroll() {
      Column() {
        // 用户信息卡片
        Row() {
          Stack() {
            Text('💪').fontSize(40)
          }.width(80).height(80).backgroundColor('#E65100').borderRadius(40).align(Alignment.Center)

          Column() {
            Text('健身达人').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
            Text('已坚持 128 天').fontSize(12).fontColor('#FFE0B2').margin({ top: 4 })
            Row() {
              Text('PRO会员').fontSize(10).fontColor('#FFB300')
                .backgroundColor('#BF360C').padding({ left: 8, right: 8, top: 3, bottom: 3 })
                .borderRadius(8)
            }.margin({ top: 6 })
          }.layoutWeight(1).margin({ left: 16 }).alignItems(HorizontalAlign.Start)
        }.width('100%').padding(20).backgroundColor('#E65100').borderRadius(16)

        // 统计数据
        Row() {
          Column() {
            Text('26').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#E65100')
            Text('餐食记录').fontSize(11).fontColor('#757575').margin({ top: 2 })
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)

          Column() {
            Text('128').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#E65100')
            Text('坚持天数').fontSize(11).fontColor('#757575').margin({ top: 2 })
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)

          Column() {
            Text('85').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#FFB300')
            Text('营养评分').fontSize(11).fontColor('#757575').margin({ top: 2 })
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)

          Column() {
            Text('1,820').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#FFB300')
            Text('日均卡路里').fontSize(11).fontColor('#757575').margin({ top: 2 })
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)
        }.width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(14).margin({ top: 12 })

        // 身体数据
        Row() {
          Text('📏 身体数据').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#212121')
        }.width('100%').margin({ top: 20, bottom: 8 })

        Column() {
          Row() {
            Text('体重').fontSize(13).fontColor('#424242')
            Row().layoutWeight(1)
            Text('68.5 kg').fontSize(13).fontColor('#E65100').fontWeight(FontWeight.Bold)
            Text(' ↓ 2.5kg').fontSize(11).fontColor('#33691E').margin({ left: 8 })
          }.width('100%').padding({ left: 16, right: 16, top: 12, bottom: 12 })

          Row() {
            Text('身高').fontSize(13).fontColor('#424242')
            Row().layoutWeight(1)
            Text('175 cm').fontSize(13).fontColor('#E65100').fontWeight(FontWeight.Bold)
          }.width('100%').padding({ left: 16, right: 16, top: 12, bottom: 12 })

          Row() {
            Text('BMI').fontSize(13).fontColor('#424242')
            Row().layoutWeight(1)
            Text('22.4').fontSize(13).fontColor('#E65100').fontWeight(FontWeight.Bold)
            Text(' 正常').fontSize(11).fontColor('#33691E').margin({ left: 8 })
          }.width('100%').padding({ left: 16, right: 16, top: 12, bottom: 12 })

          Row() {
            Text('体脂率').fontSize(13).fontColor('#424242')
            Row().layoutWeight(1)
            Text('15.2%').fontSize(13).fontColor('#E65100').fontWeight(FontWeight.Bold)
            Text(' ↓ 3.8%').fontSize(11).fontColor('#33691E').margin({ left: 8 })
          }.width('100%').padding({ left: 16, right: 16, top: 12, bottom: 12 })
        }.width('100%').backgroundColor('#FFFFFF').borderRadius(14)

        // 功能列表
        Column() {
          this.menuItemBuilder('🎯', '目标设置', '卡路里·营养素目标')
          this.menuItemBuilder('📋', '饮食历史', '查看历史记录')
          this.menuItemBuilder('💧', '饮水记录', '今日 1.5L / 2L')
          this.menuItemBuilder('🏃', '运动记录', '今日消耗 320kcal')
          this.menuItemBuilder('🔔', '餐食提醒', '定时提醒用餐')
          this.menuItemBuilder('ℹ️', '关于', '版本 v3.1.0')
        }.width('100%').backgroundColor('#FFFFFF').borderRadius(14).margin({ top: 12 })
      }.padding({ left: 16, right: 16, top: 16, bottom: 16 })
    }.layoutWeight(1).scrollBar(BarState.Off)
  }

  @Builder menuItemBuilder(icon: string, title: string, desc: string) {
    Row() {
      Text(icon).fontSize(20).width(32)
      Text(title).fontSize(14).fontColor('#212121').layoutWeight(1)
      Text(desc).fontSize(12).fontColor('#9E9E9E')
      Text('›').fontSize(18).fontColor('#BDBDBD').margin({ left: 8 })
    }.width('100%').padding({ left: 16, right: 16, top: 14, bottom: 14 })
    .alignItems(VerticalAlign.Center)
  }

  // ===== 新增弹窗 =====
  @Builder modalBg(onClose: () => void) {
    Column().width('100%').height('100%')
      .backgroundColor('rgba(0,0,0,0.5)').onClick(onClose)
  }

  @Builder addModal() {
    Column() {
      this.modalBg(() => { this.showAddModal = false })
      Column() {
        Row() {
          Text('➕ 新增餐食').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
          Row().layoutWeight(1)
          Text('✕').fontSize(18).fontColor('#999999')
            .onClick(() => { this.showAddModal = false })
        }.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
        Divider().color('#F0F0F0')
        Scroll() {
          Column() {
            this.formFieldBuilder('餐食名称', '请输入餐食名称')
            this.formFieldBuilder('餐食类型', '早餐/午餐/晚餐/加餐')
            this.formFieldBuilder('卡路里(kcal)', '请输入数字')
            this.formFieldBuilder('蛋白质(g)', '请输入数字')
            this.formFieldBuilder('碳水(g)', '请输入数字')
            this.formFieldBuilder('脂肪(g)', '请输入数字')
            this.formFieldBuilder('纤维(g)', '请输入数字')
            this.formFieldBuilder('日期', '如 2026-07-25')
            this.formFieldBuilder('时间', '如 12:00')
            this.formFieldBuilder('Emoji', '如 🍎')
            this.formFieldBuilder('食谱', '用逗号分隔')
            this.formFieldBuilder('食材', '用逗号分隔')
          }.padding({ left: 20, right: 20 })
        }.layoutWeight(1)
        Row() {
          Text('取消').fontSize(14).fontColor('#888888')
            .backgroundColor('#F5F5F5').borderRadius(20)
            .padding({ left: 24, right: 24, top: 10, bottom: 10 })
            .onClick(() => { this.showAddModal = false })
          Text('保存').fontSize(14).fontColor('#FFFFFF')
            .backgroundColor('#E65100').borderRadius(20)
            .padding({ left: 24, right: 24, top: 10, bottom: 10 })
            .margin({ left: 12 })
            .onClick(() => { this.showAddModal = false })
        }.width('100%').justifyContent(FlexAlign.Center)
        .padding({ left: 20, right: 20, top: 16, bottom: 16 })
      }
      .width('90%').constraintSize({ maxHeight: '80%' }).backgroundColor('#FFFFFF').borderRadius(18)
      .position({ x: '5%', y: '8%' }).shadow({ radius: 20, color: '#33000000', offsetY: 4 })
    }
    .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
  }

  // ===== 编辑弹窗 =====
  @Builder editModal() {
    Column() {
      this.modalBg(() => { this.showEditModal = false })
      Column() {
        Row() {
          Text('✏️ 编辑餐食').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
          Row().layoutWeight(1)
          Text('✕').fontSize(18).fontColor('#999999')
            .onClick(() => { this.showEditModal = false })
        }.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
        Divider().color('#F0F0F0')
        Scroll() {
          Column() {
            this.editFieldBuilder('餐食名称', this.meals[this.editIndex].name)
            this.editFieldBuilder('餐食类型', this.meals[this.editIndex].mealType)
            this.editFieldBuilder('卡路里', this.meals[this.editIndex].calories.toString())
            this.editFieldBuilder('蛋白质(g)', this.meals[this.editIndex].protein.toString())
            this.editFieldBuilder('碳水(g)', this.meals[this.editIndex].carbs.toString())
            this.editFieldBuilder('脂肪(g)', this.meals[this.editIndex].fat.toString())
            this.editFieldBuilder('纤维(g)', this.meals[this.editIndex].fiber.toString())
            this.editFieldBuilder('日期', this.meals[this.editIndex].date)
            this.editFieldBuilder('时间', this.meals[this.editIndex].time)
          }.padding({ left: 20, right: 20 })
        }.layoutWeight(1)
        Row() {
          Text('取消').fontSize(14).fontColor('#888888')
            .backgroundColor('#F5F5F5').borderRadius(20)
            .padding({ left: 24, right: 24, top: 10, bottom: 10 })
            .onClick(() => { this.showEditModal = false })
          Text('保存').fontSize(14).fontColor('#FFFFFF')
            .backgroundColor('#FFB300').borderRadius(20)
            .padding({ left: 24, right: 24, top: 10, bottom: 10 })
            .margin({ left: 12 })
            .onClick(() => { this.showEditModal = false })
        }.width('100%').justifyContent(FlexAlign.Center)
        .padding({ left: 20, right: 20, top: 16, bottom: 16 })
      }
      .width('90%').constraintSize({ maxHeight: '80%' }).backgroundColor('#FFFFFF').borderRadius(18)
      .position({ x: '5%', y: '8%' }).shadow({ radius: 20, color: '#33000000', offsetY: 4 })
    }
    .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
  }

  // ===== 删除确认弹窗 =====
  @Builder deleteModal() {
    Column() {
      this.modalBg(() => { this.showDeleteModal = false })
      Column() {
        Text('🗑️').fontSize(40).margin({ top: 24 })
        Text('确认删除?').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
          .margin({ top: 12 })
        Text('删除后不可恢复,确定要删除「' + this.meals[this.deleteIndex].name + '」吗?')
          .fontSize(13).fontColor('#757575').textAlign(TextAlign.Center)
          .margin({ top: 8, left: 24, right: 24 })

        Row() {
          Text('取消').fontSize(14).fontColor('#666666')
            .backgroundColor('#F5F5F5').borderRadius(20)
            .padding({ left: 32, right: 32, top: 10, bottom: 10 })
            .onClick(() => { this.showDeleteModal = false })
          Text('删除').fontSize(14).fontColor('#FFFFFF')
            .backgroundColor('#C62828').borderRadius(20)
            .padding({ left: 32, right: 32, top: 10, bottom: 10 })
            .margin({ left: 12 })
            .onClick(() => { this.showDeleteModal = false })
        }.margin({ top: 24, bottom: 24 }).justifyContent(FlexAlign.Center)
      }.width('80%').backgroundColor('#FFFFFF').borderRadius(18)
      .alignItems(HorizontalAlign.Center)
      .shadow({ radius: 20, color: '#33000000', offsetY: 4 })
    }.width('100%').height('100%').justifyContent(FlexAlign.Center)
    .position({ x: 0, y: 0 }).zIndex(999)
  }

  @Builder formFieldBuilder(label: string, placeholder: string) {
    Column() {
      Text(label).fontSize(12).fontColor('#757575').margin({ bottom: 6 })
      TextInput({ placeholder: placeholder })
        .fontSize(14).height(40).borderRadius(10)
        .backgroundColor('#F5F5F5')
    }.width('100%').margin({ top: 12 }).alignItems(HorizontalAlign.Start)
  }

  @Builder editFieldBuilder(label: string, value: string) {
    Column() {
      Text(label).fontSize(12).fontColor('#757575').margin({ bottom: 6 })
      TextInput({ text: value })
        .fontSize(14).height(40).borderRadius(10)
        .backgroundColor('#F5F5F5')
    }.width('100%').margin({ top: 12 }).alignItems(HorizontalAlign.Start)
  }
}


总结

通过对本应用源码的逐段深度解析,我们可以清晰地看到 ArkTS 声明式开发范式在 HarmonyOS 应用构建中的核心优势。整个应用以一个 @Entry 入口组件为根,通过 @State 状态变量集中管理所有交互状态(Tab 切换、弹窗显隐、筛选条件、数据索引),以 @Builder 构建器函数将复杂的 UI 拆分为可复用的组件片段,以 @Observed 类实现领域对象的可观测性。四个 Tab 页各司其职——餐食页负责核心的列表展示与 CRUD 操作入口,周计划页提供时间维度的规划概览,营养页通过多种条形图和进度条实现数据可视化,我的页聚合用户信息与功能入口。弹窗系统通过遮罩分层、回调注入和表单构建器的组合,实现了完整的增删改交互闭环。

在这里插入图片描述

在视觉设计层面,应用以橙色系为主色调,通过颜色的深浅变化和语义编码(红=警示、绿=改善、黄=高亮)构建了统一而富有层次的视觉体系。在架构层面,数据层(接口定义+模拟数据)、状态层(@State变量)和表现层(@Builder函数)三层分离,各层之间通过数据绑定和事件回调松耦合连接,为后续的功能扩展(如接入真实数据源、添加表单验证、实现数据持久化)预留了清晰的演进路径。这种"数据驱动UI、组件化复用、状态集中管理"的设计思想,正是 ArkTS 声明式开发范式的精髓所在。

Logo

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

更多推荐