一、技术前言

1.1 行业背景:母婴育儿赛道的移动化叙事

母婴育儿是一个高频、高黏性、高情感浓度的垂直赛道。新生代父母(尤其是 90 后、00 后)在育儿过程中普遍存在"记录焦虑"与"信息焦虑"双重诉求:一方面希望把宝宝每一次翻身、第一次叫妈妈、每一颗冒出的小牙都郑重地留存下来;另一方面又需要在海量的育儿知识、辅食攻略、疫苗时间表中找到可信、可操作的答案。与此同时,母婴社区天然带有强社交属性,宝妈宝爸之间的经验互助、二手闲置流转、好物推荐,构成了一个完整的"记录—成长—消费—交流"闭环。

「萌宝日记」正是瞄准这一闭环而生的母婴育儿记录平台。它并非单一功能的工具应用,而是将日记记录、成长追踪、疫苗管理、母婴商城、育儿社区、个人中心六大模块聚合在一个底部 Tab 切换的容器中,用婴儿粉与婴儿蓝的暖色调营造出温柔而不失专业的视觉氛围。这种"多模态聚合 + 情感化配色"的产品形态,对前端架构提出了非常具体的要求:既要保证六个模块各自的布局差异足够大以避免视觉疲劳,又要让它们共享同一套设计语言以维持整体一致性。

1.2 ArkUI 框架技术介绍

HarmonyOS 的 ArkUI 框架是基于 TypeScript 扩展(ArkTS)的声明式 UI 开发范式。它借鉴了现代前端框架(如 Flutter、SwiftUI、Jetpack Compose)的声明式思想,但在语言层面深度绑定 TypeScript 强类型体系,并通过装饰器(Decorator)语法提供了极具辨识度的开发体验。

ArkUI 的核心技术特征包括以下几个方面:

第一,声明式描述。开发者通过 build() 方法以嵌套的 DSL 语法描述 UI 结构,例如 Column() { Text() ... },框架负责将声明式描述编译为高效的渲染指令,开发者无需手动操作 DOM 或控件树。

第二,状态驱动渲染。通过 @State@Prop@Link@Observed@ObjectLink 等装饰器,ArkUI 建立了一条"状态变更 → 框架感知 → 差异比对 → 局部刷新"的响应式链路。当状态变量发生变化时,框架会自动重新执行依赖该状态的 build() 片段,实现精准的局部更新。

第三,@Builder 方法复用@Builder 装饰的方法可以像函数一样被调用,用于抽取可复用的 UI 片段。这相当于 Vue 中的"渲染函数"或 Flutter 中的 Widget 工厂方法,是组织大型组件代码的核心手段。本应用大量使用 @Builder 来拆分头部、六个 Tab、图表、Tab 栏与弹窗,使得千行代码依然保持可读性。

第四,@Observed 可观察对象。被 @Observed 修饰的 class,其实例在配合 @State 数组使用时,能够被框架跟踪内部字段变化,是构建数据驱动列表的基础。本应用的六大数据模型均采用这一机制。

第五,生命周期回调aboutToAppear()aboutToDisappear() 分别在组件实例创建后、销毁前触发,是初始化定时器、订阅事件以及资源清理的标准位置。

1.3 关键技术决策

在审视该源码的整体设计时,可以看到作者在以下几个关键技术点上做出了明确决策:

其一,采用单组件 + @Builder 拆分而非多组件文件拆分。所有视图逻辑集中在同一个 @Entry @Component struct 中,通过 @Builder 方法切分出 headerBaby、六个 tab、chartCard、tabBar 以及三个弹窗。这种"大组件 + 小函数"的策略在中等规模页面中可降低跨文件跳转成本,但也对命名规范提出了更高要求。

其二,状态下沉到组件级。所有列表数据均作为 @State 挂载在组件实例上,新增、编辑、删除操作直接修改这些数组(pushsplice),依赖 ArkUI 的响应式机制自动刷新 UI,没有引入独立的 Store 或状态管理库。

其三,呼吸动画由 setInterval 驱动。作者用 1000ms 的 setInterval 翻转一个布尔型 breath 状态,再配合 scaleanimation 实现头部头像与"我的"页面头像的呼吸效果。这是一种轻量且直观的动画方案,适合"存在感"装饰性动效,但需在 aboutToDisappear 中及时清理定时器,否则会造成内存泄漏。

其四,色彩体系集中常量化。所有颜色抽取为 COLORS 常量,并通过 ColorPalette 接口约束类型,避免了散落在代码各处的魔法字符串,为后续主题切换打下了基础。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

二、整体架构流程图

在深入逐行分析之前,先用一张 Mermaid 流程图俯瞰整个应用的分层结构与数据流向。该源码可以清晰地划分为静态数据层、数据模型层、工具函数层、组件主体层与视图层五个层次。

视图层 @Builder

组件主体 Page802

工具函数层

数据模型层

静态数据层

COLORS 色彩常量

TAB_LIST Tab元数据

辅助数据 MONTH_IDX 等

DiaryItem 日记项

GrowthItem 成长项

VaccineItem 疫苗项

ProductItem 商品项

ParentPostItem 帖子项

ParentItem 家长项

vaccineColor 状态色映射

categoryColor 分类色映射

@State 状态变量集合

aboutToAppear 生命周期

build 入口

headerBaby 头部

六个Tab视图

chartCard 图表

tabBar 底部栏

弹窗系统

架构解读

从流程图可以看出,该应用采用了一条自底向上、单向注入的数据流。最底层的静态数据(色彩常量、Tab 元数据、月份与身高数值等辅助数据)与六个 @Observed 数据模型实例化后的列表,全部在组件主体 Page802 中通过 @State 被持有。工具函数层则保持无状态纯函数形态,只在视图层渲染时被即时调用,负责将状态文本(如"已接种"“育儿”)映射为对应的主题色,实现了"数据归数据、表现归表现"的关注点分离。

组件主体层是整个应用的中枢:aboutToAppear 启动呼吸定时器并写入 breath 状态,build() 作为唯一入口,按"头部 → 滚动区(六个 Tab 之一 + 图表)→ 底部 Tab 栏 → 弹窗"的顺序组装整棵 UI 树。视图层的每个 @Builder 都是纯渲染函数,它们只读取状态、调用工具函数、返回 UI 描述,不自行持有数据,这保证了视图的可预测性与可测试性。

三、色彩体系设计

母婴育儿类应用对色彩的要求极为特殊:既不能用过于强烈的对比色刺激新手父母的视觉,又不能用过于冷淡的灰阶削弱产品的温度感。该源码用一套"暖白底 + 婴儿粉 + 婴儿蓝"的双主色体系,巧妙地平衡了温柔与专业。

3.1 ColorPalette 接口定义

首先通过一个 TypeScript 接口对色彩体系进行类型约束:

interface ColorPalette {
  bg: string; card: string; chip: string; title: string; sub: string;
  text3: string; pink: string; pinkD: string; blue: string; blueD: string;
  green: string; gold: string; line: string; tabOn: string; mask: string;
}

这个接口声明了 15 个颜色槽位,覆盖了背景、卡片、芯片标签、标题、副文本、三级文本、主色及其深色变体、辅助语义色(成功绿、警示金)、分割线、Tab 选中态与遮罩层。用接口约束的好处在于:一旦未来要新增一套"夜间模式"或"高对比模式",只要再实现一个符合该接口的常量对象即可,所有视图代码无需修改。

3.2 COLORS 常量逐色分析

const COLORS: ColorPalette = {
  bg: '#FFF5F7', card: '#FFFFFF', chip: '#FFE4EC', title: '#4A3B52',
  sub: '#8B7B8E', text3: '#C4B0BC', pink: '#FF7BA9', pinkD: '#E8679A',
  blue: '#7B94D9', blueD: '#5C7BB8', green: '#7BC67B', gold: '#F0C040',
  line: '#F5E6EC', tabOn: '#FF7BA9', mask: 'rgba(0,0,0,0.5)',
};

下面对每一个颜色逐一解读其设计意图:

  • bg: '#FFF5F7':全局背景色,是一种极浅的粉色雾白。它比纯白多了一层温度,又比明显粉色克制得多,是整个"暖叙事"的底色基调。
  • card: '#FFFFFF':卡片背景采用纯白,与 bg 形成微弱对比,使卡片内容在粉色底上自然浮起,营造"纸片感"。
  • chip: '#FFE4EC':芯片/标签背景色,是介于背景粉与主色粉之间的过渡色,用于头像底色、按钮底色、标签底色等需要"柔和高亮"的场景。
  • title: '#4A3B52':标题文字色。注意它并非纯黑,而是一种带紫调的深褐,与暖粉底色色温一致,避免了"黑字配粉底"的割裂感。
  • sub: '#8B7B8E':副文本色,是标题色的稀释版,用于次要说明文字,保证层次而不喧宾夺主。
  • text3: '#C4B0BC':三级文本色,接近浅灰粉,用于时间戳、单位、占位符等最弱权重的文字。
  • pink: '#FF7BA9':主色婴儿粉,是整个应用的视觉锚点,用于 Tab 选中态、主按钮、价格、强调数字等。
  • pinkD: '#E8679A':主色深色变体,比 pink 略沉,用于价格文字、强调标签等需要比主色更"重"的场景。
  • blue: '#7B94D9':副主色婴儿蓝,与 pink 形成冷暖对偶,用于交错配色(奇偶项轮流使用粉/蓝)。
  • blueD: '#5C7BB8':副主色深色变体,用于"我的"Tab 的强调色等。
  • green: '#7BC67B':成功语义色,柔和的草绿,专用于疫苗"已接种"状态与成长趋势上升箭头。
  • gold: '#F0C040':警示语义色,暖金,专用于疫苗"即将到期"状态与商品评分星标。
  • line: '#F5E6EC':分割线色,极浅粉,用于头部 Divider 与时间轴竖线,几乎不可见却起到分隔作用。
  • tabOn: '#FF7BA9':底部 Tab 选中态色,与主色 pink 同值,使选中反馈与全局主色统一。
  • mask: 'rgba(0,0,0,0.5)':弹窗遮罩色,半透明黑,用于压暗背景以突出弹窗内容。

这套色彩体系的精妙之处在于:它并非随机堆砌的色值,而是一组有语义分工、有明暗梯度、有色温统一的有机整体。粉与蓝作为对偶主色,在六个 Tab 中以"奇偶交错"的方式轮流出现(如日记项的编号色、成长项的百分位色、商城卡片的标签色),避免了单一主色带来的视觉单调。

四、Tab 元数据与辅助数据

4.1 Tab 元数据定义

interface TabMeta { name: string; icon: Resource; color: string; }

const TAB_LIST: TabMeta[] = [
  { name: '日记', icon: $r('app.media.ic_public_0'), color: COLORS.pink },
  { name: '成长', icon: $r('app.media.ic_public_1'), color: COLORS.blue },
  { name: '疫苗', icon: $r('app.media.ic_public_2'), color: COLORS.green },
  { name: '商城', icon: $r('app.media.ic_public_3'), color: COLORS.gold },
  { name: '社区', icon: $r('app.media.ic_public_4'), color: COLORS.pinkD },
  { name: '我的', icon: $r('app.media.ic_public_5'), color: COLORS.blueD },
];

TabMeta 接口定义了三个字段:name 是 Tab 的中文名称,icon 是通过 $r() 引用的本地图片资源(返回 Resource 类型),color 是该 Tab 的主题色。这里值得注意的设计决策是:每个 Tab 都被分配了一个独立的主题色——日记用粉、成长用蓝、疫苗用绿、商城用金、社区用深粉、我用深蓝。这种"一 Tab 一色"的策略让用户在切换 Tab 时能通过颜色感知模块身份,强化了模块边界感。

TAB_LIST 数组本身是一个静态常量,在底部 tabBar 中通过 ForEach 遍历渲染。将 Tab 配置抽成数据而非硬编码在视图里,是典型的"数据驱动 UI"实践:未来要增删 Tab 或调整顺序,只需修改这个数组即可。

4.2 图表与月份辅助数据

const MONTH_IDX: number[] = [0, 1, 2, 3, 4, 5];
const MONTH_NAME: string[] = ['1月', '2月', '3月', '4月', '5月', '6月'];
const GROWTH_VAL: number[] = [50, 53, 56, 58, 60, 62];

这三组常量为头部缩略柱状图与底部图表卡片提供数据源。MONTH_IDX 是索引数组,用于 ForEach 的遍历键;MONTH_NAME 是横轴标签;GROWTH_VAL 是近六个月的身高数值(单位 cm)。柱状图的高度通过 (GROWTH_VAL[idx] - 45) * 6 计算得出,即以 45cm 为基准、每 cm 映射为 6 像素高度,这是一种简化的数据可视化映射。

4.3 商城双列索引与快捷入口

const SHOP_PAIR_IDX: number[][] = [[0, 1], [2, 3], [4, 5]];
const QUICK_ENTRIES: string[] = ['📝 记日记', '📏 测身高', '💉 打疫苗', '⚖️ 量体重'];
const MINE_FUNCS: string[][] = [['📅', '记录'], ['📋', '提醒'], ['🏥', '就医'], ['📊', '报告']];
const MOOD_LIST: string[] = ['😊', '😴', '😤', '🤗', '😍', '🥰'];

SHOP_PAIR_IDX 是一个二维数组,把 6 个商品按 [0,1][2,3][4,5] 两两分组,用于商城 Tab 的双列布局——每行渲染一对商品。这种"索引对"设计避免了在视图层手动计算奇偶,使双列逻辑清晰可读。

QUICK_ENTRIES 是头部快捷入口的文案数组,每项用 emoji 增强可识别性;MINE_FUNCS 是"我的"页面的四个功能入口(记录/提醒/就医/报告),每项由 emoji 与中文名组成;MOOD_LIST 是编辑弹窗中可选的心情 emoji 列表。将这些短数据抽为常量而非内联,使视图代码更聚焦于结构而非内容。

五、工具函数

该源码定义了两个无状态的工具函数,它们都遵循"输入语义文本、输出主题色"的统一契约,是色彩体系与业务语义之间的桥梁。

5.1 vaccineColor:疫苗状态色映射

function vaccineColor(s: string): string {
  if (s === '已接种') return COLORS.green;
  if (s === '待接种') return COLORS.blue;
  if (s === '即将到期') return COLORS.gold;
  return COLORS.text3;
}

vaccineColor 接收疫苗状态字符串,返回对应的语义色。"已接种"映射为成功绿,"待接种"映射为婴儿蓝(表示待办、温和提示),"即将到期"映射为警示金(提醒用户关注),其余未知状态降级为三级文本灰。这种"状态 → 颜色"的映射函数把业务语义与视觉表现的耦合点集中到了一处,若未来要调整某状态的配色,只需改这一行而非散落各处的硬编码。

该函数在疫苗 Tab 中被两处调用:一是时间轴圆点的 fontColor,二是状态标签的 backgroundColor,实现了同状态在圆点与标签上色彩一致。

5.2 categoryColor:社区分类色映射

function categoryColor(c: string): string {
  if (c === '育儿') return COLORS.pink;
  if (c === '提问') return COLORS.blue;
  if (c === '分享') return COLORS.green;
  if (c === '二手') return COLORS.gold;
  return COLORS.text3;
}

categoryColor 接收社区帖子的分类字符串,返回对应的主题色。"育儿"用主色粉、"提问"用婴儿蓝、"分享"用成功绿、"二手"用警示金。这里可以看到一个有趣的色彩语义复用:社区分类与疫苗状态共用同一套绿、金语义色,但通过不同的映射函数赋予了不同的业务含义,避免了在视图层用 if-else 判断分类。

该函数在社区 Tab 中被三处调用:帖子分类标签背景色、两条回复中作者名前的色标,使同一帖子的分类色在帖子标签与回复列表中保持视觉串联。

六、数据模型层

该源码定义了六个 @Observed 装饰的数据模型类,分别对应六个 Tab 的数据结构。@Observed 使类的实例在被 @State 数组持有时,其内部字段变化能被框架跟踪并触发 UI 更新。下面逐一分析。

6.1 DiaryItem:日记项

@Observed export class DiaryItem {
  day: string; title: string; content: string; mood: string; weather: string; age: string;
  constructor(day: string, title: string, content: string, mood: string, weather: string, age: string) {
    this.day = day; this.title = title; this.content = content;
    this.mood = mood; this.weather = weather; this.age = age;
  }
}

DiaryItem 包含 6 个字段:day 是日记编号(如"01"),title 是标题,content 是正文,mood 是心情 emoji,weather 是天气 emoji,age 是月龄标签。构造函数采用全字段必填的位置参数,保证每个实例的字段完整。该模型驱动日记 Tab 的大编号榜布局,也是唯一支持新增、编辑、删除完整 CRUD 操作的模型,体现了日记作为核心功能的地位。

6.2 GrowthItem:成长项

@Observed export class GrowthItem {
  label: string; value: string; unit: string; percentile: string; trend: string;
  constructor(...) { ... }
}

GrowthItem 包含 5 个字段:label 是指标名(身高/体重/头围/前囟门),value 是数值,unit 是单位,percentile 是百分位(如 P75),trend 是趋势箭头(↑/→)。注意 value 设计为 string 而非 number,这是因为头围"43.5"等值需要直接显示,用 string 避免了格式化开销。该模型驱动成长 Tab 的渐变大数字卡片。

6.3 VaccineItem:疫苗项

@Observed export class VaccineItem {
  name: string; date: string; status: string; age: string; nextDate: string;
  constructor(...) { ... }
}

VaccineItem 包含 5 个字段:name 是疫苗名,date 是接种日期(未安排时为"-“),status 是状态文本(传给 vaccineColor),age 是对应月龄,nextDate 是下次接种日期(无则为”-")。该模型驱动疫苗 Tab 的状态时间轴,status 字段是色彩映射的输入源。

6.4 ProductItem:商品项

@Observed export class ProductItem {
  name: string; price: number; tag: string; rating: number; sold: number; icon: string;
  constructor(...) { ... }
}

ProductItem 包含 6 个字段:name 是商品名,price 是价格(number 类型,便于格式化),tag 是标签(热销/新品/推荐),rating 是评分,sold 是销量,icon 是 emoji 图标。该模型驱动商城 Tab 的双列卡片,是唯一 price 为 number 的模型,因为需要在卡片中拼接"¥"前缀并 toString() 显示。

6.5 ParentPostItem:社区帖子项

@Observed export class ParentPostItem {
  author: string; avatar: string; content: string; likes: number; comments: number; category: string;
  constructor(...) { ... }
}

ParentPostItem 包含 6 个字段:author 是作者昵称,avatar 是头像 emoji,content 是帖子正文,likes 是点赞数,comments 是评论数,category 是分类(传给 categoryColor)。该模型驱动社区 Tab 的评论卡片,是字段数最多的模型之一,因为社区内容天然信息密度高。

6.6 ParentItem:家长项

@Observed export class ParentItem {
  name: string; babyName: string; babyAge: string; babyGender: string; points: number; level: string;
  constructor(...) { ... }
}

ParentItem 包含 6 个字段:name 是家长姓名,babyName 是宝宝昵称,babyAge 是月龄,babyGender 是性别,points 是积分,level 是等级称号(如"金牌宝妈")。该模型驱动"我的"Tab 底部的家长排行榜列表。

六个模型的设计遵循了"字段语义明确、类型选择务实、构造器全必填"的统一风格,构成了应用的数据基座。

七、组件主体结构

7.1 @State 状态变量

@Entry
@Component
struct Page802 {
  @State currentTab: number = 0;
  @State addModal: boolean = false;
  @State editModal: boolean = false;
  @State delModal: boolean = false;
  @State editIdx: number = -1;
  @State delIdx: number = -1;
  @State breath: boolean = false;
  timer: number = -1;
  @State diaryList: DiaryItem[] = DIARY_LIST;
  @State growthList: GrowthItem[] = GROWTH_LIST;
  @State vaccineList: VaccineItem[] = VACCINE_LIST;
  @State productList: ProductItem[] = PRODUCT_LIST;
  @State postList: ParentPostItem[] = POST_LIST;
  @State parentList: ParentItem[] = PARENT_LIST;

组件 Page802 声明了 13 个状态变量,可按职责分为四组:

第一组是Tab 切换状态 currentTab,初始为 0,控制滚动区显示哪个 Tab 视图,是整个应用导航的中枢。

第二组是弹窗状态,包含三个布尔开关 addModal/editModal/delModal 与两个索引 editIdx/delIdx。三者互斥地控制三种弹窗的显隐,索引则记录当前操作的目标项。初始值 -1 作为"未选中"哨兵值。

第三组是动画状态 breath,一个布尔翻转量,配合 timer(非 @State,因为不参与渲染)驱动呼吸动画。

第四组是数据列表状态,六个 @State 数组分别持有六个模型的数据。它们被初始化为模块级常量(DIARY_LIST 等),这意味着首次渲染时所有数据已就绪,后续的 push/splice 操作会触发列表的局部刷新。

注意 timer 没有用 @State 修饰——这是有意为之:定时器 ID 不需要驱动渲染,把它排除在响应式系统外可以减少无谓的 diff 开销。

7.2 生命周期与呼吸动画

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

aboutToDisappear() {
  clearInterval(this.timer);
}

aboutToAppear 在组件实例创建后、build() 首次执行前被调用。这里启动一个 1000ms 间隔的定时器,每次回调将 breath 取反。由于 breath@State,它的变化会被框架捕获,进而触发依赖它的 scale 属性重新求值,配合 .animation({ duration: 800, curve: Curve.EaseInOut }) 形成"放大 → 缩小 → 放大"的呼吸动效。

aboutToDisappear 在组件销毁前调用,clearInterval 清理定时器。这一步至关重要:若遗漏,定时器会在组件销毁后继续运行,既造成内存泄漏,又可能操作已失效的实例引用导致崩溃。这是一对必须成对出现的生命周期钩子。

7.3 build() 入口

build() {
  Column() {
    this.headerBaby()
    Divider().strokeWidth(1).color(COLORS.line)
    Scroll() {
      Column() {
        if (this.currentTab === 0) { this.tabDiary() }
        else if (this.currentTab === 1) { this.tabGrowth() }
        // ... 其余 Tab
        this.chartCard()
      }
    }
    .scrollBar(BarState.Off)
    .layoutWeight(1)

    this.tabBar()

    if (this.addModal) { this.panelAdd(() => { this.addModal = false }) }
    if (this.editModal) { this.panelEdit(() => { this.editModal = false }) }
    if (this.delModal) { this.panelDel(() => { this.delModal = false }) }
  }
  .backgroundColor(COLORS.bg)
  .height('100%')
}

build() 用一个根 Column 自上而下叠放四部分:头部、分割线、可滚动内容区、底部 Tab 栏,弹窗在最顶层条件渲染。Scroll 设置 scrollBar(BarState.Off) 隐藏滚动条以保持视觉洁净,layoutWeight(1) 使其占据头部与 Tab 栏之间的全部剩余空间。Tab 切换通过 if/else if 链实现,而非 Tabs 容器组件——这意味着切换时旧 Tab 视图会被销毁、新 Tab 视图重新构建,适合"每个 Tab 数据独立、无需保留下拉位置"的场景。弹窗通过 if 条件渲染,传入一个关闭回调(() => { this.addModal = false })给弹窗内部,实现了弹窗自关闭与父状态解耦。

八、头部区域详解

头部 headerBaby 是整个应用最先映入眼帘的区域,它集成了宝宝信息卡、快捷入口、成长曲线缩略三段信息,是"信息密度"与"视觉呼吸"平衡的典范。

8.1 宝宝信息行

Row() {
  Stack() {
    Text('👶')
      .fontSize(32)
  }
  .width(64).height(64).borderRadius(32)
  .backgroundColor(COLORS.chip)
  .scale(this.breath ? { x: 1.05, y: 1.05 } : { x: 1, y: 1 })
  .animation({ duration: 800, curve: Curve.EaseInOut })

  Column() {
    Text('小团子').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
    Row() {
      Text('6月龄').fontSize(12).fontColor(COLORS.sub)
      Text(' · ').fontSize(12).fontColor(COLORS.text3)
      Text('男宝').fontSize(12).fontColor(COLORS.sub)
    }.margin({ top: 4 })
  }.margin({ left: 12 }).alignItems(HorizontalAlign.Start)

  Blank()
  Text('✏️').fontSize(20).onClick(() => { this.editModal = true; })
}

这一行用 Row 横向排列三部分:左侧是宝宝头像,中间是昵称与月龄性别,右侧是编辑入口。头像用 Stack 承载一个 emoji 文本,64x64 圆形(borderRadius(32),底色为 chip 暖粉。关键在于 scale 属性绑定 breath:当 breath 为 true 时缩放 1.05 倍,否则 1.0 倍,配合 800ms 的 EaseInOut 动画曲线,头像便以约 1.6 秒为周期缓缓"一呼一吸"。这种节奏比心跳略慢,传递出"安睡的婴儿"般的宁静感。

中间的昵称用 FontWeight.Bold 与标题色强化,月龄与性别用副文本色弱化,中间的" · "分隔符用更淡的三级文本色,形成三级灰阶层次。右侧的编辑铅笔 emoji 绑定 onClick 打开编辑弹窗——这里的编辑弹窗实际复用了日记编辑弹窗,是头部信息与日记共用一套编辑界面的设计选择。

8.2 快捷入口行

Row() {
  ForEach(QUICK_ENTRIES, (item: string) => {
    Text(item)
      .fontSize(12).fontColor(COLORS.pinkD)
      .padding({ left: 10, right: 10, top: 6, bottom: 6 })
      .backgroundColor(COLORS.chip)
      .borderRadius(16)
      .margin({ right: 8 })
  })
}

这一行用 ForEach 遍历四个快捷入口文案,渲染为胶囊形(borderRadius(16)的小标签。每个标签底色为 chip、文字为 pinkD 深粉,左右间距 8px。这四个入口(记日记/测身高/打疫苗/量体重)覆盖了日常育儿最高频的操作,把它们放在头部使其一键可达,是产品层面的高频操作前置策略。

8.3 成长曲线缩略

Row() {
  Text('📈 近6月身高').fontSize(12).fontColor(COLORS.sub)
  Blank()
  Row() {
    ForEach(MONTH_IDX, (idx: number) => {
      Column() {
        Column()
          .width(8).height((GROWTH_VAL[idx] - 45) * 6)
          .backgroundColor(COLORS.pink).borderRadius(4)
        Text(MONTH_NAME[idx]).fontSize(8).fontColor(COLORS.text3).margin({ top: 4 })
      }.margin({ right: 6 }).alignItems(HorizontalAlign.Center)
    })
  }.alignItems(VerticalAlign.Bottom).height(60)
}

这一段在头部右侧嵌入了一个 6 个月的迷你柱状图。柱子高度由 (GROWTH_VAL[idx] - 45) * 6 计算得出,宽 8px、圆角 4px、粉色,下方是 8 号字的月份标签。外层 Row 设置 alignItems(VerticalAlign.Bottom) 使所有柱子底部对齐,模拟真实柱状图的基线效果。这个缩略图让用户在首页就能感知成长趋势,是"数据可视化前置"的设计巧思。

整个头部用 backgroundColor(COLORS.card) 白底包裹,与全局粉色背景形成层次。

九、日记 Tab:大编号榜布局

日记 Tab 是默认首页,采用"大编号榜"布局——每条日记左侧是一个巨大的日期编号,右侧是标题、正文与心情标签,最右侧是编辑/删除操作。

@Builder
tabDiary() {
  Column() {
    Row() {
      Text('📖 宝宝日记').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
      Blank()
      Text('+ 新日记').fontSize(13).fontColor(COLORS.pink)
        .onClick(() => { this.addModal = true; })
    }.width('100%').padding({ left: 16, right: 16, top: 16, bottom: 8 })

    ForEach(this.diaryList, (item: DiaryItem, idx: number) => {
      Row() {
        Column() {
          Text(item.day).fontSize(40).fontWeight(FontWeight.Bold)
            .fontColor(idx % 2 === 0 ? COLORS.pink : COLORS.blue)
          Text('DAY').fontSize(9).fontColor(COLORS.text3)
        }.width(70).alignItems(HorizontalAlign.Center)

        Column() {
          Text(item.title).fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
            .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
          Text(item.content).fontSize(12).fontColor(COLORS.sub)
            .maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis }).margin({ top: 4 })
          Row() {
            Text(item.mood).fontSize(14)
            Text(item.weather).fontSize(14).margin({ left: 8 })
            Text(item.age).fontSize(11).fontColor(COLORS.text3).margin({ left: 8 })
          }.margin({ top: 6 })
        }.layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 12 })

        Column() {
          Text('✏️').fontSize(16).onClick(() => { this.editIdx = idx; this.editModal = true; })
          Text('🗑️').fontSize(16).margin({ top: 12 })
            .onClick(() => { this.delIdx = idx; this.delModal = true; })
        }.alignItems(HorizontalAlign.Center)
      }
      .width('100%').padding(16).margin({ left: 16, right: 16, bottom: 8 })
      .backgroundColor(COLORS.card).borderRadius(16)
    }, (item: DiaryItem) => item.day)
  }.width('100%').padding({ bottom: 16 })
}

逐段分析:

标题行用 Blank() 把"宝宝日记"标题与"+ 新日记"按钮推到两端对齐,新日记按钮绑定 addModal = true 打开新增弹窗。

列表用 ForEach 遍历 diaryList,键函数返回 item.day 保证列表项的唯一标识。每条日记是一个 Row,分为三列。

左列是一个 70px 宽的 Column,渲染 40 号字的日期编号与 9 号字的"DAY"后缀。编号颜色采用 idx % 2 === 0 ? COLORS.pink : COLORS.blue 的奇偶交错——这是粉蓝双主色体系最典型的应用方式,让相邻项色彩不同,增强扫读时的视觉节奏。

中列用 layoutWeight(1) 占据剩余空间,包含标题(单行省略)、正文(两行省略)、心情天气月龄行。maxLinestextOverflow: TextOverflow.Ellipsis 的组合是处理长文本的标准手段,保证卡片高度一致。

右列是两个操作 emoji:铅笔绑定编辑(先记录 editIdx 再开弹窗),垃圾桶绑定删除(先记录 delIdx 再开弹窗)。先记录索引再开弹窗的设计,使弹窗内部能通过 this.editIdx/this.delIdx 准确定位操作目标。

整个日记卡片白底、16px 圆角、16px 内边距,形成统一的卡片质感。

十、成长 Tab:渐变大数字卡

成长 Tab 采用"渐变大数字卡"布局,每张卡片以 48 号字的巨大数值为视觉焦点,配合百分位与趋势箭头,并叠加线性渐变背景。

@Builder
tabGrowth() {
  Column() {
    Row() {
      Text('📈 成长记录').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
      Blank()
      Text('更新 2024-08-28').fontSize(12).fontColor(COLORS.text3)
    }.width('100%').padding({ left: 16, right: 16, top: 16, bottom: 8 })

    ForEach(this.growthList, (item: GrowthItem, idx: number) => {
      Row() {
        Column() {
          Text(item.label).fontSize(14).fontColor(COLORS.sub)
          Row() {
            Text(item.value).fontSize(48).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
            Text(item.unit).fontSize(16).fontColor(COLORS.sub).margin({ left: 4, bottom: 8 })
          }.alignItems(VerticalAlign.Bottom).margin({ top: 4 })

          Row() {
            Text('百分位: ').fontSize(11).fontColor(COLORS.text3)
            Text(item.percentile).fontSize(11).fontWeight(FontWeight.Bold)
              .fontColor(idx % 2 === 0 ? COLORS.pink : COLORS.blue)
          }.margin({ top: 4 })
        }.alignItems(HorizontalAlign.Start).layoutWeight(1)

        Stack() {
          Text(item.trend).fontSize(32)
            .fontColor(item.trend === '↑' ? COLORS.green : COLORS.text3)
        }.width(56).height(56).borderRadius(28)
          .backgroundColor(COLORS.chip).alignContent(Alignment.Center)
      }
      .width('100%').padding(16).margin({ left: 16, right: 16, bottom: 8 })
      .backgroundColor(COLORS.card).borderRadius(16)
      .linearGradient({
        angle: 135,
        colors: idx % 2 === 0
          ? [[COLORS.chip, 0.0], [COLORS.card, 0.5]]
          : [[COLORS.card, 0.0], [COLORS.chip, 0.3]]
      })
    }, (item: GrowthItem) => item.label)
  }.width('100%').padding({ bottom: 16 })
}

逐段分析:

标题行右侧显示"更新 2024-08-28"日期戳,用三级文本色弱化,传递数据的时效性。

每张成长卡是左右结构。左侧 Column 中,标签用副文本色,数值用 48 号字加粗——这是全应用最大的字号,瞬间抓住视线。单位(cm/kg)用 16 号字、alignItems(VerticalAlign.Bottom) 与数值底部对齐,是经典的"大数字 + 小单位"排版范式。百分位标签同样采用奇偶粉蓝交错。

右侧是一个 56x56 圆形 Stack,内含趋势箭头。箭头颜色根据 item.trend === '↑' 判断:上升用成功绿,持平用三级灰,与色彩语义体系一致。

最关键的是 .linearGradient():通过 angle: 135(135 度对角)与一个双色 stops 数组,为卡片叠加了一层从 chip 暖粉到 card 纯白的渐变。奇偶项的渐变方向与起点相反,使相邻卡片呈现"左上粉 → 右下白"与"左上白 → 右下粉"的交错效果,极大地丰富了卡片的视觉层次,是纯色卡片无法企及的质感。

十一、疫苗 Tab:状态时间轴

疫苗 Tab 采用"状态时间轴"布局,每条疫苗项左侧是一条竖向连接的圆点时间轴,圆点颜色随接种状态变化。

@Builder
tabVaccine() {
  Column() {
    Row() {
      Text('💉 疫苗接种').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
      Blank()
      Text('已完成 4/6').fontSize(12).fontColor(COLORS.green)
    }.width('100%').padding({ left: 16, right: 16, top: 16, bottom: 8 })

    ForEach(this.vaccineList, (item: VaccineItem, idx: number) => {
      Row() {
        Column() {
          Column().width(2).height(idx === 0 ? 0 : 24).backgroundColor(COLORS.line)
          Stack() {
            Text('●').fontSize(16).fontColor(vaccineColor(item.status))
          }.width(28).height(28).borderRadius(14)
            .backgroundColor(COLORS.card).alignContent(Alignment.Center)
          Column().width(2).height(idx === this.vaccineList.length - 1 ? 0 : 24)
            .backgroundColor(COLORS.line)
        }.alignItems(HorizontalAlign.Center)

        Column() {
          Row() {
            Text(item.name).fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
            Blank()
            Text(item.status).fontSize(11).fontColor(COLORS.card)
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
              .borderRadius(10).backgroundColor(vaccineColor(item.status))
          }.width('100%')

          Row() {
            Text(item.age).fontSize(12).fontColor(COLORS.sub)
            Text(' · ').fontSize(12).fontColor(COLORS.text3)
            Text(item.date === '-' ? '待安排' : item.date).fontSize(12).fontColor(COLORS.sub)
          }.margin({ top: 4 })

          if (item.nextDate !== '-') {
            Text('下次接种: ' + item.nextDate).fontSize(11)
              .fontColor(COLORS.gold).margin({ top: 4 })
          }
        }.layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 12, top: 4, bottom: 4 })
      }.width('100%').padding({ left: 16, right: 16 })
    }, (item: VaccineItem) => item.name)
  }.width('100%').padding({ bottom: 16 })
}

逐段分析:

标题行右侧显示"已完成 4/6"进度,用成功绿突出已完成的数量,给用户进度反馈。

时间轴的实现是这一段的精髓。左侧 Column 垂直排列三段:上方一段 2px 宽、24px 高的竖线(首项高度为 0),中间是 28x28 圆形圆点,下方又是一段竖线(末项高度为 0)。首末项竖线高度归零,使时间轴呈现"中间贯通、首尾收口"的形态。圆点内的 字符的 fontColor 调用 vaccineColor(item.status),实现"已接种"绿点、"待接种"蓝点、"即将到期"金点的状态色编码。

右侧信息列中,疫苗名与状态标签同行——状态标签用白字(fontColor: COLORS.card)配状态色背景,形成高对比的色块标签。日期行用三元判断 item.date === '-' ? '待安排' : item.date,把占位符"-"转为更友好的中文。最后用 if (item.nextDate !== '-') 条件渲染下次接种提示,用警示金提醒用户关注 upcoming 的接种。

十二、商城 Tab:双列卡片

商城 Tab 采用"双列卡片"布局,利用 SHOP_PAIR_IDX 索引对把商品两两分组为行,每张卡片包含图标、名称、标签、价格、评分、销量。

@Builder
tabShop() {
  Column() {
    Row() {
      Text('🛒 母婴商城').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
      Blank()
      Text('🔍 搜索').fontSize(13).fontColor(COLORS.pink)
    }.width('100%').padding({ left: 16, right: 16, top: 16, bottom: 8 })

    ForEach(SHOP_PAIR_IDX, (pair: number[]) => {
      Row() {
        ForEach(pair, (pidx: number, pidxLocal: number) => {
          Column() {
            Stack() {
              Text(this.productList[pidx].icon).fontSize(40)
            }.width('100%').height(80).borderRadius(12)
              .backgroundColor(COLORS.chip).alignContent(Alignment.Center)

            Text(this.productList[pidx].name).fontSize(13).fontWeight(FontWeight.Bold)
              .fontColor(COLORS.title).maxLines(1)
              .textOverflow({ overflow: TextOverflow.Ellipsis }).margin({ top: 8 })

            Row() {
              Text(this.productList[pidx].tag).fontSize(10).fontColor(COLORS.card)
                .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                .borderRadius(8).backgroundColor(pidx % 2 === 0 ? COLORS.pink : COLORS.blue)
            }.margin({ top: 4 })

            Row() {
              Text('¥').fontSize(11).fontColor(COLORS.pinkD)
              Text(this.productList[pidx].price.toString()).fontSize(18)
                .fontWeight(FontWeight.Bold).fontColor(COLORS.pinkD)
            }.alignItems(VerticalAlign.Bottom).margin({ top: 4 })

            Row() {
              Text('★ ' + this.productList[pidx].rating).fontSize(10).fontColor(COLORS.gold)
              Blank()
              Text('销' + this.productList[pidx].sold).fontSize(10).fontColor(COLORS.text3)
            }.width('100%').margin({ top: 4 })
          }.layoutWeight(1).padding(12).backgroundColor(COLORS.card)
            .borderRadius(16)
            .margin({ left: pidxLocal === 0 ? 0 : 4, right: pidxLocal === 0 ? 4 : 0 })
        })
      }.width('100%').padding({ left: 16, right: 16 }).margin({ bottom: 8 })
    }, (pair: number[]) => pair[0].toString())
  }.width('100%').padding({ bottom: 16 })
}

逐段分析:

外层 ForEach 遍历 SHOP_PAIR_IDX(三组索引对),键函数返回 pair[0].toString()。内层 ForEach 遍历每一对中的两个商品索引 pidx,同时获取本地索引 pidxLocal(0 或 1)。

每张商品卡是一个 ColumnlayoutWeight(1) 使两列等宽平分。卡片顶部是 80px 高的图标区,底色 chip 暖粉、圆角 12px。商品名单行省略,标签(热销/新品)用白字配奇偶粉蓝背景的小色块。

价格行用"¥"小字 + 数字大字的组合,alignItems(VerticalAlign.Bottom) 使两者底对齐,数字 18 号加粗用 pinkD 深粉,是整张卡片的视觉焦点。底部评分用金色星标,销量用三级灰,Blank() 把它们推到两端。

最精妙的是 margin({ left: pidxLocal === 0 ? 0 : 4, right: pidxLocal === 0 ? 4 : 0 }):左卡(本地索引 0)右间距 4、左间距 0;右卡(本地索引 1)左间距 4、右间距 0。这使两张卡之间形成 8px 间隙(4+4),而与屏幕边缘无额外间距,由外层 padding({ left: 16, right: 16 }) 统一控制。

十三、社区 Tab:评论卡

社区 Tab 采用"评论卡"布局,每张帖子卡片包含作者行、正文、嵌套回复区与底部的点赞评论数。

@Builder
tabCommunity() {
  Column() {
    Row() {
      Text('💬 育儿社区').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
      Blank()
      Text('+ 发帖').fontSize(13).fontColor(COLORS.pink)
        .onClick(() => { this.addModal = true; })
    }.width('100%').padding({ left: 16, right: 16, top: 16, bottom: 8 })

    ForEach(this.postList, (item: ParentPostItem) => {
      Column() {
        Row() {
          Stack() { Text(item.avatar).fontSize(24) }
            .width(40).height(40).borderRadius(20)
            .backgroundColor(COLORS.chip).alignContent(Alignment.Center)
          Column() {
            Text(item.author).fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
            Text('2小时前').fontSize(10).fontColor(COLORS.text3).margin({ top: 2 })
          }.margin({ left: 8 }).alignItems(HorizontalAlign.Start)
          Blank()
          Text(item.category).fontSize(10).fontColor(COLORS.card)
            .padding({ left: 8, right: 8, top: 3, bottom: 3 })
            .borderRadius(10).backgroundColor(categoryColor(item.category))
        }.width('100%')

        Text(item.content).fontSize(13).fontColor(COLORS.title)
          .margin({ top: 10 }).lineHeight(20)

        Column() {
          Row() { Text('💬 最新回复').fontSize(11).fontColor(COLORS.text3) }.width('100%').margin({ top: 4 })
          Row() {
            Text('育儿达人: ').fontSize(11).fontColor(categoryColor(item.category))
            Text('说得太好了,学到了!').fontSize(11).fontColor(COLORS.sub)
          }.width('100%').padding({ left: 8, top: 4 })
          Row() {
            Text('新手妈妈: ').fontSize(11).fontColor(categoryColor(item.category))
            Text('感谢分享,收藏了!').fontSize(11).fontColor(COLORS.sub)
          }.width('100%').padding({ left: 8, top: 4 })
        }.width('100%').padding(8).margin({ top: 8 })
          .backgroundColor(COLORS.bg).borderRadius(8)

        Row() {
          Text('👍 ' + item.likes).fontSize(12).fontColor(COLORS.sub)
          Text('💬 ' + item.comments).fontSize(12).fontColor(COLORS.sub).margin({ left: 16 })
          Blank()
          Text('查看更多 >').fontSize(11).fontColor(COLORS.text3)
        }.width('100%').margin({ top: 8 })
      }.width('100%').padding(16).margin({ left: 16, right: 16, bottom: 8 })
        .backgroundColor(COLORS.card).borderRadius(16)
    }, (item: ParentPostItem) => item.author)
  }.width('100%').padding({ bottom: 16 })
}

逐段分析:

标题行的"+ 发帖"复用了 addModal,与日记 Tab 共用新增弹窗,这是组件内弹窗复用的体现。

每张帖子卡的作者行用 Row 横排头像、昵称时间、分类标签。头像 40x40 圆形 chip 底,昵称加粗,时间"2小时前"用三级灰弱化。分类标签用白字配 categoryColor(item.category) 背景,实现四分类四色编码。

正文用 lineHeight(20) 增大行高,提升多行可读性。

嵌套回复区是这一段的亮点:一个 Column 内含"最新回复"标题与两条回复,每条回复的作者名前缀用 categoryColor(item.category) 着色——这意味着回复的色标继承了帖子本身的分类色,形成了帖子与回复之间的视觉关联。整个回复区用 backgroundColor(COLORS.bg) 全局背景色包裹,在白色卡片内嵌入了一个"粉色嵌套区",层次分明。

底部用 Blank() 把点赞数、评论数与"查看更多"推到两端,引导用户深入互动。

十四、我的 Tab:中心大卡

"我的"Tab 采用"中心大卡"布局,顶部是放大的呼吸头像,下方依次是昵称、积分等级、功能宫格与家长排行榜。

@Builder
tabMine() {
  Column() {
    Stack() {
      Column() { Text('👶').fontSize(48) }
        .width(100).height(100).borderRadius(50).backgroundColor(COLORS.chip)
        .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
        .scale(this.breath ? { x: 1.03, y: 1.03 } : { x: 1, y: 1 })
        .animation({ duration: 1000, curve: Curve.EaseInOut })
    }.width('100%').height(120).alignContent(Alignment.Center).margin({ top: 24 })

    Text('小团子').fontSize(22).fontWeight(FontWeight.Bold).fontColor(COLORS.title).margin({ top: 12 })

    Row() {
      Text('6月龄').fontSize(13).fontColor(COLORS.sub)
      Text(' · ').fontSize(13).fontColor(COLORS.text3)
      Text('男宝').fontSize(13).fontColor(COLORS.sub)
    }.margin({ top: 4 })

    Row() {
      Column() {
        Text('2680').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.pink)
        Text('积分').fontSize(11).fontColor(COLORS.text3).margin({ top: 2 })
      }.alignItems(HorizontalAlign.Center)
      Column() {
        Text('金牌宝妈').fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.gold)
        Text('等级').fontSize(11).fontColor(COLORS.text3).margin({ top: 2 })
      }.alignItems(HorizontalAlign.Center).margin({ left: 32 })
    }.margin({ top: 16 })

    Row() {
      ForEach(MINE_FUNCS, (func: string[]) => {
        Column() {
          Stack() { Text(func[0]).fontSize(24) }
            .width(48).height(48).borderRadius(24)
            .backgroundColor(COLORS.chip).alignContent(Alignment.Center)
          Text(func[1]).fontSize(11).fontColor(COLORS.sub).margin({ top: 6 })
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)
      })
    }.width('100%').padding({ left: 16, right: 16 }).margin({ top: 24 })

    ForEach(this.parentList, (item: ParentItem, idx: number) => {
      Row() {
        Stack() { Text(idx === 0 ? '🧑' : '👩').fontSize(20) }
          .width(36).height(36).borderRadius(18)
          .backgroundColor(COLORS.chip).alignContent(Alignment.Center)
        Column() {
          Text(item.name + ' · ' + item.babyName).fontSize(13)
            .fontWeight(FontWeight.Bold).fontColor(COLORS.title)
          Text(item.babyAge + ' · ' + item.babyGender + ' · ' + item.level)
            .fontSize(11).fontColor(COLORS.sub).margin({ top: 2 })
        }.margin({ left: 8 }).alignItems(HorizontalAlign.Start)
        Blank()
        Text(item.points + '分').fontSize(13).fontColor(COLORS.pink)
      }.width('100%').padding(12).margin({ left: 16, right: 16, top: 8 })
        .backgroundColor(COLORS.card).borderRadius(12)
    }, (item: ParentItem) => item.name)
  }.width('100%').padding({ bottom: 16 }).alignItems(HorizontalAlign.Center)
}

逐段分析:

顶部头像是一个 100x100 的圆形,比头部的 64x64 更大,是全应用最大的头像。它同样绑定 breath 但缩放幅度更小(1.03 vs 1.05),动画时长更长(1000ms vs 800ms),呈现出比头部更缓慢、更柔和的呼吸节奏——这是"主页面"与"装饰位"的动效区分。

昵称用 22 号字,是全应用仅次于成长数值的大字号。积分用主色粉 20 号字,等级用金色 14 号字,两者左右排列形成"数据 + 称号"的组合。

功能宫格用 ForEach 遍历 MINE_FUNCS,每项是一个 48x48 圆形 emoji + 11 号字标签,layoutWeight(1) 四等分。这是典型的"金刚位"导航布局。

底部的家长排行榜用 ForEach 遍历 parentList,每行用 idx === 0 ? '🧑' : '👩' 给首项(当前用户)分配不同的头像 emoji 以区分自己与他人。姓名与宝宝名用字符串拼接 item.name + ' · ' + item.babyName 显示,月龄性别等级用副文本色弱化,积分用主色粉右对齐(通过 Blank() 推开)。

整个"我的"Tab 用 alignItems(HorizontalAlign.Center) 使所有内容水平居中,营造出"个人主页"的仪式感。

十五、图表卡片

chartCard 是一个独立的柱状图卡片,渲染在所有 Tab 内容之下(滚动区底部),展示近 6 个月身高趋势。

@Builder
chartCard() {
  Column() {
    Row() {
      Text('📊 近6月身高趋势').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
      Blank()
      Text('单位: cm').fontSize(11).fontColor(COLORS.text3)
    }.width('100%')

    Row() {
      ForEach(MONTH_IDX, (idx: number) => {
        Column() {
          Text(GROWTH_VAL[idx].toString()).fontSize(10).fontColor(COLORS.sub).margin({ bottom: 4 })
          Column()
            .width(24).height((GROWTH_VAL[idx] - 45) * 6)
            .linearGradient({ angle: 90, colors: [[COLORS.pink, 0.0], [COLORS.blue, 1.0]] })
            .borderRadius(12)
          Text(MONTH_NAME[idx]).fontSize(10).fontColor(COLORS.text3).margin({ top: 4 })
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)
      })
    }.width('100%').padding({ top: 16, bottom: 16 }).alignItems(VerticalAlign.Bottom)
  }.width('100%').padding(16).margin({ left: 16, right: 16, bottom: 16 })
    .backgroundColor(COLORS.card).borderRadius(16)
}

逐段分析:

标题行右侧标注"单位: cm",是数据可视化的基本规范。

柱状图的实现完全基于原生组件,未引入任何图表库。每根柱子是一个 Column,宽 24px,高度由 (GROWTH_VAL[idx] - 45) * 6 计算。关键在于 .linearGradient({ angle: 90, colors: [[COLORS.pink, 0.0], [COLORS.blue, 1.0]] }):90 度(自下而上)从婴儿粉渐变到婴儿蓝,使每根柱子呈现"粉底蓝顶"的双主色过渡,与全应用的色彩体系呼应。柱顶上方是数值标签,柱底下方是月份标签,外层 RowalignItems(VerticalAlign.Bottom) 使所有柱子基线对齐。

这张图表卡片常驻于所有 Tab 之下,意味着无论用户在哪个 Tab,向下滚动都能看到成长趋势——这是"核心数据全局可见"的产品决策。

十六、底部 Tab 栏

底部 Tab 栏是六个模块的导航入口,采用单排六等分布局。

@Builder
tabBar() {
  Row() {
    ForEach(TAB_LIST, (tab: TabMeta, idx: number) => {
      Column() {
        Image(tab.icon).width(22).height(22)
          .fillColor(this.currentTab === idx ? COLORS.tabOn : COLORS.text3)
        Text(tab.name).fontSize(10)
          .fontColor(this.currentTab === idx ? COLORS.tabOn : COLORS.text3)
          .margin({ top: 2 })
      }.layoutWeight(1).alignItems(HorizontalAlign.Center)
        .onClick(() => { this.currentTab = idx; })
    })
  }.width('100%').height(56).backgroundColor(COLORS.card).padding({ top: 8, bottom: 8 })
}

逐段分析:

ForEach 遍历 TAB_LIST,每个 Tab 是一个 ColumnlayoutWeight(1) 使六个 Tab 等宽平分。每个 Tab 含一个 22x22 的 Image 图标与 10 号字的名称。

选中态的核心是 fillColorfontColor 的三元判断:this.currentTab === idx ? COLORS.tabOn : COLORS.text3。选中时图标与文字均为主色粉(tabOnpink 同值),未选中时均为三级灰。Image.fillColor 是 ArkUI 对图标着色的能力,要求图标资源为单色 SVG/PNG,框架会按 fillColor 重新着色,这是实现"选中变色"而不准备两套图资源的关键。

点击事件 onClick(() => { this.currentTab = idx; }) 直接修改 currentTab,触发 build() 中 if/else 链的重新求值,切换显示对应 Tab 视图。整个 Tab 栏高 56px,白底,上下各 8px 内边距,是标准的底部导航栏高度。

十七、弹窗系统

弹窗系统由一个遮罩层 modalOverlay 与三个业务弹窗(panelAdd/panelEdit/panelDel)组成,通过 addModal/editModal/delModal 三个布尔状态控制显隐。

17.1 遮罩层 modalOverlay

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

modalOverlay 接收一个 onClose 回调,渲染一个占满全屏的半透明黑(rgba(0,0,0,0.5)Column,点击遮罩触发关闭。这是一个可复用的遮罩单元,被三个业务弹窗共同调用。把它抽为独立 @Builder 既避免了重复代码,又保证了三个弹窗遮罩行为一致。

17.2 新增弹窗 panelAdd

@Builder
panelAdd(onClose: () => void) {
  Stack() {
    this.modalOverlay(onClose)
    Column() {
      Text('📝 新写日记').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
      Text('日期').fontSize(13).fontColor(COLORS.sub).margin({ top: 16 })
      Row() {
        Text('07').fontSize(16).fontColor(COLORS.pink)
          .padding({ left: 12, right: 12, top: 6, bottom: 6 })
          .backgroundColor(COLORS.chip).borderRadius(12)
      }.width('100%').margin({ top: 4 })
      // ... 标题、内容输入区
      Row() {
        Text('取消').fontSize(14).fontColor(COLORS.sub)
          .padding({ left: 20, right: 20, top: 8, bottom: 8 })
          .backgroundColor(COLORS.bg).borderRadius(20)
          .onClick(() => { onClose(); })
        Text('保存').fontSize(14).fontColor(COLORS.card)
          .padding({ left: 20, right: 20, top: 8, bottom: 8 })
          .backgroundColor(COLORS.pink).borderRadius(20).margin({ left: 12 })
          .onClick(() => {
            this.diaryList.push(new DiaryItem('07', '今日趣事', '记录宝宝今天的成长', '😊', '☀️', '6月龄'));
            onClose();
          })
      }.margin({ top: 20 })
    }.width('80%').backgroundColor(COLORS.card).borderRadius(20).padding(20)
  }.width('100%').height('100%').alignContent(Alignment.Center)
}

panelAddStack 叠放遮罩与内容卡片,alignContent(Alignment.Center) 使卡片居中。卡片宽 80%、白底、20px 圆角。表单包含日期、标题、内容三段,当前以静态占位文本模拟输入框(真实场景应替换为 TextInput/TextArea)。

底部按钮区是关键:取消按钮用 bg 背景配副文本色(弱化),保存按钮用主色粉背景配白字(强化),左右间距 12px。保存按钮的 onClick 调用 this.diaryList.push(new DiaryItem(...)) 向列表追加一条新日记,由于 diaryList@State 数组且元素是 @Observed 实例,push 操作会触发日记 Tab 的列表自动刷新,新日记即时出现在列表末尾。随后调用 onClose() 关闭弹窗。这种"操作数据 + 关闭弹窗"的两步逻辑是增删改弹窗的标准范式。

17.3 编辑弹窗 panelEdit

@Builder
panelEdit(onClose: () => void) {
  Stack() {
    this.modalOverlay(onClose)
    Column() {
      Text('✏️ 编辑日记').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
      Text('编辑标题').fontSize(13).fontColor(COLORS.sub).margin({ top: 16 })
      Text(this.editIdx >= 0 ? this.diaryList[this.editIdx].title : '')
        .fontSize(14).fontColor(COLORS.title).padding(12)
        .backgroundColor(COLORS.bg).borderRadius(8).margin({ top: 4 })
      // ... 编辑内容、心情选择
      Row() {
        ForEach(MOOD_LIST, (m: string) => {
          Text(m).fontSize(24).padding(8).borderRadius(20)
            .backgroundColor(COLORS.chip).margin({ right: 8 })
        })
      }.margin({ top: 4 })
      // ... 取消/更新按钮(更新按钮用蓝色背景)
    }.width('80%').backgroundColor(COLORS.card).borderRadius(20).padding(20)
  }.width('100%').height('100%').alignContent(Alignment.Center)
}

panelEdit 与新增弹窗结构类似,但有两处关键差异。第一,它通过 this.editIdx 索引从 diaryList 中取出当前编辑项的数据回填到表单:this.editIdx >= 0 ? this.diaryList[this.editIdx].title : '' 这种三元判断是一种防御性编程,防止 editIdx 为 -1(未选中)时数组越界。第二,它包含一个心情选择区,用 ForEach 遍历 MOOD_LIST 渲染六个 emoji 选项,每个 24 号字、圆形 chip 底。更新按钮改用婴儿蓝背景,与新增弹窗的粉色保存按钮形成色彩区分,让用户通过按钮颜色即可辨识当前操作类型。

17.4 删除弹窗 panelDel

@Builder
panelDel(onClose: () => void) {
  Stack() {
    this.modalOverlay(onClose)
    Column() {
      Text('🗑️ 确认删除').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.title).margin({ top: 8 })
      Text('确定要删除这条日记吗?').fontSize(14).fontColor(COLORS.sub).margin({ top: 12 })
      Text(this.delIdx >= 0 ? this.diaryList[this.delIdx].title : '')
        .fontSize(13).fontColor(COLORS.pink).margin({ top: 4 })
      Row() {
        Text('取消').fontSize(14).fontColor(COLORS.sub)
          .padding({ left: 24, right: 24, top: 10, bottom: 10 })
          .backgroundColor(COLORS.bg).borderRadius(20)
          .onClick(() => { onClose(); })
        Text('删除').fontSize(14).fontColor(COLORS.card)
          .padding({ left: 24, right: 24, top: 10, bottom: 10 })
          .backgroundColor(COLORS.pinkD).borderRadius(20).margin({ left: 16 })
          .onClick(() => {
            if (this.delIdx >= 0) { this.diaryList.splice(this.delIdx, 1); }
            onClose();
          })
      }.margin({ top: 24 })
    }.width('70%').backgroundColor(COLORS.card).borderRadius(20).padding(24)
  }.width('100%').height('100%').alignContent(Alignment.Center)
}

panelDel 是一个确认对话框,宽度 70%(比新增/编辑的 80% 更窄),因为内容更少。它展示删除提示与目标日记标题(用主色粉高亮)。删除按钮用 pinkD 深粉背景——比新增弹窗的 pink 更深一档,传达"危险操作"的视觉警示。删除操作的 onClick 调用 this.diaryList.splice(this.delIdx, 1) 从数组移除指定索引项,同样触发列表自动刷新。这里同样有 if (this.delIdx >= 0) 的防御判断。

三个弹窗通过传入 onClose 回调实现关闭,而 onClosebuild() 中被定义为各自重置对应的布尔状态(如 () => { this.addModal = false }),这种"子组件回调驱动父状态"的模式使弹窗与父组件解耦,弹窗内部不直接修改父组件的 addModal/editModal/delModal,只通过回调通知,符合单向数据流原则。

十八、功能模块对比表

下表从八个维度横向对比六个 Tab 的实现差异,直观呈现"同框架下的六种布局策略"。

维度 日记 Tab 成长 Tab 疫苗 Tab 商城 Tab 社区 Tab 我的 Tab
布局方式 大编号榜(左编号 + 右内容 + 操作列) 渐变大数字卡(左数值 + 右趋势圆) 状态时间轴(左圆点竖线 + 右信息) 双列卡片(索引对两两成行) 评论卡(作者行 + 正文 + 嵌套回复) 中心大卡(居中头像 + 宫格 + 排行榜)
数据模型 DiaryItem GrowthItem VaccineItem ProductItem ParentPostItem ParentItem
字段数 6 5 5 6 6 6
核心操作 新增/编辑/删除(完整 CRUD) 无(仅展示) 无(仅展示) 无(仅展示) 发帖(复用新增弹窗) 无(仅展示)
动画效果 线性渐变背景 头像呼吸动画(1000ms)
状态颜色 奇偶粉蓝交错(编号色) 奇偶粉蓝交错(百分位色) 三态色映射(绿/蓝/金,vaccineColor) 奇偶粉蓝交错(标签色) 四分类色映射(粉/蓝/绿/金,categoryColor) 主色粉(积分)+ 金(等级)
数据量 6 条 4 条 6 条 6 条(3 行 x 2 列) 6 条 3 条
特殊组件 maxLines 省略号 linearGradient 渐变 时间轴竖线(首末归零) SHOP_PAIR_IDX 索引对 嵌套回复区(bg 底色) 功能宫格(MINE_FUNCS)

从对比表可以归纳出几个规律:其一,六个 Tab 的布局策略各不相同,避免了"同一套模板换数据"的单调感;其二,颜色映射策略分为两类——“奇偶交错”(日记/成长/商城)用于无语义的视觉节奏,“语义映射”(疫苗/社区)用于有业务含义的状态编码;其三,仅日记 Tab 实现了完整 CRUD,其余 Tab 以展示为主,反映了"记录"作为核心功能的产品定位;其四,动画效果仅在成长 Tab(渐变)与我的 Tab(呼吸)出现,前者是装饰性背景,后者是存在感动效,均未干扰交互。

十九、总结与展望

19.1 架构总结

纵观该源码,它是一个结构清晰、职责分明的母婴育儿应用原型。从架构层面看,它体现了以下几个值得借鉴的工程实践:

第一,色彩体系的集中化管理。通过 ColorPalette 接口与 COLORS 常量,将 15 个语义色槽位统一管理,所有视图只引用常量而非硬编码色值,既保证了视觉一致性,又为未来主题切换预留了扩展点。两个工具函数 vaccineColorcategoryColor 进一步把"业务状态 → 主题色"的映射逻辑收敛到一处,是色彩体系与业务逻辑之间的清洁边界。

第二,数据模型的 @Observed 化。六个数据模型全部用 @Observed 装饰,配合组件级 @State 数组,构建了一条"数据变更 → 框架感知 → 列表局部刷新"的响应式链路。新增(push)、删除(splice)操作无需手动调用刷新方法,框架自动处理,降低了状态同步的心智负担。

第三,@Builder 的合理拆分。一个千行组件通过 @Builder 拆分为头部、六个 Tab、图表、Tab 栏、遮罩、三个弹窗共十余个方法,每个方法职责单一、长度可控。这种"大组件 + 小函数"的组织方式在中等规模页面中平衡了内聚与可读性。

第四,弹窗系统的回调解耦。三个弹窗通过 onClose 回调与父组件通信,遮罩层抽为独立可复用单元,弹窗内部不直接修改父状态,遵循了单向数据流,使弹窗的显隐逻辑可预测、可测试。

第五,双主色奇偶交错策略。粉与蓝作为对偶主色,在日记编号、成长百分位、商城标签中以奇偶交错的方式轮流出现,是这套色彩体系最具辨识度的视觉手法,用两种颜色就撑起了整个列表的视觉节奏。

同时也要客观指出该原型的若干局限:弹窗内的输入区目前是静态占位文本而非真实的 TextInput/TextArea,距离可交互尚需补全;编辑弹窗的"更新"按钮仅关闭弹窗而未真正回写数据到 diaryList;六个 Tab 的数据均为静态常量,未接入网络请求与持久化存储;Tab 切换采用 if/else 销毁重建,在 Tab 内有滚动位置时无法保留状态。

19.2 未来展望

站在工程演进的角度,该应用有以下几个值得推进的升级方向:

其一,接入真实数据层。引入 @ohos.net.http@ohos.net.fetch 进行网络请求,将六个 @State 数组的初始化从静态常量改为 aboutToAppear 中的异步拉取,并增加 loading 与 error 状态。配合 @ohos.data.preferences@ohos.data.relationalStore 实现日记的本地持久化,使新增/编辑/删除操作在应用重启后依然生效。

其二,引入状态管理框架。当组件规模继续增长,组件级 @State 会变得难以维护。可引入 AppStorage/VokedLocalStorage 实现跨组件状态共享,或将六个列表数据抽离为独立的 Store 类,通过 @StorageLink 注入,使数据逻辑与视图进一步解耦。

其三,补全交互闭环。将弹窗内的占位文本替换为 TextInput/TextArea,编辑弹窗的更新按钮真正调用 this.diaryList[this.editIdx] 的字段赋值(依赖 @Observed 的字段级跟踪),新增弹窗的日期/标题/内容从输入框取值而非硬编码,心情选择区增加选中态高亮。

其四,图表能力升级。当前的柱状图基于原生 Column 高度模拟,可引入 @ohos/mc-component 中的图表组件或自绘 Canvas,支持折线、饼图、双轴图等更丰富的可视化形态,并为成长曲线增加 WHO 标准曲线对照。

其五,主题与无障碍。基于已有的 ColorPalette 接口,再实现一套"高对比模式"或"夜间模式"常量,通过运行时切换 COLORS 的指向实现动态换肤;同时为关键交互补充 accessibilityTextaccessibilityDescription,提升无障碍体验。

其六,动画体系升级。当前的呼吸动画基于 setInterval + 布尔翻转,可改用 animateToattributeModifier 实现更流畅的属性动画,并为列表项的新增/删除补充转场动画(Transition),使数据变化具备视觉过渡。

综上所述,该源码以婴儿粉与婴儿蓝为视觉锚点,用六个布局迥异的 Tab 串起了母婴育儿的完整闭环,在色彩体系、数据模型、组件拆分、弹窗解耦四个层面展现了 ArkUI 声明式范式的实践要领。它既是一个可运行的产品原型,更是一份可供初学者研读的 ArkUI 架构样本——从这套"温暖叙事"的代码中,我们能清晰地读出:优秀的前端架构,从来不是技术的堆砌,而是对用户场景、视觉语言与工程边界的同步尊重。

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

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


一、创建新项目

1.1 进入欢迎界面

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

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

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

在这里插入图片描述

1.2 选择项目模板

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

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

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

在这里插入图片描述

1.3 配置项目信息

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

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

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

在这里插入图片描述

1.4 完成创建

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

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

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

在这里插入图片描述

1.5 项目结构概览

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

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

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

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

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

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

在这里插入图片描述


二、查看 SDK 版本

2.1 查看 HarmonyOS SDK

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

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

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

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

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

在这里插入图片描述

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

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

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

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

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

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

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

在这里插入图片描述


三、小结

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

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


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

Logo

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

更多推荐