鸿蒙操作系统(HarmonyOS)作为华为推出的分布式全场景智慧操作系统,其应用开发框架提供了一套全新的声明式UI编程范式。与传统命令式UI开发不同,声明式UI让开发者通过描述界面的"当前状态"而非"操作步骤"来构建用户界面,框架会自动负责界面与数据之间的同步更新。这种编程范式极大地简化了复杂应用的界面开发流程,特别是在状态管理频繁、交互逻辑丰富的健康管理类应用中,声明式UI的优势尤为突出。在鸿蒙生态中,开发者使用ArkTS语言配合ArkUI组件体系,可以高效地构建出高性能、高一致性、跨设备适配的原生应用界面。

ArkTS是鸿蒙生态的官方应用开发语言,它在TypeScript的基础上进行了扩展和优化,保留了TypeScript的静态类型检查能力,同时深度集成了ArkUI的声明式UI语法。ArkTS引入了多种装饰器(Decorator)机制来管理组件状态和UI构建逻辑,其中最核心的包括@Entry@Component@State@Builder@Prop@Link等。这些装饰器各司其职:@Entry标记入口组件,@Component声明自定义组件,@State管理组件内部可变状态,@Builder封装可复用的UI构建逻辑。通过这些语言层面的增强,ArkTS使得状态驱动式UI开发变得直观且类型安全。

ArkUI是鸿蒙的声明式UI开发框架,它提供了丰富的内置组件和容器类型。在容器组件方面,Column用于纵向排列子元素,Row用于横向排列子元素,Stack实现层叠布局,Flex提供弹性布局能力,Scroll提供滚动能力。在基础组件方面,Text用于文本展示,Image用于图片渲染,TextInputTextArea提供文本输入,Toggle提供开关选择,Progress提供进度展示,Circle用于绘制圆形图形。这些组件通过链式调用的方式设置属性和事件,形成了一种高度可读的UI描述方式。每一个ArkUI组件都是一颗UI树的节点,通过嵌套组合形成完整的界面层级结构。

在状态管理方面,ArkTS采用了"状态变化驱动UI更新"的核心机制。当被@State装饰器标记的变量发生改变时,框架会自动触发与之关联的UI组件重新渲染。这种机制使得开发者只需关注数据的变化逻辑,而不需要手动操作DOM或视图层级。对于列表渲染场景,ArkUI提供了ForEach组件来根据数据数组动态生成子组件列表,并支持键值生成器函数来优化列表的差分更新效率。对于动画效果,ArkTS提供了animateToAPI来实现属性变化的过渡动画,支持自定义时长、曲线、播放模式和循环次数。这些能力共同构成了鸿蒙应用开发的完整技术栈,下面将通过一个家庭健康管家应用的完整源码进行深度剖析。


一、数据模型设计:TypeScript接口定义健康领域实体

健康管理类应用的核心在于对家庭成员、体检记录、疫苗登记、用药计划等多维度数据的精确建模。在本应用中,开发者使用ArkTS(TypeScript超集)的interface关键字定义了七个核心数据接口,每个接口对应一个业务实体,字段类型清晰明确,为整个应用的类型安全奠定了基础。

interface FamilyT {
  name: string
  relation: string
  age: number
  emoji: string
  score: number
  nextExam: string
  goal: string
  color: string
}
interface ExamT {
  member: string
  title: string
  date: string
  org: string
  status: string
  price: number
}
interface VaccT {
  member: string
  name: string
  dose: string
  date: string
  status: string
  note: string
}
interface MedT {
  member: string
  drug: string
  freq: string
  time: string
  left: number
  kind: string
}
interface RecT {
  member: string
  kind: string
  title: string
  date: string
  emoji: string
}
interface FeedT {
  member: string
  emoji: string
  text: string
  time: string
  likes: number
  liked: boolean
}
interface PkgT {
  name: string
  price: number
  items: number
  age: string
  hot: string
  desc: string
  covers: Array<string>
}

在这里插入图片描述

上面这段代码定义了应用中全部七个数据接口。FamilyT是家庭成员实体,包含了姓名、关系(如爷爷、奶奶、爸爸等)、年龄、表情符号、健康分数、下次体检日期、健康目标和主题颜色。ExamT是体检记录实体,记录了体检成员、项目标题、日期、机构、状态和价格。VaccT是疫苗登记实体,包含了疫苗名称、剂次、日期、状态和备注。MedT是用药管理实体,记录了药品名称、服用频率、服用时间、剩余剂量和药品分类。RecT是健康档案实体,FeedT是家庭动态实体(包含点赞数和点赞状态),PkgT是体检套餐实体(包含核心项目列表)。

技术概念:ArkTS接口(interface)
ArkTS继承了TypeScript的接口机制,接口仅声明类型的结构形状(shape),不包含实现逻辑。在编译阶段,TypeScript编译器会根据接口定义进行类型检查,确保所有对象字面量和方法参数符合接口契约。这为大型应用的数据流提供了编译时安全保障,避免了运行时因字段缺失或类型不匹配导致的错误。

从数据建模的角度来看,这组接口设计体现了几个重要原则。首先,每个接口都包含了member或关联成员的字段,使得数据能够与家庭成员建立关联关系,这是家庭维度数据管理的核心需求。其次,状态类字段(如statusliked)使用stringboolean类型,既保证了数据的可序列化性,又便于在UI层进行条件渲染判断。第三,PkgT接口中的covers字段使用了Array<string>类型,体现了接口可以嵌套复杂数据结构的能力。第四,FamilyT中的color字段存储了十六进制颜色字符串,这是一种在UI开发中常见的"数据驱动样式"的设计模式——通过数据中的颜色值直接控制界面的视觉呈现,使得每个家庭成员拥有自己的视觉标识。

技术概念:类型化数组的声明方式
在ArkTS中,数组的类型声明可以使用Array<T>T[]两种形式。本代码统一使用了Array<T>形式(如Array<FamilyT>Array<string>),这是一种泛型容器声明方式,它明确指定了数组中每个元素的类型。当对数组进行mapfilterforEach等函数式操作时,TypeScript能够自动推断回调参数的类型,减少冗余的类型标注。


二、模拟数据层:常量数组的初始化与切片策略

在真实的应用开发流程中,数据通常来自后端API或本地数据库。但在原型设计和功能验证阶段,开发者通常使用前端内置的模拟数据(Mock Data)来驱动界面。本应用在接口定义之后,紧接着声明了一组const常量数组,作为整个应用的初始数据源。

const FAMILY2: Array<FamilyT> = [
  { name: '李国栋', relation: '爷爷', age: 68, emoji: '👴', score: 72, nextExam: '09-12', goal: '控制血压,每天散步 30 分钟', color: '#5B7CFA' },
  { name: '王秀兰', relation: '奶奶', age: 65, emoji: '👵', score: 78, nextExam: '09-18', goal: '血糖稳定,少糖少油', color: '#F06292' },
  { name: '李建军', relation: '爸爸', age: 44, emoji: '👨', score: 86, nextExam: '10-02', goal: '减脂 3kg,每周跑步 2 次', color: '#26A69A' },
  { name: '陈慧敏', relation: '妈妈', age: 42, emoji: '👩', score: 90, nextExam: '10-02', goal: '颈椎养护,每小时起身活动', color: '#F5A623' },
  { name: '李小天', relation: '我', age: 22, emoji: '🧑', score: 93, nextExam: '11-15', goal: '早睡早起,23:00 前入睡', color: '#9B6BC3' },
  { name: '周雨桐', relation: '配偶', age: 23, emoji: '👩‍🦰', score: 88, nextExam: '11-15', goal: '规律三餐,每周 3 次瑜伽', color: '#5B7CFA' },
  { name: '李一一', relation: '女儿', age: 6, emoji: '👧', score: 95, nextExam: '09-30', goal: '视力保护,每天户外 2 小时', color: '#F06292' },
  { name: '李二宝', relation: '儿子', age: 3, emoji: '👦', score: 94, nextExam: '09-30', goal: '补钙长个,每天 500ml 牛奶', color: '#26A69A' }
]

上面展示了家庭成员的模拟数据数组。这个数组包含八位家庭成员,从68岁的爷爷到3岁的儿子,覆盖了三代人的年龄跨度。每条记录都严格遵循FamilyT接口定义的字段结构,包含了完整的健康信息。数据设计贴近真实生活场景:爷爷需要控制血压,奶奶关注血糖,爸爸要减脂,妈妈养护颈椎,孩子分别关注视力和补钙。这种贴近真实的数据设计使得界面在原型阶段就能展现出真实的数据密度和视觉效果。

除了家庭成员数据,代码还定义了体检记录数组EXAMS2(12条)、疫苗登记数组VACCS2(11条)、用药管理数组MEDS2(10条)、健康档案数组RECS2(12条)、家庭动态数组FEEDS2(12条)和体检套餐数组PKGS2(6条)。此外还有几个辅助常量数组,如关系选项数组RELAS2、疫苗名称选项数组VACNAMES2、日期选项数组DAYCHIPS2和本周服药依从率数组WEEKRATES2

const RELAS2: Array<string> = ['爷爷', '奶奶', '爸爸', '妈妈', '配偶', '儿子', '女儿', '其他']
const VACNAMES2: Array<string> = ['流感疫苗', 'HPV 九价', '乙肝加强', '水痘疫苗', '肺炎 23 价', '带状疱疹']
const DAYCHIPS2: Array<string> = ['09-06 周六', '09-07 周日', '09-13 周六', '09-14 周日', '09-20 周六']
const WEEKRATES2: Array<number> = [86, 100, 71, 100, 100, 86, 100]

在这里插入图片描述

这组辅助常量数组主要用于弹窗中的选项选择器。RELAS2定义了家庭成员关系的八个选项,VACNAMES2定义了六种常见疫苗名称,DAYCHIPS2定义了五个可选的体检日期,WEEKRATES2定义了一周七天的服药依从率数据(百分比)。将这些选项数据提取为顶层常量,是一种良好的工程实践——它将可配置数据与业务逻辑分离,便于后续维护和国际化适配。

技术概念:const常量与不可变引用
在ArkTS中,const声明的变量其引用地址不可变,但如果是对象或数组,其内部内容仍然可以被修改。然而在本应用中,这些常量数组仅作为初始数据源使用,组件内部通过.slice()方法创建副本来避免对原始常量的修改。这是一种防御性编程策略,确保原始数据在整个应用生命周期中保持不变。


三、组件入口与状态管理:@Entry、@Component与@State

在数据模型和模拟数据准备就绪之后,代码进入了应用的核心部分——组件定义。在ArkTS中,每一个自定义组件都必须使用@Component装饰器进行标记,而应用的根入口组件则需要额外使用@Entry装饰器。这两个装饰器共同告诉编译器:这是一个可以被系统直接加载和渲染的顶级组件。

@Entry
@Component
struct Index {
  @State selTab: number = 0
  @State family: Array<FamilyT> = FAMILY2.slice()
  @State exams: Array<ExamT> = EXAMS2.slice()
  @State vaccs: Array<VaccT> = VACCS2.slice()
  @State meds: Array<MedT> = MEDS2.slice()
  @State recs: Array<RecT> = RECS2.slice()
  @State feeds: Array<FeedT> = FEEDS2.slice()
  @State selMember: string = '李小天'
  @State showAdd: boolean = false
  @State showPkg: boolean = false
  @State showBook: boolean = false
  @State showVac: boolean = false
  @State showEdit: boolean = false
  @State showDel: boolean = false
  // 添加家人
  @State addRela: string = '爸爸'
  @State addName: string = ''
  @State addAge: number = 40
  @State addMonth: number = 6
  @State addDay: number = 15
  @State addRemind: boolean = true
  // 套餐详情 / 预约
  @State selPkg: PkgT = PKGS2[0]
  @State bookPkg: string = '青年活力套餐'
  @State bookDay: string = '09-06 周六'
  @State bookMembers: Array<string> = ['李小天']
  @State bookCount: number = 2
  @State bookSms: boolean = true
  // 疫苗登记
  @State vacName: string = '流感疫苗'
  @State vacMember: string = '李二宝'
  @State vacDose: number = 1
  @State vacSync: boolean = true
  // 编辑家人
  @State editTarget: FamilyT = FAMILY2[4]
  @State editName: string = '李小天'
  @State editRela: string = '我'
  @State editGoal: string = ''
  @State editTop: boolean = false
  // 删除
  @State delTarget: FamilyT = FAMILY2[4]
  @State delArmed: boolean = false
  // 特效
  @State heartScale: number = 1
  @State pulseOp: number = 0.4
  @State dotY: number = 0

在这里插入图片描述

这段代码是整个应用状态管理的核心。struct Index定义了一个名为Index的结构体组件(在ArkTS中,组件使用struct而非class声明)。组件内部通过@State装饰器声明了超过三十个状态变量,涵盖了应用的全部数据域。

技术概念:@State装饰器的工作原理
@State是ArkUI中最基础的状态管理装饰器。被@State标记的变量拥有"可观察性"——当变量的值发生变化时,框架会自动追踪所有引用了该变量的UI组件,并触发这些组件的重新渲染。这种机制被称为"状态驱动UI更新"(State-Driven UI Update)。需要注意的是,对于数组类型的状态变量,必须对整个数组进行重新赋值(而非仅修改数组内部元素)才能触发UI更新,这也是本代码中频繁使用this.exams = this.exams.map(...)而非直接修改数组元素的原因。

状态变量可以分为几个逻辑分组来理解。第一组是核心业务数据,包括family(家庭成员数组)、exams(体检记录)、vaccs(疫苗登记)、meds(用药管理)、recs(健康档案)、feeds(家庭动态)。这些数组都通过调用常量的.slice()方法创建了副本,这一设计非常关键——它确保了组件内部的状态数组与外部常量数组在内存上完全独立,任何对状态数组的修改都不会影响到原始数据。

第二组是界面控制状态,包括selTab(当前选中的Tab索引)、selMember(当前选中的家庭成员名)。这两个变量控制着界面展示的核心视图切换逻辑。第三组是弹窗可见性控制,六个boolean变量showAddshowPkgshowBookshowVacshowEditshowDel分别控制六个弹窗的显示和隐藏。第四组是各弹窗内部的表单状态,如添加家人弹窗中的addRela(关系)、addName(姓名)、addAge(年龄)等。

第五组是动画特效状态,包括heartScale(心脏缩放比例)、pulseOp(脉冲透明度)、dotY(圆点Y轴偏移量)。这三个变量将在aboutToAppear生命周期中被animateToAPI驱动,产生持续的动画效果。

技术概念:@Entry装饰器的作用
@Entry装饰器标记的组件会被注册为页面的根入口组件。一个ArkTS页面文件中只能有一个@Entry组件,它对应着页面UI树的根节点。当页面被加载时,系统会首先创建@Entry组件的实例,然后调用其build()方法构建UI树。@Entry组件的生命周期管理由系统框架负责,包括创建、显示、隐藏和销毁等阶段。


四、生命周期与动画初始化:aboutToAppear与animateTo

在组件被创建之后、UI树构建之前,ArkTS框架会自动调用组件的aboutToAppear生命周期回调函数。这个函数是执行初始化逻辑的理想位置——开发者可以在此处发起网络请求、读取本地存储、或启动动画。在本应用中,aboutToAppear被用于启动三个永久循环的动画效果。

aboutToAppear(): void {
  this.getUIContext().animateTo({ duration: 900, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
    this.heartScale = 1.28
  })
  this.getUIContext().animateTo({ duration: 1200, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
    this.pulseOp = 1
  })
  this.getUIContext().animateTo({ duration: 2600, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
    this.dotY = -14
  })
}

在这里插入图片描述

这段代码通过三次调用animateToAPI启动了三个独立的动画。第一个动画驱动heartScale从初始值1变化到1.28,时长900毫秒,产生心脏跳动的效果。第二个动画驱动pulseOp从0.4变化到1,时长1200毫秒,产生脉冲呼吸的透明度变化效果。第三个动画驱动dotY从0变化到-14,时长2600毫秒,产生圆点上下浮动的效果。

技术概念:animateTo动画API详解
animateTo是ArkUI提供的命令式动画API,它接受两个参数:第一个是动画配置对象,第二个是状态修改的闭包函数。在闭包函数中对@State变量进行赋值时,框架不会立即应用新值,而是以动画的方式在指定时长内平滑过渡到目标值。配置对象中的duration指定动画时长(毫秒),iterations指定循环次数(-1表示无限循环),playMode指定播放模式(PlayMode.Alternate表示正反向交替播放),curve指定动画插值曲线(Curve.EaseInOut表示先慢后快再慢的缓动曲线)。

三个动画的时长各不相同(900ms、1200ms、2600ms),这是一种精心设计的"非同步"策略——当多个动画的周期不同时,它们会产生视觉上的"错拍"效果,使界面看起来更加自然和有机,避免了所有动画同步启动和停止时的机械感。PlayMode.Alternate模式确保动画在正向和反向之间交替播放,例如heartScale先从1增长到1.28,再从1.28回落到1,如此循环往复,模拟真实心脏的舒张和收缩节律。

getUIContext()方法是获取当前UI上下文对象的调用,通过这个上下文对象可以安全地调用animateTo。在较新的ArkTS API版本中,推荐使用this.getUIContext().animateTo()而非全局的animateTo()函数调用,因为前者能够确保动画作用于正确的UI上下文,避免在多页面或多实例场景下的上下文混淆问题。


五、业务计算方法:数据聚合与状态查询

健康管理类应用需要频繁地从原始数据中计算聚合指标,如体检完成率、待种疫苗数、药品剩余总量、家庭平均健康分等。本应用将这些计算逻辑封装为组件内部的方法,在UI渲染时直接调用,体现了"计算属性"的设计思想。

examDoneRate(): number {
  let done: number = 0
  let total: number = 0
  this.exams.forEach((e: ExamT) => {
    if (e.status !== '已取消') {
      total += 1
      if (e.status === '已完成') {
        done += 1
      }
    }
  })
  return total === 0 ? 0 : Math.round(done * 100 / total)
}
vacTodo(): number {
  let n: number = 0
  this.vaccs.forEach((v: VaccT) => {
    if (v.status === '待种') {
      n += 1
    }
  })
  return n
}
medLeft(): number {
  let n: number = 0
  this.meds.forEach((m: MedT) => {
    n += m.left
  })
  return n
}
famScore(): number {
  let s: number = 0
  this.family.forEach((f: FamilyT) => {
    s += f.score
  })
  return this.family.length === 0 ? 0 : Math.round(s / this.family.length)
}

在这里插入图片描述

这四个方法分别计算体检完成率、待种疫苗数、药品剩余总量和家庭平均健康分。examDoneRate()方法遍历体检记录数组,排除了状态为"已取消"的记录后,计算已完成数量占有效总数的百分比,使用Math.round()进行四舍五入。vacTodo()方法简单地统计状态为"待种"的疫苗记录数量。medLeft()方法累加所有药品的剩余剂量。famScore()方法计算所有家庭成员健康分的平均值。

这些方法的共同特点是在遍历数组之前都进行了空数组的安全检查(如total === 0 ? 0 : ...this.family.length === 0 ? 0 : ...),这是一种防御性编程实践,确保在数据被全部删除后不会出现除零错误。在UI层调用这些方法时,返回值会被直接绑定到Text组件的文本内容中,实现数据的实时展示。

技术概念:组件方法与计算属性
在ArkTS中,组件内部定义的普通方法可以在build()方法和@Builder方法中被直接调用,其返回值会在每次UI重新渲染时重新计算。虽然这种方式在性能上不如缓存式计算属性高效(每次渲染都会重新执行计算),但对于数据量较小的数组(几十条记录)来说,计算开销可以忽略不计。这种方式的优势在于代码简洁直观,且自动保证数据与UI的实时同步。

除了聚合计算方法外,代码还定义了两个辅助方法——scoreColor2curMember——用于根据数据值返回对应的视觉样式和当前选中的成员对象。

scoreColor2(s: number): string {
  if (s >= 90) {
    return '#26A69A'
  }
  if (s >= 80) {
    return '#5B7CFA'
  }
  if (s >= 75) {
    return '#F5A623'
  }
  return '#F06292'
}
curMember(): FamilyT {
  let r: FamilyT = this.family[0]
  this.family.forEach((f: FamilyT) => {
    if (f.name === this.selMember) {
      r = f
    }
  })
  return r
}

在这里插入图片描述

scoreColor2方法接收一个健康分值,返回对应的颜色字符串。分数90及以上返回青绿色(#26A69A,表示优秀),80-89返回蓝紫色(#5B7CFA,表示良好),75-79返回橙黄色(#F5A623,表示一般),75以下返回粉红色(#F06292,表示需要关注)。这种基于数据值动态返回颜色的设计模式在数据可视化中非常常见,它使得颜色不再是硬编码的静态值,而是与数据含义紧密关联的动态属性。

curMember方法通过遍历家庭成员数组,查找名字与selMember状态变量匹配的成员对象。如果找不到匹配项,则返回数组第一个元素作为默认值。这个方法在UI中被频繁调用——当用户切换选中成员时,selMember状态变量改变,触发UI重新渲染,curMember()被重新调用返回新的成员对象,界面上的所有成员相关信息都会自动更新。


六、交互操作方法:CRUD逻辑与状态更新

健康管理应用的核心交互包括添加家人、预约体检、疫苗登记、编辑资料、删除成员、完成体检、完成接种、服药打卡、点赞动态等。这些交互操作的本质都是对状态数组进行更新。在ArkTS的状态管理机制下,正确地更新状态数组是触发UI重新渲染的关键。

confirmAdd(): void {
  if (this.addName.length === 0) {
    return
  }
  const emojis: Array<string> = ['🧑', '👨', '👩', '👴', '👵', '👦', '👧']
  const colors: Array<string> = ['#5B7CFA', '#F06292', '#26A69A', '#F5A623', '#9B6BC3']
  const nf: FamilyT = {
    name: this.addName,
    relation: this.addRela,
    age: this.addAge,
    emoji: emojis[this.addAge > 60 ? 3 : (this.addAge > 18 ? 1 : 6)],
    score: 88,
    nextExam: '待预约',
    color: colors[this.family.length % 5]
  }
  this.family = [nf].concat(this.family)
  this.showAdd = false
  this.addName = ''
}

在这里插入图片描述

confirmAdd方法处理添加家人的确认逻辑。首先检查姓名是否为空,若为空则直接返回不做任何操作。然后根据年龄动态选择emoji头像——60岁以上使用老年头像,18岁以上使用成年人头像,否则使用儿童头像。新成员的初始健康分设为88,下次体检日期设为"待预约",颜色按家庭成员数量取模循环分配。最后通过[nf].concat(this.family)将新成员插入到数组头部,并将整个数组重新赋值给this.family状态变量。

技术概念:不可变数据更新模式
在ArkTS的@State状态管理中,对于数组类型的状态变量,必须使用"整体替换"的方式更新数据。this.family = [nf].concat(this.family)这行代码创建了一个全新的数组(包含新成员和所有原有成员),然后将这个新数组赋值给this.family。框架检测到引用变化后,会触发UI的差分更新。如果使用this.family.unshift(nf)这样的直接修改方式,框架无法检测到变化,UI不会更新。这种不可变数据更新模式是声明式UI框架的共性要求,React和Flutter也采用类似策略。

应用中其余的CRUD方法遵循类似的模式:

confirmBook(): void {
  const names: Array<string> = this.bookMembers.length === 0 ? [this.selMember] : this.bookMembers
  const newExams: Array<ExamT> = []
  names.forEach((n: string) => {
    const one: ExamT = { member: n, title: this.bookPkg, date: this.bookDay.substring(0, 5), org: '爱康国宾·望京分院', status: '待检', price: this.selPkg.price }
    newExams.push(one)
  })
  this.exams = newExams.concat(this.exams)
  this.showBook = false
  this.showPkg = false
}

confirmBook方法处理体检预约的确认逻辑。它根据用户在弹窗中选择的套餐、日期和参检家人,为每位家人创建一条新的体检记录,然后将这些记录合并到exams数组的头部。值得注意的是,方法最后同时关闭了两个弹窗(showBookshowPkg),因为预约弹窗可能是从套餐详情弹窗中打开的,关闭预约弹窗的同时也应关闭其上层弹窗。

confirmEdit(): void {
  this.family = this.family.map((f: FamilyT) => {
    if (f.name === this.editTarget.name) {
      const nf: FamilyT = {
        name: this.editName.length > 0 ? this.editName : f.name,
        relation: this.editRela,
        age: f.age,
        emoji: f.emoji,
        score: f.score,
        nextExam: f.nextExam,
        goal: this.editGoal.length > 0 ? this.editGoal : f.goal,
        color: f.color
      }
      return nf
    }
    return f
  })
  if (this.editTarget.name === this.selMember && this.editName.length > 0) {
    this.selMember = this.editName
  }
  this.showEdit = false
}

在这里插入图片描述

confirmEdit方法使用map函数遍历家庭成员数组,找到目标成员后用编辑后的新数据替换原对象。map函数返回一个全新的数组,其中匹配的元素被替换为新的对象字面量,不匹配的元素保持原样。此外,如果编辑的恰好是当前选中成员且修改了姓名,方法会同步更新selMember状态变量,确保选中成员的引用不会失效。

doDel(): void {
  this.family = this.family.filter((f: FamilyT) => f.name !== this.delTarget.name)
  if (this.selMember === this.delTarget.name && this.family.length > 0) {
    this.selMember = this.family[0].name
  }
  this.showDel = false
}

doDel方法使用filter函数从家庭成员数组中过滤掉目标成员。删除操作有一个重要的善后逻辑:如果删除的恰好是当前选中成员,则自动将选中成员切换为数组的第一个成员,避免出现选中成员不存在于数组中的无效状态。这种边界情况的处理是保证应用健壮性的关键。

技术概念:函数式数组操作
ArkTS支持ES6标准的函数式数组操作方法,包括mapfilterforEachconcatsliceindexOf等。这些方法不修改原数组(forEach除外,它仅遍历不返回新数组),而是返回新数组或执行回调。在声明式UI的状态管理中,mapfilter是最常用的两个方法——map用于"映射替换"(遍历数组并对每个元素进行条件性替换),filter用于"过滤删除"(遍历数组并保留满足条件的元素)。

应用中还定义了完成体检、完成接种、服药打卡和点赞动态四个操作方法,它们都采用了map函数进行条件性替换:

doExam(e: ExamT): void {
  this.exams = this.exams.map((x: ExamT) => {
    if (x === e) {
      const nx: ExamT = { member: x.member, title: x.title, date: x.date, org: x.org, status: '已完成', price: x.price }
      return nx
    }
    return x
  })
}
takeMed(m: MedT): void {
  if (m.left <= 0) {
    return
  }
  this.meds = this.meds.map((x: MedT) => {
    if (x === m) {
      const nx: MedT = { member: x.member, drug: x.drug, freq: x.freq, time: x.time, left: x.left - 1, kind: x.kind }
      return nx
    }
    return x
  })
}
toggleLike(f: FeedT): void {
  this.feeds = this.feeds.map((x: FeedT) => {
    if (x === f) {
      const nx: FeedT = { member: x.member, emoji: x.emoji, text: x.text, time: x.time, likes: x.liked ? x.likes - 1 : x.likes + 1, liked: !x.liked }
      return nx
    }
    return x
  })
}

这三个方法都使用引用相等性(x === e)来判断要修改的目标元素。由于ArkTS中的接口对象是值类型(引用传递但在map中创建新对象),直接比较引用可以准确地定位到用户点击的那条记录。takeMed方法在执行前先检查剩余剂量是否为0,避免出现负数剂量的不合理状态。toggleLike方法通过三元运算符实现点赞/取消点赞的切换逻辑——如果当前已点赞则减一,否则加一,同时翻转liked布尔值。


七、底部导航系统:双排Tab栏与@Builder复用

移动应用通常采用底部Tab栏作为主要导航方式。本应用创新性地采用了双排底部导航设计——将七个Tab分成两排,上排四个Tab(家人、体检、疫苗、用药),下排三个Tab(档案、动态、我的)。这种设计在屏幕空间有限的移动端可以容纳更多导航入口,同时通过视觉分组暗示了功能的分类关系。

@Builder
tabBtn2(icon: string, label: string, idx: number) {
  Column({ space: 3 }) {
    Text(icon)
      .fontSize(idx === this.selTab ? 22 : 20)
      .opacity(idx === this.selTab ? 1 : 0.55)
    Text(label)
      .fontSize(11)
      .fontColor(idx === this.selTab ? '#5B7CFA' : '#8A93B5')
      .fontWeight(idx === this.selTab ? FontWeight.Bold : FontWeight.Normal)
  }
  .layoutWeight(1)
  .padding({ top: 7, bottom: 7 })
  .onClick(() => {
    this.selTab = idx
  })
}

tabBtn2是一个使用@Builder装饰器标记的可复用UI构建方法。它接收三个参数:icon(图标emoji)、label(文字标签)和idx(Tab索引)。方法内部构建了一个Column容器,纵向排列图标和文字。通过idx === this.selTab的条件判断,选中状态的Tab拥有更大的图标尺寸(22 vs 20)、更高的不透明度(1 vs 0.55)、蓝色的文字颜色(#5B7CFA vs #8A93B5)和粗体字重(Bold vs Normal)。这种通过条件表达式在属性值之间切换的设计,是声明式UI实现"选中态"视觉反馈的典型方式。

技术概念:@Builder装饰器
@Builder装饰器用于声明可复用的UI构建逻辑块。与普通方法不同,@Builder方法中可以直接使用ArkUI的声明式组件语法(如ColumnTextRow等),并且可以访问组件的this上下文(包括@State变量和其他方法)。@Builder方法的调用方式为this.methodName(params),在编译时会被内联展开为UI树的节点。@Builder是ArkUI中实现UI复用的核心机制,它比自定义组件更轻量——不需要创建独立的组件实例,适合用于在同一个组件内部复用结构相似但数据不同的UI片段。

技术概念:layoutWeight权重分配
.layoutWeight(n)是ArkUI中弹性布局的重要属性。它设置组件在父容器(RowColumn)中的权重因子,决定组件在主轴方向上占据的剩余空间比例。在本代码中,每个Tab按钮设置.layoutWeight(1),意味着七个按钮平均分配父容器的宽度——无论屏幕多宽,每个Tab的宽度都等于屏幕宽度除以该排的Tab数量。这种等权分配方式是底部导航栏的标准布局策略。

@Builder
tabBar2() {
  Column() {
    Divider()
      .color('#E4E8F5')
    Row() {
      this.tabBtn2('👪', '家人', 0)
      this.tabBtn2('🩺', '体检', 1)
      this.tabBtn2('💉', '疫苗', 2)
      this.tabBtn2('💊', '用药', 3)
    }
    .width('100%')
    Row() {
      this.tabBtn2('🗂️', '档案', 4)
      this.tabBtn2('📰', '动态', 5)
      this.tabBtn2('🙋', '我的', 6)
    }
    .width('100%')
  }
  .width('100%')
  .backgroundColor('#FFFFFF')
}

tabBar2是整个底部导航栏的构建方法。它首先用Divider组件绘制一条分隔线,然后使用两个Row容器分别放置上排四个Tab和下排三个Tab。每个Row宽度设为100%,内部的tabBtn2调用通过layoutWeight(1)自动等分宽度。整个导航栏背景色设为白色,确保在浅色主题下具有良好的可读性。

Divider是ArkUI的分割线组件,它默认渲染一条全宽的水平细线。通过.color('#E4E8F5')将其设为浅蓝灰色,在白色背景上形成柔和的视觉分隔。这种分隔线在UI设计中承担着"视觉边界"的功能——它告诉用户导航栏与上方内容区域是不同的交互层级。


八、头部信息栏:健康分展示与水平滚动家人选择器

头部信息栏是用户进入应用后首先看到的区域,它集中展示了家庭健康概况、搜索入口和家庭成员快速切换功能。这个区域的UI密度很高,在一个@Builder方法中构建了多个信息卡片和交互组件。

@Builder
headerBar2() {
  Column({ space: 12 }) {
    Row() {
      Column({ space: 2 }) {
        Text('早安,李小天 👋')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor('#2A3356')
        Text('FAMILY CARE · 全家健康一屏管理')
          .fontSize(11)
          .fontColor('#8A93B5')
      }
      .alignItems(HorizontalAlign.Start)
      Text('🔔')
        .fontSize(20)
        .padding({ left: 8, right: 8, top: 4, bottom: 4 })
        .backgroundColor('#EEF1FB')
        .borderRadius(14)
      Text('🙋')
        .fontSize(20)
        .padding({ left: 8, right: 8, top: 4, bottom: 4 })
        .backgroundColor('#FDECF3')
        .borderRadius(14)
        .onClick(() => {
          this.selTab = 6
        })
    }
    .width('100%')
    .justifyContent(FlexAlign.SpaceBetween)

头部第一行是一个Row容器,使用.justifyContent(FlexAlign.SpaceBetween)实现两端对齐——左侧是问候语和副标题,右侧是通知铃铛和个人头像按钮。justifyContent(FlexAlign.SpaceBetween)是弹性布局的主轴对齐方式之一,它将子元素之间的间距均匀分配,首尾元素紧贴容器两端。

技术概念:FlexAlign对齐枚举
FlexAlign是ArkUI弹性布局的对齐枚举类型,包含Start(起始端对齐)、Center(居中对齐)、End(末端对齐)、SpaceBetween(两端对齐,首尾无间距)、SpaceAround(等间距,两侧有半间距)、SpaceEvenly(等间距,两侧有全间距)六种模式。在Row容器中,主轴是水平方向;在Column容器中,主轴是垂直方向。合理选择对齐方式是构建精美布局的关键。

问候语区域的Column容器使用了.alignItems(HorizontalAlign.Start)设置交叉轴起始端对齐,确保两行文字左对齐。通知铃铛和头像按钮分别使用浅蓝(#EEF1FB)和浅粉(#FDECF3)背景配合圆角,形成胶囊式按钮的视觉效果。头像按钮绑定了onClick事件,点击后切换到"我的"Tab(索引6),实现了快捷导航功能。

    Row({ space: 8 }) {
      Text('全家健康分')
        .fontSize(12)
        .fontColor('#FFFFFF')
        .padding({ left: 10, right: 10, top: 3, bottom: 3 })
        .backgroundColor('#5B7CFA')
        .borderRadius(10)
      Text(this.famScore().toString())
        .fontSize(34)
        .fontWeight(FontWeight.Bold)
        .fontColor('#2A3356')
      Column({ space: 2 }) {
        Text(this.family.length.toString() + ' 位家人')
          .fontSize(11)
          .fontColor('#8A93B5')
        Text('较上月 +2 分')
          .fontSize(11)
          .fontColor('#26A69A')
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      Column() {
        Text('+ 添加')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .padding({ left: 14, right: 14, top: 8, bottom: 8 })
          .backgroundColor('#F06292')
          .borderRadius(18)
      }
      .onClick(() => {
        this.addRela = '爸爸'
        this.addName = ''
        this.addAge = 40
        this.showAdd = true
      })
    }
    .width('100%')
    .padding({ left: 14, right: 14, top: 12, bottom: 12 })
    .backgroundColor('#FFFFFF')
    .borderRadius(18)

健康分展示行是一个信息密度很高的Row容器。左侧是蓝色标签"全家健康分",紧随其后是大号数字(34px粗体)显示famScore()的返回值,然后是一个Column显示家人数量和环比变化,右侧是粉色"添加"按钮。整个Row使用白色背景和18px圆角形成卡片效果。

技术概念:borderRadius圆角属性
.borderRadius(n)设置组件的圆角半径,n可以是数值(以vp为单位)或字符串。圆角是UI设计中最常用的视觉修饰之一,它使界面的硬直角变为柔和的弧线,营造出友好和现代的视觉感受。本应用大量使用14-22vp的圆角值,形成了统一的"卡片式"设计语言。配合.padding().backgroundColor(),可以轻松构建出层次分明的卡片组件。

添加按钮的onClick回调中先重置了添加家人弹窗的表单状态(关系设为"爸爸"、姓名清空、年龄设为40),然后设置showAdd = true打开弹窗。这种"先初始化表单状态再显示弹窗"的模式确保了每次打开弹窗时表单都是干净的初始状态,避免了上一次输入的残留数据影响用户体验。

    Scroll() {
      Row({ space: 10 }) {
        ForEach(this.family, (f: FamilyT) => {
          Column({ space: 4 }) {
            Text(f.emoji)
              .fontSize(24)
            Text(f.relation)
              .fontSize(10)
              .fontColor(this.selMember === f.name ? '#5B7CFA' : '#8A93B5')
          }
          .padding({ left: 12, right: 12, top: 8, bottom: 8 })
          .backgroundColor(this.selMember === f.name ? '#EEF1FB' : '#FFFFFF')
          .borderRadius(14)
          .border({
            width: this.selMember === f.name ? 1.5 : 0.5,
            color: this.selMember === f.name ? '#5B7CFA' : '#E4E8F5'
          })
          .onClick(() => {
            this.selMember = f.name
          })
        }, (f: FamilyT) => f.name)
      }
    }
    .scrollable(ScrollDirection.Horizontal)
    .scrollBar(BarState.Off)
    .width('100%')

头部信息栏的最后部分是一个水平滚动的家人选择器。Scroll容器包裹了一个RowRow内部使用ForEach根据this.family数组动态渲染每个家庭成员的选择按钮。每个按钮是一个Column容器,纵向排列emoji头像和关系标签。选中状态的按钮拥有浅蓝背景(#EEF1FB)、蓝色文字(#5B7CFA)和1.5px蓝色边框,未选中状态则为白色背景、灰色文字和0.5px浅灰边框。

技术概念:ForEach列表渲染
ForEach是ArkUI的核心列表渲染组件,它接收三个参数:数据源数组、子项构建函数和键值生成函数。子项构建函数为每个数据元素创建对应的UI组件树;键值生成函数为每个元素生成唯一标识,框架通过比较前后两次渲染的键值列表来执行差分更新(Diff),仅对变化的元素进行增删改操作,避免全量重建。在本代码中,键值生成器返回f.name(成员姓名),当数组发生变化(如添加或删除成员)时,框架通过姓名键值精确地定位变化元素。

技术概念:Scroll滚动容器
Scroll是ArkUI的滚动容器组件,它可以包裹超出可视区域的内容并提供滚动能力。.scrollable(ScrollDirection.Horizontal)设置滚动方向为水平,.scrollBar(BarState.Off)隐藏滚动条。Scroll组件在移动端非常常用——当内容数量不确定或可能超出屏幕时,使用Scroll包裹可以确保用户通过滑动查看全部内容。在家人选择器场景中,家庭成员可能很多(超过8人),水平滚动设计确保了所有成员都可以被快速选中。


九、Tab 0 家人页面:成员详情卡与健康分排行

家人页面(homeTab)是应用的默认首页,展示了当前选中成员的详细信息、全家健康分排行和快捷操作入口。这个页面的UI结构层次分明,通过secTitle2分区标题方法将内容划分为三个区域。

@Builder
secTitle2(icon: string, title: string, extra: string) {
  Row() {
    Text(icon)
      .fontSize(16)
      .margin({ right: 6 })
    Text(title)
      .fontSize(15)
      .fontWeight(FontWeight.Bold)
      .fontColor('#2A3356')
      .layoutWeight(1)
    Text(extra)
      .fontSize(12)
      .fontColor('#5B7FA')
  }
  .width('100%')
  .margin({ top: 18, bottom: 10 })
}

secTitle2是一个通用的分区标题构建器,接收图标、标题和附加文本三个参数。布局为左侧图标+标题、右侧附加文本,标题使用layoutWeight(1)占据中间空间将附加文本推到右侧。这种分区标题在内容密集的页面中起到了视觉分隔和导航锚点的作用。

@Builder
homeTab() {
  Column({ space: 0 }) {
    this.secTitle2('🌟', '当前关注 · ' + this.curMember().relation, this.curMember().name)
    Column({ space: 10 }) {
      Row({ space: 12 }) {
        Text(this.curMember().emoji)
          .fontSize(40)
          .padding(10)
          .backgroundColor('#EEF1FB')
          .borderRadius(18)
        Column({ space: 4 }) {
          Text(this.curMember().name + ' · ' + this.curMember().age + ' 岁')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#2A3356')
          Text('下次体检:' + this.curMember().nextExam)
            .fontSize(12)
            .fontColor('#8A93B5')
          Row({ space: 6 }) {
            Text('健康目标')
              .fontSize(10)
              .fontColor('#FFFFFF')
              .padding({ left: 8, right: 8, top: 2, bottom: 2 })
              .backgroundColor('#F5A623')
              .borderRadius(8)
            Text(this.curMember().goal)
              .fontSize(11)
              .fontColor('#5A6488')
              .maxLines(1)
              .textOverflow({ overflow: TextOverflow.Ellipsis })
              .constraintSize({ maxWidth: 150 })
          }
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
      }
      .width('100%')

当前成员详情卡的上半部分是一个Row,左侧是40px的大号emoji头像(带有浅蓝背景和圆角),右侧是成员信息Column。信息包括姓名和年龄(粗体)、下次体检日期、以及健康目标标签行。健康目标行使用了一个橙色标签"健康目标"配合目标文本,目标文本设置了.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }),当文本过长时只显示一行并在末尾显示省略号。

技术概念:textOverflow文本溢出处理
.textOverflow({ overflow: TextOverflow.Ellipsis })设置文本溢出时的处理方式为省略号。配合.maxLines(1)限制最大行数为1,当文本内容超出可用宽度时,自动截断并以"…"结尾。ArkUI还支持TextOverflow.Clip(直接截断无省略号)和TextOverflow.None(不处理,允许超出)。.constraintSize({ maxWidth: 150 })进一步约束了文本组件的最大宽度为150vp,确保在长文本场景下不会挤压左侧标签的空间。

详情卡的下半部分是三个数据指标列,每列使用layoutWeight(1)等分宽度:

      Row() {
        Column({ space: 2 }) {
          Text(this.curMember().score.toString())
            .fontSize(26)
            .fontWeight(FontWeight.Bold)
            .fontColor(this.scoreColor2(this.curMember().score))
          Text('个人健康分')
            .fontSize(10)
            .fontColor('#8A93B5')
        }
        .layoutWeight(1)
        Column({ space: 2 }) {
          Text(this.vacTodo().toString())
            .fontSize(26)
            .fontWeight(FontWeight.Bold)
            .fontColor('#F06292')
          Text('待种疫苗')
            .fontSize(10)
            .fontColor('#8A93B5')
        }
        .layoutWeight(1)
        Column({ space: 2 }) {
          Text(this.examDoneRate().toString() + '%')
            .fontSize(26)
            .fontWeight(FontWeight.Bold)
            .fontColor('#26A69A')
          Text('体检完成率')
            .fontSize(10)
            .fontColor('#8A93B5')
        }
        .layoutWeight(1)
      }
      .width('100%')

三列分别展示个人健康分、待种疫苗数和体检完成率。每个数值使用26px粗体大号字体,颜色根据数据含义动态选择——健康分使用scoreColor2()返回的颜色,待种疫苗使用粉红色表示需要关注,体检完成率使用青绿色表示积极正面。下方的标签文字统一使用10px灰色字体。这种"大数字+小标签"的设计模式在数据仪表盘类界面中非常普遍。

详情卡底部是三个操作按钮行——编辑资料、预约体检和移出家庭。每个按钮使用layoutWeight(1)等分宽度,配以不同的颜色主题来区分功能性质:蓝色(编辑)、粉色(预约)、红色(移出)。红色用于危险操作是UI设计中的通用约定,它通过视觉警示提醒用户该操作具有不可逆性。

健康分排行区域使用ForEach遍历所有家庭成员,为每位成员渲染一条包含头像、姓名、分数和进度条的排行项。进度条是通过两个Row组件实现的——一个宽度等于分数百分比的彩色Row作为进度条主体,另一个layoutWeight(1)的灰色Row作为背景条的剩余部分。这种"两个Row拼合进度条"的技巧是ArkUI中实现自定义进度条的常见方式。

              Row() {
                Row()
                  .width(f.score.toString() + '%')
                  .height(7)
                  .backgroundColor(this.scoreColor2(f.score))
                  .borderRadius(4)
                Row()
                  .layoutWeight(1)
                  .height(7)
                  .backgroundColor('#EEF1FB')
                  .borderRadius(4)
              }
              .width('100%')

第一个Row的宽度设为f.score.toString() + '%',即分数的字符串值加上百分号。例如分数为72,则宽度为"72%",占父容器宽度的72%。第二个Row使用layoutWeight(1)自动占据剩余的28%。两个Row高度均为7vp,配合4vp圆角,形成了平滑的进度条视觉效果。进度条颜色使用scoreColor2()根据分数动态返回,使进度条不仅展示了数值大小,还通过颜色传达了健康等级。


十、Tab 1 体检页面:环形进度与套餐横向滚动

体检页面(examTab)展示了家庭体检总览、体检计划列表和热门套餐推荐。页面顶部是一个引人注目的环形进度组件,展示体检完成率。

@Builder
examTab() {
  Column({ space: 0 }) {
    this.secTitle2('🩺', '家庭体检总览', '本季 12 单')
    Column({ space: 12 }) {
      Row({ space: 14 }) {
        Stack() {
          Progress({ value: this.examDoneRate(), total: 100 })
            .width(86)
            .height(86)
            .style({ strokeWidth: 9 })
          Column({ space: 1 }) {
            Text('❤️')
              .fontSize(16)
              .scale({ x: this.heartScale, y: this.heartScale })
            Text(this.examDoneRate().toString() + '%')
              .fontSize(17)
              .fontWeight(FontWeight.Bold)
              .fontColor('#2A3356')
          }
        }
        Column({ space: 6 }) {
          Text('已完成 ' + (this.exams.length - this.examTodo()).toString() + ' 项 · 待检 ' + this.examTodo().toString() + ' 项')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor('#2A3356')
          Text('本月待检 ' + this.examTodo().toString() + ' 单,最早 09-12')
            .fontSize(11)
            .fontColor('#8A93B5')
          Text('爱康家庭卡享 9 折')
            .fontSize(11)
            .fontColor('#F06292')
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
      }
      .width('100%')

技术概念:Stack层叠布局
Stack是ArkUI的层叠布局容器,它将子元素按照声明顺序依次叠加,后面的元素覆盖在前面的元素之上。默认对齐方式为居中对齐。在本代码中,Stack内先声明了Progress环形进度条组件,再声明了Column包含心形emoji和百分比文字。Column叠加在Progress的中央,形成了"环形进度条+中心文字"的经典仪表盘效果。

技术概念:Progress进度组件
Progress是ArkUI的进度展示组件,通过valuetotal参数设置当前值和总量值。默认样式为线性进度条,通过.style({ strokeWidth: 9 })设置环形线条粗细。当Progress被放置在固定宽高的容器中时,它会自动渲染为环形进度。Progress组件支持ProgressStyle.Linear(线性)和ProgressStyle.Ring(环形)等样式,是数据可视化中展示百分比的常用组件。

环中心的❤️emoji使用了.scale({ x: this.heartScale, y: this.heartScale })绑定动画状态变量heartScale。在aboutToAppear中,heartScale被设置为1.28并循环动画,产生心脏持续跳动的效果。这种将动画状态变量绑定到UI组件属性的写法,是ArkTS声明式动画的核心用法——开发者只需修改状态变量值,框架自动处理插值和渲染。

体检计划列表使用ForEach遍历this.exams数组,为每条记录渲染一张包含日期、状态标签、项目标题和操作按钮的卡片。卡片的视觉样式根据体检状态动态变化——已完成状态使用青绿色标签,待检状态使用橙色标签,已取消状态使用灰色标签。点击待检状态的标签可以触发doExam(e)方法将该记录标记为已完成。

          Text(e.status)
            .fontSize(11)
            .fontColor(e.status === '已完成' ? '#26A69A' : (e.status === '已取消' ? '#B7BDD3' : '#FFFFFF'))
            .padding({ left: 10, right: 10, top: 5, bottom: 5 })
            .backgroundColor(e.status === '已完成' ? '#E6F6F1' : (e.status === '已取消' ? '#F0F1F6' : '#F5A623'))
            .borderRadius(10)
            .onClick(() => {
              if (e.status === '待检') {
                this.doExam(e)
              }
            })

这段状态标签代码使用了嵌套三元运算符来根据状态值选择颜色。对于"已完成"状态,文字为青绿色、背景为浅青绿;对于"已取消"状态,文字为灰色、背景为浅灰;对于"待检"状态(else分支),文字为白色、背景为橙色。onClick回调中先检查状态是否为"待检",只有在待检状态下点击才能完成体检,避免了重复完成或恢复已取消记录的逻辑漏洞。

热门套餐区域使用水平滚动的Scroll容器展示套餐卡片列表,每张卡片宽度固定为150vp,包含热度标签、套餐名、价格、适用年龄和"查看详情"按钮。点击按钮后设置selPkg并打开套餐详情弹窗。


十一、Tab 2 疫苗页面:时间轴布局与脉冲动画

疫苗页面(vacTab)以时间轴的形式展示疫苗接种记录。时间轴(Timeline)是一种将事件按时间顺序排列并以垂直连线串联的UI布局模式,它在展示进度和流程类信息时具有天然的适用性。

@Builder
vacTab() {
  Column({ space: 0 }) {
    this.secTitle2('💉', '接种进度', '待种 ' + this.vacTodo().toString() + ' 剂')
    Row({ space: 10 }) {
      Column({ space: 3 }) {
        Text(this.vaccs.length.toString())
          .fontSize(24)
          .fontWeight(FontWeight.Bold)
          .fontColor('#5B7CFA')
        Text('累计登记')
          .fontSize(10)
          .fontColor('#8A93B5')
      }
      .layoutWeight(1)
      .padding({ top: 12, bottom: 12 })
      .backgroundColor('#EEF1FB')
      .borderRadius(14)
      // ... 另外两列结构类似
    }
    .width('100%')

页面顶部的统计行使用三个Column等分宽度,分别展示累计登记数、待接种数和本月完成数。每个统计块使用不同的背景色(浅蓝、浅粉、浅绿)来区分数据类型,圆角14vp形成卡片效果。这种"三色统计卡片"是数据概览区域的经典布局。

时间轴的核心实现如下:

      Column() {
        ForEach(this.vaccs, (v: VaccT, i: number) => {
          Row({ space: 10 }) {
            Column() {
              Circle({ width: 10, height: 10 })
                .fill(v.status === '已种' ? '#26A69A' : '#F06292')
              if (i < this.vaccs.length - 1) {
                Row()
                  .width(2)
                  .layoutWeight(1)
                  .backgroundColor(i === 0 ? '#F06292' : '#E4E8F5')
              }
            }
            .width(14)
            .constraintSize({ minHeight: 66 })
            Column({ space: 4 }) {
              Row() {
                Text(v.name)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#2A3356')
                  .layoutWeight(1)
                Text(v.dose)
                  .fontSize(10)
                  .fontColor('#5B7CFA')
                  .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                  .backgroundColor('#EEF1FB')
                  .borderRadius(8)
              }
              .width('100%')
              Text(v.member + ' · ' + v.date + ' · ' + v.note)
                .fontSize(11)
                .fontColor('#8A93B5')
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
              Text(v.status)
                .fontSize(10)
                .fontColor(v.status === '已种' ? '#26A69A' : '#FFFFFF')
                .padding({ left: 10, right: 10, top: 4, bottom: 4 })
                .backgroundColor(v.status === '已种' ? '#E6F6F1' : '#F06292')
                .borderRadius(10)
                .alignSelf(ItemAlign.Start)
                .onClick(() => {
                  if (v.status === '待种') {
                    this.doVacc(v)
                  }
                })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            .padding({ top: 2, bottom: 14 })
          }
          .width('100%')
          .alignItems(VerticalAlign.Top)
        }, (v: VaccT) => v.member + v.name + v.status)
      }

时间轴的每条记录都是一个Row,左侧是一个Column宽度14vp,包含一个Circle圆形节点和一条连接线Row。圆形节点使用.fill()方法填充颜色——已种为青绿色,待种为粉红色。连接线宽度2vp,使用layoutWeight(1)自动占据剩余高度,颜色根据是否为第一条记录动态选择。右侧是详细信息Column,包含疫苗名称和剂次标签、成员信息文本、状态按钮。

技术概念:Circle绘制组件
Circle是ArkUI的圆形绘制组件,通过widthheight参数设置尺寸。.fill(color)方法设置填充颜色。Circle属于ArkUI的绘制类组件,与TextRow等布局类组件不同,它直接在画布上绘制图形。在本代码中,Circle被用作时间轴的节点标记,通过颜色区分不同状态。

技术概念:constraintSize约束尺寸
.constraintSize({ minHeight: 66 })设置组件的最小高度约束。在时间轴布局中,每条记录的内容高度可能不同(文本长度不同导致高度差异),但时间轴节点列需要保持一致的最小高度,确保连接线有足够的长度形成连续的视觉线。constraintSize还支持maxWidthmaxHeightminWidth等约束,是控制弹性布局尺寸的重要工具。

时间轴的键值生成器返回v.member + v.name + v.status,这是一个复合键值,由成员名、疫苗名和状态拼接而成。当疫苗状态从"待种"变为"已种"时,键值发生变化,框架可以精确地定位到该条记录并更新其UI。这种复合键值设计确保了在同名成员接种同名疫苗的极端情况下,每条记录仍然拥有唯一的标识。

页面底部还有一个脉冲动画效果:

      Row({ space: 6 }) {
        Circle({ width: 8, height: 8 })
          .fill('#F06292')
          .opacity(this.pulseOp)
        Text('下一剂:李二宝 流感疫苗 09-06,已开启接种提醒')
          .fontSize(11)
          .fontColor('#8A93B5')
          .layoutWeight(1)
      }
      .width('100%')
      .margin({ top: 10 })

这个小圆点使用了.opacity(this.pulseOp)绑定动画状态变量pulseOp。在aboutToAppear中,pulseOp从0.4循环变化到1,产生呼吸闪烁效果。这种脉冲效果在通知提醒类UI中很常见,它通过持续变化的透明度吸引用户的注意力,暗示"有新消息"或"需要关注"。


十二、Tab 3 用药页面:柱状图与药品进度条

用药页面(medTab)展示了家庭药箱概览、本周服药依从率柱状图和用药清单。其中柱状图是一个亮点——它完全使用ArkUI基础组件手工搭建,没有使用任何图表库。

@Builder
medTab() {
  Column({ space: 0 }) {
    // ... 概览卡片
    this.secTitle2('📈', '本周服药依从率', '按天统计')
    Column({ space: 8 }) {
      Row() {
        ForEach(WEEKRATES2, (r: number, i: number) => {
          Column({ space: 4 }) {
            Text(r.toString() + '%')
              .fontSize(9)
              .fontColor('#8A93B5')
            Column() {
              Row()
                .width('100%')
                .height(r.toString() + '%')
                .backgroundColor(r >= 100 ? '#26A69A' : '#F5A623')
                .borderRadius({ topLeft: 4, topRight: 4 })
            }
            .width(18)
            .height(70)
            .backgroundColor('#F0F1F6')
            .borderRadius(4)
            .justifyContent(FlexAlign.End)
            Text('周' + '一二三四五六日'.charAt(i))
              .fontSize(10)
              .fontColor('#8A93B5')
          }
          .layoutWeight(1)
        }, (r: number, i: number) => i.toString())
      }
      .width('100%')
      .alignItems(VerticalAlign.Bottom)
    }

柱状图的实现非常巧妙。外层Row使用.alignItems(VerticalAlign.Bottom)设置子元素底部对齐,确保所有柱子从底部对齐排列。每个柱子是一个Column,顶部是百分比文字,中间是柱条容器,底部是星期标签。柱条容器是一个固定宽度18vp、高度70vp的Column,背景为浅灰色,使用.justifyContent(FlexAlign.End)将内部的彩色Row推到容器底部。彩色Row的高度设为r.toString() + '%'(如"86%"),表示依从率百分比对应的柱高。颜色根据是否达到100%选择青绿色或橙黄色。

技术概念:justifyContent在Column中的作用
Column容器中,justifyContent控制子元素在主轴(垂直方向)上的排列方式。FlexAlign.End将子元素推到容器底部。在本柱状图实现中,外层Column高度固定为70vp,内层彩色Row高度为依从率百分比。通过.justifyContent(FlexAlign.End),彩色Row被推到Column的底部,形成了从底部向上生长的柱状图效果。这是一个非常精巧的布局技巧——利用固定高度容器的末端对齐实现了可变高度的柱状图。

用药清单使用ForEach遍历this.meds数组,为每种药品渲染一张包含药品名、分类标签、服用信息、剩余剂量、服药按钮和库存进度条的卡片。库存进度条的设计与健康分进度条类似,使用两个Row拼合,宽度根据剩余剂量动态计算:

            Row() {
              Row()
                .width(Math.min(m.left, 30).toString() === '30' ? '100%' : (m.left * 3.3).toFixed(0) + '%')
                .height(5)
                .backgroundColor(m.left <= 5 ? '#E2504C' : '#26A69A')
                .borderRadius(3)
              Row()
                .layoutWeight(1)
                .height(5)
                .backgroundColor('#EEF1FB')
                .borderRadius(3)
            }
            .width('100%')

进度条宽度使用了一个复杂的表达式:Math.min(m.left, 30)取剩余剂量和30的较小值,如果等于30(即剂量不少于30),宽度设为"100%";否则宽度设为(m.left * 3.3).toFixed(0) + '%'。这种计算方式将剩余剂量映射为百分比——30剂对应100%(30*3.3=99,约等于100%),剂量越少进度条越短。当剩余剂量不超过5时,进度条颜色变为红色(#E2504C)以警示库存不足。


十三、Tab 4 档案页面:构成比例条与档案列表

档案页面(fileTab)展示了家庭健康档案的构成比例和档案列表。页面顶部是一个由五段不同宽度、不同颜色的Row拼合而成的比例条,直观展示了检验、影像、处方、报告和其他类型档案的占比分布。

@Builder
fileTab() {
  Column({ space: 0 }) {
    this.secTitle2('🗂️', '家庭健康档案', '共 ' + this.recs.length.toString() + ' 条')
    Column({ space: 10 }) {
      Text('📄 档案构成')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor('#2A3356')
      Row() {
        Row()
          .width('34%')
          .height(10)
          .backgroundColor('#5B7CFA')
          .borderRadius({ topLeft: 5, bottomLeft: 5 })
        Row()
          .width('28%')
          .height(10)
          .backgroundColor('#F06292')
        Row()
          .width('20%')
          .height(10)
          .backgroundColor('#26A69A')
        Row()
          .width('12%')
          .height(10)
          .backgroundColor('#F5A623')
        Row()
          .width('6%')
          .height(10)
          .backgroundColor('#9B6BC3')
          .borderRadius({ topRight: 5, bottomRight: 5 })
      }
      .width('100%')
      Row({ space: 8 }) {
        Text('● 检验 34%')
          .fontSize(10)
          .fontColor('#5B7CFA')
        Text('● 影像 28%')
          .fontSize(10)
          .fontColor('#F06292')
        Text('● 处方 20%')
          .fontSize(10)
          .fontColor('#26A69A')
        Text('● 报告 12%')
          .fontSize(10)
          .fontColor('#F5A623')
        Text('● 其他 6%')
          .fontSize(10)
          .fontColor('#9B6BC3')
      }
      .width('100%')
    }
    .padding(14)
    .backgroundColor('#FFFFFF')
    .borderRadius(18)

比例条由五个Row组件横向排列,宽度分别为34%、28%、20%、12%和6%,总和为100%。第一段(检验)左上和左下圆角,最后一段(其他)右上和右下圆角,中间段无圆角,形成了一个完整的圆角矩形比例条。下方的图例使用五个带圆点前缀的Text标签,颜色与比例条对应。

技术概念:borderRadius的定向圆角
.borderRadius({ topLeft: 5, bottomLeft: 5 })可以单独设置组件的某个或某些角的圆角半径。ArkUI支持topLeft(左上)、topRight(右上)、bottomLeft(左下)、bottomRight(右下)四个方向的独立圆角设置。在本代码中,比例条的第一段只设置左侧两个圆角,最后一段只设置右侧两个圆角,中间段不设圆角,三段拼合后形成了一个完整的圆角矩形效果。这种定向圆角在构建分段式UI(如比例条、步骤条)时非常实用。

档案列表使用ForEach遍历this.recs数组,每条记录展示emoji图标、标题、成员和类型信息、以及"查看"按钮。列表按时间倒序排列,最新的档案显示在最前面。页面底部还有一个"档案云端同步开启"的状态提示文本,暗示数据已同步到云端。


十四、Tab 5 动态页面:社交化健康动态流与点赞交互

动态页面(feedTab)展示了家庭成员的健康动态信息流,支持点赞交互。这种将社交元素融入健康管理应用的设计,旨在通过家庭成员之间的互相鼓励和监督来提升健康管理的积极性和依从性。

@Builder
feedTab() {
  Column({ space: 0 }) {
    this.secTitle2('📰', '家庭健康动态', '点赞可切换')
    Column({ space: 12 }) {
      ForEach(this.feeds, (f: FeedT) => {
        Column({ space: 8 }) {
          Row({ space: 10 }) {
            Text(f.emoji)
              .fontSize(22)
            Column({ space: 2 }) {
              Text(f.member)
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor('#2A3356')
              Text(f.time)
                .fontSize(10)
                .fontColor('#8A93B5')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Text(f.liked ? '❤️' : '🤍')
              .fontSize(14)
            Text(f.likes.toString())
              .fontSize(12)
              .fontColor(f.liked ? '#F06292' : '#8A93B5')
          }
          .width('100%')
          Text(f.text)
            .fontSize(13)
            .fontColor('#3E4870')
            .width('100%')
            .padding({ left: 12, right: 12, top: 10, bottom: 10 })
            .backgroundColor('#F7F8FC')
            .borderRadius(12)
          Row() {
            Text('👏 鼓励一下')
              .fontSize(11)
              .fontColor(f.liked ? '#F06292' : '#8A93B5')
              .padding({ left: 12, right: 12, top: 6, bottom: 6 })
              .backgroundColor(f.liked ? '#FDECF3' : '#F0F1F6')
              .borderRadius(12)
            Text('💬 评论')
              .fontSize(11)
              .fontColor('#8A93B5')
              .padding({ left: 12, right: 12, top: 6, bottom: 6 })
              .backgroundColor('#F0F1F6')
              .borderRadius(12)
              .margin({ left: 8 })
          }
          .width('100%')
          .onClick(() => {
            this.toggleLike(f)
          })
        }
        .width('100%')
        .padding(13)
        .backgroundColor('#FFFFFF')
        .borderRadius(16)
      }, (f: FeedT) => f.text + f.likes.toString() + f.liked.toString())
    }

每条动态是一个Column卡片,包含三行内容。第一行是发布者信息——头像emoji、姓名、时间、以及点赞数和爱心图标。爱心图标根据f.liked状态在❤️(实心红心,已点赞)和🤍(空心白心,未点赞)之间切换,点赞数颜色也随之变化。第二行是动态正文,使用浅灰背景的圆角文本框包裹。第三行是操作按钮行,包含"鼓励一下"和"评论"两个标签式按钮。

技术概念:事件冒泡与onClick
在ArkUI中,onClick事件遵循冒泡模型——当子组件和父组件都绑定了onClick时,点击子组件会先触发子组件的回调,然后冒泡到父组件触发父组件的回调。在本代码中,"鼓励一下"按钮和其父Row容器都没有单独的onClick绑定,但外层的Column卡片绑定了this.toggleLike(f)。因此点击卡片的任何区域(包括"鼓励一下"按钮)都会触发点赞切换。这种设计简化了交互逻辑——用户无需精确点击爱心图标,点击整张卡片即可完成点赞。

动态卡片的键值生成器返回f.text + f.likes.toString() + f.liked.toString(),这是一个包含正文、点赞数和点赞状态的复合键值。当toggleLike方法修改了likesliked字段后,键值发生变化,框架精确地定位到该条动态并更新其UI——爱心图标切换、点赞数变化、按钮颜色变化。这种基于业务字段组合的键值设计确保了状态变化时UI的精确差分更新。


十五、Tab 6 我的页面:用户资料卡与设置列表

"我的"页面(mineTab)展示了用户资料、订单记录和设置列表。页面顶部使用Stack层叠布局叠加了用户资料卡和一个脉冲动画圆点。

@Builder
mineTab() {
  Column({ space: 0 }) {
    Stack({ alignContent: Alignment.TopEnd }) {
      Column({ space: 8 }) {
        Text('🧑')
          .fontSize(44)
          .padding(14)
          .backgroundColor('#EEF1FB')
          .borderRadius(30)
        Text('李小天')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor('#2A3356')
        Text('家庭管理员 · 爱康家庭卡会员')
          .fontSize(12)
          .fontColor('#5B7CFA')
        Row({ space: 8 }) {
          Text('家庭卡')
            .fontSize(11)
            .fontColor('#F5A623')
            .padding({ left: 10, right: 10, top: 4, bottom: 4 })
            .backgroundColor('#FEF4E2')
            .borderRadius(10)
          Text('体检 9 折')
            .fontSize(11)
            .fontColor('#26A69A')
            .padding({ left: 10, right: 10, top: 4, bottom: 4 })
            .backgroundColor('#E6F6F1')
            .borderRadius(10)
          Text('积分 3260')
            .fontSize(11)
            .fontColor('#F06292')
            .padding({ left: 10, right: 10, top: 4, bottom: 4 })
            .backgroundColor('#FDECF3')
            .borderRadius(10)
        }
      }
      .width('100%')
      .padding({ top: 24, bottom: 20 })
      .backgroundColor('#FFFFFF')
      .borderRadius(20)
      Circle({ width: 12, height: 12 })
        .fill('#F06292')
        .opacity(this.pulseOp)
        .offset({ y: this.dotY })
    }
    .width('100%')

技术概念:Stack的alignContent对齐
Stack({ alignContent: Alignment.TopEnd })设置子元素的对齐方式为右上角对齐。Alignment枚举包含TopStart(左上)、Top(中上)、TopEnd(右上)、Start(左中)、Center(居中)、End(右中)、BottomStart(左下)、Bottom(中下)、BottomEnd(右下)九种对齐模式。在本代码中,用户资料卡作为第一个子元素占据全部空间,脉冲圆点作为第二个子元素被对齐到右上角,配合.offset({ y: this.dotY })的Y轴偏移动画,形成了右上角浮动的红色通知圆点效果。

技术概念:offset偏移属性
.offset({ x: number, y: number })设置组件相对于其布局位置的偏移量。与position(绝对定位)不同,offset不会脱离文档流,组件仍然占据其原始布局位置,只是在渲染时偏移指定距离。在本代码中,圆点的offset.y绑定到dotY动画变量,在aboutToAppear中被设为-14并循环动画,产生了圆点上下浮动的效果。

用户资料卡下方是三个标签——“家庭卡”(橙色)、“体检9折”(绿色)、“积分3260”(粉色),每个标签使用不同颜色的浅色背景配合同色文字,形成了一组视觉上协调的徽章式标签。

设置列表使用ForEach遍历一个字符串数组生成六行设置项,每行包含设置名称和右箭头">"。整个列表使用.clip(true)裁剪超出圆角区域的内容,确保列表顶部和底部的行不会超出卡片的圆角边界。

技术概念:clip裁剪属性
.clip(true)设置组件是否裁剪子元素超出边界的内容。当父容器设置了borderRadius圆角,而子元素是矩形的列表项时,如果不设置clip,子元素的直角会超出父容器的圆角区域。设置.clip(true)后,超出圆角区域的部分被裁剪掉,列表项的顶部和底部行会自动获得与父容器一致的圆角效果。这是构建圆角列表的标准做法。


十六、弹窗系统:zIndex层级控制与position绝对定位

本应用实现了六个弹窗,分为两种视觉风格——居中大卡片式(添加家人、套餐详情、删除确认)和底部抽屉式(预约体检、疫苗登记、编辑家人)。两种风格的实现核心区别在于position和尺寸设置。

所有弹窗都采用了相同的遮罩层设计模式:

@Builder
addModalOverlay() {
  Column() {
    Column()
      .width('100%')
      .height('100%')
      .backgroundColor('#662A3356')
      .onClick(() => {
        this.showAdd = false
      })
  }
  .width('100%')
  .height('100%')
  .position({ x: 0, y: 0 })
  .zIndex(999)
  Column({ space: 14 }) {
    // ... 弹窗内容
  }
  .width('88%')
  .padding(18)
  .backgroundColor('#FFFFFF')
  .borderRadius(22)
  .constraintSize({ maxHeight: '78%' })
  .position({ x: '6%', y: '9%' })
  .zIndex(1000)
}

技术概念:zIndex层级控制
.zIndex(n)设置组件在Stack容器中的层级顺序。zIndex值越大的组件覆盖在值越小的组件之上。在本代码中,遮罩层zIndex(999)覆盖在主界面上方,弹窗内容zIndex(1000)覆盖在遮罩层上方。这种"遮罩+内容"的双层zIndex设计是弹窗实现的标准模式——遮罩层拦截所有点击事件并暗化背景,内容层展示弹窗主体。

技术概念:position绝对定位
.position({ x: value, y: value })将组件从文档流中脱离,按照指定的绝对坐标定位。xy可以是数值(vp单位)或百分比字符串。百分比是相对于父容器的尺寸计算的。在本代码中,居中弹窗使用position({ x: '6%', y: '9%' }),宽度88%加上左偏移6%使弹窗水平居中(6% + 88% + 6% = 100%)。底部抽屉使用position({ x: 0, y: '42%' }),从屏幕42%位置开始向下展开。

遮罩层是一个占满全屏的Column,背景色#662A3356——#66是alpha通道值(约40%不透明度),#2A3356是深蓝灰色RGB值。这种半透明遮罩使背景内容变暗但不完全遮挡,同时通过onClick绑定实现了点击遮罩关闭弹窗的交互。需要注意的是,遮罩层Column和弹窗内容Column是两个独立的组件声明,它们都设置了position和不同的zIndex,在Stack容器中通过层级叠加形成了完整的弹窗效果。

居中大卡片式弹窗的宽度通常设为88%(左右各留6%间距),最大高度通过.constraintSize({ maxHeight: '78%' })限制在屏幕高度的78%以内,防止内容过多时超出屏幕。底部抽屉式弹窗宽度为100%,从屏幕约42%-46%位置开始,顶部使用非对称圆角.borderRadius({ topLeft: 24, topRight: 24 })——只有左上和右上设圆角,底部为直角,模拟了从底部滑出的抽屉效果。

底部抽屉的顶部通常有一个拖拽指示条:

      Row()
        .width(44)
        .height(5)
        .backgroundColor('#D9DEEF')
        .borderRadius(3)
        .margin({ top: 10 })

这个44x5vp的浅灰色圆角小条是底部弹窗的标准视觉元素,它告诉用户这个弹窗可以从底部拖拽关闭(虽然本实现中并未实现拖拽手势,但视觉暗示已经建立)。


十七、弹窗表单交互:TextInput、Toggle与步进器

弹窗内部包含了丰富的表单交互组件。TextInput用于文本输入,Toggle用于开关切换,自定义的步进器(stepper)用于数值增减。这些组件配合@State表单状态变量,构成了完整的表单数据收集和验证流程。

      Column({ space: 8 }) {
        Text('姓名')
          .fontSize(12)
          .fontColor('#8A93B5')
          .width('100%')
        TextInput({ placeholder: '请输入家人姓名', text: this.addName })
          .fontSize(14)
          .height(42)
          .backgroundColor('#F7F8FC')
          .borderRadius(12)
          .onChange((v: string) => {
            this.addName = v
          })
      }
      .width('100%')

技术概念:TextInput输入框组件
TextInput是ArkUI的文本输入组件,通过placeholder参数设置占位提示文字,text参数设置当前文本值。.onChange((v: string) => { ... })绑定文本变化回调,每当用户输入或删除字符时触发,参数v为最新的文本值。在本代码中,onChange回调将输入值同步到this.addName状态变量,实现了表单数据的双向绑定。TextInput还支持TextInputType(输入类型)、maxLength(最大长度)、inputFilter(输入过滤)等属性来控制输入行为。

技术概念:Toggle开关组件
Toggle({ type: ToggleType.Switch, isOn: boolean })是ArkUI的开关切换组件。type参数设置开关类型(ToggleType.Switch为滑块开关,ToggleType.Checkbox为复选框),isOn参数设置当前开关状态。.onChange((v: boolean) => { ... })绑定状态变化回调,参数v为切换后的布尔值。在本应用的多个弹窗中,Toggle被用于"开启健康提醒"、“短信提醒”、“同步写入档案”、“置顶”、"知晓危险操作"等布尔选项的收集。

数值步进器是通过两个Text组件(减号和加号)配合onClick回调手动实现的:

        Row({ space: 14 }) {
          Text('-')
            .fontSize(16)
            .fontColor('#5B7CFA')
            .padding({ left: 14, right: 14, top: 6, bottom: 6 })
            .backgroundColor('#EEF1FB')
            .borderRadius(12)
            .onClick(() => {
              if (this.addAge > 1) {
                this.addAge -= 1
              }
            })
          Text(this.addAge.toString() + ' 岁')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#2A3356')
          Text('+')
            .fontSize(16)
            .fontColor('#F06292')
            .padding({ left: 14, right: 14, top: 6, bottom: 6 })
            .backgroundColor('#FDECF3')
            .borderRadius(12)
            .onClick(() => {
              if (this.addAge < 100) {
                this.addAge += 1
              }
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.Center)

步进器由减号按钮、当前值显示和加号按钮三部分组成。减号按钮的onClick回调中先检查this.addAge > 1(下限保护),然后将addAge减1。加号按钮的onClick回调中先检查this.addAge < 100(上限保护),然后将addAge加1。两个按钮分别使用蓝色和粉色主题,视觉上区分了"减少"和"增加"操作。整个Row使用.justifyContent(FlexAlign.Center)使三部分居中排列。这种手动实现的步进器在ArkUI中没有内置组件对应,但实现简单且可定制性高。

选项芯片选择器是另一种常见的表单交互模式:

        Row({ space: 8 }) {
          ForEach(RELAS2.slice(0, 4), (r: string) => {
            Text(r)
              .fontSize(12)
              .fontColor(this.addRela === r ? '#FFFFFF' : '#5A6488')
              .padding({ left: 12, right: 12, top: 7, bottom: 7 })
              .backgroundColor(this.addRela === r ? '#5B7CFA' : '#F0F1F6')
              .borderRadius(12)
              .onClick(() => {
                this.addRela = r
              })
          }, (r: string) => r)
        }
        .width('100%')

选项芯片使用ForEach根据选项数组生成一组Text标签,选中状态的芯片使用蓝色背景和白色文字,未选中状态使用浅灰背景和灰色文字。点击芯片时将this.addRela设为对应选项值,触发所有芯片的样式重新计算——被选中的芯片变为蓝底白字,其他芯片恢复灰底灰字。这种"单选芯片组"的交互模式在移动端表单中非常流行,它比下拉选择器更直观,且触摸目标更大。


十八、删除确认弹窗:危险操作的二次确认设计

删除操作是不可逆的危险操作,在UI设计中需要特别处理以防止误操作。本应用的删除确认弹窗采用了"窄危险卡"的视觉设计——与居中大卡片弹窗不同,它使用较窄的宽度(74%)和居中偏上的位置(y: 26%),配合红色主题色和二次确认机制。

@Builder
delModalOverlay() {
  Column() {
    Column()
      .width('100%')
      .height('100%')
      .backgroundColor('#662A3356')
      .onClick(() => {
        this.showDel = false
      })
  }
  .width('100%')
  .height('100%')
  .position({ x: 0, y: 0 })
  .zIndex(999)
  Column({ space: 12 }) {
    Text('⚠️')
      .fontSize(30)
    Text('移出家庭成员')
      .fontSize(16)
      .fontWeight(FontWeight.Bold)
      .fontColor('#E2504C')
    Text('将把「' + this.delTarget.name + '(' + this.delTarget.relation + ')」移出家庭,其体检计划、疫苗登记与档案将一并归档隐藏。')
      .fontSize(12)
      .fontColor('#5A6488')
      .textAlign(TextAlign.Center)
      .lineHeight(18)
    Row({ space: 8 }) {
      Text('我已知晓此操作')
        .fontSize(12)
        .fontColor('#5A6488')
        .layoutWeight(1)
      Toggle({ type: ToggleType.Switch, isOn: this.delArmed })
        .onChange((v: boolean) => {
          this.delArmed = v
        })
    }
    .width('100%')
    .padding({ left: 10, right: 10, top: 8, bottom: 8 })
    .backgroundColor('#FFF5F5')
    .borderRadius(12)
    Row({ space: 10 }) {
      Text('再想想')
        .fontSize(13)
        .fontColor('#5A6488')
        .padding({ left: 16, right: 16, top: 9, bottom: 9 })
        .backgroundColor('#F0F1F6')
        .borderRadius(14)
        .layoutWeight(1)
        .textAlign(TextAlign.Center)
        .onClick(() => {
          this.showDel = false
        })
      Text('确认移出')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .padding({ left: 16, right: 16, top: 9, bottom: 9 })
        .backgroundColor(this.delArmed ? '#E2504C' : '#F0B4B2')
        .borderRadius(14)
        .layoutWeight(1)
        .textAlign(TextAlign.Center)
        .onClick(() => {
          if (this.delArmed) {
            this.doDel()
          }
        })
    }
    .width('100%')
  }
  .width('74%')
  .padding(18)
  .backgroundColor('#FFFFFF')
  .borderRadius(20)
  .position({ x: '13%', y: '26%' })
  .zIndex(1000)
}

删除弹窗的设计包含了多层安全机制。第一层是视觉警示——30px的大号警告图标"⚠️"和红色的"移出家庭成员"标题,在视觉上明确告知用户这是危险操作。第二层是文字说明——详细描述了操作的后果(移出家庭,体检计划、疫苗登记与档案将一并归档隐藏),让用户充分了解影响范围。

第三层是Toggle二次确认——用户必须打开"我已知晓此操作"开关才能激活确认按钮。确认按钮的背景色绑定到this.delArmed状态变量——当开关关闭时为浅红色(#F0B4B2,不可用状态),当开关打开时为深红色(#E2504C,可用状态)。第四层是onClick中的条件检查——即使按钮被点击,代码仍然检查this.delArmed为true才执行doDel(),形成了双保险。

技术概念:危险操作的双重确认模式
在移动应用设计中,对于不可逆的操作(如删除、移除、注销等),业界推荐使用"二次确认"模式来防止误操作。本实现采用了一种比传统弹窗确认更优雅的方式——通过Toggle开关实现"武装确认"(Armed Confirmation)。用户必须先手动打开开关"武装"确认按钮,然后才能点击执行。这种模式在iOS和Android的原生设计中都有应用,它在保证安全性的同时避免了连续弹窗的烦扰。


十九、build方法:根布局的Stack层叠与条件渲染

build()方法是每个ArkTS组件必须实现的方法,它返回组件的UI树根节点。本应用的build方法使用Stack作为根容器,将主界面和六个弹窗层叠在一起。

build() {
  Stack() {
    Column() {
      this.headerBar2()
      Scroll() {
        Column() {
          if (this.selTab === 0) {
            this.homeTab()
          } else if (this.selTab === 1) {
            this.examTab()
          } else if (this.selTab === 2) {
            this.vacTab()
          } else if (this.selTab === 3) {
            this.medTab()
          } else if (this.selTab === 4) {
            this.fileTab()
          } else if (this.selTab === 5) {
            this.feedTab()
          } else {
            this.mineTab()
          }
        }
        .width('100%')
      }
      .layoutWeight(1)
      .scrollBar(BarState.Off)
      .width('100%')
      .backgroundColor('#F7F8FC')
      this.tabBar2()
    }
    .width('100%')
    .height('100%')
    if (this.showAdd) {
      this.addModalOverlay()
    }
    if (this.showPkg) {
      this.pkgModalOverlay()
    }
    if (this.showBook) {
      this.bookModalOverlay()
    }
    if (this.showVac) {
      this.vacModalOverlay()
    }
    if (this.showEdit) {
      this.editModalOverlay()
    }
    if (this.showDel) {
      this.delModalOverlay()
    }
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#F7F8FC')
}

build方法的结构清晰明了。Stack根容器内首先声明了主界面的Column——从上到下依次是headerBar2()(头部信息栏)、Scroll包裹的内容区域和tabBar2()(底部导航栏)。Scroll内部使用if-else条件语句根据this.selTab的值选择渲染对应的Tab页面。Scroll设置了layoutWeight(1)占据头部和底部导航栏之间的全部剩余空间,scrollBar(BarState.Off)隐藏了滚动条。

技术概念:if-else条件渲染
在ArkTS的build()@Builder方法中,可以使用if-else语句根据状态条件选择渲染不同的UI分支。当条件表达式的值变化时,框架会自动销毁旧分支的UI节点并创建新分支的UI节点。这种机制非常适合Tab页面切换等互斥场景——同一时刻只渲染一个Tab的UI内容,避免了同时构建七个Tab页面的内存开销。需要注意的是,if-else分支中的组件不会保留状态——切换Tab时,旧Tab的组件状态(如滚动位置)会丢失。

在主界面Column之后,六个if语句分别检查六个弹窗的可见性状态变量。当某个状态变量为true时,对应的弹窗@Builder方法被调用,生成的UI节点叠加在主界面之上。由于这些if语句位于Stack容器内,后声明的弹窗会覆盖在先声明的弹窗之上。多个弹窗可以同时显示——例如用户从套餐详情弹窗中打开预约弹窗时,两个弹窗同时可见,预约弹窗叠加在套餐弹窗之上。

技术概念:Stack作为根容器的弹窗管理模式
使用Stack作为根容器,将弹窗作为Stack的子元素叠加在主界面上方,是ArkUI中实现弹窗的一种常用方式。与使用系统DialogAPI相比,这种方式的优势在于:弹窗的UI完全由开发者自定义控制,可以使用全部ArkUI组件和属性;弹窗的状态管理通过@State变量实现,与主界面的状态管理一致;弹窗的动画效果可以通过animateTotransitionAPI自定义。缺点是需要手动管理遮罩层和层级关系。

整个应用的页面切换流程和弹窗管理流程可以通过以下流程图来描述:

selTab=0

selTab=1

selTab=2

selTab=3

selTab=4

selTab=5

selTab=6

showAdd=true

showPkg=true

showBook=true

showVac=true

showEdit=true

showDel=true

应用启动

aboutToAppear 初始化

启动三个循环动画

build方法构建UI树

Stack根容器

Column: 主界面

条件弹窗层

headerBar2 头部栏

Scroll 内容区域

tabBar2 底部导航

selTab条件判断

homeTab 家人页

examTab 体检页

vacTab 疫苗页

medTab 用药页

fileTab 档案页

feedTab 动态页

mineTab 我的页

检查弹窗状态

addModalOverlay

pkgModalOverlay

bookModalOverlay

vacModalOverlay

editModalOverlay

delModalOverlay

用户交互的数据流和状态更新流程则如下所示:

数组类变量

布尔类变量

数值类变量

字符串类变量

用户操作

事件回调

修改 @State 变量

变量类型判断

map/filter/concat 创建新数组

直接赋值新布尔值

算术运算后赋值

赋值新字符串

框架检测引用变化

触发UI差分更新

ForEach根据键值定位变化元素

重建变化的UI节点

界面实时刷新

aboutToAppear动画

animateTo修改状态变量

框架自动插值过渡

动画效果呈现


二十、完整技术对比表

下表对本应用中涉及的所有数据结构、组件、状态变量、装饰器和技术要点进行了系统对比:

序号 类别 名称 类型/签名 用途说明 使用位置
1 数据接口 FamilyT interface 家庭成员实体,8个字段 全局数据模型
2 数据接口 ExamT interface 体检记录实体,6个字段 全局数据模型
3 数据接口 VaccT interface 疫苗登记实体,6个字段 全局数据模型
4 数据接口 MedT interface 用药管理实体,6个字段 全局数据模型
5 数据接口 RecT interface 健康档案实体,5个字段 全局数据模型
6 数据接口 FeedT interface 家庭动态实体,6个字段 全局数据模型
7 数据接口 PkgT interface 体检套餐实体,7个字段含数组 全局数据模型
8 装饰器 @Entry Decorator 标记页面根入口组件 struct Index
9 装饰器 @Component Decorator 声明自定义组件 struct Index
10 装饰器 @State Decorator 管理组件内部可变状态 30+状态变量
11 装饰器 @Builder Decorator 封装可复用UI构建逻辑 16个Builder方法
12 容器组件 Column Container 纵向排列子元素 全局布局
13 容器组件 Row Container 横向排列子元素 全局布局
14 容器组件 Stack Container 层叠布局,子元素叠加 弹窗、进度环
15 容器组件 Scroll Container 滚动容器,支持水平/垂直滚动 内容区、家人选择器
16 基础组件 Text Basic 文本展示 全局文本
17 基础组件 TextInput Basic 单行文本输入 添加家人、编辑弹窗
18 基础组件 TextArea Basic 多行文本输入 编辑健康目标
19 基础组件 Toggle Basic 开关切换组件 多个弹窗表单
20 基础组件 Progress Basic 进度展示组件(环形) 体检完成率
21 基础组件 Circle Shape 圆形绘制组件 时间轴节点
22 基础组件 Divider Basic 分割线组件 导航栏分隔
23 列表渲染 ForEach Renderer 根据数组动态渲染列表 7个Tab页面全部使用
24 动画API animateTo Animation 命令式动画驱动 aboutToAppear
25 动画枚举 PlayMode.Alternate Enum 正反向交替播放模式 三个循环动画
26 动画枚举 Curve.EaseInOut Enum 先慢后快再慢缓动曲线 三个循环动画
27 布局属性 layoutWeight Attribute 弹性权重分配 Tab按钮、指标列
28 布局属性 justifyContent Attribute 主轴对齐方式 Row/Column对齐
29 布局属性 alignItems Attribute 交叉轴对齐方式 Column左对齐
30 布局属性 position Attribute 绝对定位 弹窗定位
31 布局属性 offset Attribute 相对偏移(不脱离流) 动画圆点
32 布局属性 zIndex Attribute 层级控制 弹窗遮罩与内容
33 布局属性 constraintSize Attribute 约束最大/最小尺寸 时间轴、弹窗高度
34 样式属性 borderRadius Attribute 圆角半径 全局卡片
35 样式属性 backgroundColor Attribute 背景颜色 全局
36 样式属性 fontColor Attribute 文字颜色 全局文本
37 样式属性 fontWeight Attribute 字重(粗细) 标题、数值
38 样式属性 opacity Attribute 不透明度 选中态、脉冲动画
39 样式属性 scale Attribute 缩放变换 心脏跳动动画
40 文本属性 maxLines Attribute 最大行数 目标文本截断
41 文本属性 textOverflow Attribute 溢出处理(省略号) 目标文本截断
42 状态变量 selTab @State number 当前选中Tab索引 Tab切换控制
43 状态变量 family @State Array 家庭成员数组 核心业务数据
44 状态变量 exams @State Array 体检记录数组 核心业务数据
45 状态变量 vaccs @State Array 疫苗登记数组 核心业务数据
46 状态变量 meds @State Array 用药管理数组 核心业务数据
47 状态变量 showAdd/showPkg等 @State boolean 弹窗可见性 弹窗管理
48 状态变量 heartScale/pulseOp/dotY @State number 动画状态变量 循环动画
49 计算方法 examDoneRate() Method 体检完成率百分比 体检页面
50 计算方法 famScore() Method 家庭平均健康分 头部栏
51 计算方法 scoreColor2() Method 根据分数返回颜色 排行、详情
52 计算方法 curMember() Method 当前选中成员对象 家人页面

安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// 暖白多巴胺浅色 · 7 tab 双排底部导航 · 6 弹框 · 健康分横条 / 疫苗时间轴 / 完成度环形 / 服药柱状
// 交互全部真实生效:tab 切换 / 添加家人 / 预约体检 / 疫苗登记 / 编辑 / 删除 / 服药打卡 / 动态点赞
interface FamilyT {
  name: string
  relation: string
  age: number
  emoji: string
  score: number
  nextExam: string
  goal: string
  color: string
}
interface ExamT {
  member: string
  title: string
  date: string
  org: string
  status: string
  price: number
}
interface VaccT {
  member: string
  name: string
  dose: string
  date: string
  status: string
  note: string
}
interface MedT {
  member: string
  drug: string
  freq: string
  time: string
  left: number
  kind: string
}
interface RecT {
  member: string
  kind: string
  title: string
  date: string
  emoji: string
}
interface FeedT {
  member: string
  emoji: string
  text: string
  time: string
  likes: number
  liked: boolean
}
interface PkgT {
  name: string
  price: number
  items: number
  age: string
  hot: string
  desc: string
  covers: Array<string>
}
const FAMILY2: Array<FamilyT> = [
  { name: '李国栋', relation: '爷爷', age: 68, emoji: '👴', score: 72, nextExam: '09-12', goal: '控制血压,每天散步 30 分钟', color: '#5B7CFA' },
  { name: '王秀兰', relation: '奶奶', age: 65, emoji: '👵', score: 78, nextExam: '09-18', goal: '血糖稳定,少糖少油', color: '#F06292' },
  { name: '李建军', relation: '爸爸', age: 44, emoji: '👨', score: 86, nextExam: '10-02', goal: '减脂 3kg,每周跑步 2 次', color: '#26A69A' },
  { name: '陈慧敏', relation: '妈妈', age: 42, emoji: '👩', score: 90, nextExam: '10-02', goal: '颈椎养护,每小时起身活动', color: '#F5A623' },
  { name: '李小天', relation: '我', age: 22, emoji: '🧑', score: 93, nextExam: '11-15', goal: '早睡早起,23:00 前入睡', color: '#9B6BC3' },
  { name: '周雨桐', relation: '配偶', age: 23, emoji: '👩‍🦰', score: 88, nextExam: '11-15', goal: '规律三餐,每周 3 次瑜伽', color: '#5B7CFA' },
  { name: '李一一', relation: '女儿', age: 6, emoji: '👧', score: 95, nextExam: '09-30', goal: '视力保护,每天户外 2 小时', color: '#F06292' },
  { name: '李二宝', relation: '儿子', age: 3, emoji: '👦', score: 94, nextExam: '09-30', goal: '补钙长个,每天 500ml 牛奶', color: '#26A69A' }
]
const EXAMS2: Array<ExamT> = [
  { member: '李国栋', title: '心脑血管深度套餐', date: '09-12', org: '爱康国宾·朝阳分院', status: '待检', price: 1680 },
  { member: '王秀兰', title: '骨密度 + 血糖复查', date: '09-18', org: '爱康国宾·朝阳分院', status: '待检', price: 420 },
  { member: '李一一', title: '儿童生长发育评估', date: '09-30', org: '爱康卓悦·海淀分院', status: '待检', price: 360 },
  { member: '李二宝', title: '儿童疫苗接种前体检', date: '09-30', org: '爱康卓悦·海淀分院', status: '待检', price: 280 },
  { member: '李小天', title: '青年基础体检', date: '08-14', org: '爱康国宾·望京分院', status: '已完成', price: 520 },
  { member: '周雨桐', title: '女性专项检查', date: '08-14', org: '爱康国宾·望京分院', status: '已完成', price: 880 },
  { member: '李建军', title: '职场压力评估', date: '07-21', org: '爱康卓悦·亦庄分院', status: '已完成', price: 640 },
  { member: '陈慧敏', title: '颈肩腰背专项', date: '07-21', org: '爱康卓悦·亦庄分院', status: '已完成', price: 560 },
  { member: '李国栋', title: '高血压随访', date: '06-08', org: '爱康国宾·朝阳分院', status: '已完成', price: 220 },
  { member: '王秀兰', title: '肝肾功能复查', date: '06-08', org: '爱康国宾·朝阳分院', status: '已取消', price: 300 },
  { member: '李建军', title: '幽门螺杆菌呼气', date: '05-16', org: '爱康国宾·望京分院', status: '已完成', price: 180 },
  { member: '陈慧敏', title: '甲状腺彩超复查', date: '05-16', org: '爱康国宾·望京分院', status: '已完成', price: 260 }
]
const VACCS2: Array<VaccT> = [
  { member: '李二宝', name: '流感疫苗(儿童型)', dose: '第 1 针', date: '09-06', status: '待种', note: '接种前留观 30 分钟' },
  { member: '李一一', name: '水痘疫苗加强针', dose: '第 2 针', date: '09-20', status: '待种', note: '需携带接种本' },
  { member: '王秀兰', name: '带状疱疹疫苗', dose: '第 1 针', date: '10-11', status: '待种', note: '60 岁以上适用' },
  { member: '李国栋', name: '23 价肺炎疫苗', dose: '第 1 针', date: '10-11', status: '待种', note: '与流感疫苗间隔 14 天' },
  { member: '李小天', name: 'HPV 九价疫苗', dose: '第 3 针', date: '08-02', status: '已种', note: '全部三针已完成' },
  { member: '周雨桐', name: 'HPV 九价疫苗', dose: '第 2 针', date: '08-02', status: '已种', note: '明年 2 月接种第 3 针' },
  { member: '李建军', name: '乙肝疫苗加强', dose: '第 3 针', date: '07-18', status: '已种', note: '抗体滴度达标' },
  { member: '李一一', name: '流感疫苗(儿童型)', dose: '年度加强', date: '07-05', status: '已种', note: '无不良反应' },
  { member: '李二宝', name: '手足口疫苗', dose: '第 2 针', date: '06-22', status: '已种', note: '轻微红肿已消退' },
  { member: '陈慧敏', name: '流感疫苗(成人型)', dose: '年度加强', date: '06-10', status: '已种', note: '留观无异常' },
  { member: '李国栋', name: '流感疫苗(成人型)', dose: '年度加强', date: '06-10', status: '已种', note: '留观无异常' }
]
const MEDS2: Array<MedT> = [
  { member: '李国栋', drug: '苯磺酸氨氯地平片', freq: '每日 1 次', time: '07:30', left: 12, kind: '降压' },
  { member: '李国栋', drug: '阿托伐他汀钙片', freq: '每日 1 次', time: '21:00', left: 25, kind: '调脂' },
  { member: '王秀兰', drug: '二甲双胍缓释片', freq: '每日 2 次', time: '08:00 / 18:00', left: 8, kind: '降糖' },
  { member: '王秀兰', drug: '碳酸钙 D3 片', freq: '每日 1 次', time: '12:30', left: 30, kind: '补钙' },
  { member: '陈慧敏', drug: '甲钴胺片', freq: '每日 3 次', time: '三餐后', left: 15, kind: '神经' },
  { member: '李建军', drug: '奥美拉唑肠溶胶囊', freq: '每日 1 次', time: '07:00', left: 6, kind: '胃药' },
  { member: '周雨桐', drug: '维生素 D 滴剂', freq: '每日 1 次', time: '09:00', left: 40, kind: '补剂' },
  { member: '李二宝', drug: '维生素 AD 滴剂', freq: '每日 1 次', time: '08:30', left: 18, kind: '补剂' },
  { member: '李一一', drug: '叶黄素软糖', freq: '每日 1 次', time: '19:00', left: 22, kind: '护眼' },
  { member: '王秀兰', drug: '氯雷他定片', freq: '必要时', time: '睡前', left: 9, kind: '抗敏' }
]
const RECS2: Array<RecT> = [
  { member: '李国栋', kind: '检验', title: '血脂四项报告', date: '08-20', emoji: '🧪' },
  { member: '王秀兰', kind: '检验', title: '空腹血糖 5.8 mmol/L', date: '08-20', emoji: '🧪' },
  { member: '李小天', kind: '影像', title: '胸部 DR 正位片', date: '08-14', emoji: '🩻' },
  { member: '周雨桐', kind: '检验', title: '血常规 22 项', date: '08-14', emoji: '🧪' },
  { member: '李一一', kind: '体检', title: '视力筛查:双眼 1.0', date: '07-30', emoji: '👓' },
  { member: '李二宝', kind: '体检', title: '身高体重:96cm / 14.5kg', date: '07-30', emoji: '📏' },
  { member: '李建军', kind: '报告', title: '压力负荷评估:中度', date: '07-21', emoji: '📄' },
  { member: '陈慧敏', kind: '影像', title: '颈椎侧位 DR', date: '07-21', emoji: '🩻' },
  { member: '王秀兰', kind: '处方', title: '二甲双胍 0.5g × 60 片', date: '06-08', emoji: '💊' },
  { member: '李国栋', kind: '处方', title: '氨氯地平 5mg × 28 片', date: '06-08', emoji: '💊' },
  { member: '李建军', kind: '检验', title: 'C13 呼气:阴性', date: '05-16', emoji: '🧪' },
  { member: '陈慧敏', kind: '影像', title: '甲状腺彩超:TI-RADS 2', date: '05-16', emoji: '🩻' }
]
const FEEDS2: Array<FeedT> = [
  { member: '李二宝', emoji: '👦', text: '今天喝了 500ml 牛奶,长高高!', time: '2 小时前', likes: 12, liked: false },
  { member: '李一一', emoji: '👧', text: '户外活动 2 小时打卡,跳绳 300 个', time: '4 小时前', likes: 8, liked: true },
  { member: '李国栋', emoji: '👴', text: '晨间散步 32 分钟,血压 138/86', time: '6 小时前', likes: 15, liked: false },
  { member: '陈慧敏', emoji: '👩', text: '颈椎操第 5 天,感觉轻松多了', time: '8 小时前', likes: 9, liked: false },
  { member: '李小天', emoji: '🧑', text: '连续早睡 3 天,睡眠分 91', time: '昨天 23:40', likes: 6, liked: true },
  { member: '周雨桐', emoji: '👩‍🦰', text: '瑜伽课 60 分钟完成 ✅', time: '昨天 20:15', likes: 11, liked: false },
  { member: '王秀兰', emoji: '👵', text: '午餐杂粮饭,餐后血糖 7.2', time: '昨天 13:50', likes: 7, liked: false },
  { member: '李建军', emoji: '👨', text: '夜跑 5.2km,配速 6\'30"', time: '昨天 21:30', likes: 13, liked: false },
  { member: '李国栋', emoji: '👴', text: '按时服药 30 天达成 🎉', time: '2 天前', likes: 20, liked: true },
  { member: '李一一', emoji: '👧', text: '视力复查保持 1.0,医生表扬啦', time: '3 天前', likes: 18, liked: false },
  { member: '陈慧敏', emoji: '👩', text: '体检报告出来了,全部正常 ❤️', time: '3 天前', likes: 25, liked: true },
  { member: '王秀兰', emoji: '👵', text: '骨密度 T 值 -1.2,较去年改善', time: '4 天前', likes: 14, liked: false }
]
const PKGS2: Array<PkgT> = [
  { name: '银发安心套餐', price: 1680, items: 38, age: '60 岁以上', hot: '长辈热选', desc: '心脑血管 + 骨密度 + 肿瘤标志物全覆盖', covers: ['颈动脉彩超', '头颅 MRI', '骨密度'] },
  { name: '青年活力套餐', price: 520, items: 22, age: '20-35 岁', hot: '销量第一', desc: '基础体检 + 幽门螺杆菌 + 甲状腺功能', covers: ['血常规', 'C13 呼气', '甲状腺彩超'] },
  { name: '女性关爱套餐', price: 880, items: 26, age: '成年女性', hot: '妈妈推荐', desc: '乳腺 + 宫颈 + 甲状腺专项筛查', covers: ['乳腺彩超', 'TCT + HPV', '性激素六项'] },
  { name: '职场抗压套餐', price: 640, items: 24, age: '上班族', hot: '爸爸推荐', desc: '压力负荷 + 颈椎 + 幽门螺杆菌', covers: ['压力评估', '颈椎 DR', 'C13 呼气'] },
  { name: '儿童成长套餐', price: 360, items: 16, age: '3-12 岁', hot: '宝贝专用', desc: '生长发育 + 视力 + 口腔 + 骨龄', covers: ['视力筛查', '骨龄片', '口腔检查'] },
  { name: '家庭尊享套餐', price: 3980, items: 45, age: '全家适用', hot: '全家福', desc: '两大一小全家深度体检,含早餐', covers: ['双人深度', '儿童成长', '专家报告解读'] }
]
const RELAS2: Array<string> = ['爷爷', '奶奶', '爸爸', '妈妈', '配偶', '儿子', '女儿', '其他']
const VACNAMES2: Array<string> = ['流感疫苗', 'HPV 九价', '乙肝加强', '水痘疫苗', '肺炎 23 价', '带状疱疹']
const DAYCHIPS2: Array<string> = ['09-06 周六', '09-07 周日', '09-13 周六', '09-14 周日', '09-20 周六']
const WEEKRATES2: Array<number> = [86, 100, 71, 100, 100, 86, 100]
@Entry
@Component
struct Index {
  @State selTab: number = 0
  @State family: Array<FamilyT> = FAMILY2.slice()
  @State exams: Array<ExamT> = EXAMS2.slice()
  @State vaccs: Array<VaccT> = VACCS2.slice()
  @State meds: Array<MedT> = MEDS2.slice()
  @State recs: Array<RecT> = RECS2.slice()
  @State feeds: Array<FeedT> = FEEDS2.slice()
  @State selMember: string = '李小天'
  @State showAdd: boolean = false
  @State showPkg: boolean = false
  @State showBook: boolean = false
  @State showVac: boolean = false
  @State showEdit: boolean = false
  @State showDel: boolean = false
  // 添加家人
  @State addRela: string = '爸爸'
  @State addName: string = ''
  @State addAge: number = 40
  @State addMonth: number = 6
  @State addDay: number = 15
  @State addRemind: boolean = true
  // 套餐详情 / 预约
  @State selPkg: PkgT = PKGS2[0]
  @State bookPkg: string = '青年活力套餐'
  @State bookDay: string = '09-06 周六'
  @State bookMembers: Array<string> = ['李小天']
  @State bookCount: number = 2
  @State bookSms: boolean = true
  // 疫苗登记
  @State vacName: string = '流感疫苗'
  @State vacMember: string = '李二宝'
  @State vacDose: number = 1
  @State vacSync: boolean = true
  // 编辑家人
  @State editTarget: FamilyT = FAMILY2[4]
  @State editName: string = '李小天'
  @State editRela: string = '我'
  @State editGoal: string = ''
  @State editTop: boolean = false
  // 删除
  @State delTarget: FamilyT = FAMILY2[4]
  @State delArmed: boolean = false
  // 特效
  @State heartScale: number = 1
  @State pulseOp: number = 0.4
  @State dotY: number = 0
  aboutToAppear(): void {
    this.getUIContext().animateTo({ duration: 900, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
      this.heartScale = 1.28
    })
    this.getUIContext().animateTo({ duration: 1200, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
      this.pulseOp = 1
    })
    this.getUIContext().animateTo({ duration: 2600, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
      this.dotY = -14
    })
  }
  examDoneRate(): number {
    let done: number = 0
    let total: number = 0
    this.exams.forEach((e: ExamT) => {
      if (e.status !== '已取消') {
        total += 1
        if (e.status === '已完成') {
          done += 1
        }
      }
    })
    return total === 0 ? 0 : Math.round(done * 100 / total)
  }
  vacTodo(): number {
    let n: number = 0
    this.vaccs.forEach((v: VaccT) => {
      if (v.status === '待种') {
        n += 1
      }
    })
    return n
  }
  medLeft(): number {
    let n: number = 0
    this.meds.forEach((m: MedT) => {
      n += m.left
    })
    return n
  }
  famScore(): number {
    let s: number = 0
    this.family.forEach((f: FamilyT) => {
      s += f.score
    })
    return this.family.length === 0 ? 0 : Math.round(s / this.family.length)
  }
  scoreColor2(s: number): string {
    if (s >= 90) {
      return '#26A69A'
    }
    if (s >= 80) {
      return '#5B7CFA'
    }
    if (s >= 75) {
      return '#F5A623'
    }
    return '#F06292'
  }
  curMember(): FamilyT {
    let r: FamilyT = this.family[0]
    this.family.forEach((f: FamilyT) => {
      if (f.name === this.selMember) {
        r = f
      }
    })
    return r
  }
  openEdit(f: FamilyT): void {
    this.editTarget = f
    this.editName = f.name
    this.editRela = f.relation
    this.editGoal = f.goal
    this.editTop = f.relation === '我'
    this.showEdit = true
  }
  openDel(f: FamilyT): void {
    this.delTarget = f
    this.delArmed = false
    this.showDel = true
  }
  toggleBookMember(n: string): void {
    if (this.bookMembers.indexOf(n) >= 0) {
      this.bookMembers = this.bookMembers.filter((m: string) => m !== n)
    } else {
      this.bookMembers = this.bookMembers.concat([n])
    }
    this.bookCount = this.bookMembers.length === 0 ? 1 : this.bookMembers.length
  }
  confirmAdd(): void {
    if (this.addName.length === 0) {
      return
    }
    const emojis: Array<string> = ['🧑', '👨', '👩', '👴', '👵', '👦', '👧']
    const colors: Array<string> = ['#5B7CFA', '#F06292', '#26A69A', '#F5A623', '#9B6BC3']
    const nf: FamilyT = {
      name: this.addName,
      relation: this.addRela,
      age: this.addAge,
      emoji: emojis[this.addAge > 60 ? 3 : (this.addAge > 18 ? 1 : 6)],
      score: 88,
      nextExam: '待预约',
      goal: '尚未设置健康目标',
      color: colors[this.family.length % 5]
    }
    this.family = [nf].concat(this.family)
    this.showAdd = false
    this.addName = ''
  }
  confirmBook(): void {
    const names: Array<string> = this.bookMembers.length === 0 ? [this.selMember] : this.bookMembers
    const newExams: Array<ExamT> = []
    names.forEach((n: string) => {
      const one: ExamT = { member: n, title: this.bookPkg, date: this.bookDay.substring(0, 5), org: '爱康国宾·望京分院', status: '待检', price: this.selPkg.price }
      newExams.push(one)
    })
    this.exams = newExams.concat(this.exams)
    this.showBook = false
    this.showPkg = false
  }
  confirmVac(): void {
    const nv: VaccT = {
      member: this.vacMember,
      name: this.vacName,
      dose: '第 ' + this.vacDose.toString() + ' 针',
      date: '09-05',
      status: '待种',
      note: '已登记,等待接种提醒'
    }
    this.vaccs = [nv].concat(this.vaccs)
    this.showVac = false
  }
  confirmEdit(): void {
    this.family = this.family.map((f: FamilyT) => {
      if (f.name === this.editTarget.name) {
        const nf: FamilyT = {
          name: this.editName.length > 0 ? this.editName : f.name,
          relation: this.editRela,
          age: f.age,
          emoji: f.emoji,
          score: f.score,
          nextExam: f.nextExam,
          goal: this.editGoal.length > 0 ? this.editGoal : f.goal,
          color: f.color
        }
        return nf
      }
      return f
    })
    if (this.editTarget.name === this.selMember && this.editName.length > 0) {
      this.selMember = this.editName
    }
    this.showEdit = false
  }
  doDel(): void {
    this.family = this.family.filter((f: FamilyT) => f.name !== this.delTarget.name)
    if (this.selMember === this.delTarget.name && this.family.length > 0) {
      this.selMember = this.family[0].name
    }
    this.showDel = false
  }
  doExam(e: ExamT): void {
    this.exams = this.exams.map((x: ExamT) => {
      if (x === e) {
        const nx: ExamT = { member: x.member, title: x.title, date: x.date, org: x.org, status: '已完成', price: x.price }
        return nx
      }
      return x
    })
  }
  doVacc(v: VaccT): void {
    this.vaccs = this.vaccs.map((x: VaccT) => {
      if (x === v) {
        const nv: VaccT = { member: x.member, name: x.name, dose: x.dose, date: x.date, status: '已种', note: '已完成接种,留观无异常' }
        return nv
      }
      return x
    })
  }
  takeMed(m: MedT): void {
    if (m.left <= 0) {
      return
    }
    this.meds = this.meds.map((x: MedT) => {
      if (x === m) {
        const nx: MedT = { member: x.member, drug: x.drug, freq: x.freq, time: x.time, left: x.left - 1, kind: x.kind }
        return nx
      }
      return x
    })
  }
  toggleLike(f: FeedT): void {
    this.feeds = this.feeds.map((x: FeedT) => {
      if (x === f) {
        const nx: FeedT = { member: x.member, emoji: x.emoji, text: x.text, time: x.time, likes: x.liked ? x.likes - 1 : x.likes + 1, liked: !x.liked }
        return nx
      }
      return x
    })
  }
  @Builder
  tabBtn2(icon: string, label: string, idx: number) {
    Column({ space: 3 }) {
      Text(icon)
        .fontSize(idx === this.selTab ? 22 : 20)
        .opacity(idx === this.selTab ? 1 : 0.55)
      Text(label)
        .fontSize(11)
        .fontColor(idx === this.selTab ? '#5B7CFA' : '#8A93B5')
        .fontWeight(idx === this.selTab ? FontWeight.Bold : FontWeight.Normal)
    }
    .layoutWeight(1)
    .padding({ top: 7, bottom: 7 })
    .onClick(() => {
      this.selTab = idx
    })
  }
  @Builder
  tabBar2() {
    Column() {
      Divider()
        .color('#E4E8F5')
      Row() {
        this.tabBtn2('👪', '家人', 0)
        this.tabBtn2('🩺', '体检', 1)
        this.tabBtn2('💉', '疫苗', 2)
        this.tabBtn2('💊', '用药', 3)
      }
      .width('100%')
      Row() {
        this.tabBtn2('🗂️', '档案', 4)
        this.tabBtn2('📰', '动态', 5)
        this.tabBtn2('🙋', '我的', 6)
      }
      .width('100%')
    }
    .width('100%')
    .backgroundColor('#FFFFFF')
  }
  @Builder
  headerBar2() {
    Column({ space: 12 }) {
      Row() {
        Column({ space: 2 }) {
          Text('早安,李小天 👋')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor('#2A3356')
          Text('FAMILY CARE · 全家健康一屏管理')
            .fontSize(11)
            .fontColor('#8A93B5')
        }
        .alignItems(HorizontalAlign.Start)
        Text('🔔')
          .fontSize(20)
          .padding({ left: 8, right: 8, top: 4, bottom: 4 })
          .backgroundColor('#EEF1FB')
          .borderRadius(14)
        Text('🙋')
          .fontSize(20)
          .padding({ left: 8, right: 8, top: 4, bottom: 4 })
          .backgroundColor('#FDECF3')
          .borderRadius(14)
          .onClick(() => {
            this.selTab = 6
          })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      Row({ space: 8 }) {
        Text('🔎')
          .fontSize(15)
        Text('搜索家人 / 体检项目 / 疫苗 / 药品')
          .fontSize(13)
          .fontColor('#A6AECB')
          .layoutWeight(1)
      }
      .width('100%')
      .padding({ left: 14, right: 14, top: 10, bottom: 10 })
      .backgroundColor('#FFFFFF')
      .borderRadius(20)
      Row({ space: 10 }) {
        Text('全家健康分')
          .fontSize(12)
          .fontColor('#FFFFFF')
          .padding({ left: 10, right: 10, top: 3, bottom: 3 })
          .backgroundColor('#5B7CFA')
          .borderRadius(10)
        Text(this.famScore().toString())
          .fontSize(34)
          .fontWeight(FontWeight.Bold)
          .fontColor('#2A3356')
        Column({ space: 2 }) {
          Text(this.family.length.toString() + ' 位家人')
            .fontSize(11)
            .fontColor('#8A93B5')
          Text('较上月 +2 分')
            .fontSize(11)
            .fontColor('#26A69A')
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Column() {
          Text('+ 添加')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
            .padding({ left: 14, right: 14, top: 8, bottom: 8 })
            .backgroundColor('#F06292')
            .borderRadius(18)
        }
        .onClick(() => {
          this.addRela = '爸爸'
          this.addName = ''
          this.addAge = 40
          this.showAdd = true
        })
      }
      .width('100%')
      .padding({ left: 14, right: 14, top: 12, bottom: 12 })
      .backgroundColor('#FFFFFF')
      .borderRadius(18)
      Scroll() {
        Row({ space: 10 }) {
          ForEach(this.family, (f: FamilyT) => {
            Column({ space: 4 }) {
              Text(f.emoji)
                .fontSize(24)
              Text(f.relation)
                .fontSize(10)
                .fontColor(this.selMember === f.name ? '#5B7CFA' : '#8A93B5')
            }
            .padding({ left: 12, right: 12, top: 8, bottom: 8 })
            .backgroundColor(this.selMember === f.name ? '#EEF1FB' : '#FFFFFF')
            .borderRadius(14)
            .border({
              width: this.selMember === f.name ? 1.5 : 0.5,
              color: this.selMember === f.name ? '#5B7CFA' : '#E4E8F5'
            })
            .onClick(() => {
              this.selMember = f.name
            })
          }, (f: FamilyT) => f.name)
        }
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .width('100%')
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 14, bottom: 10 })
    .backgroundColor('#F7F8FC')
  }
  @Builder
  cardShell2(inner: () => void) {
    Column() {
    }
    .width('100%')
    .padding(14)
    .backgroundColor('#FFFFFF')
    .borderRadius(18)
  }
  @Builder
  secTitle2(icon: string, title: string, extra: string) {
    Row() {
      Text(icon)
        .fontSize(16)
        .margin({ right: 6 })
      Text(title)
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor('#2A3356')
        .layoutWeight(1)
      Text(extra)
        .fontSize(12)
        .fontColor('#5B7CFA')
    }
    .width('100%')
    .margin({ top: 18, bottom: 10 })
  }
  // ==================== TAB 0 家人 ====================
  @Builder
  homeTab() {
    Column({ space: 0 }) {
      this.secTitle2('🌟', '当前关注 · ' + this.curMember().relation, this.curMember().name)
      Column({ space: 10 }) {
        Row({ space: 12 }) {
          Text(this.curMember().emoji)
            .fontSize(40)
            .padding(10)
            .backgroundColor('#EEF1FB')
            .borderRadius(18)
          Column({ space: 4 }) {
            Text(this.curMember().name + ' · ' + this.curMember().age + ' 岁')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#2A3356')
            Text('下次体检:' + this.curMember().nextExam)
              .fontSize(12)
              .fontColor('#8A93B5')
            Row({ space: 6 }) {
              Text('健康目标')
                .fontSize(10)
                .fontColor('#FFFFFF')
                .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                .backgroundColor('#F5A623')
                .borderRadius(8)
              Text(this.curMember().goal)
                .fontSize(11)
                .fontColor('#5A6488')
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
                .constraintSize({ maxWidth: 150 })
            }
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
        }
        .width('100%')
        Row() {
          Column({ space: 2 }) {
            Text(this.curMember().score.toString())
              .fontSize(26)
              .fontWeight(FontWeight.Bold)
              .fontColor(this.scoreColor2(this.curMember().score))
            Text('个人健康分')
              .fontSize(10)
              .fontColor('#8A93B5')
          }
          .layoutWeight(1)
          Column({ space: 2 }) {
            Text(this.vacTodo().toString())
              .fontSize(26)
              .fontWeight(FontWeight.Bold)
              .fontColor('#F06292')
            Text('待种疫苗')
              .fontSize(10)
              .fontColor('#8A93B5')
          }
          .layoutWeight(1)
          Column({ space: 2 }) {
            Text(this.examDoneRate().toString() + '%')
              .fontSize(26)
              .fontWeight(FontWeight.Bold)
              .fontColor('#26A69A')
            Text('体检完成率')
              .fontSize(10)
              .fontColor('#8A93B5')
          }
          .layoutWeight(1)
        }
        .width('100%')
        Row({ space: 8 }) {
          Text('✏️ 编辑资料')
            .fontSize(12)
            .fontColor('#5B7CFA')
            .padding({ left: 12, right: 12, top: 7, bottom: 7 })
            .backgroundColor('#EEF1FB')
            .borderRadius(14)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .onClick(() => {
              this.openEdit(this.curMember())
            })
          Text('🗓️ 预约体检')
            .fontSize(12)
            .fontColor('#F06292')
            .padding({ left: 12, right: 12, top: 7, bottom: 7 })
            .backgroundColor('#FDECF3')
            .borderRadius(14)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .onClick(() => {
              this.showBook = true
            })
          Text('🗑️ 移出家庭')
            .fontSize(12)
            .fontColor('#E2504C')
            .padding({ left: 12, right: 12, top: 7, bottom: 7 })
            .backgroundColor('#FDECEC')
            .borderRadius(14)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .onClick(() => {
              this.openDel(this.curMember())
            })
        }
        .width('100%')
      }
      .padding(14)
      .backgroundColor('#FFFFFF')
      .borderRadius(18)
      this.secTitle2('📊', '全家健康分排行', '共 ' + this.family.length.toString() + ' 人')
      Column({ space: 10 }) {
        ForEach(this.family, (f: FamilyT) => {
          Row({ space: 10 }) {
            Text(f.emoji)
              .fontSize(20)
            Column({ space: 5 }) {
              Row() {
                Text(f.name + ' · ' + f.relation)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#2A3356')
                  .layoutWeight(1)
                Text(f.score.toString())
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(this.scoreColor2(f.score))
              }
              .width('100%')
              Row() {
                Row()
                  .width(f.score.toString() + '%')
                  .height(7)
                  .backgroundColor(this.scoreColor2(f.score))
                  .borderRadius(4)
                Row()
                  .layoutWeight(1)
                  .height(7)
                  .backgroundColor('#EEF1FB')
                  .borderRadius(4)
              }
              .width('100%')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Text('编辑')
              .fontSize(11)
              .fontColor('#5B7CFA')
              .padding({ left: 10, right: 10, top: 5, bottom: 5 })
              .backgroundColor('#EEF1FB')
              .borderRadius(10)
              .onClick(() => {
                this.openEdit(f)
              })
          }
          .width('100%')
        }, (f: FamilyT) => f.name)
      }
      .padding(14)
      .backgroundColor('#FFFFFF')
      .borderRadius(18)
      this.secTitle2('⚡', '快捷操作', '常用功能')
      Row({ space: 8 }) {
        Column({ space: 5 }) {
          Text('💉')
            .fontSize(22)
          Text('疫苗登记')
            .fontSize(11)
            .fontColor('#5A6488')
        }
        .layoutWeight(1)
        .padding({ top: 12, bottom: 12 })
        .backgroundColor('#FFFFFF')
        .borderRadius(14)
        .onClick(() => {
          this.showVac = true
        })
        Column({ space: 5 }) {
          Text('🗓️')
            .fontSize(22)
          Text('预约体检')
            .fontSize(11)
            .fontColor('#5A6488')
        }
        .layoutWeight(1)
        .padding({ top: 12, bottom: 12 })
        .backgroundColor('#FFFFFF')
        .borderRadius(14)
        .onClick(() => {
          this.showBook = true
        })
        Column({ space: 5 }) {
          Text('👤')
            .fontSize(22)
          Text('添加家人')
            .fontSize(11)
            .fontColor('#5A6488')
        }
        .layoutWeight(1)
        .padding({ top: 12, bottom: 12 })
        .backgroundColor('#FFFFFF')
        .borderRadius(14)
        .onClick(() => {
          this.showAdd = true
        })
        Column({ space: 5 }) {
          Text('🗂️')
            .fontSize(22)
          Text('健康档案')
            .fontSize(11)
            .fontColor('#5A6488')
        }
        .layoutWeight(1)
        .padding({ top: 12, bottom: 12 })
        .backgroundColor('#FFFFFF')
        .borderRadius(14)
        .onClick(() => {
          this.selTab = 4
        })
      }
      .width('100%')
    }
    .width('100%')
    .padding({ left: 16, right: 16, bottom: 16 })
  }
  // ==================== TAB 1 体检 ====================
  @Builder
  examTab() {
    Column({ space: 0 }) {
      this.secTitle2('🩺', '家庭体检总览', '本季 12 单')
      Column({ space: 12 }) {
        Row({ space: 14 }) {
          Stack() {
            Progress({ value: this.examDoneRate(), total: 100 })
              .width(86)
              .height(86)
              .style({ strokeWidth: 9 })
            Column({ space: 1 }) {
              Text('❤️')
                .fontSize(16)
                .scale({ x: this.heartScale, y: this.heartScale })
              Text(this.examDoneRate().toString() + '%')
                .fontSize(17)
                .fontWeight(FontWeight.Bold)
                .fontColor('#2A3356')
            }
          }
          Column({ space: 6 }) {
            Text('已完成 ' + (this.exams.length - this.examTodo()).toString() + ' 项 · 待检 ' + this.examTodo().toString() + ' 项')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor('#2A3356')
            Text('本月待检 ' + this.examTodo().toString() + ' 单,最早 09-12')
              .fontSize(11)
              .fontColor('#8A93B5')
            Text('爱康家庭卡享 9 折')
              .fontSize(11)
              .fontColor('#F06292')
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
        }
        .width('100%')
        Row({ space: 10 }) {
          Text('📅 立即预约')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
            .padding({ left: 16, right: 16, top: 8, bottom: 8 })
            .backgroundColor('#5B7CFA')
            .borderRadius(16)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .onClick(() => {
              this.showBook = true
            })
          Text('🎁 套餐推荐')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor('#5B7CFA')
            .padding({ left: 16, right: 16, top: 8, bottom: 8 })
            .backgroundColor('#EEF1FB')
            .borderRadius(16)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .onClick(() => {
              this.selPkg = PKGS2[0]
              this.showPkg = true
            })
        }
        .width('100%')
      }
      .padding(14)
      .backgroundColor('#FFFFFF')
      .borderRadius(18)
      this.secTitle2('📋', '体检计划', '点击卡片完成体检')
      Column({ space: 10 }) {
        ForEach(this.exams, (e: ExamT) => {
          Row({ space: 10 }) {
            Column({ space: 3 }) {
              Text(e.date)
                .fontSize(15)
                .fontWeight(FontWeight.Bold)
                .fontColor(e.status === '已取消' ? '#B7BDD3' : '#2A3356')
              Text(e.status === '已完成' ? '已检' : '预约')
                .fontSize(9)
                .fontColor(e.status === '已完成' ? '#26A69A' : (e.status === '已取消' ? '#B7BDD3' : '#F5A623'))
            }
            .width(48)
            Column({ space: 4 }) {
              Text(e.title)
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor(e.status === '已取消' ? '#B7BDD3' : '#2A3356')
              Text(e.member + ' · ' + e.org + ' · ¥' + e.price.toString())
                .fontSize(11)
                .fontColor('#8A93B5')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Text(e.status)
              .fontSize(11)
              .fontColor(e.status === '已完成' ? '#26A69A' : (e.status === '已取消' ? '#B7BDD3' : '#FFFFFF'))
              .padding({ left: 10, right: 10, top: 5, bottom: 5 })
              .backgroundColor(e.status === '已完成' ? '#E6F6F1' : (e.status === '已取消' ? '#F0F1F6' : '#F5A623'))
              .borderRadius(10)
              .onClick(() => {
                if (e.status === '待检') {
                  this.doExam(e)
                }
              })
          }
          .width('100%')
          .padding(12)
          .backgroundColor('#FFFFFF')
          .borderRadius(14)
        }, (e: ExamT) => e.member + e.title + e.status)
      }
      .width('100%')
      this.secTitle2('🔥', '热门套餐', '横向滑动')
      Scroll() {
        Row({ space: 12 }) {
          ForEach(PKGS2, (p: PkgT) => {
            Column({ space: 6 }) {
              Text(p.hot)
                .fontSize(10)
                .fontColor('#FFFFFF')
                .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                .backgroundColor('#F06292')
                .borderRadius(8)
                .alignSelf(ItemAlign.Start)
              Text(p.name)
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor('#2A3356')
              Text('¥' + p.price.toString())
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#5B7CFA')
              Text(p.age + ' · ' + p.items.toString() + ' 项')
                .fontSize(11)
                .fontColor('#8A93B5')
              Text('查看详情')
                .fontSize(11)
                .fontColor('#FFFFFF')
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .backgroundColor('#5B7CFA')
                .borderRadius(12)
                .onClick(() => {
                  this.selPkg = p
                  this.showPkg = true
                })
            }
            .width(150)
            .padding(12)
            .backgroundColor('#FFFFFF')
            .borderRadius(16)
            .alignItems(HorizontalAlign.Start)
          }, (p: PkgT) => p.name)
        }
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .width('100%')
    }
    .width('100%')
    .padding({ left: 16, right: 16, bottom: 16 })
  }
  
  }
}


在这里插入图片描述

二十一、总结

通过对这份家庭健康管家应用源码的深度剖析,我们可以清晰地看到ArkTS语言在构建复杂移动应用时的强大表达能力和工程优势。整个应用以一个@Entry @Component struct Index为根入口,通过三十余个@State状态变量管理了从业务数据到界面控制、从表单输入到动画特效的完整状态域。这种"单一组件管理全局状态"的模式在中小型应用中具有开发效率高、状态传递简单、调试方便的优势。七个数据接口的定义体现了ArkTS作为TypeScript超集在类型安全方面的严格保障,每个字段都有明确的类型标注,编译器能够在开发阶段就发现类型不匹配的错误,大幅降低了运行时崩溃的风险。

声明式UI范式在本应用中得到了充分展现。整个界面通过ColumnRowStackScroll等容器组件的嵌套组合构建而成,每个组件的属性通过链式调用设置,形成了一种高度可读的UI描述代码。@Builder装饰器的运用尤为出色——十六个Builder方法将复杂的UI结构拆分为可复用的逻辑单元,如tabBtn2复用于七个Tab按钮、secTitle2复用于所有分区标题、headerBar2复用于头部信息栏。这种拆分使得两千五百行代码虽然逻辑庞大但结构清晰,每个Builder方法承担单一的UI构建职责,符合关注点分离的工程原则。

状态管理机制是本应用技术架构的核心。@State装饰器的"状态变化驱动UI更新"机制贯穿了所有交互逻辑——用户点击Tab时selTab变化触发页面切换,点击家人时selMember变化触发详情更新,点击服药时meds数组通过map重建触发库存刷新,点击点赞时feeds数组通过map重建触发爱心切换。数组类型状态的不可变更新模式(mapfilterconcat创建新数组而非直接修改)是保证框架正确检测变化的关键实践,这一模式在React和Flutter等声明式UI框架中也同样适用,体现了前端工程领域的共性设计理念。

动画效果的实现展示了ArkTS命令式动画API的灵活性。三个永久循环动画通过aboutToAppear中的animateTo调用启动,分别驱动心脏跳动的缩放变换、脉冲圆点的透明度变化和浮动通知点的Y轴偏移。PlayMode.Alternate模式使动画在正向和反向之间交替播放,Curve.EaseInOut曲线使运动具有自然的加减速节奏,三个动画的不同时长(900ms、1200ms、2600ms)创造了视觉上的非同步错拍效果。这种将动画状态变量绑定到UI组件属性的方式,使得开发者只需关注"动画的目标值",框架自动处理插值计算和逐帧渲染,极大简化了动画开发的复杂度。

弹窗系统的设计是本应用UI架构的另一个亮点。六个弹窗通过Stack根容器的层叠机制实现——每个弹窗由遮罩层(zIndex 999)和内容层(zIndex 1000)组成,遮罩层拦截背景点击并暗化界面,内容层展示弹窗主体。两种弹窗风格(居中大卡片和底部抽屉)通过position和尺寸设置区分。删除确认弹窗的危险操作二次确认设计(Toggle武装+条件检查双重保险)体现了移动应用在安全性设计方面的专业考量。所有弹窗的表单交互(TextInput文本输入、Toggle开关切换、自定义步进器、选项芯片组)都通过@State表单变量实现双向数据绑定,确保了用户输入实时同步到组件状态。

综上所述,这份源码完整地展示了一个基于鸿蒙ArkTS和ArkUI框架的家庭健康管理应用的技术全貌。从数据建模(7个接口)、状态管理(30+个@State变量)、UI构建(16个@Builder方法、7个Tab页面、6个弹窗)、交互逻辑(10+个CRUD方法)、动画特效(3个循环动画)到布局技巧(进度条拼合、柱状图手工搭建、时间轴布局、比例条分段),涵盖了移动应用开发的各个核心维度。对于希望深入理解鸿蒙声明式UI开发范式的开发者而言,这份代码提供了一个信息密度极高、实践性极强的学习样本。

Logo

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

更多推荐