鸿蒙ArkTS声明式UI实战:基因检测中心全场景应用深度解析
一、鸿蒙开发技术背景与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时,这个变量的值会更新,进而触发页面内容的切换。
risks8、plans8、orders8这三个数组类型的状态变量分别存储风险项目列表、随访计划列表和订单列表。它们的初始值来自对应的常量数组,但使用了.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 整体架构流程图
为了更直观地理解整个应用的架构,我们用流程图来展示页面的整体结构和数据流向。
@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容器组件详解
在头部组件的实现中,Column和Row是使用最频繁的两个容器组件。它们是ArkUI中最基础也是最重要的布局容器。
Column是垂直布局容器,它的子组件沿着垂直方向从上到下依次排列。Column提供了多种属性来控制子组件的对齐方式和间距。例如,alignItems(HorizontalAlign.Start)设置子组件在水平方向上左对齐,alignItems(HorizontalAlign.Center)则是居中对齐。
Row是水平布局容器,它的子组件沿着水平方向从左到右依次排列。Row的alignItems(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):设置字体大小为17pxfontWeight(700):设置字体粗细,700相当于粗体fontColor('#E9D5FF'):设置字体颜色,使用十六进制颜色值letterSpacing(1):设置字符间距为1pxmargin({ top: 3 }):设置顶部外边距为3pxmaxLines(1):设置最大行数为1行textOverflow({ overflow: TextOverflow.Ellipsis }):设置文本溢出时显示省略号
Text组件的属性虽然繁多,但都遵循一致的命名规范。字体相关的属性以
font开头,间距相关的属性如margin、padding支持传入对象来分别设置四个方向的值,这种设计使得属性的含义一目了然。
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中,弹框通常采用以下模式实现:
- 定义一个
@State布尔变量控制弹框的显示与隐藏 - 使用
Stack作为根布局容器,主内容在下层,弹框在上层 - 弹框通过条件渲染(
if语句)决定是否出现在UI树中 - 弹框容器使用全屏半透明遮罩 + 内容区域的双层结构
- 点击遮罩区域关闭弹框,点击内容区域不关闭(阻止冒泡)
// 主布局中的弹框层
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 弹框交互流程图
为了更清晰地理解六个弹框之间的关系和触发路径,我们用第二张流程图来展示。
这张流程图展示了六个弹框的触发来源和相互关系。可以看到,弹框并非都是从主页面直接触发的,有些弹框之间存在跳转关系——例如从风险详情弹框可以跳转到随访计划弹框或预约解读弹框。这种弹框间的跳转需要特别注意状态管理,确保关闭前一个弹框和打开后一个弹框的时机正确。
七、主布局架构: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层叠布局和条件渲染实现,没有依赖专门的弹框组件。这种方式虽然需要开发者自己处理遮罩、层级、关闭逻辑等细节,但带来了极高的自由度——弹框的样式、大小、位置、动画都可以完全自定义。对于需要高度定制化弹框的场景,这是一种非常值得借鉴的实现模式。
更多推荐




所有评论(0)