一、鸿蒙开发技术背景与ArkTS语言体系

鸿蒙操作系统(HarmonyOS)作为一款面向全场景的分布式操作系统,其应用开发框架经历了从Java到ArkTS的重大演进。ArkTS是鸿蒙生态的主力应用开发语言,它在TypeScript的基础上进行了扩展,融入了声明式UI范式、状态管理机制、自定义组件等核心特性,使得开发者能够以更加简洁、直观的方式构建跨设备的用户界面。与传统的命令式UI框架不同,声明式UI的核心思想在于"描述目标状态"而非"逐步操作过程"——开发者只需声明界面在各种状态下应有的样子,框架会自动处理状态变化到界面更新的映射逻辑。

声明式UI范式的本质是数据驱动视图:状态是数据源,视图是状态的投影。当状态发生变化时,框架自动重新计算视图并完成差异更新,开发者无需手动操作DOM或视图树节点。

ArkTS语言在语法层面提供了丰富的装饰器(Decorator)系统,这些装饰器是声明式UI的核心支柱。@Entry装饰器标记页面入口组件,@Component装饰器标记自定义组件,@State装饰器声明响应式状态变量,@Builder装饰器封装可复用的UI片段。除此之外,还有@Prop@Link@Provide@Consume等一系列用于组件间状态传递的装饰器,它们共同构成了鸿蒙完整的状态管理体系。理解这些装饰器的工作原理和适用场景,是掌握鸿蒙开发的关键所在。

ArkUI组件体系是鸿蒙UI开发的基础构件库,它包含了容器组件、基础组件、媒体组件、绘制组件等多个大类。容器组件如Column、Row、Stack、Flex、Scroll等负责布局组织,它们本身不显示具体内容,而是决定子组件的排列方式和空间分配规则。基础组件如Text、Button、Image、TextInput、Progress、Toggle等负责具体的信息展示和用户交互。每个组件都通过链式调用的方式设置属性和事件,这种属性级联的写法使得代码结构清晰、层次分明,可读性极强。

ArkUI采用"组件+属性+事件"的三段式描述模型:组件定义结构,属性定义外观,事件定义行为。三者通过链式调用组合在一起,形成完整的UI描述。

在实际的商业应用开发中,鸿蒙的声明式UI框架展现出了极高的开发效率和运行性能。以基因检测类应用为例,这类应用通常包含大量的数据展示、列表渲染、弹框交互、表单输入等场景,非常适合用来检验UI框架的综合能力。本文将通过一个完整的基因检测中心应用场景,深入剖析ArkTS声明式UI的各项核心技术点,从数据结构设计、组件封装、状态管理、动画效果到弹框交互,全方位展示鸿蒙开发的最佳实践。


二、数据结构设计:类型安全的业务模型

2.1 接口定义的价值与意义

在任何一个复杂的应用中,数据结构的设计都是整个应用架构的基石。良好的数据结构设计能够提升代码的可维护性、减少运行时错误、增强团队协作效率。在ArkTS中,我们使用interface关键字来定义数据接口,这一特性继承自TypeScript的类型系统。接口描述了对象应该具有的属性名称和类型,但不包含具体的实现逻辑。

interface RiskT8 {
  id: number
  name: string
  cate: string
  mult: number
  level: string
  gene: string
  advice: string
}

在这里插入图片描述

上面这段代码定义了一个风险项目的数据接口。我们来逐字段分析其设计思路。id字段是每条记录的唯一标识符,类型为number,在列表渲染时用作key,帮助框架高效地进行差异更新。name字段存储风险项目的名称,如"2型糖尿病"、“乳腺癌"等,是用户最直观看到的内容。cate字段表示所属分类,如"内分泌”、“肿瘤”、“心血管"等,用于信息分组和筛选。mult字段是风险倍数,用数值表示相对于普通人群的患病风险倍率,是核心的量化指标。level字段是风险等级的文字描述,如"高”、“较高”、“中高”、“中等”、“一般"等,用于快速识别风险程度。gene字段记录相关的基因位点信息,如"TCF7L2 rs7903146 CT”,这是基因检测报告的专业内容。advice字段存储干预建议的详细文字描述,为用户提供具体的健康指导。

接口定义不仅仅是类型约束,更是业务语义的文档化。一个设计良好的接口,其字段命名应当自解释,阅读者仅凭接口定义就能理解业务对象的核心属性和含义。

在鸿蒙开发中,使用接口定义数据结构具有多方面的好处。首先,它提供了编译时的类型检查,能够在开发阶段就发现属性名拼写错误、类型不匹配等问题,避免这些问题流入运行时。其次,接口为IDE的智能提示提供了依据,开发者在访问对象属性时能够获得准确的代码补全,极大地提升了编码效率。再次,接口使得数据契约清晰明确,当多个开发者协作时,大家都遵循同一套接口定义,减少了沟通成本和集成问题。最后,接口定义本身就是最好的文档,新加入项目的开发者通过阅读接口就能快速理解业务数据模型。

2.2 六大业务接口全面解析

整个应用共定义了六个核心业务接口,分别对应不同的业务场景。下面我们逐一进行分析。

interface OriginT8 {
  id: number
  name: string
  pct: number
  color: string
  region: string
}

祖源成分接口OriginT8用于描述用户的祖源构成数据。pct字段表示该祖源成分的百分比,取值范围通常在0到100之间。color字段为该成分指定一个颜色值,用于在可视化图表中进行区分展示,这是一种常见的数据可视化设计模式——将颜色信息与数据本身绑定在一起,渲染时直接取用即可。region字段描述该祖源人群的地理分布区域,如"黄河流域"、“长江流域”、"蒙古高原"等,为用户提供地理文化背景信息。

interface NutriT8 {
  id: number
  name: string
  icon: string
  score: number
  verdict: string
  detail: string
}

在这里插入图片描述

营养代谢接口NutriT8用于描述各项营养代谢能力的检测结果。icon字段使用emoji字符作为图标,这是一种轻量级的图标解决方案,无需引入额外的图片资源,在深色主题下也能保持良好的显示效果。score字段是0到100的评分值,用于量化代谢能力的强弱。verdict字段是评分对应的文字结论,如"慢代谢型"、“良好”、"高敏感"等。detail字段是详细的解读说明,包含基因位点信息和具体的生活建议。

interface ConsultT8 {
  id: number
  name: string
  title: string
  field: string
  price: number
  score: number
}

在这里插入图片描述

遗传顾问接口ConsultT8描述了可预约的遗传咨询顾问信息。title字段是顾问的职称,如"遗传咨询顾问"、“基因组学研究员”、“分子病理医师"等。field字段表示顾问擅长的领域,如"肿瘤易感”、“心脑血管”、"营养代谢"等,帮助用户匹配合适的咨询对象。price字段是咨询服务的价格,以元为单位。score字段是用户评分,通常是1到5分的小数,用于展示顾问的服务质量。

interface OrdT8 {
  id: number
  name: string
  state: string
  date: string
  price: number
}

在这里插入图片描述

订单接口OrdT8记录用户的检测和服务订单信息。state字段表示订单状态,如"报告已出"、“已完成”、“待采样”、"待服务"等,不同状态通常会用不同颜色标识。date字段是订单日期或预约时间,其格式可能根据订单类型有所不同。price字段是订单金额,当金额为0时可能表示赠品或免费项目。

interface PlanT8 {
  id: number
  risk: string
  freq: number
  on: boolean
}

在这里插入图片描述

随访计划接口PlanT8用于管理用户的健康随访计划。risk字段关联到具体的风险项目名称。freq字段表示每年需要关注的次数,是一个数值型的频次指标。on字段是一个布尔值,表示该随访计划的提醒功能是否开启。这种设计将随访计划的核心要素——关注什么、多久关注一次、是否提醒——用最少的字段表达清楚,体现了数据结构设计的简洁性原则。

数据结构设计的一个重要原则是:字段数量应当恰到好处,既不冗余也不缺失。每个字段都应该承担明确的业务职责,能够用一个字段表达的就不要用两个。

2.3 常量数据与工具函数

定义好接口之后,应用使用常量数组的形式提供了模拟数据。这些数据以const关键字声明,表示它们是不可变的基准数据。

const RISKS8: Array<RiskT8> = [
  { id: 1, name: '2型糖尿病', cate: '内分泌', mult: 2.4, level: '较高', gene: 'TCF7L2 rs7903146 CT', advice: '每年检测糖化血红蛋白+空腹胰岛素,控制精制碳水摄入,每周抗阻训练2次。' },
  // ... 更多数据
]

在实际的生产环境中,这些数据通常来自后端API接口。但在前端开发阶段,使用写死的模拟数据有诸多好处。首先,它使得前端开发可以独立于后端进行,无需等待接口联调就能完成UI开发和交互验证。其次,模拟数据可以精心设计,覆盖各种边界情况和展示效果,便于测试和调试。再次,常量数据的加载是同步的,不会有网络延迟,开发体验更加流畅。最后,当后端接口就绪后,只需要将数据获取逻辑替换为网络请求即可,业务层的渲染逻辑完全不需要改动。

除了数据常量,应用还定义了两个工具函数,用于根据业务状态返回对应的颜色值。

function riskColor8(level: string): string {
  if (level === '高' || level === '较高') {
    return '#F87171'
  }
  if (level === '中高' || level === '中等') {
    return '#FBBF24'
  }
  return '#34D399'
}

在这里插入图片描述

riskColor8函数根据风险等级返回对应的颜色:高风险和较高风险用红色(#F87171)表示警示,中高和中等风险用黄色(#FBBF24)表示注意,其余情况用绿色(#34D399)表示正常。这种颜色编码方案遵循了通用的健康警示语义,用户一眼就能识别风险程度。

function stateColor8(s: string): string {
  if (s === '待采样') {
    return '#FBBF24'
  }
  if (s === '已完成') {
    return '#34D399'
  }
  return '#22D3EE'
}

在这里插入图片描述

stateColor8函数根据订单状态返回颜色:"待采样"用黄色表示进行中或待处理,“已完成"用绿色表示已完结,其余状态(如"报告已出”)用青色表示正常可用。

将颜色映射逻辑抽取为独立的工具函数,而不是在每个使用点都写一遍条件判断,是一种重要的代码复用策略。这样做不仅减少了重复代码,更重要的是保证了颜色规则的一致性——当需要调整颜色方案时,只需修改一处即可全局生效。


三、页面主结构:组件装饰器与生命周期

3.1 @Entry与@Component装饰器

应用的主体是一个名为Index的结构体,它被两个关键装饰器所标记:@Entry@Component。这两个装饰器是鸿蒙页面开发的入口点。

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

在这里插入图片描述

@Component装饰器将一个结构体标记为自定义组件。在ArkTS中,组件是UI构建的基本单元,每个组件都有自己的状态和渲染逻辑。被@Component装饰的结构体必须实现build()方法,该方法返回组件的UI描述。结构体的成员变量可以是状态变量,也可以是普通变量,还可以包含各种方法。组件化的思想使得UI可以被拆分为一个个独立的、可复用的单元,每个单元只关注自己的职责,从而降低系统的复杂度。

@Entry装饰器则更进一步,它标记的组件是页面的入口组件。一个页面通常有且只有一个@Entry组件,它是整个页面UI树的根节点。当页面被加载时,框架会首先创建@Entry组件的实例,然后调用其build()方法构建完整的UI树。@Entry组件可以包含多个子组件,子组件又可以包含更深层级的子组件,最终形成一棵以@Entry组件为根的组件树。

理解@Entry@Component的关系非常重要:所有的@Entry组件同时也是@Component组件,但并非所有@Component组件都是@Entry组件。@Entry是特殊的@Component,它代表一个独立的页面入口。

3.2 @State状态变量详解

状态是声明式UI的灵魂。在这个基因检测应用中,Index组件定义了大量的@State状态变量,它们驱动着整个界面的变化。

@State curTab8: number = 0
@State risks8: Array<RiskT8> = RISKS8.slice()
@State plans8: Array<PlanT8> = PLANS8.slice()
@State orders8: Array<OrdT8> = ORDERS8.slice()

@State装饰器声明的变量是组件的内部状态。当这些变量的值发生变化时,框架会自动触发组件的重新渲染,更新界面以反映最新的状态。这就是声明式UI的核心机制——状态驱动视图。

curTab8变量记录当前选中的底部Tab索引,初始值为0,表示默认显示第一个Tab(报告页)。当用户点击不同的Tab时,这个变量的值会更新,进而触发页面内容的切换。

risks8plans8orders8这三个数组类型的状态变量分别存储风险项目列表、随访计划列表和订单列表。它们的初始值来自对应的常量数组,但使用了.slice()方法创建了一个副本。为什么不直接赋值呢?这是一个值得深思的设计细节。

使用.slice()创建数组副本而不是直接引用常量数组,是为了确保状态数据的可变性。常量数组是只读的,如果直接将其赋值给@State变量,当后续需要修改数组内容时(如添加、删除、更新元素),可能会因为引用同一数组而产生意外的副作用。创建副本可以保证状态数据的独立性,使得状态变更更加可控。

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

@State showBook8: boolean = false
@State showRisk8: boolean = false
@State showPlan8: boolean = false
@State showAddr8: boolean = false
@State showDel8: boolean = false
@State showUpg8: boolean = false

这六个布尔变量分别控制六个弹框的显示与隐藏。初始值都是false,表示页面加载时默认不显示任何弹框。当用户触发相应操作时(如点击"预约解读"按钮),对应的变量会被设置为true,弹框随之显示。这种用布尔值控制弹框显隐的模式是鸿蒙开发中非常常见的做法,简单直观且易于维护。

然后是预约解读表单的状态变量:

@State bkCons8: number = 0
@State bkWay8: number = 0
@State bkSlot8: number = 2
@State bkNote8: string = ''

bkCons8记录选中的遗传顾问索引,bkWay8记录选中的解读方式索引,bkSlot8记录选中的时间段索引,bkNote8存储用户输入的备注信息。这些变量共同构成了预约表单的数据模型。

此外还有风险详情、随访计划、地址信息、删除确认、升级加购、特效动画等多种用途的状态变量。每一个@State变量都有明确的职责,它们共同协作,完整地描述了页面可能处于的所有状态。

在设计@State变量时,一个重要的原则是"状态最小化"。只把真正会影响UI渲染的数据声明为状态变量,那些不会变化或者变化了也不需要更新UI的数据,就没有必要声明为@State。过多的状态变量会增加组件的复杂度,也可能导致不必要的渲染开销。

3.3 aboutToAppear生命周期与动画

鸿蒙组件提供了多个生命周期钩子函数,允许开发者在组件的不同生命阶段执行特定的逻辑。其中aboutToAppear是最常用的一个,它在组件即将出现在屏幕上之前被调用。

aboutToAppear(): void {
  this.getUIContext().animateTo({ duration: 2200, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
    this.dnaY8 = 6
  })
  this.getUIContext().animateTo({ duration: 1400, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
    this.glowOp8 = 0.9
  })
}

这段代码在页面加载时启动了两个无限循环的动画效果。我们来详细分析animateTo动画API的用法。

animateTo是ArkUI提供的显式动画API,它接受两个参数:第一个是动画配置对象,第二个是动画闭包函数。动画闭包函数中的状态变化会被框架自动动画化。

动画配置对象包含多个属性:

  • duration:动画持续时间,单位为毫秒。第一个动画持续2200毫秒(2.2秒),第二个持续1400毫秒(1.4秒)。
  • iterations:动画重复次数。设置为-1表示无限循环。
  • playMode:动画播放模式。PlayMode.Alternate表示交替播放,即正向播放结束后反向播放,形成来回往复的效果。
  • curve:动画曲线。Curve.EaseInOut表示缓入缓出,动画开始和结束时速度较慢,中间速度较快,这种曲线模拟了物理世界的惯性特性,视觉上更加自然。

第一个动画改变的是dnaY8变量,它控制DNA图标的Y轴偏移量。初始值为-6,动画目标值为6。由于是交替播放模式,DNA图标会在-6到6之间上下浮动,产生一种漂浮的视觉效果,很好地呼应了基因检测的主题。

第二个动画改变的是glowOp8变量,它控制发光横幅的不透明度。初始值为0.3,动画目标值为0.9。不透明度在0.3到0.9之间渐变,产生一种呼吸灯般的发光效果,增强了界面的科技感和动感。

getUIContext()方法获取UI上下文,然后调用其animateTo方法,这是鸿蒙中启动显式动画的标准方式。与隐式动画(通过animation()属性设置)不同,显式动画可以精确控制动画的启动时机和作用范围,适合在生命周期回调或事件处理函数中触发。

3.4 整体架构流程图

为了更直观地理解整个应用的架构,我们用流程图来展示页面的整体结构和数据流向。

渲染错误: Mermaid 渲染失败: Parse error on line 2: flowchart TD A[Index页面入口
@Ent ----------------^ Expecting 'SEMI', 'NEWLINE', 'SPACE', 'EOF', 'subgraph', 'end', 'acc_title', 'acc_descr', 'acc_descr_multiline_value', 'AMP', 'COLON', 'STYLE', 'LINKSTYLE', 'CLASSDEF', 'CLASS', 'CLICK', 'DOWN', 'DEFAULT', 'NUM', 'COMMA', 'NODE_STRING', 'BRKT', 'MINUS', 'MULT', 'UNICODE_TEXT', 'direction_tb', 'direction_bt', 'direction_rl', 'direction_lr', 'direction_td', got 'LINK_ID'

这张流程图清晰地展示了页面的层级结构。最顶层是Index页面入口,它包含状态变量层和UI构建层两大模块。状态变量层是所有数据的核心存储位置,UI构建层根据状态变量渲染出对应的界面。主内容区采用Column垂直布局,依次排列头部、滚动内容区和底部Tab栏。弹框层通过Stack的层叠特性浮在主内容区之上,根据各自的开关变量条件性显示。


四、头部与导航:@Builder封装的UI组件

4.1 @Builder装饰器的作用与价值

在鸿蒙开发中,@Builder装饰器是实现UI代码复用的重要工具。它允许我们将一段UI描述封装为一个方法,在需要的地方直接调用,从而避免重复编写相同的UI代码。@Builder方法本质上是一个UI片段生成器,它返回一段可渲染的UI结构。

@Builder headerBar8() {
  Column() {
    Row() {
      Column() {
        Text('🧬 GENE LAB').fontSize(17).fontWeight(700).fontColor('#E9D5FF').letterSpacing(1)
        Text('样本编号 GL-2026-08829 · 报告已解锁').fontSize(10).fontColor('#7C6BA8').margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)

      Text('🧬').fontSize(22).translate({ y: this.dnaY8 })
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 14, bottom: 10 })

    Row() {
      Text('🔍').fontSize(13)
      Text('搜索基因位点 / 疾病名 / 代谢项').fontSize(11).fontColor('#7C6BA8').margin({ left: 6 }).layoutWeight(1).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
      Text('🧪').fontSize(16).onClick(() => {
        this.showUpg8 = true
      })
    }
    .width('100%')
    .padding({ left: 12, right: 12, top: 9, bottom: 9 })
    .borderRadius(20)
    .backgroundColor('#1D1030')
    .margin({ left: 16, right: 16, bottom: 12 })
  }
  .width('100%')
  .backgroundColor('#170B28')
  .borderRadius({ bottomLeft: 20, bottomRight: 20 })
}

上面这段代码定义了头部导航栏的@Builder方法。整个头部由一个Column容器作为主体,内部包含两行内容。第一行是品牌信息行,左侧是品牌名称和样本编号,右侧是一个DNA图标。第二行是搜索栏,包含搜索图标、搜索提示文字和一个功能按钮。

@Builder装饰器的核心价值在于UI代码的复用和组织。当某段UI结构需要在多处使用时,将其抽取为@Builder方法可以显著减少重复代码。即使只在一处使用,将复杂的UI片段封装为命名清晰的@Builder方法,也能提升代码的可读性和可维护性。

4.2 Column与Row容器组件详解

在头部组件的实现中,ColumnRow是使用最频繁的两个容器组件。它们是ArkUI中最基础也是最重要的布局容器。

Column是垂直布局容器,它的子组件沿着垂直方向从上到下依次排列。Column提供了多种属性来控制子组件的对齐方式和间距。例如,alignItems(HorizontalAlign.Start)设置子组件在水平方向上左对齐,alignItems(HorizontalAlign.Center)则是居中对齐。

Row是水平布局容器,它的子组件沿着水平方向从左到右依次排列。RowalignItems(VerticalAlign.Center)属性设置子组件在垂直方向上居中对齐,justifyContent(FlexAlign.SpaceBetween)则设置子组件在主轴方向上两端对齐。

Column和Row是声明式布局的基础。它们的设计理念来自于Flexbox弹性布局模型,但进行了简化和优化。Column相当于flex-direction: column的flex容器,Row相当于flex-direction: row的flex容器。

在头部品牌行中,我们使用了layoutWeight(1)这个重要的属性。layoutWeight是弹性布局中的权重分配属性,当父容器有多余空间时,会按照子组件的权重比例分配额外空间。设置了layoutWeight(1)的Column会占据除右侧DNA图标之外的所有剩余空间,这样就实现了左侧文字区域自适应、右侧图标固定宽度的布局效果。

4.3 Text组件的丰富属性

Text是最基础的文本显示组件,但它提供了极其丰富的属性来控制文本的外观。在头部组件中,我们可以看到Text组件的多种属性用法:

  • fontSize(17):设置字体大小为17px
  • fontWeight(700):设置字体粗细,700相当于粗体
  • fontColor('#E9D5FF'):设置字体颜色,使用十六进制颜色值
  • letterSpacing(1):设置字符间距为1px
  • margin({ top: 3 }):设置顶部外边距为3px
  • maxLines(1):设置最大行数为1行
  • textOverflow({ overflow: TextOverflow.Ellipsis }):设置文本溢出时显示省略号

Text组件的属性虽然繁多,但都遵循一致的命名规范。字体相关的属性以font开头,间距相关的属性如marginpadding支持传入对象来分别设置四个方向的值,这种设计使得属性的含义一目了然。

4.4 底部Tab栏组件

底部Tab栏是移动应用中极为常见的导航模式。这个应用使用了5个Tab的底部导航,分别对应报告、祖源、易感、营养、我的五个功能模块。

@Builder tabItem8(icon: string, label: string, idx: number) {
  Column() {
    Text(icon).fontSize(20)
    Text(label).fontSize(10).fontColor(this.curTab8 === idx ? '#A855F7' : '#6B5B8E').margin({ top: 3 })
  }
  .alignItems(HorizontalAlign.Center)
  .padding({ top: 8, bottom: 6 })
  .layoutWeight(1)
  .onClick(() => {
    this.curTab8 = idx
  })
}

tabItem8是单个Tab项的@Builder方法,它接受三个参数:icon是Tab的图标(用emoji表示),label是Tab的文字标签,idx是Tab的索引值。这个方法的设计非常巧妙,通过参数化的方式将Tab的可变部分提取出来,使得五个Tab可以复用同一段UI代码。

值得注意的是字体颜色的动态设置:this.curTab8 === idx ? '#A855F7' : '#6B5B8E'。这是一个三元表达式,当当前选中的Tab索引等于当前Tab项的索引时,文字显示为紫色(选中态),否则显示为暗紫色(未选中态)。这种基于状态的条件渲染是声明式UI的典型写法。

@Builder方法支持参数传递,这使得UI片段的复用更加灵活。通过将可变的部分参数化,我们可以用同一个@Builder方法生成多种不同的UI变体,大大提升了代码的复用率。

@Builder tabBar8() {
  Row() {
    this.tabItem8('📋', '报告', 0)
    this.tabItem8('🌏', '祖源', 1)
    this.tabItem8('⚠️', '易感', 2)
    this.tabItem8('🥗', '营养', 3)
    this.tabItem8('👤', '我的', 4)
  }
  .width('100%')
  .backgroundColor('#160B26')
  .border({ width: 0.5, color: '#33A855F7' })
}

tabBar8方法则将五个Tab项组合在一个Row容器中。每个Tab项通过layoutWeight(1)平均分配宽度,确保五个Tab在屏幕宽度内等宽排列。底部Tab栏使用深紫色背景,顶部有一条半透明的紫色边框线,起到视觉分隔的作用。

使用.border({ width: 0.5, color: '#33A855F7' })设置边框时,颜色值#33A855F7是一个八位的十六进制颜色值,前两位33表示透明度(alpha通道),后六位A855F7是颜色本身。33大约相当于20%的不透明度,这样的半透明边框线在深色背景上显得更加柔和。


五、Tab页面详解:五大功能模块的实现

5.1 报告页:总览卡与风险榜单

报告页是用户进入应用后看到的第一个页面,它承载着展示检测报告核心信息的重要职责。整个页面采用自上而下的信息流设计,依次展示总览卡片、发光横幅、风险等级分布和重点风险榜。

@Builder tabReport8() {
  Column() {
    // 总览卡
    Row() {
      Stack() {
        Progress({ value: 92, total: 100 })
          .width(70)
          .height(70)
          .style({ strokeWidth: 8 })
          .color('#A855F7')
        Column() {
          Text('92%').fontSize(15).fontWeight(700).fontColor('#E9D5FF')
          Text('已解读').fontSize(8).fontColor('#7C6BA8')
        }
        .alignItems(HorizontalAlign.Center)
      }
      // ... 右侧文字区域
    }
    // ... 更多内容
  }
}

总览卡是报告页的视觉焦点,它使用一个Row容器将环形进度条和报告信息并排放置。环形进度条的实现是这部分的亮点,让我们重点分析。

Progress组件是鸿蒙提供的进度指示器组件,它支持多种样式。这里通过Progress({ value: 92, total: 100 })创建了一个值为92、满分为100的进度组件。默认的Progress样式就是环形的,非常适合用来展示完成度、占比等数据。

Progress组件的环形样式(默认样式)通过设置.style({ strokeWidth: 8 })来调整进度条的线宽,.color('#A855F7')设置进度条的颜色。Progress组件内部会自动绘制一个圆环,已完成的部分用指定颜色填充,未完成的部分用背景色显示。

环形进度条中间的文字是如何实现的呢?答案是Stack层叠布局。

Stack是层叠布局容器,它的子组件按照顺序堆叠在一起,后加入的子组件会覆盖在先加入的子组件上面。在这里,Progress组件作为第一个子元素放在底层,Column容器(包含"92%"和"已解读"文字)作为第二个子元素叠在上层。通过Stack的层叠特性,我们轻松实现了"圆环+中心文字"的复合组件效果。

Stack层叠布局是实现复合UI效果的利器。当需要让多个组件在同一个位置叠加显示时,Stack是首选方案。常见的应用场景包括:图片上加文字、进度条中心显示数值、卡片上浮标签、头像叠加在线状态标识等。

总览卡的右侧是报告的文字信息区域,包含报告名称、覆盖项目描述和两个操作按钮。两个按钮分别是"预约解读"和"升级检测",它们分别对应打开预约弹框和升级弹框的操作。按钮的样式一实一虚——"预约解读"使用实色填充背景,"升级检测"使用描边边框样式,形成视觉上的主次区分。

接下来是发光横幅特效:

Stack() {
  Column() {
    Text('✦ 基因不是判决书,而是健康路标 — 已为你生成 12 条随访建议').fontSize(10).fontColor('#C4B5FD')
  }
  .width('100%')
  .alignItems(HorizontalAlign.Center)
  .padding({ top: 10, bottom: 10 })
  .borderRadius(12)
  .backgroundColor('#241440')
  .border({ width: 0.8, color: '#66A855F7' })
  .opacity(this.glowOp8)
}

这段代码实现了一个带有呼吸发光效果的提示横幅。核心在于.opacity(this.glowOp8)属性,它将元素的不透明度绑定到了glowOp8状态变量上。而glowOp8变量在aboutToAppear中被设置为无限循环的动画,因此横幅的不透明度会在0.3到0.9之间持续变化,产生呼吸灯般的发光效果。

动画效果的实现遵循"状态驱动"的统一范式:先定义一个状态变量,然后将组件的某个属性绑定到这个变量上,最后通过动画API改变变量的值。框架会自动处理属性变化的过渡效果,开发者无需关心动画的帧计算。

然后是风险等级分布模块,它用四列数据展示了不同风险等级的项目数量。这里使用了经典的"Row+四个等宽Column"的布局模式,每个Column通过layoutWeight(1)平均分配宽度,确保四列等宽排列。

最后是重点风险榜,这是一个列表区域,使用ForEach进行列表渲染。

5.2 ForEach列表渲染机制

ForEach(this.risks8, (r: RiskT8) => {
  Row() {
    Column() {
      Text(r.name).fontSize(12).fontWeight(700).fontColor('#E9D5FF')
      Text(r.cate + ' · ' + r.gene).fontSize(9).fontColor('#7C6BA8').margin({ top: 4 }).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    // ... 右侧风险倍数和等级标签
  }
  // ... 卡片样式
  .onClick(() => {
    this.riskId8 = r.id
    this.showRisk8 = true
  })
}, (r: RiskT8) => 'rk' + r.id.toString())

ForEach是ArkUI中用于列表渲染的核心组件,它可以根据数组数据批量生成一组子组件。ForEach接受三个参数:

第一个参数是数据源数组,这里是this.risks8,即风险项目列表。

第二个参数是一个回调函数(也叫item生成函数),它接收数组中的每个元素作为参数,返回该元素对应的UI描述。这个函数会为数组中的每一项执行一次,生成对应的组件。

第三个参数是key生成函数,它为每个数组元素生成一个唯一的标识符。这里使用'rk' + r.id.toString(),即给每个元素的id加上一个前缀。key的作用是帮助框架高效地进行列表更新——当数组发生变化时,框架通过key来识别哪些元素是新增的、哪些是删除的、哪些是移动的,从而以最小的代价更新UI。

ForEach的key生成函数是一个容易被忽视但非常重要的参数。如果key设置不当(如使用数组索引作为key),在列表项顺序发生变化时可能导致组件状态错乱、动画异常等问题。最佳实践是使用数据本身的唯一标识(如id)作为key的基础。

每个风险项卡片的布局设计也值得仔细品味。卡片主体是一个Row容器,内部从左到右依次是:风险项目信息(名称+分类+基因位点)、风险倍数、风险等级标签。风险项目信息区域使用layoutWeight(1)占据剩余空间,风险倍数和等级标签固定宽度靠右排列。

风险等级标签使用了不同的背景色和文字色,颜色由riskColor8(r.level)函数根据风险等级动态返回。这种设计使得用户可以通过颜色快速识别风险程度,符合信息可视化的设计原则。

点击整个卡片会触发两个操作:设置当前查看的风险id(this.riskId8 = r.id)和显示风险详情弹框(this.showRisk8 = true)。这种"先设置数据再显示弹框"的模式是弹框传参的常用方式——弹框组件从状态变量中读取数据,而调用方通过修改状态变量来传递参数。

5.3 祖源页:成分堆叠条与血统卡

祖源分析是基因检测应用中一个趣味性很强的功能模块。祖源页的核心是展示用户的祖源成分构成,包括可视化的成分堆叠条和详细的图例列表。

// 成分堆叠条
Row() {
  ForEach(ORIGIN8, (o: OriginT8) => {
    Column() {
    }
    .width(o.pct + '%')
    .height(18)
    .backgroundColor(o.color)
  }, (o: OriginT8) => 'og' + o.id.toString())
}
.width('100%')
.borderRadius(9)
.clip(true)
.margin({ top: 14 })

成分堆叠条是祖源页的核心可视化元素。它的实现原理非常巧妙:用一个Row容器作为底座,内部放置多个Column组件,每个Column的宽度通过.width(o.pct + '%')设置为对应的百分比。由于Row的子组件默认从左到右排列,且每个子组件的宽度加起来正好是100%,因此它们自然拼接成了一个完整的彩色堆叠条。

使用百分比宽度实现堆叠条是一种经典的布局技巧。Row容器提供了横向排列的基础,每个子元素设置百分比宽度,加起来等于100%,就能完美地填满整个容器。这种方式比使用Canvas或自定义绘制要简单得多,而且天然支持响应式布局。

.clip(true)属性在这里起到了关键作用。Row容器设置了borderRadius(9)圆角,但内部的子组件(彩色条)默认不会自动裁剪圆角。设置clip(true)后,超出容器圆角范围的子内容会被裁剪掉,从而呈现出圆角堆叠条的视觉效果。

图例列表部分则展示了每一种祖源成分的详细信息,包括颜色标识、名称、地理区域、迷你进度条和百分比数值。每一行都是一个Row容器,内部从左到右排列着颜色方块、名称、区域、迷你进度条和百分比。

值得注意的是迷你进度条的实现方式:

Column() {
  Row() {
    Column() {
    }
    .width(o.pct + '%')
    .height('100%')
    .borderRadius(3)
    .backgroundColor(o.color)
  }
  .width(90)
  .height(5)
  .borderRadius(3)
  .backgroundColor('#170B28')
  Text(o.pct.toString() + '%').fontSize(11).fontWeight(700).fontColor(o.color).margin({ top: 3 })
}

这里用了一个Row(宽度90,高度5)作为进度条的背景槽,内部嵌套一个Column作为进度条的填充部分。填充部分的宽度设置为百分比,高度为100%(即填满父容器的高度)。这种"外层背景槽+内层填充条"的双层结构是实现线性进度条的标准模式。

实现进度条效果的关键在于嵌套结构:外层容器定义总宽度和背景色,内层容器定义已完成比例和前景色。内层容器的宽度用百分比表示,其百分比值就是进度值。这种纯布局实现的进度条,比使用Progress组件更加灵活,可以自定义各种形状和样式。

祖源页还包含了母系线粒体和父系Y染色体两个信息卡片,它们并排排列,各占一半宽度。这是通过两个Column各设置layoutWeight(1)来实现的等宽双列布局。两个卡片使用了不同的主题色——母系用青色(#22D3EE),父系用粉色(#F472B6),形成鲜明的视觉区分。

5.4 易感页:随访管理与风险列表

易感页是功能最丰富的一个Tab页面,它包含两个主要区域:随访计划管理区和全部易感项目列表区。

随访计划管理区的顶部有一个标题行,左侧显示随访计划的数量,右侧有一个"新增+"按钮。点击新增按钮会打开随访计划弹框,用于添加新的随访项目。

ForEach(this.plans8, (p: PlanT8) => {
  Row() {
    Column() {
      Text(p.risk).fontSize(12).fontWeight(700).fontColor('#E9D5FF')
      Text('每年关注 ' + p.freq.toString() + ' 次 · ' + (p.on ? '提醒已开启' : '提醒已关闭')).fontSize(9).fontColor(p.on ? '#34D399' : '#7C6BA8').margin({ top: 4 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)

    Column() {
      Progress({ value: p.freq, total: 4, type: ProgressType.Linear })
        .width(56)
        .height(4)
        .style({ strokeWidth: 4 })
        .color('#A855F7')
        .backgroundColor('#170B28')
    }
    .margin({ right: 10 })

    Text('✏️').fontSize(15).onClick(() => {
      // 编辑逻辑
    })
    Text('🗑️').fontSize(14).margin({ left: 14 }).onClick(() => {
      // 删除逻辑
    })
  }
  // ... 卡片样式
}, (p: PlanT8) => 'pl' + p.id.toString() + '_' + p.freq.toString())

随访计划列表的每一行都是一个信息丰富的卡片。左侧是风险项目名称和关注频次信息,中间是一个线性进度条(用Progress组件的Linear样式实现),右侧是编辑和删除两个操作按钮。

Progress组件的type: ProgressType.Linear参数将进度条样式设置为线性的(默认是环形)。线性进度条非常适合展示频次、完成度等数据。这里进度条的总值是4(即每年最多关注4次),当前值是关注频次,这样用户可以直观地看到当前频次在全部可选范围中的位置。

ForEach的key生成函数中包含了p.freq.toString(),这是一个值得注意的细节。为什么key中需要包含freq呢?因为当随访计划的频次发生变化时,我们希望框架能够重新渲染这一项,从而更新进度条的显示。如果key只包含id,那么当freq变化但id不变时,框架可能认为这一项没有变化,导致进度条不更新。将影响UI显示的关键字段加入key中,可以确保数据变化时UI能够正确响应。

全部易感项目列表区则展示了所有的风险项目,以更简洁的列表形式呈现。每一行包含项目名称、风险倍数和风险等级标签,点击任意一行可以查看详细的风险解读。

5.5 营养页:代谢能力横条与评分体系

营养页展示了用户的各项营养代谢能力检测结果。每个营养项目都包含图标、名称、评分、结论和详细解读。

ForEach(NUTRIS8, (n: NutriT8) => {
  Column() {
    Row() {
      Text(n.icon).fontSize(20)
      Column() {
        Text(n.name).fontSize(12).fontWeight(700).fontColor('#E9D5FF')
        Text(n.verdict).fontSize(9).fontColor(n.score >= 60 ? '#34D399' : n.score >= 40 ? '#FBBF24' : '#F87171').margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start)
      .margin({ left: 10 })
      .layoutWeight(1)

      Text(n.score.toString()).fontSize(16).fontWeight(700).fontColor('#E9D5FF')
      Text('分').fontSize(9).fontColor('#7C6BA8').margin({ left: 2 })
    }
    .width('100%')
    .alignItems(VerticalAlign.Center)

    Row() {
      Column() {
        Column() {
        }
        .width(n.score + '%')
        .height('100%')
        .borderRadius(3)
        .backgroundColor(n.score >= 60 ? '#34D399' : n.score >= 40 ? '#FBBF24' : '#F87171')
      }
      .layoutWeight(1)
      .height(6)
      .borderRadius(3)
      .backgroundColor('#170B28')
    }
    .width('100%')
    .margin({ top: 10 })

    Text(n.detail).fontSize(9).fontColor('#7C6BA8').lineHeight(14).margin({ top: 8 })
  }
  // ... 卡片样式
}, (n: NutriT8) => 'nt' + n.id.toString())

营养卡片的设计层次分明:顶部是项目基本信息(图标+名称+结论+分数),中间是一个可视化的评分进度条,底部是详细的解读文字。三层信息由浅入深,用户可以根据自己的需求选择阅读深度。

评分的颜色是动态的,根据分数的不同显示不同的颜色:60分及以上显示绿色(良好),40到59分显示黄色(中等),40分以下显示红色(较差)。这个颜色规则同时应用于结论文字和进度条填充色,保持了视觉一致性。

动态颜色的实现使用了嵌套的三元表达式:n.score >= 60 ? '#34D399' : n.score >= 40 ? '#FBBF24' : '#F87171'。这种写法等价于if-else if-else的多分支判断,但更加简洁。当分支条件不多时(如2-3个),嵌套三元表达式是一种常用的内联条件写法。

评分进度条的实现采用了和祖源页迷你进度条相同的双层结构:外层Row(带背景色的槽)和内层Column(带前景色的填充)。不同之处在于,这里的外层Row使用了layoutWeight(1)来自适应宽度,而不是固定宽度。这种自适应的进度条在不同屏幕尺寸下都能保持良好的显示效果。

5.6 我的页:用户信息与订单列表

"我的"页面是用户的个人中心,包含用户基本信息、订单列表和数据安全说明。

Row() {
  Stack() {
    Circle().width(52).height(52).fill('#241440')
    Text('🧑‍🔬').fontSize(26)
  }
  Column() {
    Text('王先生 · 样本 GL-08829').fontSize(14).fontWeight(700).fontColor('#E9D5FF')
    Text('已解锁 3 份报告 · 基因档案完整度 92%').fontSize(9).fontColor('#7C6BA8').margin({ top: 5 })
  }
  .alignItems(HorizontalAlign.Start)
  .margin({ left: 12 })
  .layoutWeight(1)

  Text('收样地址').fontSize(10).fontColor('#A855F7').padding({ left: 10, right: 10, top: 6, bottom: 6 }).borderRadius(12).border({ width: 0.7, color: '#A855F7' }).onClick(() => {
    this.showAddr8 = true
  })
}

用户信息卡片的设计有几个亮点。首先是头像区域,使用了Circle绘制组件来绘制一个圆形背景,然后用Stack叠加上emoji头像。Circle是ArkUI中的绘制组件,它可以绘制圆形形状,通过fill()方法设置填充颜色。

Circle、Ellipse、Rect、Path等都属于ArkUI的绘制组件,它们提供了基础的2D图形绘制能力。与Image组件(显示图片)不同,绘制组件是通过代码实时渲染的矢量图形,可以任意缩放而不失真,且占用的存储空间极小。

用户信息的右侧有一个"收样地址"按钮,点击后会打开地址编辑弹框。按钮使用了描边样式(有边框无填充),这是次级操作按钮的典型样式——视觉上不如主按钮醒目,但仍然清晰可点击。

订单列表区域展示了用户的所有检测和服务订单。每个订单项包含订单名称、状态、日期和金额。订单状态使用不同的颜色标识,颜色由stateColor8()函数根据状态值返回。订单金额为0时显示"赠项"而不是"¥0",这种细节处理提升了用户体验。

页面底部有一段关于数据安全的说明文字,强调用户的基因数据由国密算法加密存储,并提供数据销毁渠道。这段文字虽然不涉及交互功能,但对于基因检测这类涉及敏感个人信息的应用来说,建立用户信任感至关重要。


六、弹框交互:六大弹框的实现模式

弹框(也叫模态框、对话框)是移动应用中非常重要的交互组件,用于承载临时性的、需要用户关注和处理的内容。这个基因检测应用总共实现了六个不同功能的弹框,涵盖了底部抽屉、居中卡片、危险确认等多种弹框类型。这些弹框的实现方式具有很高的参考价值。

6.1 弹框的通用实现模式

在深入分析每个弹框之前,我们先来了解一下弹框的通用实现模式。在鸿蒙声明式UI中,弹框通常采用以下模式实现:

  1. 定义一个@State布尔变量控制弹框的显示与隐藏
  2. 使用Stack作为根布局容器,主内容在下层,弹框在上层
  3. 弹框通过条件渲染(if语句)决定是否出现在UI树中
  4. 弹框容器使用全屏半透明遮罩 + 内容区域的双层结构
  5. 点击遮罩区域关闭弹框,点击内容区域不关闭(阻止冒泡)
// 主布局中的弹框层
Stack() {
  Column() {
    // 主内容 ...
  }
  
  if (this.showBook8) {
    this.bookOverlay8()
  }
  // ... 更多弹框
}

Stack层叠布局是实现弹框的基础。因为Stack的子组件是层叠排列的,后加入的子组件会覆盖在先加入的上面。将弹框放在主内容之后,弹框就会自然地浮在主内容之上。这种方式比使用专门的弹框组件更加灵活,可以完全自定义弹框的样式和动画。

弹框内部的结构通常是这样的:

@Builder bookOverlay8() {
  Column() {
    Column() {
      // 弹框内容 ...
    }
    .width('100%')
    // ... 内容区域样式
  }
  .width('100%')
  .height('100%')
  .justifyContent(FlexAlign.End)
  .backgroundColor('#9912081F')
  .zIndex(999)
  .onClick(() => {
    this.showBook8 = false
  })
}

外层Column是全屏的遮罩层,它的背景色是半透明的深色(#9912081F,前两位99表示约60%的不透明度)。遮罩层的.zIndex(999)设置了很高的层级,确保弹框始终在最上方。

.onClick事件绑定在外层Column上,点击遮罩区域(弹框内容之外的区域)会关闭弹框。而弹框内容区域(内层Column)虽然没有显式阻止事件冒泡,但它本身有自己的点击处理逻辑(或者空的onClick),可以防止点击内容区域时关闭弹框。

zIndex属性用于控制组件的层级顺序。在Stack容器中,默认后声明的组件在上面。但通过设置zIndex可以手动调整层级,数值越大的组件显示在越上层。设置zIndex: 999可以确保弹框始终在最顶层,不会被其他元素遮挡。

6.2 底部抽屉式弹框:预约解读

预约解读弹框是典型的底部抽屉(Bottom Sheet)样式。它从屏幕底部滑出,占据屏幕的一部分高度,顶部有圆角处理。

@Builder bookOverlay8() {
  Column() {
    Column() {
      Column() {
        Text('').fontSize(4).width(40).borderRadius(2).backgroundColor('#4D3B6B')
      }
      .width('100%')
      .alignItems(HorizontalAlign.Center)
      .padding({ top: 10, bottom: 4 })
      
      // 标题栏
      Row() {
        Text('预约报告解读').fontSize(15).fontWeight(700).fontColor('#E9D5FF')
        Text('✕').fontSize(15).fontColor('#7C6BA8').onClick(() => {
          this.showBook8 = false
        })
      }
      // ... 表单内容
    }
    .width('100%')
    .padding({ left: 16, right: 16 })
    .borderRadius({ topLeft: 22, topRight: 22 })
    .backgroundColor('#1D1030')
  }
  .width('100%')
  .height('100%')
  .justifyContent(FlexAlign.End)
  .backgroundColor('#9912081F')
  .zIndex(999)
  .onClick(() => {
    this.showBook8 = false
  })
}

底部抽屉的关键实现要点:

  • 外层Column设置justifyContent(FlexAlign.End),使内容区域靠底部排列
  • 内容区域只设置顶部圆角(borderRadius({ topLeft: 22, topRight: 22 })),底部是直角与屏幕底部相连
  • 顶部有一个小的拖拽指示器(灰色小横条),提示用户这是一个可拖拽的抽屉
  • 右上角有关闭按钮(✕),提供明确的关闭入口

弹框内的表单包含多个选择项和一个输入框。遗传顾问和时间段使用了Flex容器来实现流式布局。

Flex({ wrap: FlexWrap.Wrap }) {
  ForEach(CONSULT8, (c: ConsultT8, i: number) => {
    Text(c.name + '·' + c.field).fontSize(10).fontColor(this.bkCons8 === i ? '#170B28' : '#7C6BA8').padding({ left: 10, right: 10, top: 7, bottom: 7 }).borderRadius(12).backgroundColor(this.bkCons8 === i ? '#A855F7' : '#241440').margin({ right: 8, bottom: 8 }).onClick(() => {
      this.bkCons8 = i
    })
  }, (c: ConsultT8) => 'bc' + c.id.toString())
}

Flex弹性布局容器支持换行显示,通过Flex({ wrap: FlexWrap.Wrap })设置自动换行。当一行放不下所有选项时,选项会自动换到下一行。这种布局方式非常适合展示数量不定的标签、选项等内容。

Flex容器与Row容器的主要区别在于:Row是单行布局,子元素太多时会溢出;而Flex支持换行(FlexWrap.Wrap),子元素可以自动换行排列。在选项数量不确定的场景下,Flex是更好的选择。

TextInput输入框用于收集用户的备注信息:

TextInput({ placeholder: '例:BRCA2 位点对孩子有影响吗?', text: this.bkNote8 })
  .fontSize(11)
  .fontColor('#E9D5FF')
  .placeholderColor('#7C6BA8')
  .backgroundColor('#170B28')
  .borderRadius(10)
  .height(42)
  .padding({ left: 10, right: 10 })
  .margin({ top: 6 })
  .onChange((v: string) => {
    this.bkNote8 = v
  })

TextInput是鸿蒙的文本输入框组件。它的placeholder参数设置占位提示文字,text参数绑定输入框的当前值。.onChange事件在输入内容变化时触发,通过回调函数更新状态变量,实现双向绑定的效果。

在ArkTS中,TextInput的数据绑定是"单向"的——通过text参数设置初始值,通过onChange事件监听变化并更新状态变量。这与某些框架的v-model双向绑定语法不同,但本质是一样的,都是状态驱动视图、视图更新状态的闭环。

确认预约按钮的点击事件中包含了提交逻辑:

.onClick(() => {
  const c: ConsultT8 = CONSULT8[this.bkCons8]
  const no: OrdT8 = { id: Date.now(), name: '报告解读 · ' + c.name + ' ' + WAY8[this.bkWay8], state: '待服务', date: '明天 ' + SLOT8[this.bkSlot8], price: c.price }
  this.orders8 = [no].concat(this.orders8)
  this.showBook8 = false
})

这段代码做了三件事:1)根据选中的索引获取对应的顾问信息;2)创建一个新的订单对象,id使用Date.now()生成时间戳作为唯一标识;3)将新订单添加到订单列表的开头,并关闭弹框。

使用Date.now()作为临时id是前端开发中的常见做法。时间戳的毫秒级精度在大多数场景下足以保证唯一性,而且不需要引入额外的id生成逻辑。在实际生产环境中,通常会在提交到后端后由后端返回真正的id。

6.3 居中卡片式弹框:风险详情

风险详情弹框是居中显示的卡片式弹框,用于展示单个风险项目的详细解读信息。

@Builder riskOverlay8() {
  Column() {
    Column() {
      // 标题栏
      Row() {
        Text('⚠️ 风险解读').fontSize(15).fontWeight(700).fontColor('#E9D5FF')
        Text('✕').fontSize(15).fontColor('#7C6BA8').onClick(() => {
          this.showRisk8 = false
        })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      
      // 风险名称卡片
      Column() {
        Text(this.riskId8 > 0 && this.risks8.filter((r: RiskT8) => r.id === this.riskId8).length > 0 ? this.risks8.filter((r: RiskT8) => r.id === this.riskId8)[0].name : '').fontSize(17).fontWeight(700).fontColor('#F87171')
        // ... 更多信息
      }
      // ... 风险指标行、干预建议、操作按钮
    }
    .width('86%')
    .padding(18)
    .borderRadius(18)
    .backgroundColor('#1D1030')
    .border({ width: 0.8, color: '#66A855F7' })
    .constraintSize({ maxHeight: '78%' })
    .onClick(() => {
    })
  }
  .width('100%')
  .height('100%')
  .justifyContent(FlexAlign.Center)
  .backgroundColor('#9912081F')
  .zIndex(999)
  .onClick(() => {
    this.showRisk8 = false
  })
}

居中卡片弹框与底部抽屉的区别主要在于:

  • 外层Column设置justifyContent(FlexAlign.Center),内容在屏幕中央显示
  • 内容区域宽度设置为百分比(width('86%')),而不是全屏
  • 内容区域四个角都有圆角(borderRadius(18)
  • 内容区域有最大高度限制(constraintSize({ maxHeight: '78%' })),防止内容过多时超出屏幕

.constraintSize()属性用于设置组件的尺寸约束,可以限制最大宽度、最大高度、最小宽度、最小高度等。在这里,maxHeight: '78%'确保弹框内容最高不超过屏幕高度的78%,留出足够的遮罩区域让用户点击关闭。

.constraintSize()是一个非常实用但容易被忽略的属性。当内容高度不确定时(如弹框中包含动态列表),设置maxHeight可以防止内容过多导致弹框超出屏幕。配合Scroll组件使用,可以实现"内容少时自适应高度,内容多时滚动显示"的效果。

风险详情弹框中有一个值得注意的数据获取方式:通过this.risks8.filter((r: RiskT8) => r.id === this.riskId8)[0]来获取当前选中的风险项目数据。由于riskId8存储的是风险项目的id,需要通过filter方法从数组中筛选出对应id的元素。

这种方式虽然可以工作,但每次渲染都要遍历整个数组进行查找,在数据量大时有性能损耗。更好的做法是将选中的数据对象直接存储在状态变量中,或者使用Map数据结构来存储数据以便快速查找。

6.4 新增随访计划弹框:Flex布局与步进器

新增随访计划弹框允许用户选择需要重点随访的风险项目,并设置每年关注的频次。

Flex({ wrap: FlexWrap.Wrap }) {
  ForEach(RISKS8, (r: RiskT8, i: number) => {
    Text(r.name).fontSize(10).fontColor(this.planRisk8 === i ? '#170B28' : '#7C6BA8').padding({ left: 10, right: 10, top: 7, bottom: 7 }).borderRadius(12).backgroundColor(this.planRisk8 === i ? '#A855F7' : '#241440').margin({ right: 8, bottom: 8 }).onClick(() => {
      this.planRisk8 = i
    })
  }, (r: RiskT8) => 'pr' + r.id.toString())
}

风险项目选择区使用了Flex换行布局,所有风险项目以标签形式排列,用户点击选择。选中的标签使用紫色实色背景,未选中的使用深色背景。

每年关注次数的选择使用了一个自定义的步进器组件:

Row() {
  Text('−').fontSize(18).fontColor('#A855F7').width(36).height(36).borderRadius(18).backgroundColor('#241440').textAlign(TextAlign.Center).onClick(() => {
    if (this.planFreq8 > 1) {
      this.planFreq8 -= 1
    }
  })
  Text(this.planFreq8.toString() + ' 次/年').fontSize(13).fontWeight(700).fontColor('#E9D5FF').layoutWeight(1).textAlign(TextAlign.Center)
  Text('+').fontSize(18).fontColor('#A855F7').width(36).height(36).borderRadius(18).backgroundColor('#241440').textAlign(TextAlign.Center).onClick(() => {
    if (this.planFreq8 < 4) {
      this.planFreq8 += 1
    }
  })
}
.width('60%')
.alignItems(VerticalAlign.Center)
.margin({ top: 8 })

这个步进器由三个元素组成:减号按钮、当前值显示、加号按钮。减号和加号都是圆形按钮,点击时分别对数值进行减一和加一操作。数值有上下限限制(最小1,最大4),达到边界时按钮不会再改变数值。

自定义步进器的设计体现了声明式UI的灵活性。虽然鸿蒙没有提供专门的步进器组件,但通过简单的Row+Text组合,加上点击事件和状态管理,就能轻松实现功能完整且样式美观的步进器。这正是声明式UI的魅力所在——基础组件虽然简单,但通过组合可以创造出无限可能。

保存按钮的点击逻辑也很有代表性:

.onClick(() => {
  const r: RiskT8 = RISKS8[this.planRisk8]
  const exist: Array<PlanT8> = this.plans8.filter((p: PlanT8) => p.risk === r.name)
  if (exist.length > 0) {
    this.plans8 = this.plans8.map((p: PlanT8) => {
      if (p.risk === r.name) {
        return { id: p.id, risk: p.risk, freq: this.planFreq8, on: p.on }
      }
      return p
    })
  } else {
    const np: PlanT8 = { id: Date.now(), risk: r.name, freq: this.planFreq8, on: true }
    this.plans8 = [np].concat(this.plans8)
  }
  this.showPlan8 = false
})

这段逻辑处理了"新增"和"编辑"两种情况。首先检查当前选择的风险项目是否已经存在于随访计划中:如果存在,则更新该计划的频次(使用map方法生成新数组);如果不存在,则创建一个新的随访计划并添加到列表开头。

在ArkTS中更新数组类型的状态变量时,需要特别注意:必须创建一个新的数组并赋值给状态变量,而不是直接修改原数组的内容。因为框架通过检测变量的引用变化来判断是否需要重新渲染,直接修改原数组的内容不会触发重新渲染。这就是为什么代码中使用.map().concat()等方法——它们都会返回新数组,从而触发状态更新。

6.5 收样地址弹框:表单与Toggle开关

收样地址弹框是一个底部抽屉式的表单弹框,包含收件人、手机号、详细地址三个输入框,以及一个"夏季加冰袋运输"的开关选项。

Row() {
  Text('🧊 夏季加冰袋运输(样本活性更高)').fontSize(11).fontColor('#E9D5FF').layoutWeight(1)
  Toggle({ type: ToggleType.Switch, isOn: this.addrCold8 })
    .selectedColor('#A855F7')
    .onChange((on: boolean) => {
      this.addrCold8 = on
    })
}

Toggle组件是鸿蒙提供的开关组件,支持三种类型:Switch(开关)、Checkbox(复选框)和Button(按钮式切换)。这里使用ToggleType.Switch类型,即常见的滑动开关样式。

isOn参数设置开关的初始状态,绑定到addrCold8状态变量。.selectedColor()设置开关打开时的颜色。.onChange事件在开关状态变化时触发,回调函数接收新的布尔值。

Toggle组件的Switch类型是移动端非常常用的交互控件,用于表示二元状态的切换(开/关、启用/禁用、是/否等)。它的交互方式直观,视觉反馈明确,是表单中处理布尔值字段的首选组件。

6.6 删除确认弹框:危险操作的二次确认

删除随访计划弹框是一个窄款的危险确认弹框,用于在用户执行删除操作前进行二次确认,防止误删。

@Builder delOverlay8() {
  Column() {
    Column() {
      Text('⚠️').fontSize(30)
      Text('删除「' + this.delName8 + '」随访?').fontSize(15).fontWeight(700).fontColor('#E9D5FF').margin({ top: 10 })
      Text('删除后相关筛查提醒将一并取消,风险项仍可在易感列表中重新加入。').fontSize(11).fontColor('#7C6BA8').margin({ top: 10 }).textAlign(TextAlign.Center).lineHeight(17)

      Row() {
        Text('我已知晓').fontSize(11).fontColor('#F87171').layoutWeight(1)
        Toggle({ type: ToggleType.Switch, isOn: this.armed8 })
          .selectedColor('#F87171')
          .onChange((on: boolean) => {
            this.armed8 = on
          })
      }
      // ... 取消和删除按钮
    }
    .width('74%')
    .padding(18)
    .borderRadius(16)
    .backgroundColor('#231714')
    .border({ width: 1, color: '#4DF87171' })
    // ...
  }
  // ...
}

这个删除确认弹框有几个值得学习的设计点:

首先是视觉风格。整个弹框使用了红棕色调的背景(#231714)和红色边框(#F87171带透明),与应用主色调紫色形成明显区分。红色通常与危险、警告相关联,这种色彩心理学的运用能够在视觉上提示用户这是一个危险操作。

其次是"我已知晓"的二次确认机制。删除按钮默认是禁用状态(灰色、不可点击),只有当用户打开"我已知晓"的开关后,删除按钮才变为可用状态。这种设计增加了删除操作的门槛,有效防止了误触导致的数据丢失。

危险操作的二次确认是一种重要的交互设计模式。对于删除、撤销、重置等不可逆的操作,应当通过额外的确认步骤来降低误操作的风险。除了开关确认,常见的方式还有:输入确认文字、长按确认、验证码确认等,可根据操作的危险等级选择合适的方式。

删除按钮的文案也会根据确认状态动态变化:未确认时显示"请先打开确认",确认后显示"确认删除"。按钮的样式(背景色、文字色)也会相应变化,给用户明确的状态反馈。

6.7 升级加购弹框:Scroll滚动与价格计算

升级加购弹框是内容最多的一个弹框,它包含可多选的加购模块列表、报告份数步进器和价格明细。

Scroll() {
  Column() {
    // 加购模块选项
    // 报告份数步进器
    // 价格明细
    // ...
  }
  .width('100%')
  .padding({ left: 16, right: 16, bottom: 12 })
}
.layoutWeight(1)
.align(Alignment.Top)
.edgeEffect(EdgeEffect.Spring)

由于弹框内容较多,可能超出屏幕高度,因此使用了Scroll滚动容器。Scroll是鸿蒙提供的滚动容器组件,当子内容超出容器高度时,用户可以通过滑动来查看更多内容。

Scroll组件的使用非常简单,只需要将需要滚动的内容包裹在Scroll组件内即可。Scroll默认支持垂直滚动,通过.scrollable()属性可以设置滚动方向。.scrollBar(BarState.Off)可以隐藏滚动条,让界面更加简洁。

.edgeEffect(EdgeEffect.Spring)设置了滚动到边缘时的弹性效果。EdgeEffect.Spring是弹性效果,滚动到底部或顶部时继续拖拽会有回弹效果,类似于iOS的原生滚动体验。另一个可选值是EdgeEffect.None,即没有边缘效果,滚动到边界后就无法继续拖动。

多选加购模块的实现方式也很巧妙:

ForEach(UPG8, (x: string) => {
  Text(x).fontSize(10).fontColor(this.upgSel8.indexOf(x) >= 0 ? '#170B28' : '#7C6BA8').padding({ left: 10, right: 10, top: 7, bottom: 7 }).borderRadius(12).backgroundColor(this.upgSel8.indexOf(x) >= 0 ? '#22D3EE' : '#241440').margin({ right: 8, bottom: 8 }).onClick(() => {
    if (this.upgSel8.indexOf(x) >= 0) {
      this.upgSel8 = this.upgSel8.filter((y: string) => y !== x)
    } else {
      this.upgSel8 = this.upgSel8.concat([x])
    }
  })
}, (x: string) => 'ug' + x)

upgSel8是一个字符串数组,存储了所有已选中的加购模块名称。判断某个模块是否被选中,使用this.upgSel8.indexOf(x) >= 0——如果indexOf返回的索引大于等于0,说明该模块在已选列表中。

点击时的切换逻辑也很清晰:如果已经选中,则用filter方法将其从数组中移除;如果未选中,则用concat方法将其添加到数组中。两种操作都返回新数组,确保状态更新能够触发重新渲染。

多选状态的管理通常有两种方式:一种是使用布尔值数组(每个选项对应一个布尔值),另一种是使用选中项的数组(如这里的字符串数组)。当选项数量较多且可能动态变化时,使用选中项数组的方式更加简洁高效,数组的长度就是选中的数量。

价格明细区域展示了价格的计算过程,包括加购模块总价、老客户立减金额和最终合计。价格都是实时计算的,根据选中的模块数量和报告份数动态更新。

6.8 弹框交互流程图

为了更清晰地理解六个弹框之间的关系和触发路径,我们用第二张流程图来展示。

弹框间跳转

主页面操作

报告页
预约解读按钮

showBook8 = true

报告页
升级检测按钮

showUpg8 = true

风险榜
点击风险项

showRisk8 = true

易感页
新增/编辑按钮

showPlan8 = true

我的页
收样地址按钮

showAddr8 = true

易感页
删除按钮

showDel8 = true

风险详情弹框
加入随访

风险详情弹框
预约解读

bookOverlay8
底部抽屉

upgOverlay8
底部抽屉

riskOverlay8
居中卡片

planOverlay8
居中卡片

addrOverlay8
底部抽屉

delOverlay8
居中危险卡

这张流程图展示了六个弹框的触发来源和相互关系。可以看到,弹框并非都是从主页面直接触发的,有些弹框之间存在跳转关系——例如从风险详情弹框可以跳转到随访计划弹框或预约解读弹框。这种弹框间的跳转需要特别注意状态管理,确保关闭前一个弹框和打开后一个弹框的时机正确。


七、主布局架构:Stack+Column+Scroll的经典组合

7.1 build方法与整体布局结构

build()方法是每个@Component组件必须实现的方法,它返回组件的UI描述。Index组件的build()方法定义了整个页面的布局结构。

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

      Scroll() {
        Column() {
          if (this.curTab8 === 0) {
            this.tabReport8()
          }
          if (this.curTab8 === 1) {
            this.tabOrigin8()
          }
          if (this.curTab8 === 2) {
            this.tabRisk8()
          }
          if (this.curTab8 === 3) {
            this.tabNutri8()
          }
          if (this.curTab8 === 4) {
            this.tabMine8()
          }
        }
        .width('100%')
      }
      .layoutWeight(1)
      .scrollBar(BarState.Off)
      .edgeEffect(EdgeEffect.Spring)
      .width('100%')
      .backgroundColor('#12081F')

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

    // 弹框层
    if (this.showBook8) {
      this.bookOverlay8()
    }
    // ... 更多弹框
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#12081F')
}

整个页面的根容器是一个Stack,它包含两层内容:下层是主内容区(Column),上层是弹框层(多个条件渲染的弹框)。

主内容区使用Column垂直布局,从上到下依次是:头部导航栏(headerBar8)、滚动内容区(Scroll)、底部Tab栏(tabBar8)。

滚动内容区是页面的主体,它占据了头部和底部之间的所有剩余空间。这是通过Scroll的.layoutWeight(1)实现的——设置了layoutWeight(1)的组件会在Column中占据剩余空间,而其他组件则根据内容自适应高度。

layoutWeight在Column和Row中的作用是相同的,都是分配多余空间。在Column中,layoutWeight分配的是垂直方向的多余空间;在Row中,分配的是水平方向的多余空间。灵活运用layoutWeight是实现自适应布局的关键。

Scroll内部嵌套了一个Column,Column中通过五个if语句来条件渲染当前选中的Tab页内容。当curTab8的值变化时,对应的if条件成立,对应的Tab页面组件被渲染到UI树中,其他Tab页面则不渲染。

这种通过if条件渲染实现Tab切换的方式是鸿蒙开发中非常常见的模式。它的优点是简单直接,每个Tab页独立渲染,互不干扰。缺点是每次切换Tab时都会重新创建和销毁组件,可能会丢失Tab页内的滚动位置和状态。如果需要保持Tab页的状态,可以考虑使用更高级的组件如Tabs。

7.2 position与zIndex的层级控制

虽然这个应用中没有直接使用position属性进行绝对定位,但层级控制的思想在弹框实现中得到了充分体现。让我们来系统了解一下鸿蒙中的层级控制机制。

在Stack容器中,组件的默认层级顺序是按照声明顺序排列的——后声明的组件在上面。但有时候我们需要手动调整层级,这时候就需要用到zIndex属性。

zIndex属性接受一个数值,数值越大的组件显示在越上层。如果两个组件的zIndex值相同,则按照声明顺序排列,后声明的在上面。zIndex的默认值是0。

在弹框的实现中,我们设置了.zIndex(999),确保弹框始终在最顶层。这是一个很好的实践——给弹框设置一个足够大的zIndex值,避免被页面中的其他元素意外遮挡。

position属性则用于绝对定位,它可以将组件放置在父容器的任意位置。配合position使用的还有offset属性,用于设置定位后的偏移量。绝对定位在实现悬浮按钮、角标、提示气泡等效果时非常有用。

7.3 深色主题的色彩体系

整个应用采用了深色主题设计,主色调为霓虹紫色(#A855F7)。深色主题在当前的移动应用设计中越来越流行,它不仅具有视觉上的科技感和高级感,还有助于减少OLED屏幕的功耗。

我们来梳理一下这个应用的色彩体系:

  • 主色调:#A855F7(霓虹紫)——用于主要按钮、选中状态、强调文字
  • 背景色:#12081F(深紫黑)——页面最底层的背景
  • 卡片背景:#1D1030(深紫)——内容卡片的背景
  • 次级背景:#170B28(暗紫)——输入框、列表项的背景
  • 高亮背景:#241440(亮紫)——标签、选中项的背景
  • 主文字色:#E9D5FF(淡紫白)——主要文字内容
  • 次级文字色:#7C6BA8(灰紫)——辅助文字、提示文字
  • 强调色1:#22D3EE(青色)——祖源页主题、升级按钮
  • 强调色2:#F472B6(粉色)——父系染色体主题
  • 警示色:#F87171(红色)——高风险、删除操作
  • 注意色:#FBBF24(黄色)——中风险、待处理状态
  • 成功色:#34D399(绿色)——低风险、已完成状态

一个设计良好的色彩体系应该具有一致性和层次性。一致性体现在相同功能的元素使用相同的颜色(如所有的主按钮都是紫色、所有的成功状态都是绿色),层次性体现在文字颜色有深浅之分(重要内容用亮色、次要内容用灰色),从而形成清晰的视觉层级。


八、核心技术点汇总对比

为了帮助读者更好地梳理和记忆本文涉及的鸿蒙技术点,我们整理了以下对比表格。

技术点 类型 作用 使用场景 关键属性/参数 注意事项
@Entry 装饰器 标记页面入口组件 页面根组件 一个页面只能有一个@Entry
@Component 装饰器 标记自定义组件 可复用UI单元 必须实现build()方法
@State 装饰器 声明组件内部响应式状态 组件内部状态管理 状态变化触发重新渲染
@Builder 装饰器 封装可复用UI片段 减少重复UI代码 可传参 方法内只能有UI描述
Column 容器组件 垂直方向排列子组件 垂直布局 alignItems, justifyContent 主轴为垂直方向
Row 容器组件 水平方向排列子组件 水平布局 alignItems, justifyContent 主轴为水平方向
Stack 容器组件 层叠排列子组件 弹框、叠加效果 align, zIndex 后声明的组件在上层
Flex 容器组件 弹性布局,支持换行 标签流、选项流 wrap, direction 比Row多了换行能力
Scroll 滚动容器 提供滚动能力 长内容页面 scrollBar, edgeEffect 只能有一个根子组件
Text 基础组件 显示文本内容 文字展示 fontSize, fontColor, fontWeight 支持多种文本效果
TextInput 输入组件 单行文本输入 表单输入 placeholder, text 通过onChange实现双向绑定
Progress 进度组件 展示进度/占比数据 完成度、评分展示 value, total, type 支持环形和线性两种样式
Toggle 交互组件 开关/复选/按钮切换 二元状态切换 type, isOn Switch/Checkbox/Button三种类型
ForEach 列表渲染 根据数组批量生成组件 列表、网格 数组、生成函数、key函数 key必须唯一且稳定
animateTo 动画API 显式动画 需要精确控制的动画 duration, curve, iterations 通过getUIContext()调用
layoutWeight 布局属性 弹性权重分配 自适应布局 数值类型 父容器必须有确定尺寸
zIndex 层级属性 控制组件叠放顺序 弹框、悬浮元素 数值类型 数值越大越靠上层
borderRadius 外观属性 设置圆角 卡片、按钮 数值或方向对象 配合clip(true)裁剪子内容
opacity 外观属性 设置不透明度 透明效果、淡入淡出 0~1之间的数值 影响整个组件及其子组件
aboutToAppear 生命周期 组件即将出现时回调 初始化、启动动画 只在首次出现时调用
interface 类型系统 定义对象结构契约 数据模型定义 属性名+类型 编译时检查,不生成运行时代码
Circle 绘制组件 绘制圆形 头像背景、装饰元素 width, height, fill 矢量绘制,可任意缩放
position 定位属性 绝对定位 悬浮元素、角标 Position类型 相对于父容器定位

安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// ============================================================
// 霓虹紫深色 · 5底部Tab · 6弹框
// 布局亮点:报告Tab风险榜单+环形完成度;祖源Tab成分横条+血统卡;
//           易感Tab风险分级列表;营养Tab代谢能力横条;我的Tab订单+随访
// ============================================================

// ---------------- 数据结构 ----------------
interface RiskT8 {
  id: number
  name: string
  cate: string
  mult: number
  level: string
  gene: string
  advice: string
}

interface OriginT8 {
  id: number
  name: string
  pct: number
  color: string
  region: string
}

interface NutriT8 {
  id: number
  name: string
  icon: string
  score: number
  verdict: string
  detail: string
}

interface ConsultT8 {
  id: number
  name: string
  title: string
  field: string
  price: number
  score: number
}

interface OrdT8 {
  id: number
  name: string
  state: string
  date: string
  price: number
}

interface PlanT8 {
  id: number
  risk: string
  freq: number
  on: boolean
}

// ---------------- 写死数据 ----------------
const RISKS8: Array<RiskT8> = [
  { id: 1, name: '2型糖尿病', cate: '内分泌', mult: 2.4, level: '较高', gene: 'TCF7L2 rs7903146 CT', advice: '每年检测糖化血红蛋白+空腹胰岛素,控制精制碳水摄入,每周抗阻训练2次。' },
  { id: 2, name: '阿尔茨海默病', cate: '神经', mult: 2.1, level: '较高', gene: 'APOE ε3/ε4', advice: '40岁起每年认知量表筛查,保证深睡眠,控制同型半胱氨酸水平。' },
  { id: 3, name: '乳腺癌', cate: '肿瘤', mult: 1.8, level: '中高', gene: 'BRCA2 携带1个风险位点', advice: '30岁起每年乳腺超声+钼靶交替筛查,必要时乳腺MRI。' },
  { id: 4, name: '高血压', cate: '心血管', mult: 1.7, level: '中高', gene: 'ADDUCN G460W', advice: '限盐每日5g以下,家庭血压计每周记录,每年动态血压监测。' },
  { id: 5, name: '痛风', cate: '代谢', mult: 2.6, level: '较高', gene: 'SLC22A12 rs505802', advice: '限制高嘌呤饮食,每日饮水2000ml以上,每年血尿酸检测。' },
  { id: 6, name: '冠心病', cate: '心血管', mult: 1.4, level: '中等', gene: '9p21.2 风险区域', advice: '每年血脂全套+冠脉钙化积分(45岁后),保持中等强度有氧运动。' },
  { id: 7, name: '骨质疏松', cate: '骨骼', mult: 1.9, level: '中高', gene: 'VDR ApaI CC', advice: '绝经后每年骨密度检测,补充维生素D3+钙,增加负重运动。' },
  { id: 8, name: '重度近视', cate: '眼科', mult: 3.2, level: '高', gene: 'PAX6 rs5247 AG', advice: '每半年眼轴+眼底照相,每日户外2小时,控制连续用眼时长。' },
  { id: 9, name: '哮喘', cate: '呼吸', mult: 1.3, level: '中等', gene: 'ORMDL3 rs7216389', advice: '过敏季前肺功能检测,远离已知过敏原,家中备峰流速仪。' },
  { id: 10, name: '胃溃疡', cate: '消化', mult: 1.5, level: '中等', gene: 'IL1B-511 TT', advice: '幽门螺旋杆菌根除后每年呼气复查,避免长期服用NSAIDs。' },
  { id: 11, name: '帕金森病', cate: '神经', mult: 1.2, level: '一般', gene: 'LRRK2 G2019S 阴性', advice: '保持规律运动与咖啡摄入,关注嗅觉减退等早期信号。' },
  { id: 12, name: '结直肠癌', cate: '肿瘤', mult: 1.6, level: '中高', gene: 'APC I1307K 杂合', advice: '40岁起每3年肠镜,每年便潜血检测,增加膳食纤维摄入。' }
]

const ORIGIN8: Array<OriginT8> = [
  { id: 1, name: '北方汉族', pct: 46, color: '#A855F7', region: '黄河流域' },
  { id: 2, name: '南方汉族', pct: 27, color: '#22D3EE', region: '长江流域' },
  { id: 3, name: '蒙古语族', pct: 11, color: '#F472B6', region: '蒙古高原' },
  { id: 4, name: '日本', pct: 6, color: '#FBBF24', region: '列岛' },
  { id: 5, name: '通古斯', pct: 5, color: '#34D399', region: '东北森林' },
  { id: 6, name: '东南亚', pct: 3, color: '#F87171', region: '中南半岛' },
  { id: 7, name: '西欧亚', pct: 2, color: '#818CF8', region: '草原丝路' }
]

const NUTRIS8: Array<NutriT8> = [
  { id: 1, name: '咖啡因代谢', icon: '☕', score: 25, verdict: '慢代谢型', detail: 'CYP1A2 慢型,午后摄入咖啡因易影响深睡眠,建议 14 点后不再饮用。' },
  { id: 2, name: '酒精代谢', icon: '🍺', score: 18, verdict: '差', detail: 'ALDH2 突变携带,乙醛蓄积风险高,建议滴酒不沾。' },
  { id: 3, name: '乳糖耐受', icon: '🥛', score: 72, verdict: '中度耐受', detail: 'LCT 位点部分表达,单次 200ml 以内奶制品通常无不适。' },
  { id: 4, name: '叶酸利用', icon: '🥬', score: 34, verdict: '偏低', detail: 'MTHFR CT 型,活性叶酸利用率下降,孕期需补充甲基叶酸。' },
  { id: 5, name: '维生素D合成', icon: '☀️', score: 41, verdict: '偏低', detail: 'VDR 变异,晒太阳合成效率低,建议每日补充 800IU。' },
  { id: 6, name: '碳水敏感', icon: '🍚', score: 58, verdict: '中等敏感', detail: 'TCF7L2 携带,精制碳水后血糖波动大,优选低GI主食。' },
  { id: 7, name: '脂肪吸收', icon: '🥑', score: 81, verdict: '良好', detail: 'APOA5 正常型,膳食脂肪代谢能力较好,可适量坚果深海鱼。' },
  { id: 8, name: '铁吸收', icon: '🥩', score: 64, verdict: '中等', detail: 'TMPRSS6 变异,植物性铁吸收一般,红肉与维C同食更佳。' },
  { id: 9, name: '钠敏感性', icon: '🧂', score: 30, verdict: '高敏感', detail: '血压对钠摄入敏感,每日食盐建议控制在 5g 以内。' },
  { id: 10, name: '褪黑素分泌', icon: '🌙', score: 47, verdict: '中等', detail: 'CLOCK 基因变异,入睡时间偏晚倾向,固定作息+睡前远离屏幕。' }
]

const CONSULT8: Array<ConsultT8> = [
  { id: 1, name: '程雪梨', title: '遗传咨询顾问', field: '肿瘤易感', price: 380, score: 4.9 },
  { id: 2, name: '顾明川', title: '基因组学研究员', field: '心脑血管', price: 460, score: 4.8 },
  { id: 3, name: '苏婉', title: '遗传咨询顾问', field: '营养代谢', price: 320, score: 4.9 },
  { id: 4, name: '邓一鸣', title: '分子病理医师', field: '罕见病携带', price: 520, score: 4.7 },
  { id: 5, name: '冯若琳', title: '遗传咨询顾问', field: '祖源追溯', price: 260, score: 4.8 },
  { id: 6, name: '何山', title: '基因组学研究员', field: '药物基因组', price: 440, score: 4.9 }
]

const ORDERS8: Array<OrdT8> = [
  { id: 1, name: '全基因组豪华版 WGS 30×', state: '报告已出', date: '2026-07-02', price: 9980 },
  { id: 2, name: '肿瘤易感基因 128 项', state: '报告已出', date: '2026-05-18', price: 2980 },
  { id: 3, name: '遗传咨询 · 程雪梨 60分钟', state: '已完成', date: '2026-05-20 10:00', price: 380 },
  { id: 4, name: '营养代谢基因 45 项', state: '报告已出', date: '2026-03-09', price: 880 },
  { id: 5, name: '药物基因组 · 用药安全', state: '报告已出', date: '2025-12-21', price: 1280 },
  { id: 6, name: '儿童天赋成长基因(赠)', state: '待采样', date: '待寄出', price: 0 },
  { id: 7, name: '深度报告解读 · 顾明川', state: '已完成', date: '2025-11-30 15:00', price: 460 },
  { id: 8, name: '样本加急处理', state: '已完成', date: '2025-11-02', price: 200 }
]

const PLANS8: Array<PlanT8> = [
  { id: 1, risk: '2型糖尿病', freq: 4, on: true },
  { id: 2, risk: '乳腺癌', freq: 1, on: true },
  { id: 3, risk: '重度近视', freq: 2, on: true },
  { id: 4, risk: '骨质疏松', freq: 1, on: false },
  { id: 5, risk: '结直肠癌', freq: 1, on: true }
]

const SLOT8: Array<string> = ['09:00', '10:30', '13:30', '15:00', '16:30', '19:00']
const WAY8: Array<string> = ['视频解读', '电话解读', '到店面询']
const UPG8: Array<string> = ['全外显子组', '药物基因组', '罕见病携带筛查', '微量元素代谢', '运动基因', '皮肤基因', '睡眠基因', '肠道菌群']

// ---------------- 工具函数 ----------------
function riskColor8(level: string): string {
  if (level === '高' || level === '较高') {
    return '#F87171'
  }
  if (level === '中高' || level === '中等') {
    return '#FBBF24'
  }
  return '#34D399'
}

function stateColor8(s: string): string {
  if (s === '待采样') {
    return '#FBBF24'
  }
  if (s === '已完成') {
    return '#34D399'
  }
  return '#22D3EE'
}

// ================= 页面 =================
@Entry
@Component
struct Index {
  @State curTab8: number = 0
  @State risks8: Array<RiskT8> = RISKS8.slice()
  @State plans8: Array<PlanT8> = PLANS8.slice()
  @State orders8: Array<OrdT8> = ORDERS8.slice()

  // 弹框开关
  @State showBook8: boolean = false
  @State showRisk8: boolean = false
  @State showPlan8: boolean = false
  @State showAddr8: boolean = false
  @State showDel8: boolean = false
  @State showUpg8: boolean = false

  // 解读预约表单
  @State bkCons8: number = 0
  @State bkWay8: number = 0
  @State bkSlot8: number = 2
  @State bkNote8: string = ''

  // 风险详情
  @State riskId8: number = 0

  // 随访计划
  @State planRisk8: number = 0
  @State planFreq8: number = 2

  // 地址
  @State addrName8: string = '王先生'
  @State addrPhone8: string = '138****6688'
  @State addrText8: string = '北京市朝阳区望京 SOHO T3 2808'
  @State addrCold8: boolean = true

  // 删除
  @State delId8: number = 0
  @State delName8: string = ''
  @State armed8: boolean = false

  // 升级加购
  @State upgSel8: Array<string> = []
  @State upgQty8: number = 1

  // 特效
  @State dnaY8: number = -6
  @State glowOp8: number = 0.3

  aboutToAppear(): void {
    this.getUIContext().animateTo({ duration: 2200, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
      this.dnaY8 = 6
    })
    this.getUIContext().animateTo({ duration: 1400, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
      this.glowOp8 = 0.9
    })
  }

  // ---------------- 头部(无动画 · 深色电商风) ----------------
  @Builder headerBar8() {
    Column() {
      Row() {
        Column() {
          Text('🧬 GENE LAB').fontSize(17).fontWeight(700).fontColor('#E9D5FF').letterSpacing(1)
          Text('样本编号 GL-2026-08829 · 报告已解锁').fontSize(10).fontColor('#7C6BA8').margin({ top: 3 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)

        Text('🧬').fontSize(22).translate({ y: this.dnaY8 })
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 14, bottom: 10 })

      Row() {
        Text('🔍').fontSize(13)
        Text('搜索基因位点 / 疾病名 / 代谢项').fontSize(11).fontColor('#7C6BA8').margin({ left: 6 }).layoutWeight(1).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        Text('🧪').fontSize(16).onClick(() => {
          this.showUpg8 = true
        })
      }
      .width('100%')
      .padding({ left: 12, right: 12, top: 9, bottom: 9 })
      .borderRadius(20)
      .backgroundColor('#1D1030')
      .margin({ left: 16, right: 16, bottom: 12 })
    }
    .width('100%')
    .backgroundColor('#170B28')
    .borderRadius({ bottomLeft: 20, bottomRight: 20 })
  }

  // ---------------- 底部Tab(一排5个) ----------------
  @Builder tabItem8(icon: string, label: string, idx: number) {
    Column() {
      Text(icon).fontSize(20)
      Text(label).fontSize(10).fontColor(this.curTab8 === idx ? '#A855F7' : '#6B5B8E').margin({ top: 3 })
    }
    .alignItems(HorizontalAlign.Center)
    .padding({ top: 8, bottom: 6 })
    .layoutWeight(1)
    .onClick(() => {
      this.curTab8 = idx
    })
  }

  @Builder tabBar8() {
    Row() {
      this.tabItem8('📋', '报告', 0)
      this.tabItem8('🌏', '祖源', 1)
      this.tabItem8('⚠️', '易感', 2)
      this.tabItem8('🥗', '营养', 3)
      this.tabItem8('👤', '我的', 4)
    }
    .width('100%')
    .backgroundColor('#160B26')
    .border({ width: 0.5, color: '#33A855F7' })
  }

  // ============ Tab0 报告 ============
  @Builder tabReport8() {
    Column() {
      // 总览卡
      Row() {
        Stack() {
          Progress({ value: 92, total: 100 })
            .width(70)
            .height(70)
            .style({ strokeWidth: 8 })
            .color('#A855F7')
          Column() {
            Text('92%').fontSize(15).fontWeight(700).fontColor('#E9D5FF')
            Text('已解读').fontSize(8).fontColor('#7C6BA8')
          }
          .alignItems(HorizontalAlign.Center)
        }

        Column() {
          Text('全基因组豪华版 WGS 30×').fontSize(13).fontWeight(700).fontColor('#E9D5FF')
          Text('覆盖 312 项疾病风险 · 45 项营养代谢 · 128 项肿瘤易感').fontSize(10).fontColor('#7C6BA8').margin({ top: 5 }).lineHeight(14)
          Row() {
            Text('预约解读').fontSize(10).fontColor('#170B28').fontWeight(700).padding({ left: 12, right: 12, top: 5, bottom: 5 }).borderRadius(14).backgroundColor('#A855F7').onClick(() => {
              this.showBook8 = true
            })
            Text('升级检测').fontSize(10).fontColor('#22D3EE').padding({ left: 12, right: 12, top: 5, bottom: 5 }).borderRadius(14).border({ width: 0.8, color: '#22D3EE' }).margin({ left: 8 }).onClick(() => {
              this.showUpg8 = true
            })
          }
          .margin({ top: 9 })
        }
        .alignItems(HorizontalAlign.Start)
        .margin({ left: 14 })
        .layoutWeight(1)
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)
      .padding(14)
      .borderRadius(16)
      .backgroundColor('#1D1030')
      .margin({ left: 14, right: 14, top: 12 })

      // 发光横幅(特效)
      Stack() {
        Column() {
          Text('✦ 基因不是判决书,而是健康路标 — 已为你生成 12 条随访建议').fontSize(10).fontColor('#C4B5FD')
        }
        .width('100%')
        .alignItems(HorizontalAlign.Center)
        .padding({ top: 10, bottom: 10 })
        .borderRadius(12)
        .backgroundColor('#241440')
        .border({ width: 0.8, color: '#66A855F7' })
        .opacity(this.glowOp8)
      }
      .width('100%')
      .margin({ left: 14, right: 14, top: 10 })

      // 风险等级分布
      Column() {
        Text('📊 风险等级分布').fontSize(13).fontWeight(700).fontColor('#E9D5FF')
        Row() {
          Column() {
            Text('4').fontSize(18).fontWeight(700).fontColor('#F87171')
            Text('较高风险').fontSize(9).fontColor('#7C6BA8').margin({ top: 3 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)

          Column() {
            Text('5').fontSize(18).fontWeight(700).fontColor('#FBBF24')
            Text('中高风险').fontSize(9).fontColor('#7C6BA8').margin({ top: 3 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)

          Column() {
            Text('2').fontSize(18).fontWeight(700).fontColor('#34D399')
            Text('一般风险').fontSize(9).fontColor('#7C6BA8').margin({ top: 3 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)

          Column() {
            Text('301').fontSize(18).fontWeight(700).fontColor('#22D3EE')
            Text('低风险').fontSize(9).fontColor('#7C6BA8').margin({ top: 3 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
        }
        .width('100%')
        .margin({ top: 12 })
      }
      .width('100%')
      .alignItems(HorizontalAlign.Start)
      .padding(14)
      .borderRadius(16)
      .backgroundColor('#1D1030')
      .margin({ left: 14, right: 14, top: 10 })

      // 重点风险榜
      Row() {
        Text('🔴 重点风险榜(点击查看)').fontSize(13).fontWeight(700).fontColor('#E9D5FF')
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 16, bottom: 4 })

      ForEach(this.risks8, (r: RiskT8) => {
        Row() {
          Column() {
            Text(r.name).fontSize(12).fontWeight(700).fontColor('#E9D5FF')
            Text(r.cate + ' · ' + r.gene).fontSize(9).fontColor('#7C6BA8').margin({ top: 4 }).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)

          Column() {
            Text(r.mult.toFixed(1) + '×').fontSize(14).fontWeight(700).fontColor(riskColor8(r.level))
            Text('风险倍数').fontSize(8).fontColor('#7C6BA8').margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.End)

          Text(r.level).fontSize(9).fontColor(riskColor8(r.level)).padding({ left: 7, right: 7, top: 4, bottom: 4 }).borderRadius(8).backgroundColor('#241440').margin({ left: 10 })
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        .padding(13)
        .borderRadius(14)
        .backgroundColor('#1D1030')
        .border({ width: 0.6, color: '#33A855F7' })
        .margin({ left: 14, right: 14, top: 8 })
        .onClick(() => {
          this.riskId8 = r.id
          this.showRisk8 = true
        })
      }, (r: RiskT8) => 'rk' + r.id.toString())
    }
    .width('100%')
    .padding({ bottom: 12 })
  }

  // ============ Tab1 祖源 ============
  @Builder tabOrigin8() {
    Column() {
      // 血统卡
      Column() {
        Row() {
          Text('🌏 我的祖源构成').fontSize(14).fontWeight(700).fontColor('#E9D5FF')
          Text('参考样本 12 万人').fontSize(9).fontColor('#7C6BA8')
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)

        // 成分堆叠条
        Row() {
          ForEach(ORIGIN8, (o: OriginT8) => {
            Column() {
            }
            .width(o.pct + '%')
            .height(18)
            .backgroundColor(o.color)
          }, (o: OriginT8) => 'og' + o.id.toString())
        }
        .width('100%')
        .borderRadius(9)
        .clip(true)
        .margin({ top: 14 })

        // 图例列表
        ForEach(ORIGIN8, (o: OriginT8) => {
          Row() {
            Column() {
            }
            .width(10)
            .height(10)
            .borderRadius(5)
            .backgroundColor(o.color)

            Text(o.name).fontSize(12).fontColor('#E9D5FF').margin({ left: 10 })
            Text(o.region).fontSize(9).fontColor('#7C6BA8').margin({ left: 8 }).layoutWeight(1)

            Column() {
              Row() {
                Column() {
                }
                .width(o.pct + '%')
                .height('100%')
                .borderRadius(3)
                .backgroundColor(o.color)
              }
              .width(90)
              .height(5)
              .borderRadius(3)
              .backgroundColor('#170B28')
              Text(o.pct.toString() + '%').fontSize(11).fontWeight(700).fontColor(o.color).margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.End)
          }
          .width('100%')
          .alignItems(VerticalAlign.Center)
          .padding({ top: 10, bottom: 2 })
        }, (o: OriginT8) => 'ol' + o.id.toString())
      }
      .width('100%')
      .alignItems(HorizontalAlign.Start)
      .padding(14)
      .borderRadius(16)
      .backgroundColor('#1D1030')
      .margin({ left: 14, right: 14, top: 12 })

      // 母系父系
      Row() {
        Column() {
          Text('🧬 母系线粒体').fontSize(11).fontColor('#7C6BA8')
          Text('D4b2b2').fontSize(16).fontWeight(700).fontColor('#22D3EE').margin({ top: 6 })
          Text('东北亚特征单倍群 · 约 1.2 万人同型').fontSize(9).fontColor('#7C6BA8').margin({ top: 6 }).lineHeight(13)
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        .padding(13)
        .borderRadius(14)
        .backgroundColor('#1D1030')

        Column() {
          Text('🧬 父系Y染色体').fontSize(11).fontColor('#7C6BA8')
          Text('O2a2b1a1').fontSize(16).fontWeight(700).fontColor('#F472B6').margin({ top: 6 })
          Text('华北汉族高频支系 · 约 3.6 万人同型').fontSize(9).fontColor('#7C6BA8').margin({ top: 6 }).lineHeight(13)
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        .padding(13)
        .borderRadius(14)
        .backgroundColor('#1D1030')
        .margin({ left: 10 })
      }
      .width('100%')
      .alignItems(VerticalAlign.Top)
      .padding({ left: 14, right: 14, top: 10 })

      // 近交系数
      Column() {
        Text('🧪 祖源小知识').fontSize(13).fontWeight(700).fontColor('#E9D5FF')
        Text('你的近交系数 0.0021(极低),尼安德特人基因占比 2.4%(东亚平均 2.3%)。你的北方汉族成分与山东、河北参考人群最接近。').fontSize(10).fontColor('#7C6BA8').lineHeight(16).margin({ top: 8 })
      }
      .width('100%')
      .alignItems(HorizontalAlign.Start)
      .padding(14)
      .borderRadius(16)
      .backgroundColor('#1D1030')
      .margin({ left: 14, right: 14, top: 10 })
    }
    .width('100%')
    .padding({ bottom: 12 })
  }

  // ============ Tab2 易感(随访管理) ============
  @Builder tabRisk8() {
    Column() {
      Row() {
        Text('🎯 随访计划(' + this.plans8.length.toString() + ')').fontSize(13).fontWeight(700).fontColor('#E9D5FF')
        Text('新增 +').fontSize(10).fontColor('#A855F7').onClick(() => {
          this.planRisk8 = 0
          this.planFreq8 = 2
          this.showPlan8 = true
        })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      .padding({ left: 16, right: 16, top: 14, bottom: 4 })

      ForEach(this.plans8, (p: PlanT8) => {
        Row() {
          Column() {
            Text(p.risk).fontSize(12).fontWeight(700).fontColor('#E9D5FF')
            Text('每年关注 ' + p.freq.toString() + ' 次 · ' + (p.on ? '提醒已开启' : '提醒已关闭')).fontSize(9).fontColor(p.on ? '#34D399' : '#7C6BA8').margin({ top: 4 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)

          Column() {
            Progress({ value: p.freq, total: 4, type: ProgressType.Linear })
              .width(56)
              .height(4)
              .style({ strokeWidth: 4 })
              .color('#A855F7')
              .backgroundColor('#170B28')
          }
          .margin({ right: 10 })

          Text('✏️').fontSize(15).onClick(() => {
            this.planRisk8 = Math.max(0, RISKS8.findIndex((r: RiskT8) => r.name === p.risk))
            this.planFreq8 = p.freq
            this.showPlan8 = true
          })
          Text('🗑️').fontSize(14).margin({ left: 14 }).onClick(() => {
            this.delId8 = p.id
            this.delName8 = p.risk
            this.armed8 = false
            this.showDel8 = true
          })
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        .padding(13)
        .borderRadius(14)
        .backgroundColor('#1D1030')
        .margin({ left: 14, right: 14, top: 8 })
      }, (p: PlanT8) => 'pl' + p.id.toString() + '_' + p.freq.toString())

      // 全部易感列表
      Row() {
        Text('⚠️ 全部易感项目(' + this.risks8.length.toString() + ')').fontSize(13).fontWeight(700).fontColor('#E9D5FF')
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 16, bottom: 4 })

      ForEach(this.risks8, (r: RiskT8) => {
        Row() {
          Text(r.name).fontSize(11).fontColor('#E9D5FF').layoutWeight(1)
          Text(r.mult.toFixed(1) + '×').fontSize(11).fontWeight(700).fontColor(riskColor8(r.level))
          Text(r.level).fontSize(9).fontColor(riskColor8(r.level)).padding({ left: 6, right: 6, top: 3, bottom: 3 }).borderRadius(7).backgroundColor('#241440').margin({ left: 10 })
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        .padding({ left: 13, right: 13, top: 10, bottom: 10 })
        .borderRadius(12)
        .backgroundColor('#170B28')
        .margin({ left: 14, right: 14, top: 5 })
        .onClick(() => {
          this.riskId8 = r.id
          this.showRisk8 = true
        })
      }, (r: RiskT8) => 'rk2' + r.id.toString())
    }
    .width('100%')
    .padding({ bottom: 12 })
  }

  // ============ Tab3 营养 ============
  @Builder tabNutri8() {
    Column() {
      Column() {
        Text('🥗 代谢能力总览').fontSize(13).fontWeight(700).fontColor('#E9D5FF')
        Text('咖啡因慢代谢 · 酒精差 · 钠高敏感 —— 三项需要重点注意').fontSize(10).fontColor('#FBBF24').margin({ top: 6 })
      }
      .width('100%')
      .alignItems(HorizontalAlign.Start)
      .padding(14)
      .borderRadius(16)
      .backgroundColor('#1D1030')
      .margin({ left: 14, right: 14, top: 12 })

      ForEach(NUTRIS8, (n: NutriT8) => {
        Column() {
          Row() {
            Text(n.icon).fontSize(20)
            Column() {
              Text(n.name).fontSize(12).fontWeight(700).fontColor('#E9D5FF')
              Text(n.verdict).fontSize(9).fontColor(n.score >= 60 ? '#34D399' : n.score >= 40 ? '#FBBF24' : '#F87171').margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .margin({ left: 10 })
            .layoutWeight(1)

            Text(n.score.toString()).fontSize(16).fontWeight(700).fontColor('#E9D5FF')
            Text('分').fontSize(9).fontColor('#7C6BA8').margin({ left: 2 })
          }
          .width('100%')
          .alignItems(VerticalAlign.Center)

          Row() {
            Column() {
              Column() {
              }
              .width(n.score + '%')
              .height('100%')
              .borderRadius(3)
              .backgroundColor(n.score >= 60 ? '#34D399' : n.score >= 40 ? '#FBBF24' : '#F87171')
            }
            .layoutWeight(1)
            .height(6)
            .borderRadius(3)
            .backgroundColor('#170B28')
          }
          .width('100%')
          .margin({ top: 10 })

          Text(n.detail).fontSize(9).fontColor('#7C6BA8').lineHeight(14).margin({ top: 8 })
        }
        .width('100%')
        .alignItems(HorizontalAlign.Start)
        .padding(13)
        .borderRadius(14)
        .backgroundColor('#1D1030')
        .border({ width: 0.6, color: '#33A855F7' })
        .margin({ left: 14, right: 14, top: 8 })
      }, (n: NutriT8) => 'nt' + n.id.toString())
    }
    .width('100%')
    .padding({ bottom: 12 })
  }

  // ============ Tab4 我的 ============
  @Builder tabMine8() {
    Column() {
      Row() {
        Stack() {
          Circle().width(52).height(52).fill('#241440')
          Text('🧑‍🔬').fontSize(26)
        }
        Column() {
          Text('王先生 · 样本 GL-08829').fontSize(14).fontWeight(700).fontColor('#E9D5FF')
          Text('已解锁 3 份报告 · 基因档案完整度 92%').fontSize(9).fontColor('#7C6BA8').margin({ top: 5 })
        }
        .alignItems(HorizontalAlign.Start)
        .margin({ left: 12 })
        .layoutWeight(1)

        Text('收样地址').fontSize(10).fontColor('#A855F7').padding({ left: 10, right: 10, top: 6, bottom: 6 }).borderRadius(12).border({ width: 0.7, color: '#A855F7' }).onClick(() => {
          this.showAddr8 = true
        })
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)
      .padding(14)
      .borderRadius(16)
      .backgroundColor('#1D1030')
      .margin({ left: 14, right: 14, top: 12 })

      Row() {
        Text('📦 检测订单').fontSize(13).fontWeight(700).fontColor('#E9D5FF')
        Text(this.orders8.length.toString() + ' 单').fontSize(10).fontColor('#7C6BA8')
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 16, bottom: 4 })

      ForEach(this.orders8, (o: OrdT8) => {
        Row() {
          Column() {
            Text(o.name).fontSize(11).fontColor('#E9D5FF').maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
            Row() {
              Text(o.state).fontSize(9).fontColor(stateColor8(o.state))
              Text(o.date).fontSize(9).fontColor('#7C6BA8').margin({ left: 10 })
            }
            .margin({ top: 6 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)

          Text(o.price > 0 ? '¥' + o.price.toString() : '赠项').fontSize(11).fontWeight(700).fontColor('#A855F7')
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        .padding(12)
        .borderRadius(12)
        .backgroundColor('#170B28')
        .margin({ left: 14, right: 14, top: 5 })
      }, (o: OrdT8) => 'od' + o.id.toString())

      Column() {
        Text('🧬 你的数据由国密算法加密存储,可随时在设置中申请销毁样本。').fontSize(9).fontColor('#7C6BA8').lineHeight(14)
      }
      .width('100%')
      .alignItems(HorizontalAlign.Center)
      .padding(24)
    }
    .width('100%')
    .padding({ bottom: 12 })
  }

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

        Row() {
          Text('预约报告解读').fontSize(15).fontWeight(700).fontColor('#E9D5FF')
          Text('✕').fontSize(15).fontColor('#7C6BA8').onClick(() => {
            this.showBook8 = false
          })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        .padding({ left: 16, right: 16, top: 4 })

        Text('遗传顾问').fontSize(11).fontColor('#7C6BA8').margin({ top: 12 })
        Flex({ wrap: FlexWrap.Wrap }) {
          ForEach(CONSULT8, (c: ConsultT8, i: number) => {
            Text(c.name + '·' + c.field).fontSize(10).fontColor(this.bkCons8 === i ? '#170B28' : '#7C6BA8').padding({ left: 10, right: 10, top: 7, bottom: 7 }).borderRadius(12).backgroundColor(this.bkCons8 === i ? '#A855F7' : '#241440').margin({ right: 8, bottom: 8 }).onClick(() => {
              this.bkCons8 = i
            })
          }, (c: ConsultT8) => 'bc' + c.id.toString())
        }
        .width('100%')
        .margin({ top: 6 })

        Text('解读方式').fontSize(11).fontColor('#7C6BA8').margin({ top: 8 })
        Row() {
          ForEach(WAY8, (w: string, i: number) => {
            Text(w).fontSize(10).fontColor(this.bkWay8 === i ? '#170B28' : '#7C6BA8').padding({ left: 12, right: 12, top: 7, bottom: 7 }).borderRadius(12).backgroundColor(this.bkWay8 === i ? '#A855F7' : '#241440').margin({ right: 8 }).onClick(() => {
              this.bkWay8 = i
            })
          }, (w: string) => 'bw' + w)
        }
        .margin({ top: 6 })

        Text('时间').fontSize(11).fontColor('#7C6BA8').margin({ top: 12 })
        Flex({ wrap: FlexWrap.Wrap }) {
          ForEach(SLOT8, (t: string, i: number) => {
            Text(t).fontSize(10).fontColor(this.bkSlot8 === i ? '#170B28' : '#7C6BA8').padding({ left: 12, right: 12, top: 7, bottom: 7 }).borderRadius(12).backgroundColor(this.bkSlot8 === i ? '#A855F7' : '#241440').margin({ right: 8, bottom: 8 }).onClick(() => {
              this.bkSlot8 = i
            })
          }, (t: string) => 'bt' + t)
        }
        .width('100%')
        .margin({ top: 6 })

        Text('想重点了解的问题').fontSize(11).fontColor('#7C6BA8').margin({ top: 8 })
        TextInput({ placeholder: '例:BRCA2 位点对孩子有影响吗?', text: this.bkNote8 })
          .fontSize(11)
          .fontColor('#E9D5FF')
          .placeholderColor('#7C6BA8')
          .backgroundColor('#170B28')
          .borderRadius(10)
          .height(42)
          .padding({ left: 10, right: 10 })
          .margin({ top: 6 })
          .onChange((v: string) => {
            this.bkNote8 = v
          })

        Text('确认预约')
          .fontSize(13)
          .fontWeight(700)
          .fontColor('#170B28')
          .width('100%')
          .textAlign(TextAlign.Center)
          .padding({ top: 13, bottom: 13 })
          .borderRadius(22)
          .backgroundColor('#A855F7')
          .margin({ top: 16, bottom: 16 })
          .onClick(() => {
            const c: ConsultT8 = CONSULT8[this.bkCons8]
            const no: OrdT8 = { id: Date.now(), name: '报告解读 · ' + c.name + ' ' + WAY8[this.bkWay8], state: '待服务', date: '明天 ' + SLOT8[this.bkSlot8], price: c.price }
            this.orders8 = [no].concat(this.orders8)
            this.showBook8 = false
          })
      }
      .width('100%')
      .padding({ left: 16, right: 16 })
      .borderRadius({ topLeft: 22, topRight: 22 })
      .backgroundColor('#1D1030')
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.End)
    .backgroundColor('#9912081F')
    .zIndex(999)
    .onClick(() => {
      this.showBook8 = false
    })
  }

  // ---------------- 弹框2:风险详情(居中大卡) ----------------
  @Builder riskOverlay8() {
    Column() {
      Column() {
        Row() {
          Text('⚠️ 风险解读').fontSize(15).fontWeight(700).fontColor('#E9D5FF')
          Text('✕').fontSize(15).fontColor('#7C6BA8').onClick(() => {
            this.showRisk8 = false
          })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)

        Column() {
          Text(this.riskId8 > 0 && this.risks8.filter((r: RiskT8) => r.id === this.riskId8).length > 0 ? this.risks8.filter((r: RiskT8) => r.id === this.riskId8)[0].name : '').fontSize(17).fontWeight(700).fontColor('#F87171')
          Text(this.riskId8 > 0 && this.risks8.filter((r: RiskT8) => r.id === this.riskId8).length > 0 ? this.risks8.filter((r: RiskT8) => r.id === this.riskId8)[0].cate + ' · 位点 ' + this.risks8.filter((r: RiskT8) => r.id === this.riskId8)[0].gene : '').fontSize(10).fontColor('#7C6BA8').margin({ top: 6 })
        }
        .width('100%')
        .alignItems(HorizontalAlign.Start)
        .padding(13)
        .borderRadius(12)
        .backgroundColor('#170B28')
        .margin({ top: 12 })

        Row() {
          Column() {
            Text(this.riskId8 > 0 && this.risks8.filter((r: RiskT8) => r.id === this.riskId8).length > 0 ? this.risks8.filter((r: RiskT8) => r.id === this.riskId8)[0].mult.toFixed(1) + '×' : '-').fontSize(17).fontWeight(700).fontColor('#FBBF24')
            Text('相对人群风险').fontSize(9).fontColor('#7C6BA8').margin({ top: 4 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)

          Column() {
            Text(this.riskId8 > 0 && this.risks8.filter((r: RiskT8) => r.id === this.riskId8).length > 0 ? this.risks8.filter((r: RiskT8) => r.id === this.riskId8)[0].level : '-').fontSize(17).fontWeight(700).fontColor('#F87171')
            Text('风险等级').fontSize(9).fontColor('#7C6BA8').margin({ top: 4 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)

          Column() {
            Text('可干预').fontSize(17).fontWeight(700).fontColor('#34D399')
            Text('生活方式').fontSize(9).fontColor('#7C6BA8').margin({ top: 4 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
        }
        .width('100%')
        .padding({ top: 14, bottom: 14 })
        .borderRadius(12)
        .backgroundColor('#170B28')
        .margin({ top: 10 })

        Column() {
          Text('干预建议').fontSize(11).fontColor('#7C6BA8')
          Text(this.riskId8 > 0 && this.risks8.filter((r: RiskT8) => r.id === this.riskId8).length > 0 ? this.risks8.filter((r: RiskT8) => r.id === this.riskId8)[0].advice : '').fontSize(11).fontColor('#E9D5FF').lineHeight(17).margin({ top: 7 })
        }
        .width('100%')
        .alignItems(HorizontalAlign.Start)
        .padding(13)
        .borderRadius(12)
        .backgroundColor('#241440')
        .margin({ top: 10 })

        Row() {
          Text('加入随访').fontSize(12).fontColor('#E9D5FF').layoutWeight(1).textAlign(TextAlign.Center).padding({ top: 12, bottom: 12 }).borderRadius(20).border({ width: 0.8, color: '#7C6BA8' }).onClick(() => {
            this.showRisk8 = false
            this.planRisk8 = Math.max(0, this.risks8.findIndex((r: RiskT8) => r.id === this.riskId8))
            this.planFreq8 = 2
            this.showPlan8 = true
          })
          Text('预约解读').fontSize(12).fontColor('#170B28').fontWeight(700).layoutWeight(1).textAlign(TextAlign.Center).padding({ top: 12, bottom: 12 }).borderRadius(20).backgroundColor('#A855F7').margin({ left: 10 }).onClick(() => {
            this.showRisk8 = false
            this.showBook8 = true
          })
        }
        .width('100%')
        .margin({ top: 16 })
      }
      .width('86%')
      .padding(18)
      .borderRadius(18)
      .backgroundColor('#1D1030')
      .border({ width: 0.8, color: '#66A855F7' })
      .constraintSize({ maxHeight: '78%' })
      .onClick(() => {
      })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#9912081F')
    .zIndex(999)
    .onClick(() => {
      this.showRisk8 = false
    })
  }

  // ---------------- 弹框3:新增随访计划(居中卡) ----------------
  @Builder planOverlay8() {
    Column() {
      Column() {
        Text('🎯 新增随访计划').fontSize(15).fontWeight(700).fontColor('#E9D5FF')
        Text('选择需重点随访的易感项目,管家将按频次提醒').fontSize(10).fontColor('#7C6BA8').margin({ top: 6 })

        Flex({ wrap: FlexWrap.Wrap }) {
          ForEach(RISKS8, (r: RiskT8, i: number) => {
            Text(r.name).fontSize(10).fontColor(this.planRisk8 === i ? '#170B28' : '#7C6BA8').padding({ left: 10, right: 10, top: 7, bottom: 7 }).borderRadius(12).backgroundColor(this.planRisk8 === i ? '#A855F7' : '#241440').margin({ right: 8, bottom: 8 }).onClick(() => {
              this.planRisk8 = i
            })
          }, (r: RiskT8) => 'pr' + r.id.toString())
        }
        .width('100%')
        .margin({ top: 14 })

        Text('每年关注次数').fontSize(11).fontColor('#7C6BA8').margin({ top: 10 })
        Row() {
          Text('−').fontSize(18).fontColor('#A855F7').width(36).height(36).borderRadius(18).backgroundColor('#241440').textAlign(TextAlign.Center).onClick(() => {
            if (this.planFreq8 > 1) {
              this.planFreq8 -= 1
            }
          })
          Text(this.planFreq8.toString() + ' 次/年').fontSize(13).fontWeight(700).fontColor('#E9D5FF').layoutWeight(1).textAlign(TextAlign.Center)
          Text('+').fontSize(18).fontColor('#A855F7').width(36).height(36).borderRadius(18).backgroundColor('#241440').textAlign(TextAlign.Center).onClick(() => {
            if (this.planFreq8 < 4) {
              this.planFreq8 += 1
            }
          })
        }
        .width('60%')
        .alignItems(VerticalAlign.Center)
        .margin({ top: 8 })

        Row() {
          Text('取消').fontSize(12).fontColor('#7C6BA8').layoutWeight(1).textAlign(TextAlign.Center).padding({ top: 11, bottom: 11 }).borderRadius(20).border({ width: 0.8, color: '#4D3B6B' }).onClick(() => {
            this.showPlan8 = false
          })
          Text('保存').fontSize(12).fontColor('#170B28').fontWeight(700).layoutWeight(1).textAlign(TextAlign.Center).padding({ top: 11, bottom: 11 }).borderRadius(20).backgroundColor('#A855F7').margin({ left: 10 }).onClick(() => {
            const r: RiskT8 = RISKS8[this.planRisk8]
            const exist: Array<PlanT8> = this.plans8.filter((p: PlanT8) => p.risk === r.name)
            if (exist.length > 0) {
              this.plans8 = this.plans8.map((p: PlanT8) => {
                if (p.risk === r.name) {
                  return { id: p.id, risk: p.risk, freq: this.planFreq8, on: p.on }
                }
                return p
              })
            } else {
              const np: PlanT8 = { id: Date.now(), risk: r.name, freq: this.planFreq8, on: true }
              this.plans8 = [np].concat(this.plans8)
            }
            this.showPlan8 = false
          })
        }
        .width('100%')
        .margin({ top: 18 })
      }
      .width('84%')
      .padding(18)
      .borderRadius(18)
      .backgroundColor('#1D1030')
      .border({ width: 0.8, color: '#66A855F7' })
      .onClick(() => {
      })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#9912081F')
    .zIndex(999)
    .onClick(() => {
      this.showPlan8 = false
    })
  }

  // ---------------- 弹框4:收样地址(底部抽屉) ----------------
  @Builder addrOverlay8() {
    Column() {
      Column() {
        Row() {
          Text('📮 采样包收件地址').fontSize(15).fontWeight(700).fontColor('#E9D5FF')
          Text('✕').fontSize(15).fontColor('#7C6BA8').onClick(() => {
            this.showAddr8 = false
          })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        .padding({ top: 16, bottom: 4 })

        Text('收件人').fontSize(11).fontColor('#7C6BA8').margin({ top: 10 })
        TextInput({ placeholder: '姓名', text: this.addrName8 })
          .fontSize(11)
          .fontColor('#E9D5FF')
          .placeholderColor('#7C6BA8')
          .backgroundColor('#170B28')
          .borderRadius(10)
          .height(40)
          .padding({ left: 10, right: 10 })
          .margin({ top: 6 })
          .onChange((v: string) => {
            this.addrName8 = v
          })

        Text('手机号').fontSize(11).fontColor('#7C6BA8').margin({ top: 12 })
        TextInput({ placeholder: '手机号', text: this.addrPhone8 })
          .fontSize(11)
          .fontColor('#E9D5FF')
          .placeholderColor('#7C6BA8')
          .backgroundColor('#170B28')
          .borderRadius(10)
          .height(40)
          .padding({ left: 10, right: 10 })
          .margin({ top: 6 })
          .onChange((v: string) => {
            this.addrPhone8 = v
          })

        Text('详细地址').fontSize(11).fontColor('#7C6BA8').margin({ top: 12 })
        TextInput({ placeholder: '街道 / 楼宇 / 门牌', text: this.addrText8 })
          .fontSize(11)
          .fontColor('#E9D5FF')
          .placeholderColor('#7C6BA8')
          .backgroundColor('#170B28')
          .borderRadius(10)
          .height(40)
          .padding({ left: 10, right: 10 })
          .margin({ top: 6 })
          .onChange((v: string) => {
            this.addrText8 = v
          })

        Row() {
          Text('🧊 夏季加冰袋运输(样本活性更高)').fontSize(11).fontColor('#E9D5FF').layoutWeight(1)
          Toggle({ type: ToggleType.Switch, isOn: this.addrCold8 })
            .selectedColor('#A855F7')
            .onChange((on: boolean) => {
              this.addrCold8 = on
            })
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        .margin({ top: 14 })

        Text('保存地址')
          .fontSize(13)
          .fontWeight(700)
          .fontColor('#170B28')
          .width('100%')
          .textAlign(TextAlign.Center)
          .padding({ top: 13, bottom: 13 })
          .borderRadius(22)
          .backgroundColor('#A855F7')
          .margin({ top: 16, bottom: 16 })
          .onClick(() => {
            this.showAddr8 = false
          })
      }
      .width('100%')
      .padding({ left: 16, right: 16 })
      .borderRadius({ topLeft: 22, topRight: 22 })
      .backgroundColor('#1D1030')
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.End)
    .backgroundColor('#9912081F')
    .zIndex(999)
    .onClick(() => {
      this.showAddr8 = false
    })
  }

  // ---------------- 弹框5:删除随访(窄危险卡) ----------------
  @Builder delOverlay8() {
    Column() {
      Column() {
        Text('⚠️').fontSize(30)
        Text('删除「' + this.delName8 + '」随访?').fontSize(15).fontWeight(700).fontColor('#E9D5FF').margin({ top: 10 })
        Text('删除后相关筛查提醒将一并取消,风险项仍可在易感列表中重新加入。').fontSize(11).fontColor('#7C6BA8').margin({ top: 10 }).textAlign(TextAlign.Center).lineHeight(17)

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

        Row() {
          Text('再想想').fontSize(12).fontColor('#7C6BA8').layoutWeight(1).textAlign(TextAlign.Center).padding({ top: 11, bottom: 11 }).borderRadius(20).border({ width: 0.8, color: '#4D3B6B' }).onClick(() => {
            this.showDel8 = false
          })
          Text(this.armed8 ? '确认删除' : '请先打开确认')
            .fontSize(12)
            .fontWeight(700)
            .fontColor(this.armed8 ? '#E9D5FF' : '#4D3B6B')
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .padding({ top: 11, bottom: 11 })
            .borderRadius(20)
            .backgroundColor(this.armed8 ? '#7E2B22' : '#241440')
            .margin({ left: 10 })
            .onClick(() => {
              if (this.armed8) {
                this.plans8 = this.plans8.filter((p: PlanT8) => p.id !== this.delId8)
                this.showDel8 = false
              }
            })
        }
        .width('100%')
        .margin({ top: 18 })
      }
      .width('74%')
      .padding(18)
      .borderRadius(16)
      .backgroundColor('#231714')
      .border({ width: 1, color: '#4DF87171' })
      .onClick(() => {
      })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#990E0B08')
    .zIndex(999)
    .onClick(() => {
      this.showDel8 = false
    })
  }

  // ---------------- 弹框6:升级检测加购(底部抽屉) ----------------
  @Builder upgOverlay8() {
    Column() {
      Column() {
        Column() {
          Text('').fontSize(4).width(40).borderRadius(2).backgroundColor('#4D3B6B')
        }
        .width('100%')
        .alignItems(HorizontalAlign.Center)
        .padding({ top: 10, bottom: 4 })

        Row() {
          Text('🧬 升级检测项目').fontSize(15).fontWeight(700).fontColor('#E9D5FF')
          Text('✕').fontSize(15).fontColor('#7C6BA8').onClick(() => {
            this.showUpg8 = false
          })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        .padding({ left: 16, right: 16, top: 4 })

        Scroll() {
          Column() {
            Text('加购模块(可多选 · 复用已有样本无需二次采样)').fontSize(11).fontColor('#7C6BA8').margin({ top: 12 })
            Flex({ wrap: FlexWrap.Wrap }) {
              ForEach(UPG8, (x: string) => {
                Text(x).fontSize(10).fontColor(this.upgSel8.indexOf(x) >= 0 ? '#170B28' : '#7C6BA8').padding({ left: 10, right: 10, top: 7, bottom: 7 }).borderRadius(12).backgroundColor(this.upgSel8.indexOf(x) >= 0 ? '#22D3EE' : '#241440').margin({ right: 8, bottom: 8 }).onClick(() => {
                  if (this.upgSel8.indexOf(x) >= 0) {
                    this.upgSel8 = this.upgSel8.filter((y: string) => y !== x)
                  } else {
                    this.upgSel8 = this.upgSel8.concat([x])
                  }
                })
              }, (x: string) => 'ug' + x)
            }
            .width('100%')
            .margin({ top: 6 })

            Text('报告份数').fontSize(11).fontColor('#7C6BA8').margin({ top: 10 })
            Row() {
              Text('−').fontSize(18).fontColor('#22D3EE').width(36).height(36).borderRadius(18).backgroundColor('#241440').textAlign(TextAlign.Center).onClick(() => {
                if (this.upgQty8 > 1) {
                  this.upgQty8 -= 1
                }
              })
              Text(this.upgQty8.toString() + ' 份').fontSize(13).fontWeight(700).fontColor('#E9D5FF').layoutWeight(1).textAlign(TextAlign.Center)
              Text('+').fontSize(18).fontColor('#22D3EE').width(36).height(36).borderRadius(18).backgroundColor('#241440').textAlign(TextAlign.Center).onClick(() => {
                if (this.upgQty8 < 5) {
                  this.upgQty8 += 1
                }
              })
            }
            .width('50%')
            .alignItems(VerticalAlign.Center)
            .margin({ top: 8 })

            Column() {
              Row() {
                Text('加购 ' + this.upgSel8.length.toString() + ' 模块').fontSize(10).fontColor('#7C6BA8')
                Text('¥' + (this.upgSel8.length * 680 * this.upgQty8).toString()).fontSize(10).fontColor('#E9D5FF')
              }
              .width('100%')
              .justifyContent(FlexAlign.SpaceBetween)
              Row() {
                Text('老客户立减').fontSize(10).fontColor('#7C6BA8')
                Text('-¥' + (this.upgSel8.length > 0 ? 200 : 0).toString()).fontSize(10).fontColor('#34D399')
              }
              .width('100%')
              .justifyContent(FlexAlign.SpaceBetween)
              .margin({ top: 8 })
              Row() {
                Text('合计').fontSize(12).fontWeight(700).fontColor('#E9D5FF')
                Text('¥' + (this.upgSel8.length * 680 * this.upgQty8 - (this.upgSel8.length > 0 ? 200 : 0)).toString()).fontSize(15).fontWeight(700).fontColor('#22D3EE')
              }
              .width('100%')
              .justifyContent(FlexAlign.SpaceBetween)
              .margin({ top: 10 })
            }
            .width('100%')
            .padding(12)
            .borderRadius(12)
            .backgroundColor('#170B28')
            .margin({ top: 14 })

            Text('样本余量充足(剩余 2 管),加购 10 个工作日出报告').fontSize(9).fontColor('#7C6BA8').margin({ top: 10 })
          }
          .width('100%')
          .padding({ left: 16, right: 16, bottom: 12 })
        }
        .layoutWeight(1)
        .align(Alignment.Top)
        .edgeEffect(EdgeEffect.Spring)

        Text('确认加购')
          .fontSize(13)
          .fontWeight(700)
          .fontColor('#170B28')
          .width('100%')
          .textAlign(TextAlign.Center)
          .padding({ top: 13, bottom: 13 })
          .borderRadius(22)
          .backgroundColor('#22D3EE')
          .margin({ left: 16, right: 16, bottom: 14 })
          .onClick(() => {
            if (this.upgSel8.length > 0) {
              const no: OrdT8 = { id: Date.now(), name: '升级检测 · ' + this.upgSel8.join('、'), state: '待采样', date: '待寄出', price: this.upgSel8.length * 680 * this.upgQty8 - 200 }
              this.orders8 = [no].concat(this.orders8)
            }
            this.showUpg8 = false
          })
      }
      .width('100%')
      .height('70%')
      .borderRadius({ topLeft: 22, topRight: 22 })
      .backgroundColor('#1D1030')
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.End)
    .backgroundColor('#9912081F')
    .zIndex(999)
    .onClick(() => {
      this.showUpg8 = false
    })
  }

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

        Scroll() {
          Column() {
            if (this.curTab8 === 0) {
              this.tabReport8()
            }
            if (this.curTab8 === 1) {
              this.tabOrigin8()
            }
            if (this.curTab8 === 2) {
              this.tabRisk8()
            }
            if (this.curTab8 === 3) {
              this.tabNutri8()
            }
            if (this.curTab8 === 4) {
              this.tabMine8()
            }
          }
          .width('100%')
        }
        .layoutWeight(1)
        .scrollBar(BarState.Off)
        .edgeEffect(EdgeEffect.Spring)
        .width('100%')
        .backgroundColor('#12081F')

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

      if (this.showBook8) {
        this.bookOverlay8()
      }
      if (this.showRisk8) {
        this.riskOverlay8()
      }
      if (this.showPlan8) {
        this.planOverlay8()
      }
      if (this.showAddr8) {
        this.addrOverlay8()
      }
      if (this.showDel8) {
        this.delOverlay8()
      }
      if (this.showUpg8) {
        this.upgOverlay8()
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#12081F')
  }
}


在这里插入图片描述

九、总结与回顾

通过对这个基因检测中心应用的全面解析,我们深入了解了鸿蒙ArkTS声明式UI开发的方方面面。从最基础的数据结构设计,到组件化的UI构建,再到复杂的弹框交互和动画效果,整个应用涵盖了鸿蒙开发中绝大部分常用的技术点。

首先,数据结构的设计是应用的基础。通过interface定义清晰的数据模型,不仅提供了类型安全保障,也使得业务逻辑更加清晰可读。六个业务接口分别对应不同的业务场景,每个接口的字段设计都经过了精心考量,恰如其分地表达了业务对象的核心属性。常量数据和工具函数的组织方式也体现了良好的代码复用思想——将颜色映射等公共逻辑抽取出来,保证了全局的一致性。

其次,组件化和声明式UI的思想贯穿始终。@Builder装饰器将UI片段封装为可复用的方法,大大减少了重复代码。Column、Row、Stack、Flex等容器组件各司其职,通过组合和嵌套构建出复杂的布局结构。Text、TextInput、Progress、Toggle等基础组件提供了丰富的属性和事件,能够满足绝大多数的UI展示和交互需求。ForEach列表渲染机制使得动态数据的展示变得简单而高效。

再次,状态管理是声明式UI的核心。@State装饰器标记的状态变量驱动着整个界面的变化,从Tab切换到弹框显隐,从表单输入到列表更新,一切都围绕着状态展开。理解状态驱动视图的原理,掌握状态更新的正确方式(如数组需要赋值新数组),是写出正确且高效的鸿蒙应用的关键。同时,状态变量的设计也需要权衡——既不能太少导致状态混乱,也不能太多导致性能下降。

然后,弹框交互的实现展示了鸿蒙UI框架的灵活性。六个不同类型的弹框(底部抽屉、居中卡片、危险确认等)都通过Stack层叠布局和条件渲染实现,没有依赖专门的弹框组件。这种方式虽然需要开发者自己处理遮罩、层级、关闭逻辑等细节,但带来了极高的自由度——弹框的样式、大小、位置、动画都可以完全自定义。对于需要高度定制化弹框的场景,这是一种非常值得借鉴的实现模式。

Logo

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

更多推荐