鸿蒙 ArkTS 声明式 UI 深度实践:疫苗全程管家应用架构解析
一、鸿蒙开发背景与技术范式演进
鸿蒙操作系统(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 九价”、“带状疱疹”、“乙肝” 等。这个字段会在多个界面位置展示,包括疫苗进度列表、预约弹框、接种记录等。
total 和 done 字段分别表示该疫苗系列总共需要接种的针数和已经完成的针数。这两个字段配合使用,可以计算出接种进度百分比,用于绘制进度条和环形进度图。比如 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 表示),done 和 total 分别是已接种针数和应接种总针数。这个结构既用于接种本页面顶部的家庭成员切换标签,也用于"我的"页面的家庭接种完成数柱状图。
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 页面。
vaxs9、recs9、rems9 分别是疫苗列表、接种记录列表、提醒列表的状态变量。它们通过 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) 是一个非常重要的属性,它用于弹性布局中的权重分配。当父容器是 Row 或 Column 时,设置了 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 是进度组件,通过 value 和 total 来设置进度值和总值,这里表示 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 中的基础形状组件之一,除此之外还有 Ellipse、Path、Rect 等。
圆形下方是一个空的 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 是弹性布局容器,它比 Row 和 Column 更灵活,支持更多的布局控制选项。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) 语句控制"热门"标签的显示。如果文章是热门文章(hot 为 true),就显示一个橙色的 “🔥 热” 标签。在 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
})
点击确认预约时,会执行以下操作:
- 获取当前选中的疫苗对象
- 创建一条新的提醒记录,ID 使用
Date.now()生成时间戳(确保唯一性) - 将新提醒添加到提醒列表的开头
- 更新疫苗列表中对应疫苗的
next字段,显示"已约 + 门店 + 时段" - 关闭预约弹框
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 控制。当 armed9 为 false 时,按钮显示灰色背景、灰色文字、文字为"请先打开确认",点击无效。当 armed9 为 true 时,按钮显示红色背景、白色文字、文字为"确认取消",点击有效。
这种"开关确认 + 按钮"的双重确认机制,虽然增加了用户的操作步骤,但对于不可逆的危险操作来说,是一种非常负责任的设计。它可以有效防止用户因误触而造成损失。
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 容器,它包含两层内容:
- 主界面层(
Column):头部 + 滚动内容区 + 底部 Tab 栏 - 弹框层:六个弹框,通过 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,它层叠了主界面和弹框两层。主界面采用经典的"顶部头部 + 中间内容 + 底部导航"三段式结构。六个 Tab 页面分别承载不同的功能模块,六个弹框承载不同的模态交互。页面之间通过状态变量的变化来驱动切换和弹框显示。
12.2 状态驱动界面更新流程
这张流程图展示了声明式 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 组件,实现更彻底的组件化和复用。
更多推荐




所有评论(0)