一、鸿蒙开发背景与技术范式演进

鸿蒙操作系统(HarmonyOS)作为华为推出的面向全场景智慧生活的分布式操作系统,其应用开发框架经历了从命令式到声明式的重要演进。在早期的鸿蒙应用开发中,开发者主要使用 Java 语言配合 XML 布局文件来构建用户界面,这种方式与传统的 Android 开发模式较为相似,开发者需要通过 findViewById 等方式获取控件引用,然后手动调用方法来更新界面状态。随着鸿蒙生态的不断发展,华为推出了 ArkUI 开发框架,以及配套的 ArkTS 语言,彻底改变了鸿蒙应用的开发范式。

ArkTS 是在 TypeScript 基础上扩展的鸿蒙应用开发语言,它保留了 TypeScript 的全部语法特性,同时增加了一系列装饰器(Decorator)和状态管理机制,使得开发者可以用声明式的方式来描述用户界面。所谓声明式 UI,指的是开发者只需要描述"界面在什么状态下应该是什么样子",而不需要关心"如何从一个状态切换到另一个状态"。框架会自动追踪状态变化,并高效地更新需要变化的界面部分。这种范式与 React、Flutter、SwiftUI 等现代 UI 框架的设计理念一脉相承,但又深度融入了鸿蒙系统的分布式能力和 ArkUI 组件体系。

声明式 UI 的核心思想是:UI = f(State)。给定一个状态,就有唯一确定的界面与之对应。当状态发生变化时,框架自动计算差异并更新界面。

在 ArkUI 框架中,一切界面元素都是组件。组件分为基础组件和容器组件两大类。基础组件如 Text、Image、Button、TextInput、Progress、Toggle 等,负责展示具体的内容或接收用户输入;容器组件如 Column、Row、Stack、Flex、Scroll、List 等,负责组织和布局其子组件。开发者通过组件的嵌套组合来构建复杂的界面结构,通过属性方法链式调用来设置组件的样式和行为,通过事件回调来处理用户交互。

ArkTS 的装饰器体系是整个声明式 UI 框架的灵魂。@Entry 装饰器标记页面入口组件,@Component 装饰器标记自定义组件,@State 装饰器标记组件内部的响应式状态,@Builder 装饰器标记可复用的 UI 片段,@Provide/@Consume 装饰器实现跨层级的状态共享,@Link 装饰器实现父子组件间的双向数据绑定。这些装饰器共同构成了一套完整的状态管理和组件化机制,使得开发者可以高效地构建、维护和复用复杂的用户界面。

鸿蒙 ArkUI 组件体系的设计哲学是"轻量、高效、可扩展"。每个组件都是一个独立的封装单元,拥有自己的状态、生命周期和渲染逻辑,组件之间通过属性传递和事件回调进行通信。

本文将通过一个完整的疫苗全程管家应用(VAX PASS)的代码实现,深入剖析 ArkTS 声明式 UI 的各项核心技术点。这个应用涵盖了底部 Tab 导航、时间轴列表、日历视图、卡片流布局、家庭成员切换、底部抽屉弹框、居中模态弹框、表单输入、动画效果等丰富的界面元素,是一个非常具有代表性的鸿蒙应用开发案例。我们将逐段分析代码,详细讲解每个组件的作用、每个装饰器的含义、每个状态变量的用途,以及每个交互逻辑的实现原理。

二、数据结构设计:界面的骨架与血液

在任何一个应用中,数据结构都是界面的基础。没有清晰合理的数据模型,再漂亮的界面也只是无源之水、无本之木。在鸿蒙 ArkTS 开发中,我们通常使用 interface 关键字来定义数据结构的类型,这既可以提供编译时的类型检查,又可以增强代码的可读性和可维护性。

2.1 疫苗信息接口

interface VaxT9 {
  id: number
  name: string
  total: number
  done: number
  next: string
  color: string
  age: string
}

在这里插入图片描述

这个接口定义了疫苗系列的基本信息结构。让我们逐一解析每个字段的含义:

id 字段是疫苗的唯一标识符,类型为 number。在使用 ForEach 进行列表渲染时,我们需要为每个列表项提供唯一的键值,这个 id 字段就承担了这个角色。唯一键值对于框架的高效 diff 算法至关重要,它可以帮助框架准确识别哪些元素发生了变化、哪些被添加、哪些被删除,从而最小化 DOM 操作,提升渲染性能。

name 字段存储疫苗的名称,比如 “HPV 九价”、“带状疱疹”、“乙肝” 等。这个字段会在多个界面位置展示,包括疫苗进度列表、预约弹框、接种记录等。

totaldone 字段分别表示该疫苗系列总共需要接种的针数和已经完成的针数。这两个字段配合使用,可以计算出接种进度百分比,用于绘制进度条和环形进度图。比如 HPV 九价疫苗总共需要 3 针,已完成 2 针,那么进度就是 67%。

在声明式 UI 中,数据驱动视图是核心原则。进度条的显示完全由 total 和 done 两个数据字段计算得出,不需要手动去操作进度条组件。

next 字段存储下一针接种的相关信息,它是一个字符串,可能包含日期、状态描述等内容。比如 “第3针 2026-09-12”、“已完成”、“待预约” 等。这个字段的显示颜色会根据内容动态变化,如果包含"完成"字样则显示绿色,如果包含"候补"或"待"字样则显示橙色,其他情况显示蓝色。

color 字段存储该疫苗对应的主题色值,用于进度条的着色。不同的疫苗使用不同的颜色标识,可以帮助用户快速区分不同的疫苗系列。比如 HPV 九价使用玫红色(#E64980),带状疱疹使用橙色(#F76707),乙肝使用绿色(#0CA678)。

age 字段存储该疫苗的适用年龄范围,比如 “16-26岁”、“50岁+”、“全年龄” 等。这个信息可以帮助用户了解疫苗的适用人群。

2.2 接种记录接口

interface RecT9 {
  id: number
  vax: string
  dose: string
  date: string
  org: string
  batch: string
  react: string
  ok: boolean
}

在这里插入图片描述

这个接口定义了单条接种记录的数据结构。每条记录代表一次具体的疫苗接种行为。

id 是记录的唯一标识。vax 是疫苗名称,dose 是针次描述(如 “第 2 针”),这两个字段组合起来完整描述了这次接种的是什么疫苗的哪一针。

date 字段存储接种日期,格式为 YYYY-MM-DD。org 字段存储接种机构的名称。batch 字段存储疫苗批号,这是一个非常重要的信息,在疫苗安全追溯中起着关键作用。

react 字段记录接种后的不良反应情况。如果没有不良反应则存储 “无”,否则存储具体的反应描述,比如 “轻微手臂酸胀 1 天”、“低热 37.4℃ 半天” 等。这个字段的显示颜色会根据内容动态变化,如果是 “无” 则显示浅绿色,否则显示橙色,以起到提醒作用。

ok 字段是一个布尔值,表示这条记录是否有效。虽然在当前界面中没有直接使用这个字段,但它为后续的功能扩展留下了空间,比如记录审核状态、删除标记等。

良好的数据结构设计应该为未来的功能扩展预留空间。ok 字段虽然当前未被界面直接使用,但它是数据完整性的重要组成部分。

2.3 门店信息接口

interface StoreT9 {
  id: number
  name: string
  dist: number
  addr: string
  stock: string
  score: number
}

在这里插入图片描述

这个接口定义了接种门店的信息结构。

id 是门店唯一标识。name 是门店名称,addr 是门店地址。dist 是用户到门店的距离,单位为公里,类型为 number。score 是门店的评分,满分通常为 5 分。

stock 字段描述门店的疫苗库存情况,它是一个字符串,可能包含多种疫苗的库存信息,比如 “HPV九价 有苗 · 带状疱疹 有苗”、“HPV九价 候补排队”、“狂犬 有苗(24h)” 等。这个信息对于用户选择接种门店非常关键。

2.4 文章、家庭成员与提醒接口

interface ArtT9 {
  id: number
  title: string
  cate: string
  read: string
  hot: boolean
}

interface FamT9 {
  id: number
  name: string
  icon: string
  done: number
  total: number
}

interface RemT9 {
  id: number
  vax: string
  days: number
  way: string
  on: boolean
}

ArtT9 接口定义了疫苗知识文章的数据结构。title 是文章标题,cate 是文章分类(如 “HPV”、“流感”、“乙肝” 等),read 是阅读量(如 “12.6万”),hot 是一个布尔值,表示是否为热门文章。

FamT9 接口定义了家庭成员的信息结构。name 是成员姓名,icon 是成员头像(使用 emoji 表示),donetotal 分别是已接种针数和应接种总针数。这个结构既用于接种本页面顶部的家庭成员切换标签,也用于"我的"页面的家庭接种完成数柱状图。

RemT9 接口定义了接种提醒的信息结构。vax 是疫苗名称,days 是距离接种日期的天数,way 是提醒方式(如 “推送”、“短信”、“推送+短信”、“管家电话”),on 是一个布尔值,表示该提醒是否已开启。

数据接口的命名应该简洁明了,同时保持一致性。在这个应用中,所有接口都以 T9 为后缀,所有数据数组都以 9 为后缀,这是一种统一的命名约定。

2.5 静态数据与工具函数

在定义了数据结构之后,应用中使用了大量的静态常量数据来填充界面。这些数据以 const 关键字声明,包括疫苗列表 VAXS9(12 条数据)、接种记录 RECS9(12 条数据)、门店列表 STORES9(8 条数据)、文章列表 ARTS9(10 条数据)、家庭成员 FAMS9(4 条数据)、提醒列表 REMS9(6 条数据),以及时段列表 SLOT9、不良反应选项 REACT9、提醒时机选项 DAYS9、提醒方式选项 WAY9 等辅助数据。

这些静态数据虽然是写死的,但它们完美地模拟了真实应用中的数据形态,使得界面展示更加丰富和真实。在实际的生产环境中,这些数据通常会通过网络请求从后端服务器获取,但在开发阶段或者 Demo 展示中,使用静态数据是一种常见且高效的做法。

function doneColor9(t: string): string {
  if (t.indexOf('完成') >= 0) {
    return '#0CA678'
  }
  if (t.indexOf('候补') >= 0 || t.indexOf('待') >= 0) {
    return '#F76707'
  }
  return '#3B5BDB'
}

在这里插入图片描述

doneColor9 是一个工具函数,它接收一个字符串参数,根据字符串的内容返回不同的颜色值。如果字符串中包含 “完成”,返回绿色(#0CA678),表示已完成状态;如果包含 “候补” 或 “待”,返回橙色(#F76707),表示待处理状态;其他情况返回蓝色(#3B5BDB),表示进行中状态。这个函数在疫苗进度列表中被频繁调用,用于给下一针状态文字着色。

工具函数的抽取是代码复用的重要手段。将颜色判断逻辑封装为独立函数,可以避免在多处重复编写相同的条件判断代码。

三、主组件结构:页面的入口与骨架

3.1 @Entry 与 @Component 装饰器

@Entry
@Component
struct Index {
  // ... 状态变量和方法
}

在鸿蒙 ArkTS 中,一个页面的根组件需要使用 @Entry 装饰器来标记。@Entry 表示这个组件是页面的入口,鸿蒙系统会从这个组件开始渲染整个页面。一个页面只能有一个 @Entry 组件。

@Component 装饰器用于标记一个结构体(struct)为自定义组件。被 @Component 装饰的结构体必须实现 build() 方法,在 build() 方法中描述该组件的 UI 结构。@Component 组件可以复用,可以嵌套,可以接收参数,是构建复杂界面的基本单元。

@Entry 装饰的组件是页面的根组件,它会自动充满整个屏幕。@Component 装饰的组件是可复用的 UI 单元,可以被其他组件引用。

3.2 @State 状态变量

在 Index 组件内部,定义了大量使用 @State 装饰器修饰的状态变量。这些状态变量是界面动态变化的驱动力。

@State curTab9: number = 0
@State vaxs9: Array<VaxT9> = VAXS9.slice()
@State recs9: Array<RecT9> = RECS9.slice()
@State rems9: Array<RemT9> = REMS9.slice()
@State famIdx9: number = 0
@State checked9: Array<number> = [1, 3, 5, 8, 12, 16, 20]

在这里插入图片描述

curTab9 记录当前选中的底部 Tab 索引,初始值为 0(即第一个 Tab “接种本”)。当用户点击不同的底部 Tab 时,这个值会发生变化,进而触发界面切换到对应的 Tab 页面。

vaxs9recs9rems9 分别是疫苗列表、接种记录列表、提醒列表的状态变量。它们通过 slice() 方法从静态常量数组中复制一份数据,这样做的目的是确保状态变量是可变的——因为静态 const 数组是不可修改的,而我们需要在用户操作(如预约、补录、删除等)时修改这些数据。

使用 slice() 方法复制数组是一种常见的浅拷贝技巧。对于简单数据结构的数组,浅拷贝就足够了;但对于复杂的嵌套对象,可能需要深拷贝才能确保数据的独立性。

famIdx9 记录当前选中的家庭成员索引,用于在接种本页面切换不同家庭成员的接种数据。初始值为 0,即第一位家庭成员"我"。

checked9 是一个数字数组,存储日历中被标记为接种日的日期编号。初始值包含 7 个日期,代表这些天有接种安排。用户点击日历格子时,可以切换对应日期的选中状态。

接下来是一组弹框开关状态变量:

@State showBook9: boolean = false
@State showRec9: boolean = false
@State showAdd9: boolean = false
@State showRem9: boolean = false
@State showCancel9: boolean = false
@State showReact9: boolean = false

在这里插入图片描述

这六个布尔变量分别控制六个弹框的显示与隐藏。初始值都是 false,即弹框默认不显示。当用户执行某些操作(如点击预约按钮、点击记录条目等)时,对应的变量会被设置为 true,弹框就会显示出来。

在声明式 UI 中,弹框的显示与隐藏完全由状态变量控制。不需要调用 show() 或 dismiss() 之类的命令式方法,只需要修改状态变量的值即可。

然后是预约表单相关的状态变量:

@State bkVax9: number = 0
@State bkStore9: number = 0
@State bkSlot9: number = 2
@State bkFam9: number = 0

在这里插入图片描述

这四个变量分别记录预约表单中选中的疫苗索引、门店索引、时段索引和家庭成员索引。初始都设置了默认值,用户在弹框中点击不同选项时,这些变量会相应更新。

还有记录详情 ID、补录表单数据、提醒编辑数据、取消预约确认状态、不良反应上报数据,以及动画效果相关的状态变量,我们将在后续对应的界面部分详细讲解。

3.3 aboutToAppear 生命周期与动画

aboutToAppear(): void {
  this.getUIContext().animateTo({ duration: 1500, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
    this.shieldOp9 = 0.95
  })
  this.getUIContext().animateTo({ duration: 1900, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
    this.dropY9 = 6
  })
}

在这里插入图片描述

aboutToAppear 是组件的生命周期函数之一,它会在组件即将出现在屏幕上之前被调用。这个时机非常适合执行一些初始化动画、数据预加载等操作。

在这段代码中,我们启动了两个无限循环的动画。第一个动画控制 shieldOp9(盾牌图标的透明度),持续时间为 1500 毫秒,即 1.5 秒一个周期。iterations: -1 表示无限循环,playMode: PlayMode.Alternate 表示交替播放(正向播放完后反向播放),curve: Curve.EaseInOut 表示使用缓入缓出的动画曲线。动画的目标是将透明度从初始的 0.35 变化到 0.95,然后再变化回来,形成呼吸灯般的闪烁效果。

第二个动画控制 dropY9(针管图标的垂直位移),持续时间为 1900 毫秒。它让针管图标在垂直方向上上下浮动,模拟液体滴落的效果。初始位移是 -5(向上偏移 5 像素),动画目标是 6(向下偏移 6 像素)。

animateTo 是 ArkUI 中最核心的动画 API。它的第一个参数是动画配置对象,第二个参数是一个闭包函数,在闭包中修改的状态变量所关联的所有界面属性都会产生动画效果。

getUIContext() 方法用于获取当前组件的 UI 上下文,在早期的鸿蒙版本中,animateTo 是全局函数,可以直接调用;但在较新的版本中,推荐通过 UI 上下文来调用,这样可以更精确地控制动画的作用范围。

四、头部组件:品牌展示与搜索入口

4.1 @Builder 装饰器的用途

@Builder headerBar9() {
  Column() {
    // ... 头部内容
  }
  .width('100%')
  .backgroundColor('#E6FCF5')
  .borderRadius({ bottomLeft: 20, bottomRight: 20 })
}

@Builder 是 ArkTS 中一个非常实用的装饰器,它用于装饰一个方法,使其成为一个可复用的 UI 构建函数。被 @Builder 装饰的方法可以像组件一样在 build() 方法或其他 @Builder 方法中被调用,它内部的 UI 描述会被插入到调用位置。

@Builder 的核心价值在于 UI 复用和代码组织。将一个相对独立的 UI 模块抽取为 @Builder 方法,可以让主 build 函数更加清晰简洁,同时也便于在多处复用相同的 UI 片段。

使用 @Builder 装饰器时需要注意以下几点:第一,@Builder 方法必须定义在 @Component 组件内部;第二,@Builder 方法不能有返回值,它的返回类型是 void;第三,@Builder 方法内部只能包含 UI 描述代码,不能包含复杂的业务逻辑。

4.2 Column 与 Row 容器组件

头部组件的最外层是一个 Column 容器。Column 是 ArkUI 中最基础的容器组件之一,它的子组件会沿着垂直方向(从上到下)依次排列。与之对应的是 Row 容器,子组件沿水平方向(从左到右)依次排列。

Column() {
  Row() {
    Column() {
      Text('💉 VAX PASS').fontSize(17).fontWeight(700).fontColor('#087F5B').letterSpacing(1)
      Text('全家人的疫苗数字接种本').fontSize(10).fontColor('#74B89A').margin({ top: 3 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)

    Row() {
      Text('🛡️').fontSize(18).opacity(this.shieldOp9)
      Text('已护 43 针').fontSize(10).fontColor('#087F5B').fontWeight(700).margin({ left: 4 })
    }
    .padding({ left: 10, right: 10, top: 5, bottom: 5 })
    .borderRadius(12)
    .backgroundColor('#D3F3E8')
  }
  .width('100%')
  .padding({ left: 16, right: 16, top: 14, bottom: 10 })

头部的第一行使用 Row 容器,内部包含左右两部分。左边是品牌名称和副标题,使用 Column 垂直排列两行文字。右边是一个"已护针数"的标签,使用 Row 水平排列盾牌图标和文字。

.alignItems(HorizontalAlign.Start) 设置 Column 的子组件在水平方向上左对齐。默认情况下,Column 的子组件在水平方向上是居中对齐的。与之对应的,Row 容器使用 alignItems(VerticalAlign.*) 来设置子组件在垂直方向上的对齐方式。

.layoutWeight(1) 是一个非常重要的属性,它用于弹性布局中的权重分配。当父容器是 RowColumn 时,设置了 layoutWeight 的子组件会占据父容器中剩余空间的对应比例。在这个例子中,左边的 Column 设置了 layoutWeight(1),而右边的 Row 没有设置,所以左边会占据除右边内容宽度之外的所有剩余空间。

layoutWeight 的工作原理与 Android 中的 layout_weight 和 CSS Flexbox 中的 flex-grow 类似。它定义了子组件在父容器主轴方向上占据剩余空间的权重比例。

盾牌图标的 .opacity(this.shieldOp9) 属性绑定了 shieldOp9 状态变量。由于我们在 aboutToAppear 中启动了一个改变 shieldOp9 的动画,所以这个盾牌图标会产生呼吸闪烁的动画效果。这就是声明式动画的魅力——你只需要在动画闭包中修改状态变量,所有绑定了该状态变量的属性都会自动产生过渡动画。

4.3 搜索栏与快捷入口

Row() {
  Text('🔎').fontSize(13)
  Text('查疫苗 / 查批次 / 查门店库存').fontSize(11).fontColor('#74B89A').margin({ left: 6 }).layoutWeight(1).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
  Text('💉').fontSize(16).translate({ y: this.dropY9 }).onClick(() => {
    this.showBook9 = true
  })
}
.width('100%')
.padding({ left: 12, right: 12, top: 9, bottom: 9 })
.borderRadius(20)
.backgroundColor('#FFFFFF')
.margin({ left: 16, right: 16, bottom: 12 })

搜索栏也是一个 Row 容器,内部包含搜索图标、搜索提示文字和一个快捷预约按钮。

.maxLines(1) 限制文字最多显示一行。.textOverflow({ overflow: TextOverflow.Ellipsis }) 设置当文字超出容器宽度时,以省略号(…)结尾。这两个属性经常配合使用,用于处理可能过长的文本内容。

针管图标使用了 .translate({ y: this.dropY9 }) 属性来设置垂直方向的位移变换。translate 是一种变换属性,它不会影响组件在布局中的位置,只是改变组件的视觉渲染位置。由于 dropY9 是一个受动画控制的状态变量,所以针管图标会产生上下浮动的动画效果。

.onClick() 是点击事件的回调函数。当用户点击针管图标时,会将 showBook9 设置为 true,从而显示预约弹框。

事件处理是声明式 UI 中用户交互的核心机制。通过 onClick、onTouch、onChange 等事件回调,应用可以响应用户的各种操作,并通过修改状态变量来更新界面。

头部整体使用了浅绿色的背景色 #E6FCF5,底部左右两个角设置了 20 像素的圆角,形成了顶部"头部卡片"的视觉效果。这种设计在移动应用中非常常见,可以让头部区域与下方内容区域形成视觉上的层次感。

五、底部 Tab 导航:页面切换的核心机制

5.1 自定义 Tab 项组件

@Builder tabItem9(icon: string, label: string, idx: number) {
  Column() {
    Text(icon).fontSize(19)
    Text(label).fontSize(9).fontColor(this.curTab9 === idx ? '#087F5B' : '#94B8A6').margin({ top: 3 })
  }
  .alignItems(HorizontalAlign.Center)
  .padding({ top: 8, bottom: 6 })
  .layoutWeight(1)
  .onClick(() => {
    this.curTab9 = idx
  })
}

tabItem9 是一个带参数的 @Builder 方法,它接收三个参数:icon(图标 emoji)、label(文字标签)和 idx(Tab 索引)。参数化的 @Builder 方法大大增强了 UI 复用的灵活性,同一个构建函数可以根据不同的参数生成不同的 UI 内容。

每个 Tab 项是一个 Column 容器,垂直排列图标和文字。图标使用 Text 组件展示 emoji 字符,这是一种简单而有效的图标实现方式,不需要引入额外的图片资源。

文字颜色使用了条件表达式:this.curTab9 === idx ? '#087F5B' : '#94B8A6'。这意味着如果当前 Tab 索引等于该项的索引(即该项被选中),文字显示深绿色 #087F5B;否则显示灰绿色 #94B8A6。这是声明式 UI 中实现选中态效果的典型方式——通过状态变量与当前项的比较,动态计算样式属性。

条件表达式(三元运算符)是 ArkTS 声明式 UI 中实现动态样式的最常用手段。由于 UI 描述代码中不能使用 if/else 语句(除了控制组件是否渲染的 if 语句),三元运算符成为了属性级别的条件控制利器。

每个 Tab 项都设置了 .layoutWeight(1),这意味着在父容器 Row 中,所有 Tab 项会平分水平方向的空间。六个 Tab 项就各占六分之一的宽度。

点击事件的处理非常简洁:将 curTab9 设置为当前项的索引。由于 curTab9@State 状态变量,它的变化会触发界面的重新渲染,包括 Tab 项文字颜色的变化,以及主内容区域 Tab 页面的切换。

5.2 Tab 栏容器

@Builder tabBar9() {
  Row() {
    this.tabItem9('📖', '接种本', 0)
    this.tabItem9('📅', '预约', 1)
    this.tabItem9('⏰', '提醒', 2)
    this.tabItem9('📚', '知识', 3)
    this.tabItem9('🏥', '门店', 4)
    this.tabItem9('👤', '我的', 5)
  }
  .width('100%')
  .backgroundColor('#FFFFFF')
  .border({ width: 0.5, color: '#D3F3E8' })
}

tabBar9 方法构建了完整的底部 Tab 栏。它是一个 Row 容器,内部调用了六次 tabItem9 方法,分别对应六个 Tab:接种本、预约、提醒、知识、门店、我的。

.border() 属性设置了顶部边框,宽度为 0.5 像素,颜色为浅绿色。边框属性可以分别设置四个方向的边框宽度和颜色,也可以统一设置。这里只需要顶部边框来分隔 Tab 栏和主内容区域。

虽然鸿蒙系统提供了官方的 Tabs 组件,但在实际开发中,很多设计师更喜欢自定义底部 Tab 栏的样式,以获得更灵活的视觉效果。自定义 Tab 栏配合 @State 变量和条件渲染,完全可以实现官方 Tabs 组件的功能。

六、接种本 Tab:时间轴与进度展示

6.1 家庭成员切换 Chips

Row() {
  ForEach(FAMS9, (f: FamT9, i: number) => {
    Text(f.icon + ' ' + f.name).fontSize(10).fontColor(this.famIdx9 === i ? '#FFFFFF' : '#4D7C6A').padding({ left: 11, right: 11, top: 6, bottom: 6 }).borderRadius(14).backgroundColor(this.famIdx9 === i ? '#087F5B' : '#FFFFFF').margin({ right: 8 }).onClick(() => {
      this.famIdx9 = i
    })
  }, (f: FamT9) => 'fm' + f.id.toString())
}
.width('100%')
.padding({ left: 14, right: 14, top: 12 })

接种本页面的顶部是家庭成员切换标签,也称为 Chips(芯片标签)。它使用 ForEach 来遍历家庭成员数组 FAMS9,为每个家庭成员生成一个标签。

ForEach 是 ArkUI 中用于列表渲染的核心组件,它可以根据数组数据批量生成一组子组件。ForEach 接收三个参数:第一个是要遍历的数组,第二个是生成每个列表项的回调函数,第三个是键值生成函数(用于给每个列表项分配唯一标识)。

ForEach 的第三个参数——键值生成函数——非常重要。它返回的字符串必须是每个列表项的唯一标识,框架依靠这个标识来追踪列表项的变化,从而实现高效的列表更新。如果键值重复或缺失,可能导致渲染错误或性能问题。

每个标签的样式同样通过条件表达式来实现选中态。当 famIdx9 等于当前索引时,文字为白色、背景为深绿色;否则文字为深绿灰色、背景为白色。点击标签时,更新 famIdx9 的值为当前索引,从而切换选中的家庭成员。

6.2 Stack 层叠布局与环形进度

Stack() {
  Progress({ value: 67, total: 100 })
    .width(66)
    .height(66)
    .style({ strokeWidth: 7 })
    .color('#0CA678')
  Column() {
    Text('67%').fontSize(14).fontWeight(700).fontColor('#087F5B')
    Text('全程完成').fontSize(8).fontColor('#74B89A')
  }
  .alignItems(HorizontalAlign.Center)
}

这段代码展示了 Stack 容器组件的用法。Stack 是层叠布局容器,它的子组件会沿着垂直于屏幕的方向(Z 轴方向)堆叠在一起,后定义的子组件会显示在先定义的子组件上面。

Stack 容器的特点是子组件相互叠加,常用于需要图层叠加的场景,比如在图片上显示文字、在进度条中心显示数值、弹框层叠在主界面之上等。

在这里,Progress 组件作为底层,显示一个环形进度条。Progress 是进度组件,通过 valuetotal 来设置进度值和总值,这里表示 67% 的完成度。.style({ strokeWidth: 7 }) 设置进度条的线宽为 7 像素。默认的 Progress 样式是环形的,适合展示圆形进度。

在环形进度条的上方,是一个 Column 容器,居中显示百分比数字和"全程完成"文字。由于 Stack 的层叠特性,这段文字正好显示在环形进度条的中心位置。

Stack 容器默认会将子组件在水平和垂直方向上都居中对齐。这也是为什么文字会自动居中在进度条中央的原因。

6.3 疫苗进度横条列表

ForEach(this.vaxs9, (v: VaxT9) => {
  Row() {
    Text(v.name).fontSize(11).fontColor('#213A30').width(74)
    Column() {
      Column() {
      }
      .width((v.done / v.total * 100).toFixed(0) + '%')
      .height('100%')
      .borderRadius(3)
      .backgroundColor(v.color)
    }
    .layoutWeight(1)
    .height(6)
    .borderRadius(3)
    .backgroundColor('#E6FCF5')
    Text(v.done.toString() + '/' + v.total.toString()).fontSize(9).fontColor('#74B89A').margin({ left: 8 }).width(26)
    Text(v.next).fontSize(8).fontColor(doneColor9(v.next)).layoutWeight(1).textAlign(TextAlign.End).maxLines(1)
  }
  .width('100%')
  .alignItems(VerticalAlign.Center)
  .margin({ top: 9 })
}, (v: VaxT9) => 'vx' + v.id.toString() + '_' + v.done.toString())

疫苗进度列表使用 ForEach 遍历 vaxs9 数组,为每种疫苗生成一行进度信息。每一行是一个 Row 容器,从左到右依次是:疫苗名称、进度条、针数比分、下一状态文字。

进度条的实现非常巧妙。外层是一个 Column 容器,高度固定为 6 像素,背景色为浅绿色 #E6FCF5,设置了 .layoutWeight(1) 来占据剩余空间。内层也是一个 Column 容器(空内容),它的宽度通过百分比动态计算:(v.done / v.total * 100).toFixed(0) + '%'。这个百分比就是接种进度的百分比,比如 HPV 九价完成 2/3,就是 67%。内层 Column 的背景色使用疫苗对应的主题色 v.color

用嵌套的 Column/Row 容器来实现进度条是鸿蒙开发中的一种常见技巧。通过控制内层容器的宽度/高度百分比,可以灵活地实现各种形状的进度指示器。

进度条右侧显示"已完成/总针数"的比分,如 “2/3”。最右侧显示下一针的状态文字,颜色通过 doneColor9 工具函数动态计算。

键值生成函数中包含了 v.done 的值:'vx' + v.id.toString() + '_' + v.done.toString()。这是因为当疫苗的已完成针数变化时,我们希望框架将其视为一个新的列表项来重新渲染,确保进度条的动画效果能够正确触发。

6.4 接种记录时间轴

ForEach(this.recs9, (r: RecT9) => {
  Row() {
    Column() {
      Circle().width(10).height(10).fill('#0CA678')
      Column() {
      }
      .width(2)
      .layoutWeight(1)
      . backgroundColor('#B2E5CE')
    }
    .width(12)
    .alignItems(HorizontalAlign.Center)
    .margin({ top: 2 })

    Column() {
      Row() {
        Text(r.vax + ' ' + r.dose).fontSize(12).fontWeight(700).fontColor('#213A30')
        Text(r.date).fontSize(9).fontColor('#74B89A').margin({ left: 10 })
      }
      .width('100%')
      Text(r.org + ' · 批号 ' + r.batch).fontSize(9).fontColor('#74B89A').margin({ top: 4 }).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
      Text('反应:' + r.react).fontSize(9).fontColor(r.react === '无' ? '#74B89A' : '#E8590C').margin({ top: 3 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    .padding({ left: 10, bottom: 14 })
  }
  .width('100%')
  .alignItems(VerticalAlign.Top)
  .margin({ left: 16, right: 16 })
  .onClick(() => {
    this.recId9 = r.id
    this.showRec9 = true
  })
}, (r: RecT9) => 'rc' + r.id.toString())

接种记录时间轴是接种本页面最具特色的部分。每条记录由左侧的时间轴标记和右侧的记录内容组成。

时间轴标记由一个 Column 容器实现,宽度为 12 像素。容器顶部是一个 Circle 形状组件,直径为 10 像素,填充色为绿色。Circle 是 ArkUI 中的基础形状组件之一,除此之外还有 EllipsePathRect 等。

圆形下方是一个空的 Column 容器,宽度为 2 像素,设置了 .layoutWeight(1),因此它会占据圆形下方的所有剩余高度,形成一条垂直的时间轴线。这条线的颜色是浅绿色 #B2E5CE

时间轴布局的核心思路是:用一个小圆点标记每个事件的位置,用一条垂直线将所有事件串联起来。通过 Row 将时间轴标记和内容并排放置,就形成了经典的时间轴视觉效果。

右侧的记录内容使用 Column 容器,包含三行信息:第一行是疫苗名称和针次(左)与接种日期(右),第二行是接种机构和疫苗批号,第三行是不良反应情况。

不良反应文字的颜色会动态变化:如果反应为 “无”,显示浅绿灰色 #74B89A;否则显示橙色 #E8590C,起到警示作用。

点击整条记录时,会记录当前记录的 ID 到 recId9 变量中,然后将 showRec9 设置为 true,显示接种记录详情弹框。

七、预约 Tab:门店卡片与待办预约

7.1 待办预约卡片

Column() {
  Text('⏳ 待办预约').fontSize(13).fontWeight(700).fontColor('#213A30')
  Row() {
    Text('HPV 九价 第 3 针').fontSize(11).fontColor('#E64980').padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8).backgroundColor('#FCE7F1')
    Text('2026-09-12 10:20 · 望京分院').fontSize(10).fontColor('#74B89A').margin({ left: 8 }).layoutWeight(1)
    Text('取消').fontSize(9).fontColor('#F03E3E').padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8).border({ width: 0.7, color: '#F03E3E' }).onClick(() => {
      this.armed9 = false
      this.showCancel9 = true
    })
  }
  .width('100%')
  .alignItems(VerticalAlign.Center)
  .margin({ top: 10 })
}

预约页面的顶部是一个待办预约卡片,展示用户即将到来的预约。卡片标题为"待办预约",下方是具体的预约信息。

疫苗名称和针次使用了一个标签样式:玫红色文字 + 浅玫红色背景 + 圆角。这种标签样式在界面中非常常见,用于突出显示关键信息。

预约时间和门店信息使用浅绿灰色文字,设置了 .layoutWeight(1) 来占据中间的剩余空间。

右侧是一个"取消"按钮,使用红色文字 + 红色边框 + 透明背景的样式,这是典型的"危险操作"次级按钮设计。点击取消按钮时,先将 armed9 重置为 false(取消确认开关关闭),然后显示取消预约弹框。

危险操作的二次确认是一个重要的 UX 设计原则。对于删除、取消等不可逆操作,应该引导用户仔细确认,避免误操作造成损失。

7.2 门店卡片列表

ForEach(STORES9, (s: StoreT9) => {
  Column() {
    Row() {
      Column() {
        Text(s.name).fontSize(12).fontWeight(700).fontColor('#213A30')
        Text(s.addr).fontSize(9).fontColor('#74B89A').margin({ top: 4 }).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)

      Column() {
        Text(s.dist.toFixed(1) + 'km').fontSize(11).fontWeight(700).fontColor('#3B5BDB')
        Text('⭐ ' + s.score.toFixed(1)).fontSize(9).fontColor('#F59F00').margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.End)
    }
    .width('100%')

    Row() {
      Text('🟢 ' + s.stock).fontSize(9).fontColor('#0CA678').layoutWeight(1).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
      Text('预约').fontSize(10).fontColor('#FFFFFF').fontWeight(700).padding({ left: 14, right: 14, top: 5, bottom: 5 }).borderRadius(13).backgroundColor('#0CA678').onClick(() => {
        this.bkStore9 = Math.max(0, STORES9.findIndex((x: StoreT9) => x.id === s.id))
        this.showBook9 = true
      })
    }
    .width('100%')
    .alignItems(VerticalAlign.Center)
    .margin({ top: 9 })
  }
  .width('100%')
  .alignItems(HorizontalAlign.Start)
  .padding(13)
  .borderRadius(14)
  .backgroundColor('#FFFFFF')
  .margin({ left: 14, right: 14, top: 8 })
}, (s: StoreT9) => 'st' + s.id.toString())

门店列表是预约页面的主体内容。每个门店以卡片形式展示,卡片分为上下两部分。

上半部分是一个 Row,左侧是门店名称和地址(Column 垂直排列),右侧是距离和评分(Column 垂直排列,右对齐)。距离使用蓝色粗体字突出显示,评分配有星星图标和金黄色。

s.dist.toFixed(1) 将距离数值格式化为一位小数。toFixed() 是 JavaScript/TypeScript 中 Number 类型的标准方法,用于将数字格式化为指定小数位数的字符串。

下半部分也是一个 Row,左侧是疫苗库存信息,使用绿色文字和绿色圆点图标,表示有库存。右侧是一个绿色的"预约"按钮。

findIndex 是数组的标准方法,用于查找满足条件的第一个元素的索引。在这里,它用于根据门店 ID 在 STORES9 数组中查找对应的索引,以便在预约弹框中默认选中该门店。

点击预约按钮时,会做两件事:一是设置 bkStore9 为当前门店的索引(通过 findIndex 查找),二是显示预约弹框。Math.max(0, ...) 确保即使找不到也返回 0 而不是 -1,避免数组越界。

八、提醒 Tab:日历视图与提醒列表

8.1 Flex 弹性布局与日历格子

Flex({ wrap: FlexWrap.Wrap }) {
  ForEach([0, 1, 2, ... 34], (d: number) => {
    Column() {
      if (d < 31) {
        Text((d + 1).toString()).fontSize(11).fontColor(this.checked9.indexOf(d + 1) >= 0 ? '#FFFFFF' : '#213A30')
        if (this.checked9.indexOf(d + 1) >= 0) {
          Text('💉').fontSize(8).margin({ top: 1 })
        } else {
          Text('').fontSize(8).margin({ top: 1 })
        }
      }
    }
    .width('12.5%')
    .padding({ top: 6, bottom: 6 })
    .borderRadius(8)
    .backgroundColor(this.checked9.indexOf(d + 1) >= 0 ? '#0CA678' : '#F0FBF6')
    .margin({ top: 4 })
    .onClick(() => {
      if (d < 31) {
        if (this.checked9.indexOf(d + 1) >= 0) {
          this.checked9 = this.checked9.filter((x: number) => x !== d + 1)
        } else {
          this.checked9 = this.checked9.concat([d + 1])
        }
      }
    })
  }, (d: number) => 'cd' + d.toString())
}
.width('100%')

提醒页面的核心是一个日历视图,使用 Flex 容器配合换行模式来实现日历格子布局。

Flex 是弹性布局容器,它比 RowColumn 更灵活,支持更多的布局控制选项。Flex({ wrap: FlexWrap.Wrap }) 设置 Flex 容器为换行模式——当一行的子组件总宽度超过容器宽度时,自动换行到下一行。这是实现网格布局的关键。

Flex 容器与 Row/Column 的区别在于:Flex 支持换行(wrap)、对齐方式更丰富、弹性控制更灵活。在需要多行多列的网格布局场景中,Flex 是首选。

每个日历格子的宽度设置为 12.5%,也就是八分之一。为什么是八分之一而不是七分之一呢?因为 7 天一周的话,七分之一在计算上不够整齐,而 12.5%(1/8)留出了一些间距空间。实际上,配合 Flex 的换行特性,7 个格子加起来占 87.5%,剩余 12.5% 的空间会被分配到间距中,形成均匀的分布效果。

每个格子是一个 Column 容器,内部显示日期数字。如果该日期被标记为接种日(即包含在 checked9 数组中),则显示白色文字 + 绿色背景 + 针管 emoji 图标;否则显示深色文字 + 浅绿背景。

if (d < 31) 这个判断是为了处理数组中多余的元素。由于数组有 35 个元素(索引 0-34),但 9 月份只有 30 天(日期 1-30,对应索引 0-29),所以索引 30 及以上的格子是空的,不显示任何内容。

点击日历格子时,会切换该日期的选中状态。如果日期已经在 checked9 数组中,就用 filter 方法将其移除;如果不在,就用 concat 方法将其添加进去。

在 ArkTS 中,要触发数组类型 @State 变量的界面更新,需要给数组赋一个新的引用值。直接使用 push、pop、splice 等方法修改原数组可能不会触发界面更新。因此,代码中使用 filter 和 concat 等返回新数组的方法来确保状态变化被正确追踪。

8.2 提醒列表

ForEach(this.rems9, (r: RemT9) => {
  Row() {
    Column() {
      Text(r.vax).fontSize(12).fontWeight(700).fontColor('#213A30')
      Row() {
        Text(r.days.toString() + ' 天后').fontSize(10).fontColor(r.days <= 30 ? '#E8590C' : '#3B5BDB').fontWeight(700)
        Text('· ' + r.way).fontSize(9).fontColor('#74B89A').margin({ left: 8 })
        Text(r.on ? ' 已开启' : ' 已关闭').fontSize(9).fontColor(r.on ? '#0CA678' : '#94B89A').margin({ left: 8 })
      }
      .margin({ top: 4 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)

    Text('✏️').fontSize(15).onClick(() => {
      this.remId9 = r.id
      this.remDay9 = 1
      this.remWay9 = Math.max(0, WAY9.indexOf(r.way))
      this.showRem9 = true
    })
    Text(r.on ? '🔔' : '🔕').fontSize(14).margin({ left: 14 }).onClick(() => {
      this.rems9 = this.rems9.map((x: RemT9) => {
        if (x.id === r.id) {
          return { id: x.id, vax: x.vax, days: x.days, way: x.way, on: !x.on }
        }
        return x
      })
    })
  }
  // ... 样式属性
}, (r: RemT9) => 'rm' + r.id.toString() + '_' + r.on.toString())

提醒列表展示了所有的接种提醒计划。每条提醒左侧是疫苗名称和详细信息,右侧是编辑按钮和开关按钮。

提醒天数的颜色根据紧急程度变化:如果距离接种日期小于等于 30 天,显示橙色(紧迫);否则显示蓝色(正常)。这是一种常见的 UX 设计手法,通过颜色来传达信息的紧急程度。

铃铛图标的切换是一个有趣的交互。点击铃铛图标时,会切换该提醒的开启/关闭状态。实现方式是使用 map 方法遍历 rems9 数组,找到 ID 匹配的那一项,返回一个新的对象(on 字段取反),其他项保持不变。最后将新数组赋值给 rems9,触发界面更新。

map 方法是数组变换的常用工具,它会对数组中的每个元素执行回调函数,并将回调函数的返回值组成一个新数组。这种不可变数据操作方式非常适合声明式 UI 的状态管理。

键值生成函数中包含了 r.on 的值,这是因为提醒的开关状态变化需要触发列表项的重新渲染。如果键值中不包含状态变化的信息,框架可能会认为列表项没有变化而跳过渲染。

九、知识、门店与我的 Tab

9.1 知识 Tab:文章卡片流

ForEach(ARTS9, (a: ArtT9) => {
  Row() {
    Column() {
      Row() {
        Text(a.cate).fontSize(8).fontColor('#087F5B').padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(6).backgroundColor('#D3F3E8')
        if (a.hot) {
          Text('🔥 热').fontSize(8).fontColor('#E8590C').padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(6).backgroundColor('#FFE8D9').margin({ left: 6 })
        }
        Text(a.read + ' 阅读').fontSize(9).fontColor('#94B89A').margin({ left: 8 })
      }
      .width('100%')
      Text(a.title).fontSize(12).fontWeight(700).fontColor('#213A30').margin({ top: 7 }).lineHeight(17)
      Text('约 3 分钟读完 · 疾控中心资料审核').fontSize(9).fontColor('#94B89A').margin({ top: 6 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)

    Text('📄').fontSize(26).margin({ left: 12 })
  }
  // ... 卡片样式
}, (a: ArtT9) => 'ar' + a.id.toString())

知识 Tab 展示了疫苗知识库文章列表,每篇文章以卡片形式展示。卡片左侧是文章详细信息,右侧是一个文档图标。

文章信息区域包含三行内容:第一行是分类标签、热门标签和阅读量;第二行是文章标题;第三行是阅读时长和审核信息。

if (a.hot) 语句控制"热门"标签的显示。如果文章是热门文章(hottrue),就显示一个橙色的 “🔥 热” 标签。在 ArkTS 声明式 UI 中,可以直接使用 if 语句来控制组件是否渲染,这比条件表达式更适合组件级别的显示控制。

if 语句在声明式 UI 中有两种用途:一种是在属性中使用三元运算符进行属性值的条件判断,另一种是在组件树中使用 if 语句进行组件是否渲染的条件控制。两者的使用场景不同,但都是条件渲染的重要手段。

.lineHeight(17) 设置文字的行高为 17 像素。行高对于多行文字的可读性非常重要,合适的行高可以让文字看起来更舒适。

9.2 门店 Tab:列表视图

ForEach(STORES9, (s: StoreT9) => {
  Column() {
    Row() {
      Text('🏥').fontSize(26)
      Column() {
        Row() {
          Text(s.name).fontSize(12).fontWeight(700).fontColor('#213A30').layoutWeight(1)
          Text('⭐ ' + s.score.toFixed(1)).fontSize(10).fontColor('#F59F00')
        }
        .width('100%')
        Text(s.addr).fontSize(9).fontColor('#74B89A').margin({ top: 4 })
        Text(s.stock).fontSize(9).fontColor('#0CA678').margin({ top: 4 })
      }
      .alignItems(HorizontalAlign.Start)
      .margin({ left: 12 })
      .layoutWeight(1)
    }
    .width('100%')

    Row() {
      Text('🧭 距离 ' + s.dist.toFixed(1) + 'km · 营业 08:30-17:30').fontSize(9).fontColor('#94B89A').layoutWeight(1)
      Text('约这针').fontSize(10).fontColor('#FFFFFF').fontWeight(700).padding({ left: 14, right: 14, top: 5, bottom: 5 }).borderRadius(13).backgroundColor('#3B5BDB').onClick(() => {
        this.bkStore9 = Math.max(0, STORES9.findIndex((x: StoreT9) => x.id === s.id))
        this.showBook9 = true
      })
    }
    .width('100%')
    .alignItems(VerticalAlign.Center)
    .margin({ top: 10 })
  }
  // ... 卡片样式
}, (s: StoreT9) => 'st2' + s.id.toString())

门店 Tab 展示了所有接种门店的列表。虽然数据来源和预约 Tab 中的门店列表相同(都是 STORES9),但展示方式有所不同。

门店 Tab 中的卡片信息更丰富:左侧有一个更大的医院图标,中间有门店名称、评分、地址、库存信息,底部有营业时间和蓝色的预约按钮。

预约按钮使用了蓝色(#3B5BDB)而不是绿色,与预约 Tab 中的绿色按钮形成区分。这是因为在不同的上下文中,主操作的视觉权重需要有所不同。

同一个数据在不同页面中以不同的形式展示,这是应用开发中非常常见的模式。通过不同的 @Builder 方法来构建不同的展示形式,可以保证数据的一致性和界面的多样性。

键值使用了 'st2' 前缀而不是 'st',这是因为两个 Tab 页面都使用了 STORES9 数组,如果键值前缀相同,可能会导致框架在页面切换时混淆两个列表中的元素。使用不同的前缀可以确保列表项的唯一性。

9.3 我的 Tab:个人中心

"我的"页面内容较为丰富,包含用户信息卡片、家庭接种完成数柱状图、电子接种凭证等模块。

用户信息卡片
Row() {
  Stack() {
    Circle().width(52).height(52).fill('#D3F3E8')
    Text('👨').fontSize(26)
  }
  Column() {
    Text('王先生 · 家庭接种管理员').fontSize(14).fontWeight(700).fontColor('#213A30')
    Text('管理 4 位家人 · 累计记录 43 针').fontSize(9).fontColor('#74B89A').margin({ top: 5 })
  }
  .alignItems(HorizontalAlign.Start)
  .margin({ left: 12 })
  .layoutWeight(1)

  Text('补录旧针').fontSize(10).fontColor('#0CA678').padding({ left: 10, right: 10, top: 6, bottom: 6 }).borderRadius(12).border({ width: 0.7, color: '#0CA678' }).onClick(() => {
    this.showAdd9 = true
  })
}

用户信息卡片使用 Stack 实现了圆形头像背景 + emoji 头像的组合。Circle 形状组件作为背景,Text 组件显示 emoji 头像,两者层叠在一起,形成了简洁的用户头像效果。

右侧是用户姓名和身份描述,以及一个"补录旧针"按钮。按钮使用绿色边框 + 绿色文字 + 透明背景的样式,这是典型的次级按钮设计。

家庭接种完成数柱状图
Row() {
  ForEach(FAMS9, (f: FamT9) => {
    Column() {
      Text(f.done.toString()).fontSize(10).fontColor('#087F5B').margin({ bottom: 3 })
      Column() {
        Column() {
        }
        .width(20)
        .height(f.done * 6)
        .borderRadius({ topLeft: 4, topRight: 4 })
        .backgroundColor(f.done >= 12 ? '#0CA678' : '#8ED9BC')
      }
      .width('100%')
      .height(96)
      .justifyContent(FlexAlign.End)
      Text(f.icon).fontSize(13).margin({ top: 5 })
      Text(f.name).fontSize(9).fontColor('#74B89A').margin({ top: 2 })
    }
    .alignItems(HorizontalAlign.Center)
    .layoutWeight(1)
  }, (f: FamT9) => 'fmc' + f.id.toString())
}
.width('100%')
.margin({ top: 12 })

家庭接种完成数柱状图是一个非常巧妙的纯代码实现的图表。它使用嵌套的 Column 容器来模拟柱状图的柱子。

每个家庭成员对应一列,列内从上到下依次是:已接种针数文字、柱子、头像图标、姓名。

柱子的实现方式是:外层 Column 高度固定为 96 像素,设置 .justifyContent(FlexAlign.End) 使子组件底部对齐。内层空 Column 的高度为 f.done * 6(已接种针数乘以 6 像素),这样柱子的高度就与已接种针数成正比。

justifyContent(FlexAlign.End) 配合固定高度的容器,可以让子组件从底部开始向上排列。这是实现柱状图、柱状进度条等向上增长效果的常用技巧。

柱子的颜色根据完成度动态变化:如果已接种针数大于等于 12 针,使用深绿色(#0CA678);否则使用浅绿色(#8ED9BC)。这种颜色区分可以让用户一眼看出哪些家庭成员的接种完成度较高。

柱子顶部设置了圆角(borderRadius({ topLeft: 4, topRight: 4 })),让柱子看起来更圆润美观。

十、弹框系统:模态交互的六种形态

这个应用包含了六个不同功能的弹框,涵盖了底部抽屉弹框、居中模态弹框、窄危险确认弹框等多种形态。弹框的实现充分展示了 Stack 层叠布局、zIndex 层级控制、绝对定位等技术的综合运用。

10.1 弹框的实现原理

在深入分析每个弹框之前,我们先来了解一下弹框的基本实现原理。

// 主布局中
Stack() {
  Column() {
    // ... 主界面内容
  }
  .width('100%')
  .height('100%')

  if (this.showBook9) {
    this.bookOverlay9()
  }
  // ... 其他弹框
}

弹框的实现基于 Stack 层叠布局。主界面内容是 Stack 的第一层,所有弹框都是 Stack 的后续层。当弹框对应的 show*9 状态变量为 true 时,弹框组件就会渲染在 Stack 中,层叠在主界面之上。

每个弹框的最外层都是一个充满整个屏幕的 Column 容器,背景色是半透明的遮罩色(如 #99213A30#99087F5B)。这个半透明遮罩既可以突出弹框内容,又可以通过点击遮罩来关闭弹框。

zIndex 属性用于控制组件在 Z 轴方向上的层级顺序。zIndex 值越大,组件显示在越上层。在这个应用中,所有弹框都设置了 zIndex(999),确保它们始终显示在主界面之上。

弹框的位置通过 justifyContent 来控制:

  • justifyContent(FlexAlign.End):底部抽屉弹框,内容从底部弹出
  • justifyContent(FlexAlign.Center):居中模态弹框,内容居中显示

10.2 弹框一:新建预约(底部抽屉)

新建预约弹框是一个底部抽屉样式的弹框,高度占屏幕的 76%。弹框内部包含疫苗选择、家庭成员选择、门店选择、时段选择等表单项,以及底部的确认预约按钮。

Scroll() {
  Column() {
    // ... 表单内容
  }
  .width('100%')
  .padding({ left: 16, right: 16, bottom: 12 })
}
.layoutWeight(1)
.align(Alignment.Top)
.edgeEffect(EdgeEffect.Spring)

表单内容区域使用了 Scroll 滚动容器。因为表单项较多,可能超出弹框的可视区域,所以需要用 Scroll 来支持滚动查看。

Scroll 容器是鸿蒙开发中最常用的滚动容器。当内容的高度/宽度超过容器的高度/宽度时,Scroll 允许用户滑动查看全部内容。Scroll 可以设置滚动方向、滚动条显示、边缘效果等属性。

.edgeEffect(EdgeEffect.Spring) 设置滚动到边缘时的弹性效果。EdgeEffect.Spring 是弹性效果,即滚动到尽头时会有橡皮筋般的回弹效果,这是 iOS 和鸿蒙系统的默认交互风格。

Text('疫苗').fontSize(11).fontColor('#74B89A').margin({ top: 12 })
Flex({ wrap: FlexWrap.Wrap }) {
  ForEach(this.vaxs9, (v: VaxT9, i: number) => {
    Text(v.name).fontSize(10).fontColor(this.bkVax9 === i ? '#FFFFFF' : '#4D7C6A').padding({ left: 10, right: 10, top: 7, bottom: 7 }).borderRadius(12).backgroundColor(this.bkVax9 === i ? '#087F5B' : '#F0FBF6').margin({ right: 8, bottom: 8 }).onClick(() => {
      this.bkVax9 = i
    })
  }, (v: VaxT9) => 'bv' + v.id.toString())
}
.width('100%')

每个选择项都使用了 Flex 换行布局 + ForEach 遍历的模式。选项以标签(Chip)的形式展示,选中状态通过背景色和文字颜色的变化来体现。点击选项时更新对应的索引状态变量。

确认预约按钮的点击事件处理逻辑比较丰富:

.onClick(() => {
  const v: VaxT9 = this.vaxs9[this.bkVax9]
  const nr: RemT9 = { id: Date.now(), vax: v.name + '(新预约)', days: 14, way: '推送+短信', on: true }
  this.rems9 = [nr].concat(this.rems9)
  this.vaxs9 = this.vaxs9.map((x: VaxT9) => {
    if (x.id === v.id) {
      return { id: x.id, name: x.name, total: x.total, done: x.done, next: '已约 ' + STORES9[this.bkStore9].name.replace('爱康', '') + ' ' + SLOT9[this.bkSlot9], color: x.color, age: x.age }
    }
    return x
  })
  this.showBook9 = false
})

点击确认预约时,会执行以下操作:

  1. 获取当前选中的疫苗对象
  2. 创建一条新的提醒记录,ID 使用 Date.now() 生成时间戳(确保唯一性)
  3. 将新提醒添加到提醒列表的开头
  4. 更新疫苗列表中对应疫苗的 next 字段,显示"已约 + 门店 + 时段"
  5. 关闭预约弹框

Date.now() 是生成唯一 ID 的简单方法。在客户端 Demo 中,使用时间戳作为临时 ID 是完全可行的;但在真实的生产环境中,通常需要后端返回全局唯一的 ID。

replace('爱康', '') 用于简化门店名称的显示,去掉"爱康"前缀,让名称更简洁。

10.3 弹框二:接种记录详情(居中大卡)

接种记录详情弹框是一个居中显示的模态弹框,宽度占屏幕的 86%,最大高度为屏幕高度的 78%。

Text(this.recId9 > 0 && this.recs9.filter((r: RecT9) => r.id === this.recId9).length > 0 ? this.recs9.filter((r: RecT9) => r.id === this.recId9)[0].vax + ' ' + this.recs9.filter((r: RecT9) => r.id === this.recId9)[0].dose : '')

这段代码展示了如何根据 recId9 从记录数组中查找对应的记录。它使用 filter 方法过滤出 ID 匹配的记录,然后取第一条。由于 filter 返回的是数组,所以需要先判断数组长度是否大于 0,再访问第一个元素。

这种写法虽然功能正确,但性能不够优化——同一条记录被 filter 了多次。在实际开发中,可以将查找结果缓存到一个变量中,或者使用 find 方法直接返回匹配的元素。

弹框内容分为几个部分:

  • 顶部:疫苗名称和接种日期(绿色背景区域)
  • 中部:接种机构、疫苗批号、不良反应、核验码等详细信息
  • 底部:删除记录和上报反应两个操作按钮

删除记录按钮使用红色边框 + 红色文字的危险操作样式。点击删除时,使用 filter 方法过滤掉对应 ID 的记录,然后关闭弹框。

10.4 弹框三:补录历史接种(居中卡)

补录弹框使用了三个 TextInput 输入框组件,分别用于输入疫苗名称、接种日期和接种机构。

TextInput({ placeholder: '例:乙肝疫苗 第 2 针', text: this.addVax9 })
  .fontSize(11)
  .fontColor('#213A30')
  .placeholderColor('#94B89A')
  .backgroundColor('#F0FBF6')
  .borderRadius(10)
  .height(42)
  .padding({ left: 10, right: 10 })
  .margin({ top: 6 })
  .onChange((v: string) => {
    this.addVax9 = v
  })

TextInput 是鸿蒙 ArkUI 中的文本输入组件。它的构造函数可以接收两个参数:placeholder 是占位提示文字,text 是输入框的当前值(即双向绑定的值)。

在声明式 UI 中,TextInput 的值需要通过 @State 变量来管理。通过 onChange 回调将用户输入的值同步到状态变量,再通过 text 参数将状态变量的值反映到输入框中,这就形成了双向数据绑定。

.placeholderColor() 设置占位文字的颜色。.backgroundColor() 设置输入框的背景色。输入框本身默认没有边框和背景色,通过设置背景色和圆角,可以让输入框看起来更像一个表单输入区域。

.onChange() 是输入内容变化的回调,每当用户输入或删除文字时都会触发。回调函数的参数是输入框当前的文本值。

保存补录的逻辑如下:

.onClick(() => {
  const nr: RecT9 = { id: Date.now(), vax: this.addVax9.length > 0 ? this.addVax9 : '未命名疫苗', dose: '补录', date: this.addDate9.length > 0 ? this.addDate9 : '日期不详', org: this.addOrg9.length > 0 ? this.addOrg9 : '机构不详', batch: '补录', react: '无', ok: true }
  this.recs9 = [nr].concat(this.recs9)
  this.showAdd9 = false
})

创建新记录时,对每个字段做了空值处理:如果用户没有输入,就使用默认值(如"未命名疫苗"、"日期不详"等)。新记录被添加到记录列表的开头,然后关闭弹框。

10.5 弹框四:编辑提醒(底部抽屉)

编辑提醒弹框也是底部抽屉样式,包含提醒时机和提醒方式两个选择项。

提醒时机使用 Row 容器排列五个选项(当天、提前1天、提前3天、提前7天、提前14天),因为只有五个选项,一行可以放下,所以不需要 Flex 换行。

提醒方式使用 Flex 换行布局,因为四个选项中有的文字较长,可能一行放不下。

保存修改的逻辑是:

.onClick(() => {
  this.rems9 = this.rems9.map((r: RemT9) => {
    if (r.id === this.remId9) {
      return { id: r.id, vax: r.vax, days: r.days, way: WAY9[this.remWay9], on: true }
    }
    return r
  })
  this.showRem9 = false
})

使用 map 方法遍历提醒列表,找到 ID 匹配的记录,更新其提醒方式为当前选中的值,并自动将提醒设为开启状态。其他记录保持不变。

10.6 弹框五:取消预约(窄危险卡)

取消预约弹框是一个窄款的居中危险确认弹框,宽度只有屏幕的 74%,使用红色边框和浅红色背景,突出"危险操作"的视觉感受。

Row() {
  Text('我已知晓不可恢复').fontSize(11).fontColor('#F03E3E').layoutWeight(1)
  Toggle({ type: ToggleType.Switch, isOn: this.armed9 })
    .selectedColor('#F03E3E')
    .onChange((on: boolean) => {
      this.armed9 = on
    })
}

这个弹框最有特点的是"确认开关"设计。用户需要先打开一个开关,表示"我已知晓不可恢复",然后确认按钮才会变为可用状态。这是一种比普通确认对话框更严格的防误操作设计。

Toggle 是开关组件,支持三种类型:ToggleType.Switch(开关)、ToggleType.Checkbox(复选框)、ToggleType.Button(按钮式切换)。这里使用的是开关类型。

Toggle 组件的 selectedColor 属性用于设置开关打开时的颜色。默认是系统主题色,这里设置为红色,与危险操作的主题保持一致。

Text(this.armed9 ? '确认取消' : '请先打开确认')
  .fontSize(12)
  .fontWeight(700)
  .fontColor(this.armed9 ? '#FFFFFF' : '#94B89A')
  .layoutWeight(1)
  .textAlign(TextAlign.Center)
  .padding({ top: 11, bottom: 11 })
  .borderRadius(20)
  .backgroundColor(this.armed9 ? '#F03E3E' : '#F0FBF6')
  .margin({ left: 10 })
  .onClick(() => {
    if (this.armed9) {
      this.showCancel9 = false
    }
  })

确认按钮的状态完全由 armed9 控制。当 armed9false 时,按钮显示灰色背景、灰色文字、文字为"请先打开确认",点击无效。当 armed9true 时,按钮显示红色背景、白色文字、文字为"确认取消",点击有效。

这种"开关确认 + 按钮"的双重确认机制,虽然增加了用户的操作步骤,但对于不可逆的危险操作来说,是一种非常负责任的设计。它可以有效防止用户因误触而造成损失。

10.7 弹框六:不良反应上报(底部抽屉)

不良反应上报弹框也是底部抽屉样式,包含症状选择(多选)、严重程度选择(单选)、是否已就医开关,以及提交按钮。

Flex({ wrap: FlexWrap.Wrap }) {
  ForEach(REACT9, (x: string) => {
    Text(x).fontSize(10).fontColor(this.reactSel9.indexOf(x) >= 0 ? '#FFFFFF' : '#4D7C6A').padding({ left: 10, right: 10, top: 7, bottom: 7 }).borderRadius(12).backgroundColor(this.reactSel9.indexOf(x) >= 0 ? '#E8590C' : '#F0FBF6').margin({ right: 8, bottom: 8 }).onClick(() => {
      if (this.reactSel9.indexOf(x) >= 0) {
        this.reactSel9 = this.reactSel9.filter((y: string) => y !== x)
      } else {
        this.reactSel9 = this.reactSel9.concat([x])
      }
    })
  }, (x: string) => 'ra' + x)
}

症状选择支持多选,实现方式是将选中的症状保存在数组 reactSel9 中。点击症状标签时,如果已经在数组中,就用 filter 移除;如果不在,就用 concat 添加。这与日历格子的选中逻辑类似。

严重程度是单选,使用 reactDeg9 数字变量记录选中的索引(0=轻微,1=中等,2=明显)。

是否已就医使用 Toggle 开关组件,选中状态保存在 reactHosp9 布尔变量中。

不良反应上报功能是医疗类应用中非常重要的安全监测功能。通过收集用户接种后的不良反应数据,可以为疫苗安全性评估提供真实世界的证据支持。

十一、主布局结构:Stack 层叠与 Tab 切换

build() {
  Stack() {
    Column() {
      this.headerBar9()

      Scroll() {
        Column() {
          if (this.curTab9 === 0) {
            this.tabBook9()
          }
          if (this.curTab9 === 1) {
            this.tabReserve9()
          }
          if (this.curTab9 === 2) {
            this.tabRemind9()
          }
          if (this.curTab9 === 3) {
            this.tabKnow9()
          }
          if (this.curTab9 === 4) {
            this.tabStore9()
          }
          if (this.curTab9 === 5) {
            this.tabMine9()
          }
        }
        .width('100%')
      }
      .layoutWeight(1)
      .scrollBar(BarState.Off)
      .edgeEffect(EdgeEffect.Spring)
      .width('100%')
      .backgroundColor('#EFFBF6')

      this.tabBar9()
    }
    .width('100%')
    .height('100%')

    // 弹框层
    if (this.showBook9) {
      this.bookOverlay9()
    }
    // ... 其他弹框
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#EFFBF6')
}

build() 方法是每个 @Component 组件必须实现的方法,它描述了组件的 UI 结构。框架会自动调用 build() 方法来渲染界面,并在状态变化时重新调用 build() 来更新界面。

主布局的最外层是一个 Stack 容器,它包含两层内容:

  1. 主界面层(Column):头部 + 滚动内容区 + 底部 Tab 栏
  2. 弹框层:六个弹框,通过 if 条件控制是否显示

主界面层是一个 Column 容器,垂直排列三个部分:头部、滚动内容区、底部 Tab 栏。

滚动内容区使用 Scroll 容器,内部是一个 Column,通过六个 if 语句根据 curTab9 的值来渲染对应的 Tab 页面内容。

使用 if 语句进行 Tab 页面切换是一种简单直接的方式。它的优点是逻辑清晰、易于理解;缺点是每次切换 Tab 时都会重新创建和销毁页面组件,可能导致状态丢失和性能损耗。对于需要保持页面状态的场景,可以考虑使用 TabContent 组件或条件渲染配合状态持久化。

.scrollBar(BarState.Off) 隐藏滚动条。默认情况下,Scroll 容器会显示滚动条,设置为 BarState.Off 可以去掉滚动条,让界面更简洁。

.layoutWeight(1) 设置在 Scroll 上,使滚动区域占据 Column 中除头部和底部 Tab 栏之外的所有剩余空间。这是典型的"顶-中-底"三栏布局,中间内容区自适应高度。

十二、应用架构流程图

12.1 页面整体架构流程

点击切换

点击操作

点击记录

补录按钮

预约按钮

取消按钮

编辑提醒

上报反应

Stack 根容器

主界面 Column

弹框层

头部 headerBar9

Scroll 滚动区

底部 Tab 栏 tabBar9

Tab0 接种本

Tab1 预约

Tab2 提醒

Tab3 知识

Tab4 门店

Tab5 我的

预约弹框

记录详情弹框

补录弹框

编辑提醒弹框

取消预约弹框

不良反应弹框

这张流程图展示了应用的整体架构。根容器是 Stack,它层叠了主界面和弹框两层。主界面采用经典的"顶部头部 + 中间内容 + 底部导航"三段式结构。六个 Tab 页面分别承载不同的功能模块,六个弹框承载不同的模态交互。页面之间通过状态变量的变化来驱动切换和弹框显示。

12.2 状态驱动界面更新流程

用户操作

事件回调

修改 @State 变量

框架检测状态变化

重新执行 build

计算差异 diff

更新界面 DOM

用户看到新界面

animateTo 动画

逐帧更新状态

这张流程图展示了声明式 UI 的核心工作原理。用户操作触发事件回调,回调中修改 @State 状态变量。框架检测到状态变化后,重新执行 build() 方法生成新的 UI 描述,然后与旧的 UI 描述进行差异比较(diff),只更新发生变化的部分,最终用户看到更新后的界面。如果使用了 animateTo 动画,框架会在动画周期内逐帧更新状态,产生平滑的过渡效果。

十三、核心技术点对比表格

技术点 类型 作用 使用场景 关键属性/方法 状态关联
@Entry 装饰器 标记页面入口组件 页面根组件 不涉及
@Component 装饰器 标记自定义组件 可复用 UI 单元 build() 方法 不涉及
@State 装饰器 声明响应式状态变量 组件内部状态管理 自身驱动
@Builder 装饰器 声明可复用 UI 构建函数 UI 片段复用 不涉及
Column 容器组件 垂直方向排列子组件 垂直布局 alignItems、justifyContent
Row 容器组件 水平方向排列子组件 水平布局 alignItems、justifyContent
Stack 容器组件 层叠排列子组件 图层叠加、弹框 alignContent、zIndex
Flex 容器组件 弹性布局,支持换行 网格、标签流 wrap、justifyContent
Scroll 滚动容器 可滚动的内容区域 长内容滚动 scrollBar、edgeEffect
ForEach 列表渲染 根据数组批量生成组件 列表、网格 数组、生成函数、键函数 数组变化触发更新
Text 基础组件 显示文本 文字展示 fontSize、fontColor、maxLines
TextInput 输入组件 接收用户文本输入 表单输入 placeholder、text、onChange text 双向绑定
Progress 进度组件 显示进度 进度条、环形进度 value、total、style、color value 变化
Toggle 开关组件 开关/复选/按钮切换 布尔值选择 type、isOn、selectedColor isOn 双向绑定
Circle 形状组件 绘制圆形 头像背景、时间轴节点 width、height、fill
animateTo 动画 API 显式动画 属性过渡动画 duration、curve、iterations 动画闭包内的状态
layoutWeight 属性 弹性权重分配 Row/Column 子组件 数值
zIndex 属性 Z 轴层级控制 层叠顺序控制 数值
opacity 属性 透明度控制 渐显渐隐、呼吸效果 0-1 可动画
translate 属性 位移变换 移动动画 x、y、z 可动画
borderRadius 属性 圆角 卡片、按钮 数值或对象
onClick 事件 点击事件回调 用户交互 回调函数 修改状态
onChange 事件 值变化回调 输入框、开关 回调函数 修改状态
aboutToAppear 生命周期 组件即将出现时 初始化、启动动画
doneColor9 工具函数 根据文字返回颜色 状态文字着色 字符串参数

十四、总结与技术要点回顾

通过对这个疫苗全程管家应用的完整代码分析,我们可以看到鸿蒙 ArkTS 声明式 UI 开发的全貌。这个应用虽然只是一个界面 Demo,但它涵盖了鸿蒙应用开发中绝大多数常用的技术点,是一个非常好的学习案例。

首先,数据结构设计是整个应用的基础。六个 interface 清晰地定义了疫苗、接种记录、门店、文章、家庭成员、提醒等核心数据模型,每个字段都有明确的含义和用途。良好的数据结构设计不仅可以让代码更易读、更易维护,还可以为后续的功能扩展打下坚实的基础。在声明式 UI 范式下,数据驱动视图是核心原则,数据的质量直接决定了界面的质量。

数据结构的设计应该遵循"单一职责"和"高内聚低耦合"的原则。每个数据模型只描述一类事物,模型之间的关联通过 ID 或引用建立,而不是通过嵌套来耦合。

其次,组件化和 UI 复用是提高开发效率的关键。这个应用大量使用了 @Builder 装饰器来抽取可复用的 UI 片段,包括头部、Tab 栏、六个 Tab 页面、六个弹框等。每个 @Builder 方法都有明确的职责,方法命名清晰地反映了其功能。这种模块化的组织方式使得 build() 主函数非常简洁,只有几十行代码,却能构建出功能丰富的完整界面。

第三,状态管理是声明式 UI 的核心。应用中使用了大量的 @State 状态变量,包括 Tab 索引、列表数据、弹框开关、表单数据、动画参数等。每个状态变量都有明确的职责,通过事件回调来修改状态,通过状态变化来驱动界面更新。这种单向数据流的模式使得状态变化的追踪变得非常清晰——你只需要看哪些地方修改了某个状态变量,就知道哪些操作会影响对应的界面部分。

@State 装饰器是鸿蒙 ArkUI 状态管理的基石。理解 @State 的工作原理,是掌握声明式 UI 开发的关键。@State 变量的变化会触发其所在组件的重新渲染,框架会自动计算差异并最小化更新操作。

第四,容器组件的灵活运用是布局的基础。Column、Row、Stack、Flex、Scroll 这五个容器组件是构建复杂布局的基本工具。Column 和 Row 负责基础的垂直和水平布局,Stack 负责层叠布局,Flex 负责弹性布局和换行布局,Scroll 负责滚动布局。通过这些容器的嵌套组合,可以构建出几乎任何你能想象的界面布局。特别是 layoutWeight 权重属性,它让弹性布局变得非常灵活,可以轻松实现各种比例分配的布局需求。

第五,ForEach 列表渲染是数据到界面的桥梁。几乎所有列表类的界面(疫苗列表、记录列表、门店列表、文章列表、提醒列表等)都是通过 ForEach 来实现的。ForEach 的键值生成机制是保证列表正确渲染和高效更新的关键。在实际开发中,要特别注意键值的唯一性,以及在列表项内容发生变化时是否需要更新键值来触发重新渲染。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// ============================================================
// 清新蓝绿浅色 · 6底部Tab · 6弹框
// 布局亮点:接种本Tab时间轴;预约Tab门店卡片;提醒Tab日历格子;
//           知识Tab文章卡片流;门店Tab列表;我的Tab家庭成员切换chips
// ============================================================

// ---------------- 数据结构 ----------------
interface VaxT9 {
  id: number
  name: string
  total: number
  done: number
  next: string
  color: string
  age: string
}

interface RecT9 {
  id: number
  vax: string
  dose: string
  date: string
  org: string
  batch: string
  react: string
  ok: boolean
}

interface StoreT9 {
  id: number
  name: string
  dist: number
  addr: string
  stock: string
  score: number
}

interface ArtT9 {
  id: number
  title: string
  cate: string
  read: string
  hot: boolean
}

interface FamT9 {
  id: number
  name: string
  icon: string
  done: number
  total: number
}

interface RemT9 {
  id: number
  vax: string
  days: number
  way: string
  on: boolean
}

// ---------------- 写死数据 ----------------
const VAXS9: Array<VaxT9> = [
  { id: 1, name: 'HPV 九价', total: 3, done: 2, next: '第3针 2026-09-12', color: '#E64980', age: '16-26岁' },
  { id: 2, name: '带状疱疹', total: 2, done: 1, next: '第2针 2026-10-02', color: '#F76707', age: '50岁+' },
  { id: 3, name: '乙肝', total: 3, done: 3, next: '已完成 ✅', color: '#0CA678', age: '全年龄' },
  { id: 4, name: '流感四价', total: 1, done: 1, next: '每年 9 月续种', color: '#3B5BDB', age: '6月龄+' },
  { id: 5, name: '23价肺炎', total: 1, done: 0, next: '待预约', color: '#7048E8', age: '2岁+/60岁+' },
  { id: 6, name: '狂犬病', total: 5, done: 5, next: '已完成 ✅', color: '#F03E3E', age: '暴露后' },
  { id: 7, name: '麻腮风', total: 2, done: 2, next: '已完成 ✅', color: '#1098AD', age: '8月龄+' },
  { id: 8, name: '水痘', total: 2, done: 1, next: '第2针 待预约', color: '#F59F00', age: '1岁+' },
  { id: 9, name: '破伤风', total: 1, done: 0, next: '十年加强到期', color: '#5C940D', age: '成人' },
  { id: 10, name: '甲肝灭活', total: 2, done: 0, next: '待预约', color: '#1C7ED6', age: '1岁+' },
  { id: 11, name: '戊肝', total: 3, done: 0, next: '待预约', color: '#E8590C', age: '16岁+' },
  { id: 12, name: '四价流脑', total: 2, done: 0, next: '待预约', color: '#0B7285', age: '3岁+' }
]

const RECS9: Array<RecT9> = [
  { id: 1, vax: 'HPV 九价', dose: '第 2 针', date: '2026-06-18', org: '爱康国宾·望京分院', batch: 'MSD-L6832', react: '轻微手臂酸胀 1 天', ok: true },
  { id: 2, vax: '流感四价', dose: '年度 1 针', date: '2025-10-09', org: '爱康君安·国贸分院', batch: 'HXB-25F31', react: '无', ok: true },
  { id: 3, vax: '狂犬病', dose: '第 5 针', date: '2025-08-22', org: '朝阳区疾控门诊', batch: 'RB-25A07', react: '无', ok: true },
  { id: 4, vax: 'HPV 九价', dose: '第 1 针', date: '2025-05-08', org: '爱康国宾·望京分院', batch: 'MSD-K9210', react: '无', ok: true },
  { id: 5, vax: '带状疱疹', dose: '第 1 针', date: '2025-04-11', org: '爱康卓悦·亚运村分院', batch: 'GSK-Z4288', react: '低热 37.4℃ 半天', ok: true },
  { id: 6, vax: '狂犬病', dose: '第 3 针', date: '2025-08-08', org: '朝阳区疾控门诊', batch: 'RB-25A07', react: '无', ok: true },
  { id: 7, vax: '麻腮风', dose: '第 2 针', date: '2018-06-20', org: '社区卫生服务中心', batch: '补录', react: '无', ok: true },
  { id: 8, vax: '乙肝', dose: '第 3 针', date: '2015-12-02', org: '社区卫生服务中心', batch: '补录', react: '无', ok: true },
  { id: 9, vax: '水痘', dose: '第 1 针', date: '2014-04-15', org: '社区卫生服务中心', batch: '补录', react: '无', ok: true },
  { id: 10, vax: '狂犬病', dose: '第 2 针', date: '2025-07-25', org: '朝阳区疾控门诊', batch: 'RB-25A07', react: '无', ok: true },
  { id: 11, vax: '狂犬病', dose: '第 1/4 针', date: '2025-07-21', org: '朝阳区疾控门诊', batch: 'RB-25A07', react: '无', ok: true },
  { id: 12, vax: '流感三价', dose: '年度 1 针', date: '2024-10-15', org: '爱康君安·国贸分院', batch: 'HXB-24F22', react: '无', ok: true }
]

const STORES9: Array<StoreT9> = [
  { id: 1, name: '爱康国宾·望京分院', dist: 1.2, addr: '朝阳区望京街 10 号院 3 层', stock: 'HPV九价 有苗 · 带状疱疹 有苗', score: 4.9 },
  { id: 2, name: '爱康君安·国贸分院', dist: 3.8, addr: '朝阳区建国门外大街 1 号', stock: '流感四价 有苗 · 戊肝 有苗', score: 4.8 },
  { id: 3, name: '爱康卓悦·亚运村分院', dist: 5.1, addr: '朝阳区北辰东路 8 号', stock: '23价肺炎 有苗 · 破伤风 有苗', score: 4.7 },
  { id: 4, name: '爱康国宾·中关村分院', dist: 8.6, addr: '海淀区中关村大街 27 号', stock: 'HPV九价 候补排队', score: 4.8 },
  { id: 5, name: '爱康君安·亦庄分院', dist: 12.4, addr: '大兴区荣华中路 10 号', stock: '甲肝 有苗 · 四价流脑 有苗', score: 4.6 },
  { id: 6, name: '爱康卓悦·西单分院', dist: 9.2, addr: '西城区西单北大街 131 号', stock: 'HPV九价 有苗 · 水痘 有苗', score: 4.9 },
  { id: 7, name: '爱康国宾·丰台分院', dist: 11.0, addr: '丰台区丰台北路 18 号', stock: '狂犬 有苗(24h)', score: 4.7 },
  { id: 8, name: '爱康君安·通州分院', dist: 18.5, addr: '通州区新华西街 58 号', stock: '流感 有苗 · 破伤风 有苗', score: 4.6 }
]

const ARTS9: Array<ArtT9> = [
  { id: 1, title: 'HPV 九价扩龄到 45 岁?关于接种时机的一次说清', cate: 'HPV', read: '12.6万', hot: true },
  { id: 2, title: '带状疱疹疫苗:为什么 50 岁后建议接种', cate: '带状疱疹', read: '8.3万', hot: true },
  { id: 3, title: '流感季前接种黄金窗口:9-10 月最划算', cate: '流感', read: '21.4万', hot: false },
  { id: 4, title: '接种后发热怎么办?三级反应处置指南', cate: '不良反应', read: '15.2万', hot: true },
  { id: 5, title: '狂犬病暴露分级与「十日观察法」', cate: '狂犬', read: '9.8万', hot: false },
  { id: 6, title: '乙肝抗体滴度衰减后要不要补种?', cate: '乙肝', read: '6.7万', hot: false },
  { id: 7, title: '肺炎疫苗 13 价 / 23 价怎么选', cate: '肺炎', read: '7.9万', hot: false },
  { id: 8, title: '备孕期疫苗接种时间表(男女通用)', cate: '备孕', read: '11.1万', hot: true },
  { id: 9, title: '儿童免疫规划疫苗补种最全攻略', cate: '儿童', read: '13.5万', hot: false },
  { id: 10, title: '破伤风十年加强针,你打了吗?', cate: '破伤风', read: '5.2万', hot: false }
]

const FAMS9: Array<FamT9> = [
  { id: 1, name: '我', icon: '👨', done: 12, total: 18 },
  { id: 2, name: '妈妈', icon: '👩', done: 9, total: 14 },
  { id: 3, name: '爸爸', icon: '🧓', done: 7, total: 14 },
  { id: 4, name: '女儿', icon: '👧', done: 15, total: 22 }
]

const REMS9: Array<RemT9> = [
  { id: 1, vax: 'HPV 九价 第3针', days: 15, way: '推送+短信', on: true },
  { id: 2, vax: '带状疱疹 第2针', days: 35, way: '推送', on: true },
  { id: 3, vax: '流感四价 年度续种', days: 42, way: '短信', on: false },
  { id: 4, vax: '破伤风 十年加强', days: 60, way: '管家电话', on: true },
  { id: 5, vax: '女儿·水痘 第2针', days: 90, way: '推送+短信', on: true },
  { id: 6, vax: '爸爸·23价肺炎', days: 120, way: '管家电话', on: false }
]

const SLOT9: Array<string> = ['09:00', '09:40', '10:20', '11:00', '14:00', '14:40', '15:20', '16:00']
const REACT9: Array<string> = ['无不适', '注射部位红肿', '低热(<38℃)', '乏力嗜睡', '头痛', '皮疹']
const DAYS9: Array<string> = ['当天', '提前1天', '提前3天', '提前7天', '提前14天']
const WAY9: Array<string> = ['推送', '短信', '推送+短信', '管家电话']

// ---------------- 工具函数 ----------------
function doneColor9(t: string): string {
  if (t.indexOf('完成') >= 0) {
    return '#0CA678'
  }
  if (t.indexOf('候补') >= 0 || t.indexOf('待') >= 0) {
    return '#F76707'
  }
  return '#3B5BDB'
}

// ================= 页面 =================
@Entry
@Component
struct Index {
  @State curTab9: number = 0
  @State vaxs9: Array<VaxT9> = VAXS9.slice()
  @State recs9: Array<RecT9> = RECS9.slice()
  @State rems9: Array<RemT9> = REMS9.slice()
  @State famIdx9: number = 0
  @State checked9: Array<number> = [1, 3, 5, 8, 12, 16, 20]

  // 弹框开关
  @State showBook9: boolean = false
  @State showRec9: boolean = false
  @State showAdd9: boolean = false
  @State showRem9: boolean = false
  @State showCancel9: boolean = false
  @State showReact9: boolean = false

  // 预约表单
  @State bkVax9: number = 0
  @State bkStore9: number = 0
  @State bkSlot9: number = 2
  @State bkFam9: number = 0

  // 记录详情
  @State recId9: number = 0

  // 补录
  @State addVax9: string = ''
  @State addDate9: string = ''
  @State addOrg9: string = ''

  // 提醒编辑
  @State remId9: number = 0
  @State remDay9: number = 1
  @State remWay9: number = 0

  // 取消预约
  @State armed9: boolean = false

  // 不良反应上报
  @State reactSel9: Array<string> = []
  @State reactDeg9: number = 1
  @State reactHosp9: boolean = false

  // 特效
  @State shieldOp9: number = 0.35
  @State dropY9: number = -5

  aboutToAppear(): void {
    this.getUIContext().animateTo({ duration: 1500, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
      this.shieldOp9 = 0.95
    })
    this.getUIContext().animateTo({ duration: 1900, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
      this.dropY9 = 6
    })
  }

  // ---------------- 头部(无动画 · 浅色健康风) ----------------
  @Builder headerBar9() {
    Column() {
      Row() {
        Column() {
          Text('💉 VAX PASS').fontSize(17).fontWeight(700).fontColor('#087F5B').letterSpacing(1)
          Text('全家人的疫苗数字接种本').fontSize(10).fontColor('#74B89A').margin({ top: 3 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)

        Row() {
          Text('🛡️').fontSize(18).opacity(this.shieldOp9)
          Text('已护 43 针').fontSize(10).fontColor('#087F5B').fontWeight(700).margin({ left: 4 })
        }
        .padding({ left: 10, right: 10, top: 5, bottom: 5 })
        .borderRadius(12)
        .backgroundColor('#D3F3E8')
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 14, bottom: 10 })

      Row() {
        Text('🔎').fontSize(13)
        Text('查疫苗 / 查批次 / 查门店库存').fontSize(11).fontColor('#74B89A').margin({ left: 6 }).layoutWeight(1).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        Text('💉').fontSize(16).translate({ y: this.dropY9 }).onClick(() => {
          this.showBook9 = true
        })
      }
      .width('100%')
      .padding({ left: 12, right: 12, top: 9, bottom: 9 })
      .borderRadius(20)
      .backgroundColor('#FFFFFF')
      .margin({ left: 16, right: 16, bottom: 12 })
    }
    .width('100%')
    .backgroundColor('#E6FCF5')
    .borderRadius({ bottomLeft: 20, bottomRight: 20 })
  }

  // ---------------- 底部Tab(一排6个) ----------------
  @Builder tabItem9(icon: string, label: string, idx: number) {
    Column() {
      Text(icon).fontSize(19)
      Text(label).fontSize(9).fontColor(this.curTab9 === idx ? '#087F5B' : '#94B8A6').margin({ top: 3 })
    }
    .alignItems(HorizontalAlign.Center)
    .padding({ top: 8, bottom: 6 })
    .layoutWeight(1)
    .onClick(() => {
      this.curTab9 = idx
    })
  }

  @Builder tabBar9() {
    Row() {
      this.tabItem9('📖', '接种本', 0)
      this.tabItem9('📅', '预约', 1)
      this.tabItem9('⏰', '提醒', 2)
      this.tabItem9('📚', '知识', 3)
      this.tabItem9('🏥', '门店', 4)
      this.tabItem9('👤', '我的', 5)
    }
    .width('100%')
    .backgroundColor('#FFFFFF')
    .border({ width: 0.5, color: '#D3F3E8' })
  }

  // ============ Tab0 接种本(时间轴) ============
  @Builder tabBook9() {
    Column() {
      // 家庭成员 chips
      Row() {
        ForEach(FAMS9, (f: FamT9, i: number) => {
          Text(f.icon + ' ' + f.name).fontSize(10).fontColor(this.famIdx9 === i ? '#FFFFFF' : '#4D7C6A').padding({ left: 11, right: 11, top: 6, bottom: 6 }).borderRadius(14).backgroundColor(this.famIdx9 === i ? '#087F5B' : '#FFFFFF').margin({ right: 8 }).onClick(() => {
            this.famIdx9 = i
          })
        }, (f: FamT9) => 'fm' + f.id.toString())
      }
      .width('100%')
      .padding({ left: 14, right: 14, top: 12 })

      // 完成度环
      Row() {
        Stack() {
          Progress({ value: 67, total: 100 })
            .width(66)
            .height(66)
            .style({ strokeWidth: 7 })
            .color('#0CA678')
          Column() {
            Text('67%').fontSize(14).fontWeight(700).fontColor('#087F5B')
            Text('全程完成').fontSize(8).fontColor('#74B89A')
          }
          .alignItems(HorizontalAlign.Center)
        }

        Column() {
          Text(FAMS9[this.famIdx9].name + ' · 已接种 ' + FAMS9[this.famIdx9].done.toString() + ' / ' + FAMS9[this.famIdx9].total.toString() + ' 针').fontSize(12).fontWeight(700).fontColor('#213A30')
          Text('下一针:HPV 九价 第 3 针 · 9 月 12 日 望京分院').fontSize(10).fontColor('#74B89A').margin({ top: 5 })
          Row() {
            Text('去预约').fontSize(10).fontColor('#FFFFFF').fontWeight(700).padding({ left: 12, right: 12, top: 5, bottom: 5 }).borderRadius(14).backgroundColor('#0CA678').onClick(() => {
              this.showBook9 = true
            })
            Text('补录旧针').fontSize(10).fontColor('#0CA678').padding({ left: 12, right: 12, top: 5, bottom: 5 }).borderRadius(14).border({ width: 0.8, color: '#0CA678' }).margin({ left: 8 }).onClick(() => {
              this.showAdd9 = true
            })
          }
          .margin({ top: 9 })
        }
        .alignItems(HorizontalAlign.Start)
        .margin({ left: 14 })
        .layoutWeight(1)
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)
      .padding(14)
      .borderRadius(16)
      .backgroundColor('#FFFFFF')
      .margin({ left: 14, right: 14, top: 10 })

      // 各疫苗系列进度横条
      Column() {
        Text('💉 疫苗系列进度').fontSize(13).fontWeight(700).fontColor('#213A30')
        ForEach(this.vaxs9, (v: VaxT9) => {
          Row() {
            Text(v.name).fontSize(11).fontColor('#213A30').width(74)
            Column() {
              Column() {
              }
              .width((v.done / v.total * 100).toFixed(0) + '%')
              .height('100%')
              .borderRadius(3)
              .backgroundColor(v.color)
            }
            .layoutWeight(1)
            .height(6)
            .borderRadius(3)
            .backgroundColor('#E6FCF5')
            Text(v.done.toString() + '/' + v.total.toString()).fontSize(9).fontColor('#74B89A').margin({ left: 8 }).width(26)
            Text(v.next).fontSize(8).fontColor(doneColor9(v.next)).layoutWeight(1).textAlign(TextAlign.End).maxLines(1)
          }
          .width('100%')
          .alignItems(VerticalAlign.Center)
          .margin({ top: 9 })
        }, (v: VaxT9) => 'vx' + v.id.toString() + '_' + v.done.toString())
      }
      .width('100%')
      .alignItems(HorizontalAlign.Start)
      .padding(14)
      .borderRadius(16)
      .backgroundColor('#FFFFFF')
      .margin({ left: 14, right: 14, top: 10 })

      // 接种时间轴
      Row() {
        Text('📜 接种记录(' + this.recs9.length.toString() + ')').fontSize(13).fontWeight(700).fontColor('#213A30')
        Text('上报反应').fontSize(10).fontColor('#E8590C').onClick(() => {
          this.reactSel9 = []
          this.showReact9 = true
        })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      .padding({ left: 16, right: 16, top: 16, bottom: 4 })

      ForEach(this.recs9, (r: RecT9) => {
        Row() {
          Column() {
            Circle().width(10).height(10).fill('#0CA678')
            Column() {
            }
            .width(2)
            .layoutWeight(1)
            .backgroundColor('#B2E5CE')
          }
          .width(12)
          .alignItems(HorizontalAlign.Center)
          .margin({ top: 2 })

          Column() {
            Row() {
              Text(r.vax + ' ' + r.dose).fontSize(12).fontWeight(700).fontColor('#213A30')
              Text(r.date).fontSize(9).fontColor('#74B89A').margin({ left: 10 })
            }
            .width('100%')
            Text(r.org + ' · 批号 ' + r.batch).fontSize(9).fontColor('#74B89A').margin({ top: 4 }).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
            Text('反应:' + r.react).fontSize(9).fontColor(r.react === '无' ? '#74B89A' : '#E8590C').margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .padding({ left: 10, bottom: 14 })
        }
        .width('100%')
        .alignItems(VerticalAlign.Top)
        .margin({ left: 16, right: 16 })
        .onClick(() => {
          this.recId9 = r.id
          this.showRec9 = true
        })
      }, (r: RecT9) => 'rc' + r.id.toString())
    }
    .width('100%')
    .padding({ bottom: 12 })
  }

  // ============ Tab1 预约(门店卡片) ============
  @Builder tabReserve9() {
    Column() {
      Column() {
        Text('⏳ 待办预约').fontSize(13).fontWeight(700).fontColor('#213A30')
        Row() {
          Text('HPV 九价 第 3 针').fontSize(11).fontColor('#E64980').padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8).backgroundColor('#FCE7F1')
          Text('2026-09-12 10:20 · 望京分院').fontSize(10).fontColor('#74B89A').margin({ left: 8 }).layoutWeight(1)
          Text('取消').fontSize(9).fontColor('#F03E3E').padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8).border({ width: 0.7, color: '#F03E3E' }).onClick(() => {
            this.armed9 = false
            this.showCancel9 = true
          })
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        .margin({ top: 10 })
      }
      .width('100%')
      .alignItems(HorizontalAlign.Start)
      .padding(14)
      .borderRadius(16)
      .backgroundColor('#FFFFFF')
      .margin({ left: 14, right: 14, top: 12 })

      Row() {
        Text('🏥 可预约门店').fontSize(13).fontWeight(700).fontColor('#213A30')
        Text('按距离').fontSize(9).fontColor('#74B89A').margin({ left: 8 })
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)
      .padding({ left: 16, right: 16, top: 16, bottom: 4 })

      ForEach(STORES9, (s: StoreT9) => {
        Column() {
          Row() {
            Column() {
              Text(s.name).fontSize(12).fontWeight(700).fontColor('#213A30')
              Text(s.addr).fontSize(9).fontColor('#74B89A').margin({ top: 4 }).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Column() {
              Text(s.dist.toFixed(1) + 'km').fontSize(11).fontWeight(700).fontColor('#3B5BDB')
              Text('⭐ ' + s.score.toFixed(1)).fontSize(9).fontColor('#F59F00').margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.End)
          }
          .width('100%')

          Row() {
            Text('🟢 ' + s.stock).fontSize(9).fontColor('#0CA678').layoutWeight(1).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
            Text('预约').fontSize(10).fontColor('#FFFFFF').fontWeight(700).padding({ left: 14, right: 14, top: 5, bottom: 5 }).borderRadius(13).backgroundColor('#0CA678').onClick(() => {
              this.bkStore9 = Math.max(0, STORES9.findIndex((x: StoreT9) => x.id === s.id))
              this.showBook9 = true
            })
          }
          .width('100%')
          .alignItems(VerticalAlign.Center)
          .margin({ top: 9 })
        }
        .width('100%')
        .alignItems(HorizontalAlign.Start)
        .padding(13)
        .borderRadius(14)
        .backgroundColor('#FFFFFF')
        .margin({ left: 14, right: 14, top: 8 })
      }, (s: StoreT9) => 'st' + s.id.toString())
    }
    .width('100%')
    .padding({ bottom: 12 })
  }

  // ============ Tab2 提醒(日历格子) ============
  @Builder tabRemind9() {
    Column() {
      // 九月日历
      Column() {
        Text('📅 2026 年 9 月 · 接种日历').fontSize(13).fontWeight(700).fontColor('#213A30')
        Row() {
          ForEach(['一', '二', '三', '四', '五', '六', '日'], (w: string) => {
            Text(w).fontSize(9).fontColor('#94B89A').layoutWeight(1).textAlign(TextAlign.Center).padding({ top: 8, bottom: 4 })
          }, (w: string) => 'wd' + w)
        }
        .width('100%')

        Flex({ wrap: FlexWrap.Wrap }) {
          ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34], (d: number) => {
            Column() {
              if (d < 31) {
                Text((d + 1).toString()).fontSize(11).fontColor(this.checked9.indexOf(d + 1) >= 0 ? '#FFFFFF' : '#213A30')
                if (this.checked9.indexOf(d + 1) >= 0) {
                  Text('💉').fontSize(8).margin({ top: 1 })
                } else {
                  Text('').fontSize(8).margin({ top: 1 })
                }
              }
            }
            .width('12.5%')
            .padding({ top: 6, bottom: 6 })
            .borderRadius(8)
            .backgroundColor(this.checked9.indexOf(d + 1) >= 0 ? '#0CA678' : '#F0FBF6')
            .margin({ top: 4 })
            .onClick(() => {
              if (d < 31) {
                if (this.checked9.indexOf(d + 1) >= 0) {
                  this.checked9 = this.checked9.filter((x: number) => x !== d + 1)
                } else {
                  this.checked9 = this.checked9.concat([d + 1])
                }
              }
            })
          }, (d: number) => 'cd' + d.toString())
        }
        .width('100%')
      }
      .width('100%')
      .alignItems(HorizontalAlign.Start)
      .padding(14)
      .borderRadius(16)
      .backgroundColor('#FFFFFF')
      .margin({ left: 14, right: 14, top: 12 })

      // 提醒列表
      Row() {
        Text('⏰ 提醒计划(' + this.rems9.length.toString() + ')').fontSize(13).fontWeight(700).fontColor('#213A30')
        Text('新增提醒走预约后自动生成').fontSize(9).fontColor('#94B89A')
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 16, bottom: 4 })

      ForEach(this.rems9, (r: RemT9) => {
        Row() {
          Column() {
            Text(r.vax).fontSize(12).fontWeight(700).fontColor('#213A30')
            Row() {
              Text(r.days.toString() + ' 天后').fontSize(10).fontColor(r.days <= 30 ? '#E8590C' : '#3B5BDB').fontWeight(700)
              Text('· ' + r.way).fontSize(9).fontColor('#74B89A').margin({ left: 8 })
              Text(r.on ? ' 已开启' : ' 已关闭').fontSize(9).fontColor(r.on ? '#0CA678' : '#94B89A').margin({ left: 8 })
            }
            .margin({ top: 4 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)

          Text('✏️').fontSize(15).onClick(() => {
            this.remId9 = r.id
            this.remDay9 = 1
            this.remWay9 = Math.max(0, WAY9.indexOf(r.way))
            this.showRem9 = true
          })
          Text(r.on ? '🔔' : '🔕').fontSize(14).margin({ left: 14 }).onClick(() => {
            this.rems9 = this.rems9.map((x: RemT9) => {
              if (x.id === r.id) {
                return { id: x.id, vax: x.vax, days: x.days, way: x.way, on: !x.on }
              }
              return x
            })
          })
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        .padding(13)
        .borderRadius(14)
        .backgroundColor('#FFFFFF')
        .margin({ left: 14, right: 14, top: 8 })
      }, (r: RemT9) => 'rm' + r.id.toString() + '_' + r.on.toString())
    }
    .width('100%')
    .padding({ bottom: 12 })
  }

  // ============ Tab3 知识(文章卡片流) ============
  @Builder tabKnow9() {
    Column() {
      Row() {
        Text('📚 疫苗知识库').fontSize(13).fontWeight(700).fontColor('#213A30')
        Text('更新至 2026-08').fontSize(9).fontColor('#94B89A')
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 14, bottom: 4 })

      ForEach(ARTS9, (a: ArtT9) => {
        Row() {
          Column() {
            Row() {
              Text(a.cate).fontSize(8).fontColor('#087F5B').padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(6).backgroundColor('#D3F3E8')
              if (a.hot) {
                Text('🔥 热').fontSize(8).fontColor('#E8590C').padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(6).backgroundColor('#FFE8D9').margin({ left: 6 })
              }
              Text(a.read + ' 阅读').fontSize(9).fontColor('#94B89A').margin({ left: 8 })
            }
            .width('100%')
            Text(a.title).fontSize(12).fontWeight(700).fontColor('#213A30').margin({ top: 7 }).lineHeight(17)
            Text('约 3 分钟读完 · 疾控中心资料审核').fontSize(9).fontColor('#94B89A').margin({ top: 6 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)

          Text('📄').fontSize(26).margin({ left: 12 })
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        .padding(13)
        .borderRadius(14)
        .backgroundColor('#FFFFFF')
        .margin({ left: 14, right: 14, top: 8 })
      }, (a: ArtT9) => 'ar' + a.id.toString())
    }
    .width('100%')
    .padding({ bottom: 12 })
  }

  // ============ Tab4 门店(列表) ============
  @Builder tabStore9() {
    Column() {
      ForEach(STORES9, (s: StoreT9) => {
        Column() {
          Row() {
            Text('🏥').fontSize(26)
            Column() {
              Row() {
                Text(s.name).fontSize(12).fontWeight(700).fontColor('#213A30').layoutWeight(1)
                Text('⭐ ' + s.score.toFixed(1)).fontSize(10).fontColor('#F59F00')
              }
              .width('100%')
              Text(s.addr).fontSize(9).fontColor('#74B89A').margin({ top: 4 })
              Text(s.stock).fontSize(9).fontColor('#0CA678').margin({ top: 4 })
            }
            .alignItems(HorizontalAlign.Start)
            .margin({ left: 12 })
            .layoutWeight(1)
          }
          .width('100%')

          Row() {
            Text('🧭 距离 ' + s.dist.toFixed(1) + 'km · 营业 08:30-17:30').fontSize(9).fontColor('#94B89A').layoutWeight(1)
            Text('约这针').fontSize(10).fontColor('#FFFFFF').fontWeight(700).padding({ left: 14, right: 14, top: 5, bottom: 5 }).borderRadius(13).backgroundColor('#3B5BDB').onClick(() => {
              this.bkStore9 = Math.max(0, STORES9.findIndex((x: StoreT9) => x.id === s.id))
              this.showBook9 = true
            })
          }
          .width('100%')
          .alignItems(VerticalAlign.Center)
          .margin({ top: 10 })
        }
        .width('100%')
        .alignItems(HorizontalAlign.Start)
        .padding(13)
        .borderRadius(14)
        .backgroundColor('#FFFFFF')
        .margin({ left: 14, right: 14, top: 8 })
      }, (s: StoreT9) => 'st2' + s.id.toString())
    }
    .width('100%')
    .padding({ top: 12, bottom: 12 })
  }

  // ============ Tab5 我的 ============
  @Builder tabMine9() {
    Column() {
      Row() {
        Stack() {
          Circle().width(52).height(52).fill('#D3F3E8')
          Text('👨').fontSize(26)
        }
        Column() {
          Text('王先生 · 家庭接种管理员').fontSize(14).fontWeight(700).fontColor('#213A30')
          Text('管理 4 位家人 · 累计记录 43 针').fontSize(9).fontColor('#74B89A').margin({ top: 5 })
        }
        .alignItems(HorizontalAlign.Start)
        .margin({ left: 12 })
        .layoutWeight(1)

        Text('补录旧针').fontSize(10).fontColor('#0CA678').padding({ left: 10, right: 10, top: 6, bottom: 6 }).borderRadius(12).border({ width: 0.7, color: '#0CA678' }).onClick(() => {
          this.showAdd9 = true
        })
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)
      .padding(14)
      .borderRadius(16)
      .backgroundColor('#FFFFFF')
      .margin({ left: 14, right: 14, top: 12 })

      // 家庭接种完成柱状
      Column() {
        Text('👨‍👩‍👧 家庭接种完成数').fontSize(13).fontWeight(700).fontColor('#213A30')
        Row() {
          ForEach(FAMS9, (f: FamT9) => {
            Column() {
              Text(f.done.toString()).fontSize(10).fontColor('#087F5B').margin({ bottom: 3 })
              Column() {
                Column() {
                }
                .width(20)
                .height(f.done * 6)
                .borderRadius({ topLeft: 4, topRight: 4 })
                .backgroundColor(f.done >= 12 ? '#0CA678' : '#8ED9BC')
              }
              .width('100%')
              .height(96)
              .justifyContent(FlexAlign.End)
              Text(f.icon).fontSize(13).margin({ top: 5 })
              Text(f.name).fontSize(9).fontColor('#74B89A').margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.Center)
            .layoutWeight(1)
          }, (f: FamT9) => 'fmc' + f.id.toString())
        }
        .width('100%')
        .margin({ top: 12 })
      }
      .width('100%')
      .alignItems(HorizontalAlign.Start)
      .padding(14)
      .borderRadius(16)
      .backgroundColor('#FFFFFF')
      .margin({ left: 14, right: 14, top: 10 })

      // 电子接种凭证
      Column() {
        Row() {
          Text('🎫 电子接种凭证').fontSize(13).fontWeight(700).fontColor('#213A30')
          Text('可导出 PDF').fontSize(9).fontColor('#94B89A')
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        Text('已签发 12 条记录 · 入学/出国/入职体检通用,含疾控核验码。').fontSize(10).fontColor('#74B89A').lineHeight(15).margin({ top: 8 })
      }
      .width('100%')
      .alignItems(HorizontalAlign.Start)
      .padding(14)
      .borderRadius(16)
      .backgroundColor('#FFFFFF')
      .margin({ left: 14, right: 14, top: 10 })

      Column() {
        Text('💉 疫苗接种是给自己最划算的健康投资 — VAX PASS').fontSize(9).fontColor('#94B89A')
      }
      .width('100%')
      .alignItems(HorizontalAlign.Center)
      .padding(24)
    }
    .width('100%')
    .padding({ bottom: 12 })
  }

  // ---------------- 弹框1:新建预约(底部抽屉) ----------------
  @Builder bookOverlay9() {
    Column() {
      Column() {
        Column() {
          Text('').fontSize(4).width(40).borderRadius(2).backgroundColor('#B2E5CE')
        }
        .width('100%')
        .alignItems(HorizontalAlign.Center)
        .padding({ top: 10, bottom: 4 })

        Row() {
          Text('💉 新建接种预约').fontSize(15).fontWeight(700).fontColor('#213A30')
          Text('✕').fontSize(15).fontColor('#94B89A').onClick(() => {
            this.showBook9 = false
          })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        .padding({ left: 16, right: 16, top: 4 })

        Scroll() {
          Column() {
            Text('疫苗').fontSize(11).fontColor('#74B89A').margin({ top: 12 })
            Flex({ wrap: FlexWrap.Wrap }) {
              ForEach(this.vaxs9, (v: VaxT9, i: number) => {
                Text(v.name).fontSize(10).fontColor(this.bkVax9 === i ? '#FFFFFF' : '#4D7C6A').padding({ left: 10, right: 10, top: 7, bottom: 7 }).borderRadius(12).backgroundColor(this.bkVax9 === i ? '#087F5B' : '#F0FBF6').margin({ right: 8, bottom: 8 }).onClick(() => {
                  this.bkVax9 = i
                })
              }, (v: VaxT9) => 'bv' + v.id.toString())
            }
            .width('100%')

            Text('家庭成员').fontSize(11).fontColor('#74B89A').margin({ top: 6 })
            Row() {
              ForEach(FAMS9, (f: FamT9, i: number) => {
                Text(f.icon + f.name).fontSize(10).fontColor(this.bkFam9 === i ? '#FFFFFF' : '#4D7C6A').padding({ left: 11, right: 11, top: 7, bottom: 7 }).borderRadius(12).backgroundColor(this.bkFam9 === i ? '#087F5B' : '#F0FBF6').margin({ right: 8 }).onClick(() => {
                  this.bkFam9 = i
                })
              }, (f: FamT9) => 'bf' + f.id.toString())
            }
            .margin({ top: 6 })

            Text('门店').fontSize(11).fontColor('#74B89A').margin({ top: 12 })
            Flex({ wrap: FlexWrap.Wrap }) {
              ForEach(STORES9.slice(0, 5), (s: StoreT9, i: number) => {
                Text(s.name.replace('爱康', '')).fontSize(10).fontColor(this.bkStore9 === i ? '#FFFFFF' : '#4D7C6A').padding({ left: 10, right: 10, top: 7, bottom: 7 }).borderRadius(12).backgroundColor(this.bkStore9 === i ? '#087F5B' : '#F0FBF6').margin({ right: 8, bottom: 8 }).onClick(() => {
                  this.bkStore9 = i
                })
              }, (s: StoreT9) => 'bs' + s.id.toString())
            }
            .width('100%')

            Text('时段').fontSize(11).fontColor('#74B89A').margin({ top: 6 })
            Flex({ wrap: FlexWrap.Wrap }) {
              ForEach(SLOT9, (t: string, i: number) => {
                Text(t).fontSize(10).fontColor(this.bkSlot9 === i ? '#FFFFFF' : '#4D7C6A').padding({ left: 11, right: 11, top: 7, bottom: 7 }).borderRadius(12).backgroundColor(this.bkSlot9 === i ? '#087F5B' : '#F0FBF6').margin({ right: 8, bottom: 8 }).onClick(() => {
                  this.bkSlot9 = i
                })
              }, (t: string) => 'bt' + t)
            }
            .width('100%')

            Text('预约成功后自动生成提醒,可随时改期一次。').fontSize(9).fontColor('#94B89A').margin({ top: 10 })
          }
          .width('100%')
          .padding({ left: 16, right: 16, bottom: 12 })
        }
        .layoutWeight(1)
        .align(Alignment.Top)
        .edgeEffect(EdgeEffect.Spring)

        Text('确认预约')
          .fontSize(13)
          .fontWeight(700)
          .fontColor('#FFFFFF')
          .width('100%')
          .textAlign(TextAlign.Center)
          .padding({ top: 13, bottom: 13 })
          .borderRadius(22)
          .backgroundColor('#0CA678')
          .margin({ left: 16, right: 16, bottom: 14 })
          .onClick(() => {
            const v: VaxT9 = this.vaxs9[this.bkVax9]
            const nr: RemT9 = { id: Date.now(), vax: v.name + '(新预约)', days: 14, way: '推送+短信', on: true }
            this.rems9 = [nr].concat(this.rems9)
            this.vaxs9 = this.vaxs9.map((x: VaxT9) => {
              if (x.id === v.id) {
                return { id: x.id, name: x.name, total: x.total, done: x.done, next: '已约 ' + STORES9[this.bkStore9].name.replace('爱康', '') + ' ' + SLOT9[this.bkSlot9], color: x.color, age: x.age }
              }
              return x
            })
            this.showBook9 = false
          })
      }
      .width('100%')
      .height('76%')
      .borderRadius({ topLeft: 22, topRight: 22 })
      .backgroundColor('#FFFFFF')
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.End)
    .backgroundColor('#99087F5B')
    .zIndex(999)
    .onClick(() => {
      this.showBook9 = false
    })
  }

  // ---------------- 弹框2:接种记录详情(居中大卡) ----------------
  @Builder recOverlay9() {
    Column() {
      Column() {
        Row() {
          Text('🎫 接种凭证').fontSize(15).fontWeight(700).fontColor('#213A30')
          Text('✕').fontSize(15).fontColor('#94B89A').onClick(() => {
            this.showRec9 = false
          })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)

        Column() {
          Text(this.recId9 > 0 && this.recs9.filter((r: RecT9) => r.id === this.recId9).length > 0 ? this.recs9.filter((r: RecT9) => r.id === this.recId9)[0].vax + ' ' + this.recs9.filter((r: RecT9) => r.id === this.recId9)[0].dose : '').fontSize(17).fontWeight(700).fontColor('#087F5B')
          Text(this.recId9 > 0 && this.recs9.filter((r: RecT9) => r.id === this.recId9).length > 0 ? this.recs9.filter((r: RecT9) => r.id === this.recId9)[0].date : '').fontSize(11).fontColor('#74B89A').margin({ top: 6 })
        }
        .width('100%')
        .alignItems(HorizontalAlign.Start)
        .padding(13)
        .borderRadius(12)
        .backgroundColor('#F0FBF6')
        .margin({ top: 12 })

        Column() {
          Row() {
            Text('接种机构').fontSize(11).fontColor('#74B89A').width(80)
            Text(this.recId9 > 0 && this.recs9.filter((r: RecT9) => r.id === this.recId9).length > 0 ? this.recs9.filter((r: RecT9) => r.id === this.recId9)[0].org : '').fontSize(11).fontColor('#213A30').layoutWeight(1)
          }
          .width('100%')
          Row() {
            Text('疫苗批号').fontSize(11).fontColor('#74B89A').width(80)
            Text(this.recId9 > 0 && this.recs9.filter((r: RecT9) => r.id === this.recId9).length > 0 ? this.recs9.filter((r: RecT9) => r.id === this.recId9)[0].batch : '').fontSize(11).fontColor('#213A30').layoutWeight(1)
          }
          .width('100%')
          .margin({ top: 10 })
          Row() {
            Text('不良反应').fontSize(11).fontColor('#74B89A').width(80)
            Text(this.recId9 > 0 && this.recs9.filter((r: RecT9) => r.id === this.recId9).length > 0 ? this.recs9.filter((r: RecT9) => r.id === this.recId9)[0].react : '').fontSize(11).fontColor('#E8590C').layoutWeight(1)
          }
          .width('100%')
          .margin({ top: 10 })
          Row() {
            Text('核验码').fontSize(11).fontColor('#74B89A').width(80)
            Text('CDC-' + this.recId9.toString() + '-8829').fontSize(11).fontColor('#213A30').layoutWeight(1)
          }
          .width('100%')
          .margin({ top: 10 })
        }
        .width('100%')
        .alignItems(HorizontalAlign.Start)
        .padding(13)
        .borderRadius(12)
        .backgroundColor('#F0FBF6')
        .margin({ top: 10 })

        Row() {
          Text('删除记录').fontSize(12).fontColor('#F03E3E').layoutWeight(1).textAlign(TextAlign.Center).padding({ top: 12, bottom: 12 }).borderRadius(20).border({ width: 0.8, color: '#F03E3E' }).onClick(() => {
            this.recs9 = this.recs9.filter((r: RecT9) => r.id !== this.recId9)
            this.showRec9 = false
          })
          Text('上报反应').fontSize(12).fontColor('#FFFFFF').fontWeight(700).layoutWeight(1).textAlign(TextAlign.Center).padding({ top: 12, bottom: 12 }).borderRadius(20).backgroundColor('#E8590C').margin({ left: 10 }).onClick(() => {
            this.showRec9 = false
            this.reactSel9 = []
            this.showReact9 = true
          })
        }
        .width('100%')
        .margin({ top: 16 })
      }
      .width('86%')
      .padding(18)
      .borderRadius(18)
      .backgroundColor('#FFFFFF')
      .constraintSize({ maxHeight: '78%' })
      .onClick(() => {
      })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#99213A30')
    .zIndex(999)
    .onClick(() => {
      this.showRec9 = false
    })
  }

  // ---------------- 弹框3:补录历史接种(居中卡) ----------------
  @Builder addOverlay9() {
    Column() {
      Column() {
        Text('📝 补录历史接种').fontSize(15).fontWeight(700).fontColor('#213A30')
        Text('纸质接种本 / 儿童接种证均可补录').fontSize(10).fontColor('#74B89A').margin({ top: 6 })

        Text('疫苗名称').fontSize(11).fontColor('#74B89A').margin({ top: 14 })
        TextInput({ placeholder: '例:乙肝疫苗 第 2 针', text: this.addVax9 })
          .fontSize(11)
          .fontColor('#213A30')
          .placeholderColor('#94B89A')
          .backgroundColor('#F0FBF6')
          .borderRadius(10)
          .height(42)
          .padding({ left: 10, right: 10 })
          .margin({ top: 6 })
          .onChange((v: string) => {
            this.addVax9 = v
          })

        Text('接种日期').fontSize(11).fontColor('#74B89A').margin({ top: 12 })
        TextInput({ placeholder: '例:2015-12-02', text: this.addDate9 })
          .fontSize(11)
          .fontColor('#213A30')
          .placeholderColor('#94B89A')
          .backgroundColor('#F0FBF6')
          .borderRadius(10)
          .height(42)
          .padding({ left: 10, right: 10 })
          .margin({ top: 6 })
          .onChange((v: string) => {
            this.addDate9 = v
          })

        Text('接种机构').fontSize(11).fontColor('#74B89A').margin({ top: 12 })
        TextInput({ placeholder: '例:社区卫生服务中心', text: this.addOrg9 })
          .fontSize(11)
          .fontColor('#213A30')
          .placeholderColor('#94B89A')
          .backgroundColor('#F0FBF6')
          .borderRadius(10)
          .height(42)
          .padding({ left: 10, right: 10 })
          .margin({ top: 6 })
          .onChange((v: string) => {
            this.addOrg9 = v
          })

        Row() {
          Text('取消').fontSize(12).fontColor('#74B89A').layoutWeight(1).textAlign(TextAlign.Center).padding({ top: 11, bottom: 11 }).borderRadius(20).border({ width: 0.8, color: '#B2E5CE' }).onClick(() => {
            this.showAdd9 = false
          })
          Text('保存补录').fontSize(12).fontColor('#FFFFFF').fontWeight(700).layoutWeight(1).textAlign(TextAlign.Center).padding({ top: 11, bottom: 11 }).borderRadius(20).backgroundColor('#0CA678').margin({ left: 10 }).onClick(() => {
            const nr: RecT9 = { id: Date.now(), vax: this.addVax9.length > 0 ? this.addVax9 : '未命名疫苗', dose: '补录', date: this.addDate9.length > 0 ? this.addDate9 : '日期不详', org: this.addOrg9.length > 0 ? this.addOrg9 : '机构不详', batch: '补录', react: '无', ok: true }
            this.recs9 = [nr].concat(this.recs9)
            this.showAdd9 = false
          })
        }
        .width('100%')
        .margin({ top: 18 })
      }
      .width('84%')
      .padding(18)
      .borderRadius(18)
      .backgroundColor('#FFFFFF')
      .onClick(() => {
      })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#99213A30')
    .zIndex(999)
    .onClick(() => {
      this.showAdd9 = false
    })
  }

  // ---------------- 弹框4:编辑提醒(底部抽屉) ----------------
  @Builder remOverlay9() {
    Column() {
      Column() {
        Row() {
          Text('⏰ 编辑提醒').fontSize(15).fontWeight(700).fontColor('#213A30')
          Text('✕').fontSize(15).fontColor('#94B89A').onClick(() => {
            this.showRem9 = false
          })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        .padding({ top: 16, bottom: 4 })

        Text('提醒时机').fontSize(11).fontColor('#74B89A').margin({ top: 12 })
        Row() {
          ForEach(DAYS9, (d: string, i: number) => {
            Text(d).fontSize(10).fontColor(this.remDay9 === i ? '#FFFFFF' : '#4D7C6A').padding({ left: 10, right: 10, top: 7, bottom: 7 }).borderRadius(12).backgroundColor(this.remDay9 === i ? '#087F5B' : '#F0FBF6').margin({ right: 8 }).onClick(() => {
              this.remDay9 = i
            })
          }, (d: string) => 'rd' + d)
        }
        .margin({ top: 6 })

        Text('提醒方式').fontSize(11).fontColor('#74B89A').margin({ top: 14 })
        Flex({ wrap: FlexWrap.Wrap }) {
          ForEach(WAY9, (w: string, i: number) => {
            Text(w).fontSize(10).fontColor(this.remWay9 === i ? '#FFFFFF' : '#4D7C6A').padding({ left: 10, right: 10, top: 7, bottom: 7 }).borderRadius(12).backgroundColor(this.remWay9 === i ? '#087F5B' : '#F0FBF6').margin({ right: 8, bottom: 8 }).onClick(() => {
              this.remWay9 = i
            })
          }, (w: string) => 'rw' + w)
        }
        .width('100%')
        .margin({ top: 6 })

        Text('重复提醒:开启后未确认前每天催一次').fontSize(9).fontColor('#94B89A').margin({ top: 12 })

        Text('保存修改')
          .fontSize(13)
          .fontWeight(700)
          .fontColor('#FFFFFF')
          .width('100%')
          .textAlign(TextAlign.Center)
          .padding({ top: 13, bottom: 13 })
          .borderRadius(22)
          .backgroundColor('#0CA678')
          .margin({ top: 16, bottom: 16 })
          .onClick(() => {
            this.rems9 = this.rems9.map((r: RemT9) => {
              if (r.id === this.remId9) {
                return { id: r.id, vax: r.vax, days: r.days, way: WAY9[this.remWay9], on: true }
              }
              return r
            })
            this.showRem9 = false
          })
      }
      .width('100%')
      .padding({ left: 16, right: 16 })
      .borderRadius({ topLeft: 22, topRight: 22 })
      .backgroundColor('#FFFFFF')
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.End)
    .backgroundColor('#99213A30')
    .zIndex(999)
    .onClick(() => {
      this.showRem9 = false
    })
  }

  // ---------------- 弹框5:取消预约(窄危险卡) ----------------
  @Builder cancelOverlay9() {
    Column() {
      Column() {
        Text('⚠️').fontSize(30)
        Text('取消 HPV 九价预约?').fontSize(15).fontWeight(700).fontColor('#213A30').margin({ top: 10 })
        Text('取消后 30 天内不可再次预约该分院九价,苗源将释放给候补队列。').fontSize(11).fontColor('#74B89A').margin({ top: 10 }).textAlign(TextAlign.Center).lineHeight(17)

        Row() {
          Text('我已知晓不可恢复').fontSize(11).fontColor('#F03E3E').layoutWeight(1)
          Toggle({ type: ToggleType.Switch, isOn: this.armed9 })
            .selectedColor('#F03E3E')
            .onChange((on: boolean) => {
              this.armed9 = on
            })
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        .margin({ top: 16 })

        Row() {
          Text('再想想').fontSize(12).fontColor('#74B89A').layoutWeight(1).textAlign(TextAlign.Center).padding({ top: 11, bottom: 11 }).borderRadius(20).border({ width: 0.8, color: '#B2E5CE' }).onClick(() => {
            this.showCancel9 = false
          })
          Text(this.armed9 ? '确认取消' : '请先打开确认')
            .fontSize(12)
            .fontWeight(700)
            .fontColor(this.armed9 ? '#FFFFFF' : '#94B89A')
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 11, bottom: 11 })
            .borderRadius(20)
            .backgroundColor(this.armed9 ? '#F03E3E' : '#F0FBF6')
            .margin({ left: 10 })
            .onClick(() => {
              if (this.armed9) {
                this.showCancel9 = false
              }
            })
        }
        .width('100%')
        .margin({ top: 18 })
      }
      .width('74%')
      .padding(18)
      .borderRadius(16)
      .backgroundColor('#FFF5F3')
      .border({ width: 1, color: '#F03E3E' })
      .onClick(() => {
      })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#99213A30')
    .zIndex(999)
    .onClick(() => {
      this.showCancel9 = false
    })
  }

  // ---------------- 弹框6:不良反应上报(底部抽屉) ----------------
  @Builder reactOverlay9() {
    Column() {
      Column() {
        Column() {
          Text('').fontSize(4).width(40).borderRadius(2).backgroundColor('#B2E5CE')
        }
        .width('100%')
        .alignItems(HorizontalAlign.Center)
        .padding({ top: 10, bottom: 4 })

        Row() {
          Text('🤒 不良反应上报').fontSize(15).fontWeight(700).fontColor('#213A30')
          Text('✕').fontSize(15).fontColor('#94B89A').onClick(() => {
            this.showReact9 = false
          })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        .padding({ left: 16, right: 16, top: 4 })

        Text('症状(可多选)').fontSize(11).fontColor('#74B89A').margin({ top: 12 })
        Flex({ wrap: FlexWrap.Wrap }) {
          ForEach(REACT9, (x: string) => {
            Text(x).fontSize(10).fontColor(this.reactSel9.indexOf(x) >= 0 ? '#FFFFFF' : '#4D7C6A').padding({ left: 10, right: 10, top: 7, bottom: 7 }).borderRadius(12).backgroundColor(this.reactSel9.indexOf(x) >= 0 ? '#E8590C' : '#F0FBF6').margin({ right: 8, bottom: 8 }).onClick(() => {
              if (this.reactSel9.indexOf(x) >= 0) {
                this.reactSel9 = this.reactSel9.filter((y: string) => y !== x)
              } else {
                this.reactSel9 = this.reactSel9.concat([x])
              }
            })
          }, (x: string) => 'ra' + x)
        }
        .width('100%')
        .margin({ top: 6 })

        Text('严重程度').fontSize(11).fontColor('#74B89A').margin({ top: 10 })
        Row() {
          ForEach(['轻微', '中等', '明显'], (d: string, i: number) => {
            Text(d).fontSize(10).fontColor(this.reactDeg9 === i ? '#FFFFFF' : '#4D7C6A').padding({ left: 14, right: 14, top: 7, bottom: 7 }).borderRadius(12).backgroundColor(this.reactDeg9 === i ? '#E8590C' : '#F0FBF6').margin({ right: 8 }).onClick(() => {
              this.reactDeg9 = i
            })
          }, (d: string) => 'dg' + d)
        }
        .margin({ top: 6 })

        Row() {
          Text('🚑 已就医(勾选后同步疫苗安全监测系统)').fontSize(11).fontColor('#213A30').layoutWeight(1)
          Toggle({ type: ToggleType.Switch, isOn: this.reactHosp9 })
            .selectedColor('#F03E3E')
            .onChange((on: boolean) => {
              this.reactHosp9 = on
            })
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        .margin({ top: 14 })

        Text('提交上报')
          .fontSize(13)
          .fontWeight(700)
          .fontColor('#FFFFFF')
          .width('100%')
          .textAlign(TextAlign.Center)
          .padding({ top: 13, bottom: 13 })
          .borderRadius(22)
          .backgroundColor('#E8590C')
          .margin({ top: 16, bottom: 16 })
          .onClick(() => {
            this.showReact9 = false
          })
      }
      .width('100%')
      .padding({ left: 16, right: 16 })
      .borderRadius({ topLeft: 22, topRight: 22 })
      .backgroundColor('#FFFFFF')
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.End)
    .backgroundColor('#99213A30')
    .zIndex(999)
    .onClick(() => {
      this.showReact9 = false
    })
  }

  // ---------------- 主布局 ----------------
  build() {
    Stack() {
      Column() {
        this.headerBar9()

        Scroll() {
          Column() {
            if (this.curTab9 === 0) {
              this.tabBook9()
            }
            if (this.curTab9 === 1) {
              this.tabReserve9()
            }
            if (this.curTab9 === 2) {
              this.tabRemind9()
            }
            if (this.curTab9 === 3) {
              this.tabKnow9()
            }
            if (this.curTab9 === 4) {
              this.tabStore9()
            }
            if (this.curTab9 === 5) {
              this.tabMine9()
            }
          }
          .width('100%')
        }
        .layoutWeight(1)
        .scrollBar(BarState.Off)
        .edgeEffect(EdgeEffect.Spring)
        .width('100%')
        .backgroundColor('#EFFBF6')

        this.tabBar9()
      }
      .width('100%')
      .height('100%')

      if (this.showBook9) {
        this.bookOverlay9()
      }
      if (this.showRec9) {
        this.recOverlay9()
      }
      if (this.showAdd9) {
        this.addOverlay9()
      }
      if (this.showRem9) {
        this.remOverlay9()
      }
      if (this.showCancel9) {
        this.cancelOverlay9()
      }
      if (this.showReact9) {
        this.reactOverlay9()
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#EFFBF6')
  }
}


在这里插入图片描述

第六,弹框的实现体现了 Stack 层叠布局的强大能力。通过 Stack 的层叠特性,可以轻松实现各种形态的弹框——底部抽屉、居中模态、窄确认卡等。半透明遮罩层 + 内容区 + 点击遮罩关闭,是弹框实现的经典模式。zIndex 属性确保弹框始终显示在主界面之上,不会被其他元素遮挡。

弹框设计的关键在于交互的明确性和操作的安全性。对于重要操作,应该提供清晰的确认机制;对于危险操作,应该提供额外的防误触措施(如二次确认、开关确认等)。

第七,动画是提升用户体验的重要手段。虽然这个应用中的动画不多(盾牌呼吸效果和针管浮动效果),但它们很好地展示了 animateTo 动画 API 的用法。animateTo 可以让任何绑定了 @State 变量的属性产生平滑的过渡动画,只需要在动画闭包中修改状态变量的值即可。这种声明式的动画方式大大简化了动画实现的复杂度。

第八,表单交互是应用功能的重要组成部分。TextInput 输入框、Toggle 开关、标签选择等表单项,配合 @State 状态变量和 onChange 事件回调,可以构建出完整的表单交互流程。多选、单选、开关、输入框等不同的表单控件有不同的状态管理方式,理解它们的差异并灵活运用,可以大大提升表单开发的效率。

最后,整体架构的清晰性是应用可维护性的保障。这个应用采用了"单组件 + 多 @Builder 方法"的架构模式,虽然所有代码都在一个组件中,但通过合理的方法抽取和代码组织,保持了良好的可读性和可维护性。对于更复杂的应用,可以进一步将每个 Tab 页面和每个弹框抽取为独立的 @Component 组件,实现更彻底的组件化和复用。

Logo

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

更多推荐