引言:鸿蒙开发背景与ArkTS语言演进

鸿蒙操作系统(HarmonyOS)作为华为面向万物互联时代打造的分布式操作系统,其应用开发框架经历了从早期的Java-based到如今全面拥抱声明式UI范式的重大变革。在鸿蒙的生态体系中,ArkUI作为统一的UI开发框架,提供了两种主要的开发范式:基于JavaScript/TypeScript的类Web范式和基于ArkTS的声明式范式。其中,ArkTS声明式UI范式是华为官方主推的现代化开发方式,它深度融合了TypeScript的类型系统和响应式编程理念,让开发者能够以更加简洁、高效的方式构建出性能优异、体验流畅的跨设备应用界面。

ArkTS是在TypeScript基础上扩展而来的编程语言,它保留了TypeScript的静态类型检查、接口定义、泛型等核心特性,同时针对鸿蒙的声明式UI框架进行了深度定制。在ArkTS中,开发者通过struct结构体来定义组件,通过一系列装饰器(如@Component、@Entry、@State、@Builder等)来标注组件的角色和状态管理策略。这种基于装饰器的元编程方式,使得UI组件的声明、状态的管理、生命周期的回调都变得清晰而有序。与传统的命令式UI开发不同,ArkTS的声明式范式让开发者只需描述"界面应该是什么样子",而将DOM树的更新、差异比较、局部刷新等复杂工作交给框架底层自动完成,极大降低了开发心智负担。

声明式UI范式的核心思想是"状态驱动视图"。在ArkTS中,UI是状态的函数映射——当状态变量发生改变时,框架会自动触发与之关联的UI组件的重新渲染。这种数据单向流动的模型,使得应用的数据流变得可追踪、可预测。开发者通过@State、@Prop、@Link、@Provide、@Consume等状态管理装饰器,可以精确地控制状态在不同层级的组件之间如何传递和共享。@State用于组件内部管理的私有状态,当该状态变化时,仅触发当前组件的重新渲染;@Prop用于父向子单向传递只读状态;@Link则实现了父子之间的双向绑定。这种细粒度的状态管理机制,是鸿蒙ArkUI实现高性能渲染的重要保障。

ArkUI组件体系是鸿蒙声明式UI的基石,它提供了一套从基础容器到复杂交互的完整组件库。容器组件方面,Column(纵向线性布局)、Row(横向线性布局)、Stack(层叠布局)、Flex(弹性布局)构成了四大基础布局容器,它们可以互相嵌套组合出任意复杂的界面结构。基础组件方面,Text(文本)、Image(图片)、TextInput(输入框)、Button(按钮)、Toggle(开关)、Progress(进度条)、Scroll(滚动容器)等覆盖了绝大多数UI场景。此外,ArkUI还提供了ForEach(列表渲染)、if/else(条件渲染)等渲染控制语句,以及animateTo(显式动画)、attributeModifier(属性修饰)、@Builder(构建器)等高级能力。本文将通过一个完整的VIP健康管家应用案例,逐段解析这些组件和特性在实际业务场景中的运用方式。

一、数据结构定义:接口与类型系统

在ArkTS中,接口(interface)是定义数据结构的核心手段。与TypeScript的interface一样,ArkTS的interface用于声明对象的形状,包含属性名、属性类型等信息。下面是本应用定义的第一个数据结构——消息体接口:

interface MsgT7 {
  id: number
  from: string
  kind: string
  title: string
  text: string
  time: string
}

在这里插入图片描述

这段代码定义了一个名为MsgT7的接口,它描述了聊天消息的完整数据模型。其中id为数字类型的唯一标识符,from字段标识消息的发送方(如"butler"表示管家发送,"me"表示用户本人发送),kind字段区分消息类型("txt"为纯文本消息,"card"为卡片消息),titletext分别承载消息的标题和正文内容,time记录消息的时间戳。

技术要点: ArkTS中的interface与TypeScript中的interface在语法层面高度一致,但在运行时行为上存在差异。ArkTS在编译阶段会将interface转换为具体的类型约束,在运行时并不产生实际的JavaScript对象。这意味着interface纯粹是编译期的类型检查工具,不会增加任何运行时开销。在声明式UI开发中,合理使用interface定义数据模型,能够在开发阶段捕获类型错误,提升代码的可靠性。

接口的属性都是必选的,这在实际业务中确保了数据的完整性。当应用需要创建一个消息对象时,必须提供所有字段,否则编译器会报错。这种强类型约束在大型应用开发中尤为重要——它使得团队成员在阅读接口定义时就能清楚地知道一个数据对象需要包含哪些字段,而不需要翻阅文档或猜测。在本应用中,消息接口支撑了整个聊天式管家Tab的消息流渲染逻辑,每一条消息都会被映射为一个MsgT7对象,传入到消息气泡组件中进行展示。

接下来是体检套餐数据结构:

interface PkgT7 {
  id: number
  name: string
  tag: string
  items: string
  price: number
  orig: number
  sold: number
  hot: boolean
}

在这里插入图片描述

PkgT7接口定义了体检套餐的完整数据模型。name为套餐名称,tag为标签文字(如"含无痛胃肠镜"),items为套餐包含的检查项目描述,priceorig分别代表会员价和原价,sold记录已售数量,hot是布尔型标识是否为热销套餐。这里特别值得注意的是hot字段使用了boolean类型而非字符串——这在数据建模中是一个良好的实践,布尔类型在逻辑判断时更加直观,且避免了字符串比较可能带来的大小写、空格等隐患。

priceorig的双价格设计是电商类应用的标准做法。在UI展示中,price会以金色大字号显示,而orig则以灰色小字号并添加删除线效果,形成价格对比的视觉冲击力。sold字段经过fmtWan7工具函数处理后,可以自动将大于一万的数字转换为"X.X万"的格式,这在订单量较大的场景下能够保持UI的简洁性。

继续看专家数据结构:

interface ExpT7 {
  id: number
  name: string
  title: string
  hosp: string
  dept: string
  score: number
  patients: number
  online: boolean
  skills: string
}

在这里插入图片描述

ExpT7描述了问诊专家的完整信息模型。name为专家姓名,title为职称(如"主任医师"、“副主任医师”、“主治医师”),hosp为所属医院,dept为科室,score为评分(满分5.0),patients为累计问诊人数,online标识是否当前在线可问诊,skills为擅长方向的文字描述。

技术要点: 在ArkTS声明式UI中,接口定义的数据结构最终会被用于ForEach列表渲染的参数。ForEach的第三个参数(键值生成器)需要为每个数据项生成唯一标识,通常使用id字段。合理的接口设计直接影响ForEach的渲染性能——如果键值不稳定,会导致不必要的组件销毁与重建,影响滑动流畅度。

除了上述三个核心接口外,应用还定义了以下数据结构,它们各自服务于不同的业务模块:

interface BenT7 {
  id: number
  name: string
  desc: string
  icon: string
  used: number
  total: number
  color: string
}

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

interface AppealT7 {
  id: number
  text: string
  level: string
  freq: number
  on: boolean
}

interface SrvT7 {
  day: string
  cnt: number
}

在这里插入图片描述

BenT7是会员权益接口,usedtotal构成使用进度对,color字段直接存储了十六进制颜色值用于UI着色。OrdT7是订单接口,state为订单状态字符串。AppealT7是健康诉求接口,on字段控制诉求的开关状态。SrvT7是服务统计接口,day为星期、cnt为服务次数,用于柱状图渲染。这些接口共同构成了应用的数据层基础。

二、静态数据初始化与工具函数

定义完接口后,应用通过const常量声明了一系列静态数据数组。以下是消息数据的初始化片段:

const MSGS7: Array<MsgT7> = [
  { id: 1, from: 'butler', kind: 'txt', title: '', text: '王先生您好,我是您的专属健康管家小艾 👋 钻石会员服务已生效,全年 12 次管家主动关怀已排期。', time: '09:00' },
  { id: 2, from: 'me', kind: 'txt', title: '', text: '好的,我最近体检报告出来了,帮我看看。', time: '09:02' },
  { id: 3, from: 'butler', kind: 'card', title: '📊 2026 年度体检报告解读', text: '综合健康评分 86 分(良好)。血脂两项偏高:总胆固醇 5.8 / 低密度脂蛋白 3.9;幽门螺旋杆菌抗体弱阳性;其余 42 项指标在参考范围内。', time: '09:03' },
  // ...更多消息
]

这里使用Array<MsgT7>泛型语法对数组类型进行了显式标注,确保数组中每个元素都符合MsgT7接口的形状。const声明意味着这些数组在运行时不可被重新赋值(但数组内容可以通过方法修改)。数据中既有butler发送的管家消息,也有me发送的用户消息,混合了txt纯文本和card卡片两种消息类型,这为后续的条件渲染分支提供了数据基础。

技术要点: 在ArkTS中,const声明的数组虽然不能被重新赋值,但数组本身的元素是可以被修改的(如push、pop、splice等)。如果需要真正的不可变数据,可以使用readonly修饰符或使用不可变数据库。在本应用中,这些静态数据作为初始值,会在组件的@State中通过.slice()方法进行浅拷贝,从而保证组件内部的状态操作不会污染原始常量。

下面是三个工具函数的定义:

function saveYuan7(p: PkgT7): number {
  return p.orig - p.price
}

function stateColor7(s: string): string {
  if (s === '待到检' || s === '待预约') {
    return '#F39C12'
  }
  if (s === '进行中') {
    return '#5DADE2'
  }
  if (s === '已支付') {
    return '#D4AF37'
  }
  return '#27AE60'
}

function fmtWan7(n: number): string {
  if (n >= 10000) {
    return (n / 10000).toFixed(1).toString() + '万'
  }
  return n.toString()
}

在这里插入图片描述

这三个工具函数各自承担了特定的UI辅助职责。saveYuan7接收一个PkgT7对象,计算原价与会员价的差值,用于在套餐卡片中展示"立省"金额。stateColor7是一个典型的状态到颜色的映射函数,它根据订单状态字符串返回对应的十六进制颜色值——待到检和待预约返回橙色(#F39C12),进行中返回蓝色(#5DADE2),已支付返回金色(#D4AF37),其他(已完成)返回绿色(#27AE60)。fmtWan7负责数字格式化,当数字达到一万时自动转换为"X.X万"的中文简写格式。

技术要点: 在鸿蒙ArkTS中,工具函数定义在组件外部以function关键字声明,与组件内的@Builder方法不同,普通函数不参与UI渲染流程,纯粹是逻辑层的工具。将这类纯函数抽离到组件外部是一个良好的工程实践——它们不依赖组件的this上下文,可以被任意组件复用,也更容易进行单元测试。在函数内部使用if/else而非switch语句,在分支较少时是更直观的写法。

三、页面入口与状态管理

3.1 @Entry与@Component装饰器

@Entry
@Component
struct Index {
  @State curTab7: number = 0
  @State msgs7: Array<MsgT7> = MSGS7.slice()
  @State pkgs7: Array<PkgT7> = PKGS7.slice()
  @State exps7: Array<ExpT7> = EXPS7.slice()
  @State bens7: Array<BenT7> = BENS7.slice()
  @State ords7: Array<OrdT7> = ORDERS7.slice()
  @State appeals7: Array<AppealT7> = APPEALS7.slice()
  @State expFilter7: number = 0
  // ...
}

在这里插入图片描述

@Entry@Component是ArkTS声明式UI中最基础的两个装饰器。@Component装饰器将一个struct结构体标记为可复用的UI组件——被装饰的struct可以包含@Builder方法、@State状态变量、build()渲染方法等。@Entry则进一步标记该组件为页面的入口组件,一个ArkTS页面文件中有且仅有一个@Entry组件,它是整个页面UI树的根节点。

技术要点: @Entry组件是页面的根,它会被框架自动实例化并挂载到窗口上。@Component组件可以被其他组件通过函数调用的方式引用(如this.tabButler7()),但不会自动实例化——只有被调用时才会在UI树中创建对应的节点。这种设计实现了组件的按需渲染,提升了页面初始化性能。

3.2 @State状态变量

@State是ArkTS中最核心的状态管理装饰器。被@State修饰的变量具有响应式特性——当变量值发生变化时,框架会自动触发引用了该变量的UI组件进行重新渲染。在本应用中,curTab7记录当前选中的底部Tab索引(0-4分别对应管家、体检、问诊、权益、我的五个Tab),msgs7pkgs7exps7等数组则是各Tab页面所需的数据集合。

注意到这些数组都是通过.slice()方法对全局常量进行了浅拷贝。这是因为@State变量虽然可以被修改,但如果直接引用全局常量,修改操作会影响到常量本身,造成数据污染。通过.slice()创建一个新数组,既保留了原始数据的引用,又保证了组件内部状态的独立性。

技术要点: @State对基本类型(number、string、boolean)的监听是值级别的——只要值改变就会触发重渲染。但对于引用类型(Array、Object),@State只监听引用地址的变化。这意味着直接修改数组元素(如this.msgs7[0].text = '新文本')不会触发重渲染,必须通过赋值新数组的方式(如this.msgs7 = [...this.msgs7]this.msgs7 = newArray)才能触发更新。本应用中大量使用了this.msgs7 = [nm].concat(this.msgs7.slice(0, 24))的模式来更新消息列表,正是基于这一机制。

3.3 弹框开关与表单状态

// 弹框开关
@State showAsk7: boolean = false
@State showCbk7: boolean = false
@State showAppeal7: boolean = false
@State showUnsub7: boolean = false
@State showExp7: boolean = false
@State showBuy7: boolean = false

// 问诊表单
@State askExpIdx7: number = 0
@State askWayIdx7: number = 1
@State askTimeIdx7: number = 3
@State askDesc7: string = ''
@State askUrgent7: boolean = false

在这里插入图片描述

应用定义了6个布尔型@State变量作为弹框的显示开关。当某个开关为true时,对应的弹框组件会在UI树中渲染;为false时则不渲染。这种"状态控制渲染"的模式是声明式UI处理弹框的标准方式——无需手动调用show/hide方法,只需切换布尔值即可。

问诊表单部分定义了5个状态变量,分别记录用户选择的专家索引、问诊方式索引、期望时间索引、病情描述文本和加急开关。这些变量与弹框中的UI控件双向绑定,用户在弹框中的每一次选择都会实时更新到对应的@State变量,提交时统一读取这些变量组装数据。

技术要点: 用多个独立的布尔变量而非一个枚举值来管理弹框,是因为本应用的弹框之间可能存在叠加场景(如从专家详情弹框直接跳转到问诊弹框)。独立布尔变量允许同时只有一个弹框显示,也可以灵活地在关闭一个弹框后立即打开另一个,通过赋值顺序即可控制。这种设计的代价是需要在build()中为每个弹框写独立的if判断,但带来的灵活性远超这点代码量。

3.4 特效状态与生命周期

// 特效
@State shineX7: number = -46
@State msgOp7: number = 0.25

aboutToAppear(): void {
  this.getUIContext().animateTo({ duration: 1900, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
    this.shineX7 = 46
  })
  this.getUIContext().animateTo({ duration: 1200, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
    this.msgOp7 = 0.9
  })
}

在这里插入图片描述

aboutToAppear()是ArkUI组件的生命周期回调之一,它在组件实例创建后、build()方法首次执行前被调用。这个时机非常适合做数据初始化和动画启动。在本应用中,aboutToAppear启动了两个无限循环动画。

animateTo是ArkUI提供的显式动画API,它接收两个参数:动画配置对象和闭包函数。配置对象中duration指定动画时长(毫秒),iterations设置为-1表示无限循环,playMode设置为PlayMode.Alternate表示往返播放(正向结束后反向播放),curve设置为Curve.EaseInOut表示先加速后减速的缓动曲线。闭包函数内只需修改@State变量的目标值,框架会自动在duration时间内从当前值平滑过渡到目标值。

第一个动画将shineX7从-46平滑过渡到46,往返循环——这实现了一个"金光扫过"的横向移动特效,用于VIP金卡和优惠横条上方的星星emoji。第二个动画将msgOp7从0.25过渡到0.9,实现了管家在线指示灯的呼吸闪烁效果。

技术要点: animateTo的闭包内只能修改@State变量,不能有其他副作用操作。框架会在闭包执行后自动捕获@State变量的变化,并启动从旧值到新值的动画过渡。iterations为-1时表示无限循环,这在呼吸灯、加载动画等场景中非常常用。PlayMode.Alternate模式让动画往返播放,避免了每次循环结束时突然跳回起点的生硬感。

以下是应用生命周期的整体流程:

0

1

2

3

4

任一为true

全部false

组件实例创建

aboutToAppear 生命周期

启动金光扫过动画
duration:1900ms iterations:-1

启动呼吸灯动画
duration:1200ms iterations:-1

build 方法执行

渲染 Stack 根布局

渲染 Header 头部栏

渲染 Scroll 内容区

curTab7 值判断

渲染管家Tab

渲染体检Tab

渲染问诊Tab

渲染权益Tab

渲染我的Tab

渲染底部TabBar

检查6个弹框开关

叠加渲染对应弹框

渲染完成

四、头部栏Builder与布局解析

4.1 headerBar7头部栏

@Builder headerBar7() {
  Column() {
    Row() {
      Text('👑').fontSize(24)
      Column() {
        Text('PLUS CONCIERGE').fontSize(16).fontWeight(700).fontColor('#D4AF37').letterSpacing(1)
        Text('钻石会员 · 专属健康管家服务年卡').fontSize(10).fontColor('#A79C87').margin({ top: 2 })
      }
      .alignItems(HorizontalAlign.Start)
      .margin({ left: 8 })

      Row() {
        Text('小艾在线').fontSize(10).fontColor('#27AE60')
        Circle().width(6).height(6).fill('#27AE60').opacity(this.msgOp7)
      }
      .padding({ left: 10, right: 10, top: 4, bottom: 4 })
      .borderRadius(10)
      .backgroundColor('#1E1B12')
    }
    .width('100%')
    .justifyContent(FlexAlign.SpaceBetween)
    .padding({ left: 16, right: 16, top: 12, bottom: 10 })
  }
  .width('100%')
  .backgroundColor('#17130C')
  .borderRadius({ bottomLeft: 18, bottomRight: 18 })
}

@Builder是ArkTS中用于定义可复用UI片段的装饰器。被@Builder修饰的方法不返回任何值,方法体内部直接书写声明式UI组件代码。@Builder方法可以在组件的build()方法或其他@Builder方法中通过this.methodName()的方式调用,调用处会将Builder内部声明的UI组件展开到当前位置。

头部栏的整体结构是一个Column容器内嵌一个Row容器。Column是ArkUI的纵向线性布局容器,它的子元素会从上到下依次排列。Row是横向线性布局容器,子元素从左到右排列。这个头部栏的Row中包含三个子元素:皇冠emoji、品牌信息Column、在线状态Row,通过justifyContent(FlexAlign.SpaceBetween)实现了两端对齐——皇冠在最左侧,在线状态在最右侧,品牌信息在中间。

技术要点: Column组件的alignItems属性控制子元素在交叉轴(水平方向)的对齐方式,默认值为HorizontalAlign.Center(居中)。在本例中品牌信息Column设置了.alignItems(HorizontalAlign.Start),使内部两行文字左对齐。Row组件的alignItems控制子元素在交叉轴(垂直方向)的对齐方式,默认值为VerticalAlign.Center。理解Column和Row各自的主轴和交叉轴方向,是掌握ArkUI线性布局的关键。

Circle()是ArkUI的基础图形组件之一,用于绘制圆形。这里通过width(6).height(6).fill('#27AE60')绘制了一个6x6像素的绿色小圆点,配合opacity(this.msgOp7)实现了呼吸闪烁效果——由于msgOp7在aboutToAppear中被animateTo驱动在0.25到0.9之间往返变化,这个小圆点的透明度会不断闪烁,形成"在线"的视觉暗示。

头部栏下半部分是搜索条:

Row() {
  Text('🔎').fontSize(14)
  Text('向管家提问:输 "头痛" 试试 · 全年不限次').fontSize(11).fontColor('#A79C87').margin({ left: 6 }).layoutWeight(1).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
  Text('📞').fontSize(16).onClick(() => {
    this.showCbk7 = true
  })
}
.width('100%')
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.borderRadius(20)
.backgroundColor('#201B12')
.margin({ left: 16, right: 16, bottom: 10 })

在这里插入图片描述

这个搜索条Row中,中间的提示文字使用了layoutWeight(1)maxLines(1)textOverflow({ overflow: TextOverflow.Ellipsis })三个属性配合。layoutWeight(1)让该Text占据Row中剩余的所有空间(排除搜索图标和电话图标后),maxLines(1)限制为一行,TextOverflow.Ellipsis在文字超长时以省略号截断。

技术要点: layoutWeight是ArkUI线性布局中非常关键的属性,它类似于CSS中的flex: 1。在一个Row或Column中,如果某个子元素设置了layoutWeight(1),它会先满足其他未设置layoutWeight的子元素的尺寸需求,然后占据剩余的全部空间。多个子元素同时设置layoutWeight时,按权重比例分配剩余空间。这在需要"自适应宽度文字+固定宽度按钮"的场景中几乎是必用的。

.onClick()是ArkUI组件的通用事件回调属性,接收一个箭头函数作为点击事件处理器。这里点击电话图标后将showCbk7设为true,触发回电预约弹框的显示。.borderRadius(20)将搜索条设为圆角胶囊形状,配合深色背景#201B12形成了搜索输入框的视觉外观。

五、底部Tab栏的实现

5.1 tabItem7单Tab项

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

@Builder方法可以接收参数,这里tabItem7接收三个参数:icon(emoji图标字符串)、label(标签文字)、idx(Tab索引值)。在Builder内部,根据this.curTab7 === idx的判断结果,动态切换标签文字的颜色——选中时为金色#D4AF37,未选中时为暗金#8A7435

这个Builder使用了layoutWeight(1)来均分底部Tab栏的水平空间——5个Tab项各占1/5宽度。当用户点击某个Tab项时,onClick回调将this.curTab7设为该Tab的索引值,由于curTab7是@State变量,其变化会触发build()方法重新执行,根据新的curTab7值渲染对应的Tab内容。

技术要点: @Builder方法接收参数时,参数类型在方法签名中声明。参数值在调用时传入。当参数值或@State变量变化时,Builder内部引用了这些变量的组件会自动更新。在本例中,label文字的fontColor绑定了一个三元表达式this.curTab7 === idx ? '#D4AF37' : '#8A7435',当curTab7变化时,所有5个Tab项的文字颜色都会重新计算——选中的变金,未选中的变暗。

5.2 tabBar7完整Tab栏

@Builder tabBar7() {
  Row() {
    this.tabItem7('🤵', '管家', 0)
    this.tabItem7('🩺', '体检', 1)
    this.tabItem7('💬', '问诊', 2)
    this.tabItem7('👑', '权益', 3)
    this.tabItem7('👤', '我的', 4)
  }
  .width('100%')
  .backgroundColor('#1B1710')
  .border({ width: 0.5, color: '#33D4AF37' })
}

tabBar7是一个简单的Row容器,内部依次调用了5次this.tabItem7(),传入不同的emoji和标签文字。Row的width('100%')使其占满父容器宽度,5个Tab项通过各自的layoutWeight(1)实现等分。.border({ width: 0.5, color: '#33D4AF37' })添加了一条半透明的金色边框线,在黑金主题中起到分隔线的作用。

.border()属性接收一个对象参数,width指定边框宽度(支持小数),color指定颜色。颜色值#33D4AF37中的33是Alpha通道值(十六进制的33约等于十进制的51,即20%不透明度),D4AF37是金色RGB值。ArkUI支持8位十六进制颜色(RGBA格式),前两位为透明度,后六位为RGB。

六、Tab0管家:聊天式消息流

6.1 消息气泡组件msgBubble7

这是本应用中最复杂的@Builder之一,它根据消息的发送方和类型渲染不同样式的气泡。

@Builder msgBubble7(m: MsgT7) {
  if (m.from === 'butler') {
    Row() {
      Column() {
        Text('🤵').fontSize(24)
        Text('管家小艾').fontSize(8).fontColor('#A79C87').margin({ top: 2 })
      }
      .alignItems(HorizontalAlign.Center)

      Column() {
        if (m.kind === 'card') {
          Column() {
            Text(m.title).fontSize(13).fontWeight(700).fontColor('#D4AF37')
            Text(m.text).fontSize(11).fontColor('#F0E9DA').margin({ top: 6 }).lineHeight(17)
          }
          .alignItems(HorizontalAlign.Start)
          .padding(12)
          .borderRadius({ topLeft: 4, topRight: 14, bottomLeft: 14, bottomRight: 14 })
          .backgroundColor('#241E12')
          .border({ width: 0.6, color: '#4DD4AF37' })
          .constraintSize({ maxWidth: '78%' })
        } else {
          Text(m.text)
            .fontSize(12)
            .fontColor('#F0E9DA')
            .lineHeight(18)
            .padding(11)
            .borderRadius({ topLeft: 4, topRight: 14, bottomLeft: 14, bottomRight: 14 })
            .backgroundColor('#241E12')
            .constraintSize({ maxWidth: '78%' })
        }
        Text(m.time).fontSize(8).fontColor('#6B6252').margin({ top: 4, left: 4 })
      }
      .alignItems(HorizontalAlign.Start)
      .margin({ left: 8 })
    }
    .width('100%')
    .alignItems(VerticalAlign.Top)
    .padding({ left: 14, right: 14, top: 6, bottom: 6 })
  } else {
    // 用户消息(右对齐),结构类似但镜像
  }
}

这段代码展示了ArkTS声明式UI中if/else条件渲染的用法。最外层通过if (m.from === 'butler')判断消息发送方:如果是管家发送,渲染左对齐的气泡布局(头像在左、气泡在右);如果是用户发送,则渲染右对齐的气泡布局(头像在右、气泡在左)。

在管家消息分支内部,又嵌套了一个if (m.kind === 'card')判断:如果是卡片消息,气泡内部包含标题和正文两行文字,标题使用金色加粗字体,正文使用浅色文字;如果是纯文本消息,则只渲染一行正文。两种气泡都设置了不对称的圆角borderRadius({ topLeft: 4, topRight: 14, bottomLeft: 14, bottomRight: 14 })——左上角小圆角(4px),其余三角大圆角(14px),模拟了聊天气泡"尾巴"在左上角的效果。

技术要点: ArkTS中的if/else条件渲染与JavaScript的条件表达式有本质区别。在声明式UI中,if/else是在UI描述阶段的分支——条件为true的分支内的组件会被构建到UI树中,false分支的组件不会创建。当条件变化时(如m.kind从’txt’变为’card’),框架会自动销毁旧分支的组件并创建新分支的组件。这与传统DOM操作中手动show/hide的方式截然不同,它是声明式"状态驱动视图"理念的直接体现。

.constraintSize({ maxWidth: '78%' })用于限制气泡的最大宽度为父容器的78%,防止长消息占满整行宽度。这是聊天界面中常见的做法——气泡宽度不超过屏幕宽度的四分之三左右,留出空间显示对方头像和时间戳。

6.2 服务概览卡与环形进度

Row() {
  Stack() {
    Progress({ value: 86, total: 100 })
      .width(64)
      .height(64)
      .style({ strokeWidth: 7 })
      .color('#D4AF37')
    Column() {
      Text('86%').fontSize(14).fontWeight(700).fontColor('#D4AF37')
      Text('年度目标').fontSize(8).fontColor('#A79C87')
    }
    .alignItems(HorizontalAlign.Center)
  }

  Column() {
    Text('管家服务完成度').fontSize(13).fontWeight(700).fontColor('#F0E9DA')
    Text('本月已服务 21 次 · 主动关怀 9 次 · 代约 5 次').fontSize(10).fontColor('#A79C87').margin({ top: 5 })
    Row() {
      Text('预约管家回电').fontSize(10).fontColor('#14110B').fontWeight(700).padding({ left: 12, right: 12, top: 5, bottom: 5 }).borderRadius(14).backgroundColor('#D4AF37').onClick(() => {
        this.showCbk7 = true
      })
      Text('发起问诊').fontSize(10).fontColor('#D4AF37').padding({ left: 12, right: 12, top: 5, bottom: 5 }).borderRadius(14).border({ width: 0.8, color: '#D4AF37' }).margin({ left: 8 }).onClick(() => {
        this.showAsk7 = true
      })
    }
    .margin({ top: 9 })
  }
  .alignItems(HorizontalAlign.Start)
  .margin({ left: 14 })
  .layoutWeight(1)
}

Stack是ArkUI的层叠布局容器,它的子元素会像图层一样从下到上叠放。在本例中,Progress环形进度条在底层,Column(包含百分比文字和"年度目标"标签)在上层,实现了进度条中心显示文字的经典效果。

Progress是ArkUI的进度组件,通过{ value: 86, total: 100 }参数设置当前值和总值。默认类型为环形进度(ProgressType.Ring),style({ strokeWidth: 7 })设置环线宽度为7像素,color('#D4AF37')设置进度条颜色为金色。进度条背景色默认为透明或浅灰,可以根据需要通过backgroundColor()设置。

技术要点: Stack容器的alignContent属性控制所有子元素在Stack内的对齐方式,默认值为Alignment.Center(居中)。这意味着所有子元素默认叠放在Stack的中心位置。如果需要子元素偏移到特定位置,可以使用position()绝对定位或offset()相对偏移。在本例中,Progress和Column都居中叠放,自然形成了"环形进度+中心文字"的布局。

右侧的信息Column通过layoutWeight(1)占据了Row中除环形进度以外的所有剩余空间。内部包含一个主标题、一个副标题描述和两个操作按钮。两个按钮分别使用了实心和描边两种样式——"预约管家回电"使用金色填充背景配深色文字(backgroundColor('#D4AF37') + fontColor('#14110B')),是主要操作按钮;"发起问诊"使用透明背景配金色描边和文字(border({ width: 0.8, color: '#D4AF37' }) + fontColor('#D4AF37')),是次要操作按钮。这种"主次按钮"的视觉区分是移动端UI设计的基本规范。

6.3 本周服务柱状图

Column() {
  Text('📊 本周管家服务次数').fontSize(13).fontWeight(700).fontColor('#F0E9DA')
  Row() {
    ForEach(WEEKSRV7, (d: SrvT7) => {
      Column() {
        Text(d.cnt.toString()).fontSize(9).fontColor(d.cnt >= 6 ? '#D4AF37' : '#A79C87').margin({ bottom: 3 })
        Column() {
          Column() {
          }
          .width(14)
          .height(Math.max(d.cnt * 11, 6))
          .borderRadius({ topLeft: 3, topRight: 3 })
          .backgroundColor(d.cnt >= 6 ? '#D4AF37' : '#6B5A28')
        }
        .width('100%')
        .height(90)
        .justifyContent(FlexAlign.End)

        Text(d.day).fontSize(9).fontColor('#8A7435').margin({ top: 5 })
      }
      .alignItems(HorizontalAlign.Center)
      .layoutWeight(1)
    }, (d: SrvT7) => 'wk' + d.day)
  }
  .width('100%')
  .margin({ top: 12 })
}

ForEach是ArkUI的列表渲染控制语句,它接收三个参数:数据源数组、子项渲染函数、键值生成函数。数据源WEEKSRV7是一个包含7天服务数据的数组,ForEach会为每个数据项调用渲染函数,生成一个Column作为柱状图的一根柱子。

柱状图的实现技巧值得关注:每根柱子的容器是一个height(90)的Column,内部通过justifyContent(FlexAlign.End)实现内容从底部对齐——这样不同高度的柱子都能"立"在同一基线上。柱子本身是一个空的Column,其高度通过Math.max(d.cnt * 11, 6)动态计算——服务次数乘以11像素作为柱高,最低6像素保证可见。Math.max的使用确保了即使某天服务次数为0,柱子也有一个最小高度。

柱子颜色通过三元表达式d.cnt >= 6 ? '#D4AF37' : '#6B5A28'动态设置——服务次数达到6次及以上的柱子为亮金色,低于6次的为暗金色,形成视觉上的数据高亮。

技术要点: ForEach的第三个参数(键值生成器)非常重要,它为每个数据项生成唯一标识。框架通过比对前后两次渲染的键值来决定是复用、移动还是销毁/重建组件。在本例中,键值为'wk' + d.day(如"wk周一"),由于星期是固定的,键值稳定不变,ForEach在数据更新时只会更新柱子高度而不会销毁重建整个列表。如果省略键值生成器或使用不稳定的键值(如数组索引),在列表数据增删时可能导致不必要的重渲染甚至闪烁。

6.4 消息流列表与空状态

ForEach(this.msgs7, (m: MsgT7) => {
  this.msgBubble7(m)
}, (m: MsgT7) => 'msg' + m.id.toString())

if (this.msgs7.length === 0) {
  Column() {
    Text('🕊️').fontSize(34)
    Text('对话已清空,向管家发起一次问诊吧').fontSize(11).fontColor('#8A7435').margin({ top: 8 })
  }
  .alignItems(HorizontalAlign.Center)
  .padding(30)
}

消息流通过ForEach渲染this.msgs7数组中的每条消息,调用this.msgBubble7(m)将消息气泡Builder展开到列表中。键值生成器使用'msg' + m.id.toString(),由于每条消息的id唯一(通过Date.now()生成),键值稳定且无冲突。

当消息数组为空时(用户点击了"清空对话"),if (this.msgs7.length === 0)条件为真,渲染空状态提示。空状态包含一个鸽子emoji和一句引导文字,居中显示。这种"空状态"处理是列表型UI的必备交互——当数据为空时,不能显示空白页面,而应该给用户一个明确的视觉反馈和引导操作。

6.5 快捷输入条

Row() {
  Text('🩺 报告解读').fontSize(10).fontColor('#D4AF37').padding({ left: 9, right: 9, top: 6, bottom: 6 }).borderRadius(12).border({ width: 0.7, color: '#D4AF37' }).onClick(() => {
    const nm: MsgT7 = { id: Date.now(), from: 'me', kind: 'txt', title: '', text: '请帮我解读最新体检报告。', time: '刚刚' }
    this.msgs7 = [nm].concat(this.msgs7.slice(0, 24))
  })
  // ...更多快捷按钮
}
.width('100%')
.padding({ left: 14, right: 14, top: 10, bottom: 16 })

快捷输入条提供了4个快捷操作按钮。点击"报告解读"按钮时,代码创建了一个新的MsgT7对象,然后通过[nm].concat(this.msgs7.slice(0, 24))将新消息插入到消息列表头部,并保留原有消息的前24条。这种"头部插入新消息"的模式使得最新的消息总是显示在列表顶部。

Date.now()用于生成消息的唯一ID——当前时间戳,确保每条新消息的id不重复。from: 'me'标识这是用户发送的消息。通过赋值新数组给this.msgs7(@State变量),触发UI重渲染,新消息气泡出现在列表顶部。

技术要点: concat方法返回一个新数组而不修改原数组,这是函数式编程中的不可变数据操作方式。在ArkTS声明式UI中,使用不可变数据操作(如concat、map、filter)来更新@State数组是推荐的做法——每次操作都生成新数组引用,确保框架能检测到引用变化并触发重渲染。相比之下,直接调用push/splice修改原数组可能不会被框架捕获到(取决于ArkUI的具体实现版本),因此赋值新数组是最可靠的方式。

七、Tab1体检:套餐榜单

7.1 体检概览统计

Row() {
  Column() {
    Text('3').fontSize(22).fontWeight(700).fontColor('#D4AF37')
    Text('累计体检次数').fontSize(9).fontColor('#A79C87').margin({ top: 3 })
  }
  .layoutWeight(1)
  .alignItems(HorizontalAlign.Center)

  Column() {
    Text('86').fontSize(22).fontWeight(700).fontColor('#5DADE2')
    Text('最近健康评分').fontSize(9).fontColor('#A79C87').margin({ top: 3 })
  }
  .layoutWeight(1)
  .alignItems(HorizontalAlign.Center)

  Column() {
    Text('2').fontSize(22).fontWeight(700).fontColor('#27AE60')
    Text('待随访项').fontSize(9).fontColor('#A79C87').margin({ top: 3 })
  }
  .layoutWeight(1)
  .alignItems(HorizontalAlign.Center)

  Column() {
    Text('98').fontSize(22).fontWeight(700).fontColor('#E74C3C')
    Text('加项抵扣余额').fontSize(9).fontColor('#A79C87').margin({ top: 3 })
  }
  .layoutWeight(1)
  .alignItems(HorizontalAlign.Center)
}
.width('100%')
.padding({ top: 16, bottom: 16 })
.borderRadius(14)
.backgroundColor('#201B12')
.margin({ left: 14, right: 14, top: 12 })

这是一个典型的四等分统计栏。外层Row包含四个Column,每个Column通过layoutWeight(1)均分宽度。每个统计项由一个大字号数字和一个小字号标签组成,数字使用不同颜色区分——金色表示体检次数、蓝色表示健康评分、绿色表示待随访、红色表示抵扣余额(金额相关用红色是电商常见做法)。

技术要点: layoutWeight的均分原理是:父容器(Row)在分配子元素宽度时,先满足没有设置layoutWeight或设置了固定width的子元素,然后将剩余空间按layoutWeight的值比例分配给设置了layoutWeight的子元素。本例中四个Column都没有设置固定width,都设置了layoutWeight(1),因此剩余空间(即Row的全部可用宽度)被四等分。如果某个Column设置了layoutWeight(2),它将占据两倍于其他Column的宽度。

7.2 优惠横条与金光扫过特效

Stack() {
  Column() {
    Text('🎁 会员日:本月下单套餐立减 ¥300,加项 8 折').fontSize(11).fontColor('#D4AF37').fontWeight(600)
  }
  .width('100%')
  .alignItems(HorizontalAlign.Center)
  .padding({ top: 10, bottom: 10 })
  .borderRadius(12)
  .backgroundColor('#241E12')
  .border({ width: 0.8, color: '#4DD4AF37' })

  Text('✨').fontSize(14).translate({ x: this.shineX7 }).opacity(0.85)
}
.width('100%')
.alignContent(Alignment.Start)
.margin({ left: 14, right: 14, top: 10 })

这里Stack层叠了两个元素:底层是优惠信息的Column,顶层是一个星星emoji。星星通过.translate({ x: this.shineX7 })实现了水平位移,由于shineX7在aboutToAppear中被animateTo驱动在-46到46之间往返变化,星星会从左到右再从右到左反复扫过横条,形成"金光扫过"的视觉特效。

.translate()是ArkUI的2D变换属性,它通过x/y参数实现元素的位移。与position()(绝对定位)不同,translate是相对位移——元素在布局中的原始位置不变,只是在渲染时偏移一定距离,不影响其他元素的布局。这使得星星可以在不干扰横条内部布局的情况下自由移动。

7.3 套餐卡片pkgCard7

@Builder pkgCard7(p: PkgT7) {
  Column() {
    Row() {
      Text(p.name).fontSize(13).fontWeight(700).fontColor('#F0E9DA').layoutWeight(1).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
      Text(p.tag).fontSize(9).fontColor('#14110B').padding({ left: 7, right: 7, top: 3, bottom: 3 }).borderRadius(8).backgroundColor(p.hot ? '#E74C3C' : '#D4AF37')
    }
    .width('100%')

    Text(p.items).fontSize(10).fontColor('#A79C87').margin({ top: 7 }).lineHeight(15).maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })

    Row() {
      Column() {
        Row() {
          Text('¥').fontSize(10).fontColor('#D4AF37')
          Text(p.price.toString()).fontSize(18).fontWeight(700).fontColor('#D4AF37')
          Text('¥' + p.orig.toString()).fontSize(9).fontColor('#6B6252').decoration({ type: TextDecorationType.LineThrough }).margin({ left: 6 })
        }
        .alignItems(VerticalAlign.Bottom)
        Text('已售 ' + fmtWan7(p.sold) + ' · 立省 ¥' + saveYuan7(p).toString()).fontSize(9).fontColor('#8A7435').margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start)

      Text('加购').fontSize(11).fontColor('#14110B').fontWeight(700).padding({ left: 16, right: 16, top: 7, bottom: 7 }).borderRadius(16).backgroundColor('#D4AF37').onClick(() => {
        this.buyId7 = p.id
        this.buyQty7 = 1
        this.buyExtras7 = []
        this.showBuy7 = true
      })
    }
    .width('100%')
    .justifyContent(FlexAlign.SpaceBetween)
    .alignItems(VerticalAlign.Center)
    .margin({ top: 10 })
  }
  .width('100%')
  .alignItems(HorizontalAlign.Start)
  .padding(13)
  .borderRadius(14)
  .backgroundColor('#201B12')
  .border({ width: 0.6, color: '#33D4AF37' })
  .margin({ left: 14, right: 14, top: 10 })
}

套餐卡片是一个信息密集的UI组件。顶部Row包含套餐名称(layoutWeight占据剩余空间,超长省略)和标签(热销为红色背景#E74C3C,普通为金色背景#D4AF37)。中间是检查项目描述,限制两行显示,超出省略。底部Row使用justifyContent(FlexAlign.SpaceBetween)两端对齐:左侧是价格信息,右侧是"加购"按钮。

价格信息的渲染值得仔细分析。它使用了三层Row嵌套:最外层Row包含人民币符号(小字号10)、价格数字(大字号18加粗)、原价(小字号9带删除线)。decoration({ type: TextDecorationType.LineThrough })为原价添加了删除线效果,这是电商价格展示的标准做法。三个Text通过.alignItems(VerticalAlign.Bottom)底对齐,使不同字号的文字底部齐平。

技术要点: TextDecorationType是ArkUI的文本装饰类型枚举,包含None(无装饰)、Underline(下划线)、LineThrough(删除线)、Overline(上划线)四种。在电商场景中,LineThrough常用于原价、Underline常用于强调或链接文字。配合不同的字号和颜色,可以在一个Row中构建出层次分明的价格展示。

八、Tab2问诊:专家双列瀑布流

8.1 筛选条expChip7

@Builder expChip7(label: string, idx: number) {
  Text(label).fontSize(10).fontColor(this.expFilter7 === idx ? '#14110B' : '#A79C87').padding({ left: 12, right: 12, top: 6, bottom: 6 }).borderRadius(14).backgroundColor(this.expFilter7 === idx ? '#D4AF37' : '#241E12').onClick(() => {
    this.expFilter7 = idx
  })
}

筛选Chip是一个简单的Text组件,通过this.expFilter7 === idx三元判断切换选中态和未选中态的样式。选中时背景为金色、文字为深色;未选中时背景为深色、文字为灰色。点击时将expFilter7设为当前Chip的索引值,触发列表重新过滤。

8.2 专家卡片expCard7

@Builder expCard7(e: ExpT7) {
  Column() {
    Row() {
      Stack() {
        Circle().width(42).height(42).fill('#241E12')
        Text(e.name.substring(0, 1)).fontSize(17).fontWeight(700).fontColor('#D4AF37')
      }
      Column() {
        Text(e.name + ' ' + e.title).fontSize(12).fontWeight(700).fontColor('#F0E9DA').maxLines(1)
        Text(e.dept).fontSize(9).fontColor('#D4AF37').margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start)
      .margin({ left: 8 })
      .layoutWeight(1)
    }
    .width('100%')

    Text(e.hosp).fontSize(9).fontColor('#8A7435').margin({ top: 7 }).maxLines(1)

    Column() {
      Row() {
        Text('评分 ' + e.score.toFixed(1)).fontSize(9).fontColor('#F39C12')
        Text(fmtWan7(e.patients) + '人问诊').fontSize(9).fontColor('#6B6252')
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      Row() {
        Column() {
          Column() {
          }
          .width((e.score / 5 * 100).toFixed(0) + '%')
          .height('100%')
          .borderRadius(3)
          .backgroundColor('#F39C12')
        }
        .layoutWeight(1)
        .height(5)
        .borderRadius(3)
        .backgroundColor('#33D4AF37')
        Row() {
        }
        .width('100%')
        .margin({ top: 5 })
      }
    }
    .width('100%')
    .margin({ top: 7 })
  }
  // ...更多内容
}

专家卡片中头像部分使用了Stack层叠一个Circle(深色背景圆)和一个Text(专家姓氏首字,金色),形成了圆形头像的效果。e.name.substring(0, 1)截取姓名的第一个字符作为头像文字。

评分横条是一个自定义进度条:外层ColumnlayoutWeight(1) + height(5) + 深色背景#33D4AF37)作为轨道,内层空Column通过width((e.score / 5 * 100).toFixed(0) + '%')动态设置宽度百分比——评分除以5再乘以100得到百分比,如评分4.9对应的宽度为"98%"。内层Column使用橙色#F39C12背景,形成进度条效果。

技术要点: Circle是ArkUI的基础图形组件之一,通过fill属性设置填充色。除了Circle,ArkUI还提供了Rect(矩形)、Ellipse(椭圆)、Line(直线)、Polyline(折线)、Polygon(多边形)等图形组件,它们可以用于绘制各种自定义视觉效果。在本应用中,Circle被广泛用于头像背景、在线指示灯等圆形元素。结合Stack层叠布局,可以实现"图形+文字"的组合效果。

8.3 双列瀑布流布局

Row() {
  Column() {
    ForEach(this.exps7.filter((e: ExpT7) => {
      if (this.expFilter7 === 1) {
        return e.online
      }
      if (this.expFilter7 === 2) {
        return e.title === '主任医师'
      }
      if (this.expFilter7 === 3) {
        return e.price < 400
      }
      return true
    }).filter((e: ExpT7, i: number) => i % 2 === 0), (e: ExpT7) => {
      this.expCard7(e)
    }, (e: ExpT7) => 'exA' + e.id.toString())
  }
  .layoutWeight(1)

  Column() {
    ForEach(this.exps7.filter((e: ExpT7) => {
      // ...相同过滤逻辑
    }).filter((e: ExpT7, i: number) => i % 2 === 1), (e: ExpT7) => {
      this.expCard7(e)
    }, (e: ExpT7) => 'exB' + e.id.toString())
  }
  .layoutWeight(1)
  .margin({ left: 8 })
}
.width('100%')
.alignItems(VerticalAlign.Top)

双列瀑布流的实现思路是:先将数据源通过filter方法根据筛选条件过滤,然后再用filter((e, i) => i % 2 === 0)取偶数索引项作为左列、filter((e, i) => i % 2 === 1)取奇数索引项作为右列。两个Column各占layoutWeight(1)实现等宽双列。

筛选逻辑通过this.expFilter7的值控制:0为全部、1为在线可问、2为主任医师、3为400元以下。每次切换筛选条件时,expFilter7变化触发@State重渲染,两个ForEach的filter函数重新执行,生成新的过滤结果。

技术要点: filter是Array原型方法,它接收一个谓词函数,返回所有满足条件的元素组成的新数组。在ArkTS声明式UI中,在ForEach的数据源中使用filter是一种常见的动态列表实现方式——数据源本身不变,但通过filter动态计算渲染哪些项。需要注意的是,filter在每次渲染时都会执行,如果数据量大且过滤逻辑复杂,可能影响性能。在这种场景下,更好的做法是将过滤结果缓存到另一个@State变量中,仅在筛选条件变化时重新计算。

九、Tab3权益:VIP金卡与权益列表

9.1 VIP金卡与扫光特效

Stack() {
  Column() {
    Row() {
      Column() {
        Text('PLUS CONCIERGE').fontSize(15).fontWeight(700).fontColor('#14110B').letterSpacing(1)
        Text('DIAMOND · NO.8829 6688').fontSize(9).fontColor('#4D3E14').margin({ top: 5 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)

      Text('👑').fontSize(30)
    }
    .width('100%')

    Row() {
      Text('有效期至 2027-01-01').fontSize(9).fontColor('#4D3E14')
      Text('剩余权益 ' + this.bens7.length.toString() + ' 项').fontSize(9).fontColor('#14110B').fontWeight(700)
    }
    .width('100%')
    .justifyContent(FlexAlign.SpaceBetween)
    .margin({ top: 18 })
  }
  .padding(16)
  .width('100%')
  .borderRadius(18)
  .backgroundColor('#D4AF37')

  Text('✨').fontSize(16).translate({ x: this.shineX7 }).opacity(0.9)
}
.width('100%')
.alignContent(Alignment.Start)
.margin({ left: 14, right: 14, top: 12 })

VIP金卡是整个应用中最具视觉冲击力的元素之一。它使用金色背景(#D4AF37)作为卡面,深色文字(#14110B)形成高对比度。letterSpacing(1)为品牌名称添加了字间距,增强了高级感。卡面上叠放了星星emoji,通过与优惠横条相同的translate({ x: this.shineX7 })实现金光扫过效果。

StackalignContent(Alignment.Start)设置使子元素左对齐叠放——星星emoji从卡面左侧开始扫过。如果不设置alignContent,默认为居中叠放,星星会从卡面中心开始,效果不够自然。

9.2 权益使用率进度条

Row() {
  Text('🧾 权益使用率').fontSize(12).fontWeight(700).fontColor('#F0E9DA')
  Text('16 / 39 次').fontSize(10).fontColor('#D4AF37').margin({ left: 8 })
  Row() {
    Column() {
      Column() {
      }
      .width('41%')
      .height('100%')
      .borderRadius(3)
      .backgroundColor('#D4AF37')
    }
    .layoutWeight(1)
    .height(5)
    .borderRadius(3)
    .backgroundColor('#33D4AF37')
    .margin({ left: 8 })
  }
  .layoutWeight(1)
  .margin({ left: 10 })
}

这是一个自定义的线性进度条实现:外层Row包含一个标签Column和一个进度条Row。进度条Row通过layoutWeight(1)占据剩余空间,内部嵌套一个height(5)的Column作为轨道(深色背景#33D4AF37),轨道内再嵌套一个空Column作为进度填充(width('41%') + 金色背景#D4AF37)。

技术要点: ArkUI虽然提供了Progress组件(如环形进度和线性进度),但在需要精确控制样式(如自定义颜色、高度、圆角等)的场景中,手动用Column嵌套实现进度条是更灵活的选择。这种"容器+填充"的进度条实现模式在声明式UI中非常通用,核心思路是:外层容器设置总宽度/高度和背景色作为轨道,内层元素设置百分比宽度/高度和不同背景色作为进度填充。

9.3 权益列表与退订

ForEach(this.bens7, (b: BenT7) => {
  Column() {
    Row() {
      Text(b.icon).fontSize(22)
      Column() {
        Text(b.name).fontSize(12).fontWeight(700).fontColor('#F0E9DA')
        Text(b.desc).fontSize(9).fontColor('#A79C87').margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start)
      .margin({ left: 10 })
      .layoutWeight(1)

      Column() {
        Text(b.used.toString() + ' / ' + b.total.toString()).fontSize(11).fontWeight(700).fontColor(b.color)
        Text(b.used >= b.total ? '已用完' : '可使用').fontSize(8).fontColor('#8A7435').margin({ top: 2 })
      }
      .alignItems(HorizontalAlign.End)
    }
    .width('100%')

    Row() {
      Progress({ value: b.used, total: b.total, type: ProgressType.Linear })
        .layoutWeight(1)
        .height(5)
        .style({ strokeWidth: 5 })
        .color(b.color)
        .backgroundColor('#17130C')
      Text('退订').fontSize(9).fontColor('#6B6252').padding({ left: 10, right: 10, top: 4, bottom: 4 }).borderRadius(10).border({ width: 0.7, color: '#3D3628' }).margin({ left: 10 }).onClick(() => {
        this.unsubId7 = b.id
        this.unsubName7 = b.name
        this.armed7 = false
        this.showUnsub7 = true
      })
    }
    .width('100%')
    .alignItems(VerticalAlign.Center)
    .margin({ top: 10 })
  }
  .width('100%')
  .alignItems(HorizontalAlign.Start)
  .padding(13)
  .borderRadius(14)
  .backgroundColor('#201B12')
  .margin({ left: 14, right: 14, top: 8 })
}, (b: BenT7) => 'ben' + b.id.toString() + '_' + b.used.toString())

权益列表中每项包含一个emoji图标、名称、描述、使用次数和线性进度条。这里使用了ArkUI内置的Progress组件的线性模式(type: ProgressType.Linear),通过color(b.color)设置进度条颜色为权益项自身的颜色属性。不同权益项有不同的颜色,使列表色彩丰富而不单调。

键值生成器使用了'ben' + b.id.toString() + '_' + b.used.toString()——将使用次数也纳入键值。这意味着当权益的使用次数变化时(如使用了一次),键值会变化,ForEach会认为这是一个新数据项并重新创建对应的组件。这种设计在需要强制刷新进度条动画时是有用的,但也可能带来额外的渲染开销。

退订按钮点击时设置unsubId7unsubName7armed7(确认开关重置为false)三个状态变量,然后打开退订确认弹框。这种"先设置上下文参数再打开弹框"的模式在本应用中反复出现,是处理弹框上下文数据的通用做法。

十、Tab4我的:诉求与订单

10.1 个人信息卡

Row() {
  Stack() {
    Circle().width(52).height(52).fill('#241E12')
    Text('👨').fontSize(26)
  }
  Column() {
    Text('王先生').fontSize(15).fontWeight(700).fontColor('#F0E9DA')
    Row() {
      Text('👑 钻石会员').fontSize(9).fontColor('#14110B').padding({ left: 8, right: 8, top: 2, bottom: 2 }).borderRadius(8).backgroundColor('#D4AF37')
      Text('健康评分 86 · 超越 92% 同龄人').fontSize(9).fontColor('#A79C87').margin({ left: 8 })
    }
    .margin({ top: 5 })
  }
  .alignItems(HorizontalAlign.Start)
  .margin({ left: 12 })
  .layoutWeight(1)

  Text('编辑档案').fontSize(10).fontColor('#D4AF37').padding({ left: 10, right: 10, top: 6, bottom: 6 }).borderRadius(12).border({ width: 0.7, color: '#D4AF37' }).onClick(() => {
    this.editAid7 = 0
    this.appealText7 = ''
    this.appealLvl7 = 0
    this.appealFreq7 = 3
    this.showAppeal7 = true
  })
}
.width('100%')
.alignItems(VerticalAlign.Center)
.padding(14)
.borderRadius(16)
.backgroundColor('#201B12')
.margin({ left: 14, right: 14, top: 12 })

个人信息卡使用了与专家卡片相同的头像实现方式——Stack层叠Circle和emoji/文字。右侧的"编辑档案"按钮点击时会重置诉求编辑表单的四个状态变量(editAid7为0表示新增、清空文本、优先级默认0、频率默认3),然后打开诉求编辑弹框。

10.2 健康诉求列表

ForEach(this.appeals7, (a: AppealT7) => {
  Row() {
    Column() {
      Text(a.level).fontSize(8).fontColor(a.level === '重点' ? '#E74C3C' : '#A79C87').padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(6).backgroundColor(a.level === '重点' ? '#26E74C3C' : '#241E12')
      Text('每周关注 ' + a.freq.toString() + ' 次').fontSize(8).fontColor('#8A7435').margin({ top: 5 })
    }
    .alignItems(HorizontalAlign.Start)

    Text(a.text).fontSize(11).fontColor('#F0E9DA').layoutWeight(1).margin({ left: 10 }).maxLines(2)

    Column() {
      Text('✏️').fontSize(14).onClick(() => {
        this.editAid7 = a.id
        this.appealText7 = a.text
        this.appealLvl7 = Math.max(0, LVLS7.indexOf(a.level))
        this.appealFreq7 = a.freq
        this.showAppeal7 = true
      })
      Text(a.on ? '🔔' : '🔕').fontSize(13).margin({ top: 8 }).onClick(() => {
        this.appeals7 = this.appeals7.map((x: AppealT7) => {
          if (x.id === a.id) {
            return { id: x.id, text: x.text, level: x.level, freq: x.freq, on: !x.on }
          }
          return x
        })
      })
    }
    .alignItems(HorizontalAlign.Center)
  }
  .width('100%')
  .alignItems(VerticalAlign.Center)
  .padding(12)
  .borderRadius(12)
  .backgroundColor('#201B12')
  .margin({ left: 14, right: 14, top: 6 })
}, (a: AppealT7) => 'ap' + a.id.toString() + '_' + a.on.toString())

诉求列表中每项包含优先级标签、关注频率、诉求文本、编辑按钮和开关按钮。优先级标签根据级别动态着色——"重点"为红色背景(#26E74C3C,26为15%透明度),其他为深色背景。

编辑按钮点击时,将当前诉求的各项数据填充到编辑表单的状态变量中:editAid7设为当前诉求ID(非0表示编辑模式),appealText7填入诉求文本,appealLvl7通过LVLS7.indexOf(a.level)反查优先级数组的索引值,appealFreq7直接填入频率值。然后打开诉求编辑弹框——由于表单状态已填充,弹框会显示当前诉求的内容,用户可以在此基础上修改。

开关按钮使用了map方法更新数组——遍历所有诉求,找到ID匹配的项,将其on字段取反,其他项保持不变。map方法返回新数组,赋值给this.appeals7触发重渲染。键值生成器包含a.on.toString(),确保开关状态变化时对应项的组件能正确更新。

技术要点: map方法是函数式编程中不可变数据更新的核心手段。它接收一个转换函数,对数组每个元素执行转换并返回新数组。在本例中,转换函数检查当前元素是否为目标元素,如果是则返回修改后的新对象(on: !x.on),否则返回原对象。这种"找到目标并修改、其他保持不变"的模式在React/Vue等响应式框架中也非常常见,是处理列表数据更新的标准方式。

10.3 服务订单列表

ForEach(this.ords7, (o: OrdT7) => {
  Row() {
    Column() {
      Text(o.name).fontSize(11).fontColor('#F0E9DA').maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
      Row() {
        Text(o.state).fontSize(9).fontColor(stateColor7(o.state))
        Text(o.date).fontSize(9).fontColor('#6B6252').margin({ left: 10 })
      }
      .margin({ top: 6 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)

    Text(o.price > 0 ? '¥' + o.price.toString() : '权益核销').fontSize(11).fontWeight(700).fontColor(o.price > 0 ? '#D4AF37' : '#27AE60')
  }
  .width('100%')
  .alignItems(VerticalAlign.Center)
  .padding(12)
  .borderRadius(12)
  .backgroundColor('#1B1710')
  .margin({ left: 14, right: 14, top: 5 })
}, (o: OrdT7) => 'od' + o.id.toString())

订单列表中每项的右侧价格文字根据o.price > 0判断显示金额还是"权益核销"——价格为正数显示金色金额(#D4AF37),为0则显示绿色"权益核销"文字(#27AE60)。状态颜色通过之前定义的stateColor7工具函数动态计算。这种将颜色逻辑抽离到工具函数的做法,使得状态与颜色的映射关系集中管理,便于后续维护和修改。

十一、弹框系统

11.1 弹框架构概览

本应用定义了6个弹框Builder,分别服务于不同的交互场景。以下是弹框系统的整体架构:

弹框关闭

弹框触发源

showCbk7=true

showCbk7=true

showAsk7=true

showAsk7=true

showAsk7=true

showBuy7=true

showUnsub7=true

showExp7=true

showAppeal7=true

showAppeal7=true

showCbk7=false

showAsk7=false + 新消息

showBuy7=false + 新订单

showUnsub7=false + 权益移除

showExp7=false 或 跳转问诊

showAppeal7=false + 诉求更新

弹框渲染

头部电话图标

cbkOverlay7
管家回电预约

服务概览-预约回电

服务概览-发起问诊

askOverlay7
发起问诊

快捷输入-加号

专家卡片-问诊

套餐卡片-加购

buyOverlay7
体检加购

权益列表-退订

unsubOverlay7
退订权益

专家卡片-详情

expOverlay7
专家详情

我的-编辑档案

appealOverlay7
编辑诉求

诉求-编辑

返回主界面

消息列表更新

订单列表更新

权益列表更新

返回或打开问诊弹框

诉求列表更新

11.2 问诊弹框askOverlay7

@Builder askOverlay7() {
  Column() {
    Column() {
      // 拖拽指示器
      Column() {
        Text('').fontSize(4).width(40).borderRadius(2).backgroundColor('#4D4636')
      }
      .width('100%')
      .alignItems(HorizontalAlign.Center)
      .padding({ top: 10, bottom: 4 })

      // 标题栏
      Row() {
        Text('发起视频/图文问诊').fontSize(15).fontWeight(700).fontColor('#F0E9DA')
        Text('✕').fontSize(15).fontColor('#8A7435').onClick(() => {
          this.showAsk7 = false
        })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      .padding({ left: 16, right: 16, top: 6 })

      // 可滚动内容区
      Scroll() {
        Column() {
          Text('选择专家').fontSize(11).fontColor('#A79C87').margin({ top: 10 })
          Flex({ wrap: FlexWrap.Wrap }) {
            ForEach(EXPS7, (e: ExpT7, i: number) => {
              Text(e.name + '·' + e.title).fontSize(10).fontColor(this.askExpIdx7 === i ? '#14110B' : '#A79C87').padding({ left: 10, right: 10, top: 6, bottom: 6 }).borderRadius(12).backgroundColor(this.askExpIdx7 === i ? '#D4AF37' : '#241E12').margin({ right: 8, bottom: 8 }).onClick(() => {
                this.askExpIdx7 = i
              })
            }, (e: ExpT7) => 'askExp' + e.id.toString())
          }
          .width('100%')
          // ...问诊方式、期望时间、病情描述等
        }
        .width('100%')
        .padding({ left: 16, right: 16, bottom: 16 })
      }
      .layoutWeight(1)
      .align(Alignment.Top)
      .edgeEffect(EdgeEffect.Spring)

      // 确认按钮
      Text('确认发起')
        .fontSize(13)
        .fontWeight(700)
        .fontColor('#14110B')
        .width('100%')
        .textAlign(TextAlign.Center)
        .padding({ top: 13, bottom: 13 })
        .borderRadius(22)
        .backgroundColor('#D4AF37')
        .margin({ left: 16, right: 16, bottom: 14 })
        .onClick(() => {
          const e: ExpT7 = EXPS7[this.askExpIdx7]
          const nm: MsgT7 = {
            id: Date.now(),
            from: 'butler',
            kind: 'txt',
            title: '',
            text: '已为您预约 ' + e.name + ' ' + e.title + '...',
            time: '刚刚'
          }
          this.msgs7 = [nm].concat(this.msgs7.slice(0, 24))
          this.showAsk7 = false
        })
    }
    .width('100%')
    .height('72%')
    .borderRadius({ topLeft: 22, topRight: 22 })
    .backgroundColor('#201B12')
  }
  .width('100%')
  .height('100%')
  .justifyContent(FlexAlign.End)
  .backgroundColor('#B314110B')
  .zIndex(999)
  .onClick(() => {
    this.showAsk7 = false
  })
}

问诊弹框是一个底部抽屉式弹框。整体结构由三层Column嵌套构成:最外层Column是全屏遮罩(width('100%') + height('100%') + 半透明背景#B314110B),通过justifyContent(FlexAlign.End)将内容推到底部;中间层Column是抽屉面板(height('72%') + 顶部圆角borderRadius({ topLeft: 22, topRight: 22 }));最内层是标题栏、Scroll内容区和确认按钮。

技术要点: zIndex是ArkUI的层级控制属性,它决定了兄弟组件在Stack中的渲染顺序(或在同一布局流中的覆盖优先级)。zIndex值越大,组件越在上层。本应用中所有弹框都设置了zIndex(999),确保它们覆盖在主内容之上。需要注意的是,zIndex只在同一父容器的子组件之间生效——如果两个组件位于不同的父容器中,zIndex的比较没有意义。

Scroll是ArkUI的滚动容器组件,它使其子内容可以超出容器边界滚动。.layoutWeight(1)让Scroll占据弹框中标题栏和确认按钮之间的所有剩余空间。.align(Alignment.Top)设置内容从顶部开始排列。.edgeEffect(EdgeEffect.Spring)设置滚动到边缘时的回弹效果,模拟iOS的弹性滚动体验。

Flex({ wrap: FlexWrap.Wrap })是ArkUI弹性布局容器,FlexWrap.Wrap设置子元素可以换行。与Row不同,Flex支持换行——当子元素在一行排满后,会自动换到下一行。这在标签选择、Chip列表等数量不固定的场景中非常实用。本例中专家选择列表使用了Flex Wrap,使专家标签可以自动排列换行,适应不同屏幕宽度。

技术要点: FlexRow/Column的主要区别在于Flex支持wrap(换行)和alignContent(多行对齐)。当子元素数量不确定或需要自动换行时,使用Flex是最佳选择。FlexWrap枚举包含NoWrap(不换行,默认)、Wrap(正向换行)、WrapReverse(反向换行)三种模式。在本应用中,标签选择类弹框(专家、时间、加购项)都使用了Flex({ wrap: FlexWrap.Wrap })

11.3 回电预约弹框cbkOverlay7

@Builder cbkOverlay7() {
  Column() {
    Column() {
      Text('📞 预约管家回电').fontSize(15).fontWeight(700).fontColor('#F0E9DA')
      Text('选择您方便的时间,专属管家小艾将致电您').fontSize(10).fontColor('#A79C87').margin({ top: 6 })

      Flex({ wrap: FlexWrap.Wrap }) {
        ForEach(CBK7, (t: string, i: number) => {
          Text(t).fontSize(10).fontColor(this.cbkIdx7 === i ? '#14110B' : '#A79C87').padding({ left: 10, right: 10, top: 7, bottom: 7 }).borderRadius(12).backgroundColor(this.cbkIdx7 === i ? '#D4AF37' : '#241E12').margin({ right: 8, bottom: 8 }).onClick(() => {
            this.cbkIdx7 = i
          })
        }, (t: string) => 'cbk' + t)
      }
      .width('100%')
      .margin({ top: 14 })

      Text('回电号码').fontSize(11).fontColor('#A79C87').margin({ top: 8 })
      TextInput({ placeholder: '请输入回电手机号', text: this.cbkPhone7 })
        .fontSize(11)
        .fontColor('#F0E9DA')
        .placeholderColor('#6B6252')
        .backgroundColor('#17130C')
        .borderRadius(10)
        .height(40)
        .padding({ left: 10, right: 10 })
        .margin({ top: 6 })
        .onChange((v: string) => {
          this.cbkPhone7 = v
        })

      Row() {
        Text('提前 15 分钟短信提醒').fontSize(11).fontColor('#F0E9DA').layoutWeight(1)
        Toggle({ type: ToggleType.Switch, isOn: this.cbkRemind7 })
          .selectedColor('#D4AF37')
          .onChange((on: boolean) => {
            this.cbkRemind7 = on
          })
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)
      .margin({ top: 14 })

      Row() {
        Text('取消').fontSize(12).fontColor('#A79C87').layoutWeight(1).textAlign(TextAlign.Center).padding({ top: 11, bottom: 11 }).borderRadius(20).border({ width: 0.8, color: '#4D4636' }).onClick(() => {
          this.showCbk7 = false
        })
        Text('确认预约').fontSize(12).fontColor('#14110B').fontWeight(700).layoutWeight(1).textAlign(TextAlign.Center).padding({ top: 11, bottom: 11 }).borderRadius(20).backgroundColor('#D4AF37').margin({ left: 10 }).onClick(() => {
          const nm: MsgT7 = {
            id: Date.now(),
            from: 'butler',
            kind: 'txt',
            title: '',
            text: '回电已预约:' + CBK7[this.cbkIdx7] + ' 致电 ' + this.cbkPhone7 + (this.cbkRemind7 ? ',已开启提前提醒 📲' : '') + '。届时为您逐项过健康计划。',
            time: '刚刚'
          }
          this.msgs7 = [nm].concat(this.msgs7.slice(0, 24))
          this.showCbk7 = false
        })
      }
      .width('100%')
      .margin({ top: 18 })
    }
    .width('84%')
    .padding(18)
    .borderRadius(18)
    .backgroundColor('#201B12')
    .border({ width: 0.8, color: '#4DD4AF37' })
    .onClick(() => {
    })
  }
  .width('100%')
  .height('100%')
  .justifyContent(FlexAlign.Center)
  .backgroundColor('#B314110B')
  .zIndex(999)
  .onClick(() => {
    this.showCbk7 = false
  })
}

回电预约弹框是一个居中卡片式弹框。与问诊弹框的底部抽屉不同,它通过justifyContent(FlexAlign.Center)将卡片居中显示,卡片宽度为84%。内层Column设置了.onClick(() => {})空函数——这是为了阻止点击事件冒泡到外层遮罩。当用户点击卡片内部时,空onClick捕获事件并阻止其继续传播到外层的关闭逻辑;只有点击遮罩区域(卡片外部)才会触发showCbk7 = false关闭弹框。

TextInput是ArkUI的文本输入组件,通过{ placeholder: '...', text: this.cbkPhone7 }参数设置占位提示文字和当前文本值。placeholderColor设置占位文字颜色,backgroundColor设置输入框背景。onChange回调在文本变化时将新值同步到@State变量cbkPhone7。这种"参数传入值 + onChange回写值"的模式实现了@State变量与TextInput的双向绑定。

技术要点: TextInputtext参数与@State变量绑定后,需要注意:当外部代码修改@State变量时(如重置表单),TextInput的内容会同步更新;当用户在输入框中打字时,onChange回调将新值写回@State变量,TextInput也会保持一致。但在实际开发中,频繁的onChange→@State更新→TextInput重渲染可能导致光标位置跳动的问题。ArkUI对此做了优化,在大多数场景下光标位置能正确保持。

Toggle是ArkUI的开关组件,通过{ type: ToggleType.Switch, isOn: this.cbkRemind7 }参数设置类型(Switch开关样式)和当前开关状态。selectedColor设置开启状态的轨道颜色,这里设为金色#D4AF37onChange回调在用户切换开关时将新布尔值同步到@State变量。

11.4 退订权益弹框unsubOverlay7

@Builder unsubOverlay7() {
  Column() {
    Column() {
      Text('⚠️').fontSize(30)
      Text('退订「' + this.unsubName7 + '」?').fontSize(15).fontWeight(700).fontColor('#F0E9DA').margin({ top: 10 })
      Text('退订后立即生效,本年度不可恢复,剩余 ' + (this.bens7.filter((b: BenT7) => b.id === this.unsubId7).length > 0 ? (this.bens7.filter((b: BenT7) => b.id === this.unsubId7)[0].total - this.bens7.filter((b: BenT7) => b.id === this.unsubId7)[0].used).toString() : '0') + ' 次权益将作废。').fontSize(11).fontColor('#A79C87').margin({ top: 10 }).textAlign(TextAlign.Center).lineHeight(17)

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

      Row() {
        Text('再想想').fontSize(12).fontColor('#A79C87').layoutWeight(1).textAlign(TextAlign.Center).padding({ top: 11, bottom: 11 }).borderRadius(20).border({ width: 0.8, color: '#4D4636' }).onClick(() => {
          this.showUnsub7 = false
        })
        Text(this.armed7 ? '确认退订' : '请先打开确认')
          .fontSize(12)
          .fontWeight(700)
          .fontColor(this.armed7 ? '#F0E9DA' : '#4D4636')
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .padding({ top: 11, bottom: 11 })
          .borderRadius(20)
          .backgroundColor(this.armed7 ? '#7E2B22' : '#241E12')
          .margin({ left: 10 })
          .onClick(() => {
            if (this.armed7) {
              this.bens7 = this.bens7.filter((b: BenT7) => b.id !== this.unsubId7)
              this.showUnsub7 = false
            }
          })
      }
      .width('100%')
      .margin({ top: 18 })
    }
    .width('74%')
    .padding(18)
    .borderRadius(16)
    .backgroundColor('#231714')
    .border({ width: 1, color: '#4DE74C3C' })
    .onClick(() => {
    })
  }
  .width('100%')
  .height('100%')
  .justifyContent(FlexAlign.Center)
  .backgroundColor('#B30E0B08')
  .zIndex(999)
  .onClick(() => {
    this.showUnsub7 = false
  })
}

退订弹框是一个危险操作的二次确认弹框。它的设计值得仔细分析:首先使用了警告符号emoji和红色系配色(背景#231714偏红、边框#4DE74C3C半透明红),营造危险操作的视觉氛围。其次引入了"武装确认"机制——用户必须先打开"我已知晓不可恢复"的Toggle开关(armed7变量),确认按钮才会从灰色不可点击状态变为可点击状态。

确认按钮的文字和样式都通过this.armed7三元判断动态切换:未武装时显示"请先打开确认"(灰色文字#4D4636 + 深灰背景#241E12),武装后显示"确认退订"(白色文字#F0E9DA + 深红背景#7E2B22)。只有armed7为true时,onClick才会执行实际的退订逻辑——通过filter方法从bens7数组中移除对应ID的权益项。

技术要点: 危险操作的二次确认是移动端交互设计的最佳实践。在本实现中,"武装确认"机制比简单的"确定/取消"对话框更加安全——用户必须主动切换开关才能执行操作,这一额外的交互步骤可以有效防止误操作。ToggleselectedColor设置为红色#E74C3C而非金色,与整个弹框的危险主题保持一致,这种"颜色语义一致性"在UI设计中非常重要。

11.5 体检加购弹框buyOverlay7

体检加购弹框是功能最复杂的弹框之一,它包含了套餐信息展示、加购项多选、人数步进器和价格明细四个部分。

加购项选择部分使用了多选逻辑:

Flex({ wrap: FlexWrap.Wrap }) {
  ForEach(EXTRA7, (x: string) => {
    Text(x).fontSize(10).fontColor(this.buyExtras7.indexOf(x) >= 0 ? '#14110B' : '#A79C87').padding({ left: 10, right: 10, top: 7, bottom: 7 }).borderRadius(12).backgroundColor(this.buyExtras7.indexOf(x) >= 0 ? '#D4AF37' : '#241E12').margin({ right: 8, bottom: 8 }).onClick(() => {
      if (this.buyExtras7.indexOf(x) >= 0) {
        this.buyExtras7 = this.buyExtras7.filter((y: string) => y !== x)
      } else {
        this.buyExtras7 = this.buyExtras7.concat([x])
      }
    })
  }, (x: string) => 'ex' + x)
}

多选逻辑通过this.buyExtras7.indexOf(x) >= 0判断某项是否已被选中——如果indexOf返回非负值,表示已在数组中,点击则通过filter移除;如果返回-1,表示未选中,点击则通过concat添加。每次操作都生成新数组赋值给buyExtras7,触发UI更新。

人数步进器部分:

Row() {
  Text('−').fontSize(18).fontColor('#D4AF37').width(36).height(36).borderRadius(18).backgroundColor('#241E12').textAlign(TextAlign.Center).onClick(() => {
    if (this.buyQty7 > 1) {
      this.buyQty7 -= 1
    }
  })
  Text(this.buyQty7.toString() + ' 人').fontSize(14).fontWeight(700).fontColor('#F0E9DA').layoutWeight(1).textAlign(TextAlign.Center)
  Text('+').fontSize(18).fontColor('#D4AF37').width(36).height(36).borderRadius(18).backgroundColor('#241E12').textAlign(TextAlign.Center).onClick(() => {
    if (this.buyQty7 < 6) {
      this.buyQty7 += 1
    }
  })
}
.width('50%')
.alignItems(VerticalAlign.Center)
.margin({ top: 8 })

步进器由减号按钮、数值显示和加号按钮组成。减号按钮在buyQty7 > 1时才执行减1操作(防止低于1人),加号按钮在buyQty7 < 6时才执行加1操作(防止超过6人)。这种边界值保护是步进器组件的必备逻辑。

价格明细部分通过实时计算展示费用组成:

// 合计 = 套餐基础费 × 人数 + 加购项数 × 160 × 人数 - 体检券抵扣300
Text('¥' + ((this.buyId7 > 0 && this.pkgs7.filter((p: PkgT7) => p.id === this.buyId7).length > 0 ? this.pkgs7.filter((p: PkgT7) => p.id === this.buyId7)[0].price : 0) * this.buyQty7 + this.buyExtras7.length * 160 * this.buyQty7 - (this.buyQty7 > 0 ? 300 : 0)).toString())

这段计算逻辑虽然复杂,但核心公式是:套餐价格 × 人数 + 加购项数 × 160元 × 人数 - 300元抵扣。每当用户切换加购项或调整人数时,@State变量的变化会触发UI重渲染,价格明细自动更新——这正是声明式UI"状态驱动视图"的典型体现:开发者只需定义"价格 = 套餐费 × 人数 + 加购费 - 抵扣"的计算公式,UI框架会自动在相关变量变化时重新计算并更新显示。

技术要点: 在声明式UI中,派生数据(如本例中的价格合计)不需要手动存储到@State变量中——它可以直接在build()或@Builder方法中通过表达式计算。每当表达式依赖的@State变量变化时,框架自动重新执行表达式并更新UI。这种"计算属性"的理念与Vue的computed属性类似,但ArkTS中不需要显式声明computed,直接在UI描述中书写计算表达式即可。

十二、主布局build方法

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

      Scroll() {
        Column() {
          if (this.curTab7 === 0) {
            this.tabButler7()
          }
          if (this.curTab7 === 1) {
            this.tabCheck7()
          }
          if (this.curTab7 === 2) {
            this.tabConsult7()
          }
          if (this.curTab7 === 3) {
            this.tabRights7()
          }
          if (this.curTab7 === 4) {
            this.tabMine7()
          }
        }
        .width('100%')
      }
      .layoutWeight(1)
      .scrollBar(BarState.Off)
      .edgeEffect(EdgeEffect.Spring)
      .width('100%')
      .backgroundColor('#14110B')

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

    if (this.showAsk7) {
      this.askOverlay7()
    }
    if (this.showCbk7) {
      this.cbkOverlay7()
    }
    if (this.showAppeal7) {
      this.appealOverlay7()
    }
    if (this.showUnsub7) {
      this.unsubOverlay7()
    }
    if (this.showExp7) {
      this.expOverlay7()
    }
    if (this.showBuy7) {
      this.buyOverlay7()
    }
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#14110B')
}

build()方法是每个@Component组件必须实现的方法,它返回该组件的UI描述。本应用的build()方法以Stack为根容器,内部包含两层:底层是一个Column(包含头部栏、可滚动内容区和底部Tab栏),上层是6个条件渲染的弹框。

Stack的使用使得弹框可以覆盖在主内容之上——Stack的子元素按声明顺序从下到上叠放,后声明的在上层。6个弹框的if判断互不依赖,可以同时只有一个弹框显示(通常的使用场景),也可以灵活地支持多个弹框叠加(如专家详情弹框中点击"立即预约"关闭自身并打开问诊弹框)。

内容区通过5个独立的if语句根据curTab7的值渲染对应的Tab内容。与if/else if不同,使用5个独立if意味着当curTab7为3时,前三个if(0、1、2)都为false不渲染,第四个if(3)为true渲染权益Tab,第五个if(4)为false不渲染。效果与if/else if相同,但代码结构更统一。

技术要点: Scroll组件的scrollBar(BarState.Off)隐藏了滚动条,edgeEffect(EdgeEffect.Spring)设置了弹性滚动效果。在鸿蒙应用中,内容区隐藏滚动条、启用弹性效果是常见的做法——滚动条在移动端体验不佳(遮挡内容、占用空间),而弹性效果提供了更自然的触觉反馈。BarState枚举包含On(显示)、Off(隐藏)、Auto(自动显示/隐藏)三种模式。

layoutWeight(1)在Scroll上使用,使Scroll占据Column中头部栏和底部Tab栏之间的所有剩余空间。Column的子元素从上到下依次是headerBar7(固定高度)、Scroll(layoutWeight(1)占据剩余)、tabBar7(固定高度),形成了"头部-可滚动内容-底部栏"的经典三段式布局。

十三、综合对比表格

以下对本应用中涉及的核心数据结构、组件、装饰器、状态变量等进行全面对比:

序号 类别 名称 类型/签名 用途 关键特性
1 数据结构 MsgT7 interface 聊天消息数据模型 含from/kind字段支持条件渲染分支
2 数据结构 PkgT7 interface 体检套餐数据模型 双价格(price/orig)支持折扣展示
3 数据结构 ExpT7 interface 问诊专家数据模型 online布尔值控制在线状态显示
4 数据结构 BenT7 interface 会员权益数据模型 used/total对+color字段驱动进度条
5 数据结构 OrdT7 interface 服务订单数据模型 state字符串映射颜色
6 数据结构 AppealT7 interface 健康诉求数据模型 on布尔值控制开关切换
7 数据结构 SrvT7 interface 服务统计数据模型 day+cnt驱动柱状图渲染
8 装饰器 @Entry struct级 标记页面入口组件 每个页面仅一个
9 装饰器 @Component struct级 标记自定义UI组件 可包含@Builder/@State/build
10 装饰器 @State 成员变量级 响应式状态管理 值变化触发UI重渲染
11 装饰器 @Builder 方法级 定义可复用UI片段 可接收参数,通过this调用
12 生命周期 aboutToAppear 方法 组件创建后渲染前调用 适合初始化数据和启动动画
13 布局容器 Column 组件 纵向线性布局 主轴垂直,交叉轴水平
14 布局容器 Row 组件 横向线性布局 主轴水平,交叉轴垂直
15 布局容器 Stack 组件 层叠布局 子元素从下到上叠放
16 布局容器 Flex 组件 弹性布局 支持wrap换行和多行对齐
17 布局容器 Scroll 组件 滚动容器 使子内容可超出边界滚动
18 基础组件 Text 组件 文本显示 支持fontSize/fontColor/decoration等
19 基础组件 TextInput 组件 文本输入 支持placeholder/onChange双向绑定
20 基础组件 Toggle 组件 开关切换 Switch样式,支持selectedColor
21 基础组件 Progress 组件 进度展示 Ring环形和Linear线性两种类型
22 基础组件 Circle 组件 圆形图形 通过fill设置填充色
23 渲染控制 ForEach 语句 列表渲染 三参数:数据源/渲染函数/键值生成器
24 渲染控制 if/else 语句 条件渲染 根据条件动态构建/销毁UI分支
25 布局属性 layoutWeight 属性 权重分配 占据父容器剩余空间
26 布局属性 justifyContent 属性 主轴对齐 SpaceBetween两端对齐等
27 布局属性 alignItems 属性 交叉轴对齐 Start/Center/End等
28 布局属性 constraintSize 属性 约束尺寸 maxWidth限制最大宽度
29 视觉属性 borderRadius 属性 圆角 支持四角分别设置
30 视觉属性 backgroundColor 属性 背景色 支持RGBA十六进制
31 视觉属性 border 属性 边框 width+color组成
32 动画API animateTo 函数 显式动画 duration/iterations/playMode/curve
33 变换属性 translate 属性 2D位移 不影响布局,仅渲染偏移
34 层级属性 zIndex 属性 层级控制 值越大越在上层
35 工具函数 saveYuan7 function 计算节省金额 接收PkgT7返回number
36 工具函数 stateColor7 function 状态到颜色映射 接收string返回hex颜色
37 工具函数 fmtWan7 function 数字格式化 万以上转为"X.X万"

十四、总结

本文通过一个完整的黑金尊享风VIP健康管家应用,深入解析了鸿蒙ArkTS声明式UI的架构设计与实现细节。从数据结构定义到静态数据初始化,从工具函数到状态管理,从布局容器到弹框系统,每一个代码段落都承载着特定的鸿蒙技术知识点。这个应用虽然是一个单文件的前端实现,但它涵盖了ArkTS声明式UI开发中绝大多数常见的技术要素,可以说是一个"麻雀虽小五脏俱全"的鸿蒙应用范例。

在数据建模层面,应用使用了7个interface定义了消息、套餐、专家、权益、订单、诉求、统计等业务数据结构。每个接口的字段设计都紧贴UI展示需求——如MsgT7fromkind字段直接驱动消息气泡的条件渲染分支,BenT7used/total/color三个字段直接支撑进度条的渲染逻辑。这种"数据结构服务于UI展示"的设计理念,是声明式UI开发中数据建模的核心原则。接口定义的强类型约束,在编译阶段就能捕获数据形状不匹配的错误,大幅提升了代码的可靠性。

在状态管理层面,应用通过20余个@State变量管理了Tab切换、数据列表、弹框开关、表单输入、特效参数等所有可变状态。@State的响应式机制确保了"状态变化→UI自动更新"的数据流,开发者无需手动操作DOM。对于数组类型的状态变量,应用统一采用了"不可变数据更新"模式——通过concat、map、filter等函数式方法生成新数组并赋值给@State变量,确保框架能检测到引用变化并触发重渲染。这种模式虽然在内存效率上略有牺牲(每次更新都创建新数组),但在代码可维护性和渲染可靠性上具有显著优势。

在布局架构层面,应用展示了ArkUI四大布局容器(Column、Row、Stack、Flex)的协同配合。Column和Row构成了基础的线性布局骨架,Stack处理了层叠场景(环形进度+中心文字、金卡+扫光特效、弹框遮罩+弹框面板),Flex处理了需要换行的标签列表。layoutWeight在这些容器中被广泛使用,实现了等分宽度、占据剩余空间等弹性分配需求。Scroll容器配合layoutWeight(1)实现了"固定头部+可滚动内容+固定底部栏"的经典三段式页面布局。

在组件复用层面,@Builder装饰器是应用的核心组织手段。17个@Builder方法将数百行UI代码拆分成了可复用的逻辑单元——头部栏、Tab栏、消息气泡、套餐卡片、专家卡片等。@Builder方法可以接收参数(如msgBubble7接收MsgT7对象),使同一Builder可以渲染不同数据的内容。@Builder方法之间可以互相调用(如tabBar7调用tabItem7),形成了层次化的UI组件树。这种基于Builder的组件化方式,是ArkTS声明式UI区别于其他框架的特色之一。

在交互层面,应用实现了6个功能完整的弹框,覆盖了底部抽屉、居中卡片、危险确认三种弹框形态。每个弹框都遵循"遮罩层+面板层"的双层Stack结构,通过zIndex(999)确保覆盖主内容。弹框内嵌了TextInput输入框、Toggle开关、Flex标签选择、Scroll滚动内容等多种交互组件,展示了ArkUI丰富的表单能力。事件冒泡控制通过内层onClick空函数实现了"点击面板不关闭、点击遮罩才关闭"的交互逻辑。退订弹框的"武装确认"机制更是体现了对危险操作二次确认的精心设计。

在动画层面,应用通过aboutToAppear生命周期启动了两个无限循环动画——金光扫过(translate位移)和呼吸灯(opacity透明度)。animateTo API的闭包式写法简洁明了,PlayMode.Alternate模式实现了自然的往返动画。这些动画虽然是细节装饰,但它们为黑金主题的静态界面注入了生命力,使应用在视觉上更加高端和精致。translate作为渲染时变换属性,不影响布局流,是实现这类装饰性动画的理想选择。

在视觉设计层面,应用采用了黑金尊享风的色彩体系——以深棕黑色(#14110B#17130C#201B12等)为背景基调,以金色(#D4AF37)为主色调,辅以橙色(#F39C12)、红色(#E74C3C)、绿色(#27AE60)、蓝色(#5DADE2)等语义色,形成了一个层次分明、对比强烈的视觉体系。深色背景搭配金色主色调,营造出高端VIP服务的尊贵感;不同功能区域使用不同深度的背景色(头部栏#17130C比内容区#14110B略浅),通过微妙的色差区分模块边界。文字颜色遵循"越重要越亮"的原则——主标题用#F0E9DA(浅米色,近白),副标题和正文用#A79C87(暖灰色),辅助信息用#8A7435(暗金色),形成清晰的视觉层次。这种精心设计的色彩体系,是黑金主题应用区别于普通深色模式应用的关键所在。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// ============================================================
// 黑金尊享风 · 聊天式健康管家消息流 · 5底部Tab · 6弹框
// 布局亮点:管家Tab为消息对话流(气泡+卡片消息),问诊Tab专家双列,
//           权益Tab金卡+横向权益进度,体检Tab套餐榜单,我的Tab诉求+订单
// ============================================================

// ---------------- 数据结构 ----------------
interface MsgT7 {
  id: number
  from: string
  kind: string
  title: string
  text: string
  time: string
}

interface PkgT7 {
  id: number
  name: string
  tag: string
  items: string
  price: number
  orig: number
  sold: number
  hot: boolean
}

interface ExpT7 {
  id: number
  name: string
  title: string
  hosp: string
  dept: string
  score: number
  patients: number
  price: number
  online: boolean
  skills: string
}

interface BenT7 {
  id: number
  name: string
  desc: string
  icon: string
  used: number
  total: number
  color: string
}

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

interface AppealT7 {
  id: number
  text: string
  level: string
  freq: number
  on: boolean
}

interface SrvT7 {
  day: string
  cnt: number
}

// ---------------- 写死数据 ----------------
const MSGS7: Array<MsgT7> = [
  { id: 1, from: 'butler', kind: 'txt', title: '', text: '王先生您好,我是您的专属健康管家小艾 👋 钻石会员服务已生效,全年 12 次管家主动关怀已排期。', time: '09:00' },
  { id: 2, from: 'me', kind: 'txt', title: '', text: '好的,我最近体检报告出来了,帮我看看。', time: '09:02' },
  { id: 3, from: 'butler', kind: 'card', title: '📊 2026 年度体检报告解读', text: '综合健康评分 86 分(良好)。血脂两项偏高:总胆固醇 5.8 / 低密度脂蛋白 3.9;幽门螺旋杆菌抗体弱阳性;其余 42 项指标在参考范围内。', time: '09:03' },
  { id: 4, from: 'butler', kind: 'txt', title: '', text: '已为您预约三甲消化内科副主任医师周四 15:00 视频问诊,重点解读血脂与幽门螺旋杆菌结果,问诊前 30 分钟我会提醒您 📞', time: '09:04' },
  { id: 5, from: 'me', kind: 'txt', title: '', text: '另外我妈的膝关节复查也想安排一下。', time: '09:10' },
  { id: 6, from: 'butler', kind: 'txt', title: '', text: '收到,已为您母亲建立家庭健康档案 📁 推荐骨科·关节外科专家,正在协调本周六上午的号源,确认后第一时间同步您。', time: '09:11' },
  { id: 7, from: 'butler', kind: 'card', title: '🏃 本周运动处方提醒', text: '目标:每周中等强度运动 150 分钟。本周已完成 95 分钟,缺口 55 分钟。建议明晚安排快走 40 分钟 + 拉伸 15 分钟。', time: '周三 08:00' },
  { id: 8, from: 'me', kind: 'txt', title: '', text: '体检报告里的甲状腺结节要不要紧?', time: '周三 12:30' },
  { id: 9, from: 'butler', kind: 'card', title: '🩺 甲状腺结节随访建议', text: 'TI-RADS 3 类,直径 4mm,边界清。指南建议 6~12 个月超声随访,无需用药。已为您设置 2027 年 2 月复查提醒。', time: '周三 12:32' },
  { id: 10, from: 'butler', kind: 'txt', title: '', text: '检测到您本月有 6 天睡眠不足 6 小时 😴 已为您开通助眠课程《七日睡眠修复》,可在权益中心免费领取。', time: '周四 07:30' },
  { id: 11, from: 'me', kind: 'txt', title: '', text: '帮我把体检加项里的胃镜换成无痛的。', time: '周四 10:00' },
  { id: 12, from: 'butler', kind: 'txt', title: '', text: '已将您 10 月套餐中的普通胃镜升级为无痛胃镜,需补差价 ¥300,麻醉评估问卷已发送到您的档案,填写后即可生效 ✅', time: '周四 10:02' },
  { id: 13, from: 'butler', kind: 'card', title: '🎁 会员专属福利到账', text: '本月权益:全年体检券 1 张、专家绿通 2 次、口腔洁牙 1 次已发放至权益中心,有效期至 12 月 31 日,记得使用哦。', time: '周五 09:00' },
  { id: 14, from: 'me', kind: 'txt', title: '', text: '好的谢谢,下周帮我约一次体脂分析。', time: '周五 09:15' },
  { id: 15, from: 'butler', kind: 'txt', title: '', text: '已登记 📌 预计下周一 08:30 分院体成分分析(InBody 770),当日空腹前往即可,报告同步管家解读。', time: '周五 09:16' }
]

const PKGS7: Array<PkgT7> = [
  { id: 1, name: '钻石尊享全身深度体检', tag: '含无痛胃肠镜', items: '肿瘤12项标志物 / 无痛胃镜+肠镜 / 头颈CTA / 心脏彩超 / 颈动脉超声', price: 6980, orig: 9280, sold: 326, hot: true },
  { id: 2, name: '精英白领抗压体检', tag: '熬夜族推荐', items: '皮质醇节律 / 甲状腺功能7项 / 维生素D / 颈椎MRI / 眼底照相', price: 3280, orig: 4560, sold: 892, hot: true },
  { id: 3, name: '心脑血管深度风险评估', tag: '40+必查', items: '冠脉钙化积分 / 同型半胱氨酸 / 载脂蛋白全套 / 动态血压监测', price: 4560, orig: 5980, sold: 514, hot: false },
  { id: 4, name: '女性两癌筛查尊享版', tag: 'HPV+TCT', items: 'HPV分型27型 / TCT / 乳腺钼靶 / 妇科彩超 / 乳腺AI阅片', price: 2560, orig: 3380, sold: 1023, hot: true },
  { id: 5, name: '男性专项深度体检', tag: 'PSA全套', items: 'PSA三项 / 前列腺彩超 / 胸部低剂量CT / 肺功能全套', price: 2980, orig: 3980, sold: 745, hot: false },
  { id: 6, name: '糖尿病精准管理筛查', tag: '糖耐+胰岛素', items: 'OGTT / 胰岛素释放试验 / 糖化血红蛋白 / 尿微量白蛋白 / 眼底照相', price: 1980, orig: 2680, sold: 662, hot: false },
  { id: 7, name: '儿童青少年成长评估', tag: '骨龄+视力', items: '骨龄片 / 生长激素 / 脊柱侧弯筛查 / 视力+视功能 / 过敏原20项', price: 1680, orig: 2280, sold: 438, hot: false },
  { id: 8, name: '高端抗衰功能医学检测', tag: '限量预约', items: '端粒长度 / 氧化应激全套 / 重金属6项 / 肠道菌群宏基因', price: 12800, orig: 16800, sold: 96, hot: true },
  { id: 9, name: '睡眠呼吸监测套餐', tag: '居家监测', items: '便携式睡眠监测 / 鼾症评估 / 耳鼻喉内镜 / 白天嗜怠量表', price: 1280, orig: 1780, sold: 356, hot: false },
  { id: 10, name: '过敏全程管理筛查', tag: '吸入+食入', items: '吸入性过敏原 / 食入性过敏原 / 总IgE / 嗜酸性粒细胞', price: 1180, orig: 1580, sold: 521, hot: false }
]

const EXPS7: Array<ExpT7> = [
  { id: 1, name: '陈景明', title: '主任医师', hosp: '北京协和医院', dept: '消化内科', score: 4.9, patients: 12680, price: 680, online: true, skills: '胃肠镜精查、早癌筛查、幽门螺旋杆菌根治' },
  { id: 2, name: '林婉如', title: '主任医师', hosp: '上海瑞金医院', dept: '内分泌科', score: 4.9, patients: 15320, price: 720, online: true, skills: '糖尿病逆转、甲状腺结节、骨质疏松' },
  { id: 3, name: '赵国梁', title: '副主任医师', hosp: '广州中山一院', dept: '心内科', score: 4.8, patients: 9860, price: 560, online: true, skills: '冠心病、高血压精准用药、血脂管理' },
  { id: 4, name: '苏曼青', title: '主任医师', hosp: '华西医院', dept: '乳腺外科', score: 5.0, patients: 8740, price: 780, online: false, skills: '乳腺结节良恶性鉴别、两癌筛查' },
  { id: 5, name: '何志远', title: '副主任医师', hosp: '浙大一院', dept: '骨科·关节外科', score: 4.8, patients: 7120, price: 540, online: true, skills: '膝关节退变、运动损伤、关节镜微创' },
  { id: 6, name: '郑晓棠', title: '主治医师', hosp: '北大人民医院', dept: '呼吸与危重症', score: 4.7, patients: 6340, price: 380, online: true, skills: '肺结节随访、慢阻肺、睡眠呼吸暂停' },
  { id: 7, name: '欧阳珊', title: '主任医师', hosp: '湘雅医院', dept: '皮肤科', score: 4.9, patients: 11020, price: 620, online: true, skills: '损容性皮肤病、皮肤肿瘤、医学护肤' },
  { id: 8, name: '马跃川', title: '副主任医师', hosp: '西京医院', dept: '神经内科', score: 4.8, patients: 8290, price: 580, online: false, skills: '头痛头晕、脑血管病、睡眠障碍' },
  { id: 9, name: '宋雨薇', title: '主治医师', hosp: '山东省立医院', dept: '营养科', score: 4.7, patients: 5410, price: 320, online: true, skills: '体重管理、慢病营养、围手术期营养' },
  { id: 10, name: '郭启光', title: '主任医师', hosp: '同济医院', dept: '泌尿外科', score: 4.8, patients: 9130, price: 660, online: true, skills: '前列腺增生、泌尿系结石、PSA异常解读' }
]

const BENS7: Array<BenT7> = [
  { id: 1, name: '专家绿通', desc: '全年 2 次三甲专家加号', icon: '🚀', used: 1, total: 2, color: '#D4AF37' },
  { id: 2, name: '全年体检券', desc: '尊享套餐抵扣 ¥1000', icon: '🎟️', used: 0, total: 1, color: '#E74C3C' },
  { id: 3, name: '口腔洁牙', desc: '全年 1 次超声波洁牙', icon: '🦷', used: 0, total: 1, color: '#5DADE2' },
  { id: 4, name: '管家代约', desc: '不限次代约检查挂号', icon: '📅', used: 9, total: 20, color: '#27AE60' },
  { id: 5, name: '助眠课程', desc: '七日睡眠修复全套', icon: '😴', used: 0, total: 1, color: '#8E7CC3' },
  { id: 6, name: '报告加急', desc: '体检报告 24h 加急出', icon: '⚡', used: 3, total: 6, color: '#F39C12' },
  { id: 7, name: '中医理疗', desc: '推拿/艾灸全年 4 次', icon: '🌿', used: 1, total: 4, color: '#16A085' },
  { id: 8, name: '机场贵宾厅', desc: '出行贵宾休息室 2 次', icon: '✈️', used: 0, total: 2, color: '#3498DB' },
  { id: 9, name: '陪诊服务', desc: '老人就医专属陪诊 2 次', icon: '🤝', used: 1, total: 2, color: '#E67E22' }
]

const ORDERS7: Array<OrdT7> = [
  { id: 1, name: '钻石尊享全身深度体检(含无痛胃肠镜)', state: '待到检', date: '2026-10-18 08:00', price: 6980 },
  { id: 2, name: '视频问诊 · 陈景明 主任医师', state: '已完成', date: '2026-08-21 15:00', price: 680 },
  { id: 3, name: '中医推拿 · 肩颈调理 60 分钟', state: '已完成', date: '2026-08-14 10:30', price: 0 },
  { id: 4, name: '体成分分析 InBody 770', state: '已完成', date: '2026-07-30 08:30', price: 0 },
  { id: 5, name: '母亲 · 骨科专家绿通加号', state: '已完成', date: '2026-07-12 09:00', price: 0 },
  { id: 6, name: '助眠课程 · 七日睡眠修复', state: '进行中', date: '2026-07-02 21:00', price: 0 },
  { id: 7, name: '超声波洁牙(权益核销)', state: '待预约', date: '待定', price: 0 },
  { id: 8, name: '低剂量螺旋CT 加项', state: '已完成', date: '2026-06-20 09:40', price: 480 },
  { id: 9, name: '营养科视频咨询 · 宋雨薇', state: '已完成', date: '2026-06-08 19:00', price: 320 },
  { id: 10, name: '年度 VIP 服务费(钻石级)', state: '已支付', date: '2026-01-01 00:00', price: 3680 }
]

const APPEALS7: Array<AppealT7> = [
  { id: 1, text: '低密度脂蛋白降到 3.0 以下', level: '重点', freq: 7, on: true },
  { id: 2, text: '每周运动 150 分钟达标', level: '常规', freq: 4, on: true },
  { id: 3, text: '母亲膝关节复查不缺席', level: '重点', freq: 3, on: true },
  { id: 4, text: '工作日睡眠不少于 6.5 小时', level: '常规', freq: 7, on: false },
  { id: 5, text: '体重减到 74kg(当前 78.6kg)', level: '常规', freq: 2, on: true },
  { id: 6, text: '幽门螺旋杆菌复查转阴', level: '重点', freq: 1, on: false }
]

const WEEKSRV7: Array<SrvT7> = [
  { day: '周一', cnt: 3 },
  { day: '周二', cnt: 5 },
  { day: '周三', cnt: 4 },
  { day: '周四', cnt: 8 },
  { day: '周五', cnt: 6 },
  { day: '周六', cnt: 2 },
  { day: '周日', cnt: 3 }
]

const ASKT7: Array<string> = ['08:00', '09:30', '11:00', '14:00', '15:30', '17:00', '19:00', '20:30']
const WAYS7: Array<string> = ['图文问诊', '电话问诊', '视频问诊']
const CBK7: Array<string> = ['今天 15:00', '今天 18:30', '明天 09:00', '明天 14:00', '后天 10:00']
const LVLS7: Array<string> = ['重点', '常规', '留意']
const EXTRA7: Array<string> = ['肿瘤12项', '胸CT平扫', '心脏彩超', '颈动脉超声', '骨密度', '眼底照相', '幽门呼气', '动脉硬化检测']

// ---------------- 工具函数 ----------------
function saveYuan7(p: PkgT7): number {
  return p.orig - p.price
}

function stateColor7(s: string): string {
  if (s === '待到检' || s === '待预约') {
    return '#F39C12'
  }
  if (s === '进行中') {
    return '#5DADE2'
  }
  if (s === '已支付') {
    return '#D4AF37'
  }
  return '#27AE60'
}

function fmtWan7(n: number): string {
  if (n >= 10000) {
    return (n / 10000).toFixed(1).toString() + '万'
  }
  return n.toString()
}

// ================= 页面 =================
@Entry
@Component
struct Index {
  @State curTab7: number = 0
  @State msgs7: Array<MsgT7> = MSGS7.slice()
  @State pkgs7: Array<PkgT7> = PKGS7.slice()
  @State exps7: Array<ExpT7> = EXPS7.slice()
  @State bens7: Array<BenT7> = BENS7.slice()
  @State ords7: Array<OrdT7> = ORDERS7.slice()
  @State appeals7: Array<AppealT7> = APPEALS7.slice()
  @State expFilter7: number = 0

  // 弹框开关
  @State showAsk7: boolean = false
  @State showCbk7: boolean = false
  @State showAppeal7: boolean = false
  @State showUnsub7: boolean = false
  @State showExp7: boolean = false
  @State showBuy7: boolean = false

  // 问诊表单
  @State askExpIdx7: number = 0
  @State askWayIdx7: number = 1
  @State askTimeIdx7: number = 3
  @State askDesc7: string = ''
  @State askUrgent7: boolean = false

  // 回调表单
  @State cbkIdx7: number = 0
  @State cbkPhone7: string = '138****6688'
  @State cbkRemind7: boolean = true

  // 诉求编辑
  @State editAid7: number = 0
  @State appealText7: string = ''
  @State appealLvl7: number = 0
  @State appealFreq7: number = 3

  // 退订
  @State unsubId7: number = 0
  @State unsubName7: string = ''
  @State armed7: boolean = false

  // 专家详情
  @State expId7: number = 0

  // 体检加购
  @State buyId7: number = 0
  @State buyQty7: number = 1
  @State buyExtras7: Array<string> = []

  // 特效
  @State shineX7: number = -46
  @State msgOp7: number = 0.25

  aboutToAppear(): void {
    this.getUIContext().animateTo({ duration: 1900, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
      this.shineX7 = 46
    })
    this.getUIContext().animateTo({ duration: 1200, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
      this.msgOp7 = 0.9
    })
  }

  // ---------------- 头部(无动画 · 黑金电商风) ----------------
  @Builder headerBar7() {
    Column() {
      Row() {
        Text('👑').fontSize(24)
        Column() {
          Text('PLUS CONCIERGE').fontSize(16).fontWeight(700).fontColor('#D4AF37').letterSpacing(1)
          Text('钻石会员 · 专属健康管家服务年卡').fontSize(10).fontColor('#A79C87').margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.Start)
        .margin({ left: 8 })
        Row() {
          Text('小艾在线').fontSize(10).fontColor('#27AE60')
          Circle().width(6).height(6).fill('#27AE60').opacity(this.msgOp7)
        }
        .padding({ left: 10, right: 10, top: 4, bottom: 4 })
        .borderRadius(10)
        .backgroundColor('#1E1B12')
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      .padding({ left: 16, right: 16, top: 12, bottom: 10 })

      Row() {
        Text('🔎').fontSize(14)
        Text('向管家提问:输 "头痛" 试试 · 全年不限次').fontSize(11).fontColor('#A79C87').margin({ left: 6 }).layoutWeight(1).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        Text('📞').fontSize(16).onClick(() => {
          this.showCbk7 = true
        })
      }
      .width('100%')
      .padding({ left: 12, right: 12, top: 8, bottom: 8 })
      .borderRadius(20)
      .backgroundColor('#201B12')
      .margin({ left: 16, right: 16, bottom: 10 })
    }
    .width('100%')
    .backgroundColor('#17130C')
    .borderRadius({ bottomLeft: 18, bottomRight: 18 })
  }

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

  @Builder tabBar7() {
    Row() {
      this.tabItem7('🤵', '管家', 0)
      this.tabItem7('🩺', '体检', 1)
      this.tabItem7('💬', '问诊', 2)
      this.tabItem7('👑', '权益', 3)
      this.tabItem7('👤', '我的', 4)
    }
    .width('100%')
    .backgroundColor('#1B1710')
    .border({ width: 0.5, color: '#33D4AF37' })
  }

  // ============ Tab0 管家:聊天式消息流 ============
  @Builder msgBubble7(m: MsgT7) {
    if (m.from === 'butler') {
      Row() {
        Column() {
          Text('🤵').fontSize(24)
          Text('管家小艾').fontSize(8).fontColor('#A79C87').margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.Center)

        Column() {
          if (m.kind === 'card') {
            Column() {
              Text(m.title).fontSize(13).fontWeight(700).fontColor('#D4AF37')
              Text(m.text).fontSize(11).fontColor('#F0E9DA').margin({ top: 6 }).lineHeight(17)
            }
            .alignItems(HorizontalAlign.Start)
            .padding(12)
            .borderRadius({ topLeft: 4, topRight: 14, bottomLeft: 14, bottomRight: 14 })
            .backgroundColor('#241E12')
            .border({ width: 0.6, color: '#4DD4AF37' })
            .constraintSize({ maxWidth: '78%' })
          } else {
            Text(m.text)
              .fontSize(12)
              .fontColor('#F0E9DA')
              .lineHeight(18)
              .padding(11)
              .borderRadius({ topLeft: 4, topRight: 14, bottomLeft: 14, bottomRight: 14 })
              .backgroundColor('#241E12')
              .constraintSize({ maxWidth: '78%' })
          }
          Text(m.time).fontSize(8).fontColor('#6B6252').margin({ top: 4, left: 4 })
        }
        .alignItems(HorizontalAlign.Start)
        .margin({ left: 8 })
      }
      .width('100%')
      .alignItems(VerticalAlign.Top)
      .padding({ left: 14, right: 14, top: 6, bottom: 6 })
    } else {
      Row() {
        Column() {
          if (m.kind === 'card') {
            Column() {
              Text(m.title).fontSize(13).fontWeight(700).fontColor('#F0E9DA')
              Text(m.text).fontSize(11).fontColor('#F0E9DA').margin({ top: 6 }).lineHeight(17)
            }
            .alignItems(HorizontalAlign.End)
            .padding(12)
            .borderRadius({ topLeft: 14, topRight: 4, bottomLeft: 14, bottomRight: 14 })
            .backgroundColor('#5A4A1E')
            .constraintSize({ maxWidth: '78%' })
          } else {
            Text(m.text)
              .fontSize(12)
              .fontColor('#1A1508')
              .lineHeight(18)
              .padding(11)
              .borderRadius({ topLeft: 14, topRight: 4, bottomLeft: 14, bottomRight: 14 })
              .backgroundColor('#D4AF37')
              .constraintSize({ maxWidth: '78%' })
          }
          Text(m.time).fontSize(8).fontColor('#6B6252').margin({ top: 4, right: 4 })
        }
        .alignItems(HorizontalAlign.End)
        .margin({ right: 8 })

        Column() {
          Text('👨').fontSize(24)
          Text('我').fontSize(8).fontColor('#A79C87').margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.Center)
      }
      .width('100%')
      .justifyContent(FlexAlign.End)
      .alignItems(VerticalAlign.Top)
      .padding({ left: 14, right: 14, top: 6, bottom: 6 })
    }
  }

  @Builder tabButler7() {
    Column() {
      // 服务概览卡
      Row() {
        Stack() {
          Progress({ value: 86, total: 100 })
            .width(64)
            .height(64)
            .style({ strokeWidth: 7 })
            .color('#D4AF37')
          Column() {
            Text('86%').fontSize(14).fontWeight(700).fontColor('#D4AF37')
            Text('年度目标').fontSize(8).fontColor('#A79C87')
          }
          .alignItems(HorizontalAlign.Center)
        }

        Column() {
          Text('管家服务完成度').fontSize(13).fontWeight(700).fontColor('#F0E9DA')
          Text('本月已服务 21 次 · 主动关怀 9 次 · 代约 5 次').fontSize(10).fontColor('#A79C87').margin({ top: 5 })
          Row() {
            Text('预约管家回电').fontSize(10).fontColor('#14110B').fontWeight(700).padding({ left: 12, right: 12, top: 5, bottom: 5 }).borderRadius(14).backgroundColor('#D4AF37').onClick(() => {
              this.showCbk7 = true
            })
            Text('发起问诊').fontSize(10).fontColor('#D4AF37').padding({ left: 12, right: 12, top: 5, bottom: 5 }).borderRadius(14).border({ width: 0.8, color: '#D4AF37' }).margin({ left: 8 }).onClick(() => {
              this.showAsk7 = true
            })
          }
          .margin({ top: 9 })
        }
        .alignItems(HorizontalAlign.Start)
        .margin({ left: 14 })
        .layoutWeight(1)
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)
      .padding(14)
      .borderRadius(16)
      .backgroundColor('#201B12')
      .margin({ left: 14, right: 14, top: 12 })

      // 本周服务柱状图
      Column() {
        Text('📊 本周管家服务次数').fontSize(13).fontWeight(700).fontColor('#F0E9DA')
        Row() {
          ForEach(WEEKSRV7, (d: SrvT7) => {
            Column() {
              Text(d.cnt.toString()).fontSize(9).fontColor(d.cnt >= 6 ? '#D4AF37' : '#A79C87').margin({ bottom: 3 })
              Column() {
                Column() {
                }
                .width(14)
                .height(Math.max(d.cnt * 11, 6))
                .borderRadius({ topLeft: 3, topRight: 3 })
                .backgroundColor(d.cnt >= 6 ? '#D4AF37' : '#6B5A28')
              }
              .width('100%')
              .height(90)
              .justifyContent(FlexAlign.End)

              Text(d.day).fontSize(9).fontColor('#8A7435').margin({ top: 5 })
            }
            .alignItems(HorizontalAlign.Center)
            .layoutWeight(1)
          }, (d: SrvT7) => 'wk' + d.day)
        }
        .width('100%')
        .margin({ top: 12 })
      }
      .width('100%')
      .alignItems(HorizontalAlign.Start)
      .padding(14)
      .borderRadius(16)
      .backgroundColor('#201B12')
      .margin({ left: 14, right: 14, top: 10 })

      // 消息流标题
      Row() {
        Text('💬 管家对话').fontSize(13).fontWeight(700).fontColor('#F0E9DA')
        Text('共 ' + this.msgs7.length.toString() + ' 条').fontSize(10).fontColor('#8A7435').margin({ left: 8 })
        Row() {
          Text('🪄 清空对话').fontSize(9).fontColor('#A79C87')
        }
        .padding({ left: 8, right: 8, top: 3, bottom: 3 })
        .borderRadius(10)
        .backgroundColor('#17130C')
        .margin({ left: 8 })
        .onClick(() => {
          this.msgs7 = []
        })
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)
      .padding({ left: 16, right: 16, top: 16, bottom: 4 })

      // 消息列表
      ForEach(this.msgs7, (m: MsgT7) => {
        this.msgBubble7(m)
      }, (m: MsgT7) => 'msg' + m.id.toString())

      if (this.msgs7.length === 0) {
        Column() {
          Text('🕊️').fontSize(34)
          Text('对话已清空,向管家发起一次问诊吧').fontSize(11).fontColor('#8A7435').margin({ top: 8 })
        }
        .alignItems(HorizontalAlign.Center)
        .padding(30)
      }

      // 快捷输入条
      Row() {
        Text('🩺 报告解读').fontSize(10).fontColor('#D4AF37').padding({ left: 9, right: 9, top: 6, bottom: 6 }).borderRadius(12).border({ width: 0.7, color: '#D4AF37' }).onClick(() => {
          const nm: MsgT7 = { id: Date.now(), from: 'me', kind: 'txt', title: '', text: '请帮我解读最新体检报告。', time: '刚刚' }
          this.msgs7 = [nm].concat(this.msgs7.slice(0, 24))
        })
        Text('📅 代约检查').fontSize(10).fontColor('#D4AF37').padding({ left: 9, right: 9, top: 6, bottom: 6 }).borderRadius(12).border({ width: 0.7, color: '#D4AF37' }).margin({ left: 8 }).onClick(() => {
          const nm: MsgT7 = { id: Date.now(), from: 'me', kind: 'txt', title: '', text: '帮我代约一次检查。', time: '刚刚' }
          this.msgs7 = [nm].concat(this.msgs7.slice(0, 24))
        })
        Text('🏃 运动处方').fontSize(10).fontColor('#D4AF37').padding({ left: 9, right: 9, top: 6, bottom: 6 }).borderRadius(12).border({ width: 0.7, color: '#D4AF37' }).margin({ left: 8 }).onClick(() => {
          const nm: MsgT7 = { id: Date.now(), from: 'me', kind: 'txt', title: '', text: '给我更新一份运动处方。', time: '刚刚' }
          this.msgs7 = [nm].concat(this.msgs7.slice(0, 24))
        })
        Text('➕').fontSize(13).fontColor('#14110B').padding({ left: 12, right: 12, top: 6, bottom: 6 }).borderRadius(12).backgroundColor('#D4AF37').margin({ left: 8 }).onClick(() => {
          this.showAsk7 = true
        })
      }
      .width('100%')
      .padding({ left: 14, right: 14, top: 10, bottom: 16 })
    }
    .width('100%')
  }

  // ============ Tab1 体检:套餐榜单 ============
  @Builder pkgCard7(p: PkgT7) {
    Column() {
      Row() {
        Text(p.name).fontSize(13).fontWeight(700).fontColor('#F0E9DA').layoutWeight(1).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        Text(p.tag).fontSize(9).fontColor('#14110B').padding({ left: 7, right: 7, top: 3, bottom: 3 }).borderRadius(8).backgroundColor(p.hot ? '#E74C3C' : '#D4AF37')
      }
      .width('100%')

      Text(p.items).fontSize(10).fontColor('#A79C87').margin({ top: 7 }).lineHeight(15).maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })

      Row() {
        Column() {
          Row() {
            Text('¥').fontSize(10).fontColor('#D4AF37')
            Text(p.price.toString()).fontSize(18).fontWeight(700).fontColor('#D4AF37')
            Text('¥' + p.orig.toString()).fontSize(9).fontColor('#6B6252').decoration({ type: TextDecorationType.LineThrough }).margin({ left: 6 })
          }
          .alignItems(VerticalAlign.Bottom)
          Text('已售 ' + fmtWan7(p.sold) + ' · 立省 ¥' + saveYuan7(p).toString()).fontSize(9).fontColor('#8A7435').margin({ top: 3 })
        }
        .alignItems(HorizontalAlign.Start)

        Text('加购').fontSize(11).fontColor('#14110B').fontWeight(700).padding({ left: 16, right: 16, top: 7, bottom: 7 }).borderRadius(16).backgroundColor('#D4AF37').onClick(() => {
          this.buyId7 = p.id
          this.buyQty7 = 1
          this.buyExtras7 = []
          this.showBuy7 = true
        })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      .alignItems(VerticalAlign.Center)
      .margin({ top: 10 })
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
    .padding(13)
    .borderRadius(14)
    .backgroundColor('#201B12')
    .border({ width: 0.6, color: '#33D4AF37' })
    .margin({ left: 14, right: 14, top: 10 })
  }

  @Builder tabCheck7() {
    Column() {
      // 会员体检概览
      Row() {
        Column() {
          Text('3').fontSize(22).fontWeight(700).fontColor('#D4AF37')
          Text('累计体检次数').fontSize(9).fontColor('#A79C87').margin({ top: 3 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)

        Column() {
          Text('86').fontSize(22).fontWeight(700).fontColor('#5DADE2')
          Text('最近健康评分').fontSize(9).fontColor('#A79C87').margin({ top: 3 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)

        Column() {
          Text('2').fontSize(22).fontWeight(700).fontColor('#27AE60')
          Text('待随访项').fontSize(9).fontColor('#A79C87').margin({ top: 3 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)

        Column() {
          Text('98').fontSize(22).fontWeight(700).fontColor('#E74C3C')
          Text('加项抵扣余额').fontSize(9).fontColor('#A79C87').margin({ top: 3 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
      }
      .width('100%')
      .padding({ top: 16, bottom: 16 })
      .borderRadius(14)
      .backgroundColor('#201B12')
      .margin({ left: 14, right: 14, top: 12 })

      // 优惠横条(金光扫过特效)
      Stack() {
        Column() {
          Text('🎁 会员日:本月下单套餐立减 ¥300,加项 8 折').fontSize(11).fontColor('#D4AF37').fontWeight(600)
        }
        .width('100%')
        .alignItems(HorizontalAlign.Center)
        .padding({ top: 10, bottom: 10 })
        .borderRadius(12)
        .backgroundColor('#241E12')
        .border({ width: 0.8, color: '#4DD4AF37' })

        Text('✨').fontSize(14).translate({ x: this.shineX7 }).opacity(0.85)
      }
      .width('100%')
      .alignContent(Alignment.Start)
      .margin({ left: 14, right: 14, top: 10 })

      // 套餐榜单
      Row() {
        Text('🏆 钻石会员专享套餐').fontSize(13).fontWeight(700).fontColor('#F0E9DA')
        Text('按销量').fontSize(9).fontColor('#8A7435').margin({ left: 8 })
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)
      .padding({ left: 16, right: 16, top: 14, bottom: 2 })

      ForEach(this.pkgs7, (p: PkgT7) => {
        this.pkgCard7(p)
      }, (p: PkgT7) => 'pkg' + p.id.toString())
    }
    .width('100%')
    .padding({ bottom: 12 })
  }

  // ============ Tab2 问诊:专家双列 ============
  @Builder expChip7(label: string, idx: number) {
    Text(label).fontSize(10).fontColor(this.expFilter7 === idx ? '#14110B' : '#A79C87').padding({ left: 12, right: 12, top: 6, bottom: 6 }).borderRadius(14).backgroundColor(this.expFilter7 === idx ? '#D4AF37' : '#241E12').onClick(() => {
      this.expFilter7 = idx
    })
  }

  @Builder expCard7(e: ExpT7) {
    Column() {
      Row() {
        Stack() {
          Circle().width(42).height(42).fill('#241E12')
          Text(e.name.substring(0, 1)).fontSize(17).fontWeight(700).fontColor('#D4AF37')
        }
        Column() {
          Text(e.name + ' ' + e.title).fontSize(12).fontWeight(700).fontColor('#F0E9DA').maxLines(1)
          Text(e.dept).fontSize(9).fontColor('#D4AF37').margin({ top: 3 })
        }
        .alignItems(HorizontalAlign.Start)
        .margin({ left: 8 })
        .layoutWeight(1)
      }
      .width('100%')

      Text(e.hosp).fontSize(9).fontColor('#8A7435').margin({ top: 7 }).maxLines(1)

      // 评分横条
      Column() {
        Row() {
          Text('评分 ' + e.score.toFixed(1)).fontSize(9).fontColor('#F39C12')
          Text(fmtWan7(e.patients) + '人问诊').fontSize(9).fontColor('#6B6252')
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        Row() {
          Column() {
            Column() {
            }
            .width((e.score / 5 * 100).toFixed(0) + '%')
            .height('100%')
            .borderRadius(3)
            .backgroundColor('#F39C12')
          }
          .layoutWeight(1)
          .height(5)
          .borderRadius(3)
          .backgroundColor('#33D4AF37')
        }
        .width('100%')
        .margin({ top: 5 })
      }
      .width('100%')
      .margin({ top: 7 })

      Row() {
        Text(e.online ? '🟢 在线' : '⚪ 停诊').fontSize(9).fontColor(e.online ? '#27AE60' : '#6B6252')
        Text('¥' + e.price.toString()).fontSize(14).fontWeight(700).fontColor('#D4AF37').layoutWeight(1).textAlign(TextAlign.End)
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)
      .margin({ top: 8 })

      Row() {
        Text('详情').fontSize(9).fontColor('#A79C87').padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(12).border({ width: 0.7, color: '#8A7435' }).layoutWeight(1).textAlign(TextAlign.Center).onClick(() => {
          this.expId7 = e.id
          this.showExp7 = true
        })
        Text('问诊').fontSize(9).fontColor('#14110B').fontWeight(700).padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(12).backgroundColor('#D4AF37').margin({ left: 8 }).layoutWeight(1).textAlign(TextAlign.Center).onClick(() => {
          this.askExpIdx7 = Math.max(0, EXPS7.findIndex((x: ExpT7) => x.id === e.id))
          this.showAsk7 = true
        })
        
      }
      if (this.showBuy7) {
        this.buyOverlay7()
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#14110B')
  }
}


在这里插入图片描述

在数据流层面,应用展现了"单一数据源"的设计理念。所有页面的数据都存储在组件的@State变量中,数据的更新通过不可变操作(concat、map、filter、slice)完成。当用户在问诊弹框中提交问诊请求时,代码构造一个新消息对象,通过concat插入到消息列表头部,消息列表的变化自动触发管家Tab的消息流重新渲染;当用户在体检加购弹框中确认加购时,代码同时更新消息列表(插入管家确认消息)和订单列表(插入新订单),两个@State变量的变化分别触发各自关联的UI更新。这种"一处状态变更,关联UI自动更新"的机制,是声明式UI的核心优势——开发者只需关心"状态应该变成什么",不需要关心"哪些UI需要更新以及如何更新"。

在事件处理层面,应用大量使用了箭头函数作为onClick等事件回调。箭头函数的一个重要特性是它不绑定自己的this,而是捕获外层作用域的this——在本应用中,这意味着事件回调内的this始终指向当前组件实例,可以直接访问和修改@State变量。这种特性使得在ArkTS中编写事件处理逻辑非常自然——只需在箭头函数内修改this.xxx = yyy,框架就会自动处理后续的UI更新。同时,事件回调中通过修改多个@State变量来联动更新多个UI区域,这种"一次操作触发多处更新"的模式在命令式UI中需要手动调用多处刷新,而在声明式UI中完全自动化。

在列表渲染层面,ForEach作为ArkUI的核心渲染控制语句,在本应用中被用于渲染消息列表、套餐列表、专家列表、权益列表、诉求列表、订单列表、柱状图数据、弹框中的标签选择列表等多种场景。ForEach的三个参数——数据源、渲染函数、键值生成器——构成了一个完整的声明式列表渲染模型。数据源提供渲染数据,渲染函数定义每个数据项如何映射为UI组件,键值生成器为每个数据项生成唯一标识供框架进行Diff比较。在本应用中,键值生成器的实现有一个值得注意的细节:部分键值包含了状态字段(如权益列表的'ben' + b.id + '_' + b.used、诉求列表的'ap' + a.id + '_' + a.on),这意味着当使用次数或开关状态变化时,键值会改变,框架会将该项视为新数据项进行销毁重建。这种设计在需要强制重新创建组件(以触发内部动画或重置状态)时是有意的,但在不需要时可能带来额外的渲染开销,需要根据具体场景权衡。

整体来看,本应用的代码组织体现了良好的工程实践:数据结构集中定义在文件顶部、工具函数独立于组件之外、@State变量按功能分组声明并添加注释、@Builder方法按Tab和功能模块分块编写。build()方法作为唯一入口,通过Stack+Column+if的条件组合,将所有模块有机整合为一个完整的页面。这种"自顶向下、模块分块"的代码结构,使得数百行的单文件代码依然保持可读性和可维护性。对于鸿蒙应用开发者而言,深入理解本应用中展示的每一项技术要素——从interface数据建模到@State状态管理,从Column/Row布局到Stack层叠,从ForEach列表渲染到if条件分支,从animateTo动画到zIndex层级控制,从@Builder组件化到aboutToAppear生命周期——是掌握ArkTS声明式UI开发的关键路径。这些技术要素并非孤立存在,而是通过有机组合构成了一个功能完整、交互丰富、视觉精致的鸿蒙应用,充分展现了ArkTS声明式UI范式在复杂业务场景下的表达能力和工程价值。

Logo

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

更多推荐