引言:鸿蒙开发背景与 ArkTS 语言生态

鸿蒙操作系统(HarmonyOS)作为华为面向全场景多设备打造的分布式操作系统,其应用开发框架经历了从早期 Java UI 到方舟开发框架(ArkUI)的演进。ArkUI 框架的核心编程语言便是 ArkTS——一种在 TypeScript 基础上扩展而来的类型安全语言。ArkTS 保留了 TypeScript 的静态类型系统、接口定义、泛型等高级特性,同时为声明式 UI 范式做了深度定制,引入了诸如 @Component@Entry@State@Builder@Prop@Link 等一系列装饰器,使开发者能够以声明式的方式描述界面结构与状态驱动关系。这种设计范式与 SwiftUI、Jetpack Compose 等现代声明式 UI 框架理念相通,但在语法细节和运行时机制上具有鸿蒙自身的特色。

声明式 UI 范式的核心思想在于"状态即视图"。开发者无需像命令式编程那样手动调用 findViewByIdsetTextsetOnClickListener 等方法来操作界面,而是通过声明状态变量并绑定到 UI 描述中,由框架负责监听状态变化并自动触发界面重渲染。在 ArkTS 中,@State 装饰器标记的变量一旦被修改,框架会自动找到所有引用该变量的 UI 组件并执行局部刷新。这种数据驱动的方式极大地简化了状态管理与界面同步的复杂度,尤其在构建包含多 Tab、多弹窗、多列表交互的复杂应用时,优势尤为明显。本篇将以一个完整的中医体质辨识应用为例,逐段剖析 ArkTS 代码的组织方式、状态流转机制以及 ArkUI 组件体系的使用方法。

ArkUI 组件体系是鸿蒙应用开发的基石。它提供了丰富的内置组件,从基础容器(ColumnRowStackFlex)到交互组件(TextImageTextInputToggleProgress),再到滚动容器(ScrollList)和装饰能力(ForEachif/else 条件渲染),形成了一套完整的前端开发工具链。容器组件负责布局组织,其中 Column 实现纵向排列、Row 实现横向排列、Stack 实现层叠堆放,三者构成了 ArkUI 布局的三大支柱。在布局属性层面,layoutWeight 用于弹性分配剩余空间、position 用于绝对定位、zIndex 用于控制层叠顺序、paddingmargin 分别控制内边距与外边距,这些属性共同支撑了精密的界面布局控制。此外,ArkUI 还提供了 animateTo 动画 API,允许开发者以闭包形式定义动画终态,框架自动计算插值并驱动界面平滑过渡。理解这些组件与机制的协作方式,是深入鸿蒙原生开发的关键。

本应用以中医体质辨识为核心业务场景,融合了九种体质辨识、药膳推荐、穴位按摩打卡、经络时辰养生、调理计划管理、医案记录追溯、代茶饮定制等多维度功能。界面采用中式米白宣纸风格设计,底部以双排三列导航呈现六个功能入口,配合六个弹层覆盖交互场景,构成了一个结构完整、交互丰富的典型鸿蒙应用。接下来我们将从数据模型定义、状态管理、业务逻辑、UI 构建器、弹窗系统到根布局,逐层拆解这份 ArkTS 代码的技术实现细节。


一、数据模型层:interface 接口定义与类型安全

在 ArkTS 中,interface 关键字用于定义数据结构的契约,这与 TypeScript 中的接口概念一脉相承。接口本身不产生运行时对象,它只是一种编译期的类型约束,确保数据在传递过程中字段类型正确、字段名称完整。在本应用中,开发者首先定义了六个接口,分别对应六类核心业务实体。

1.1 体质数据接口

interface BodyT {
  name: string
  pct: number
  desc: string
  color: string
  emoji: string
}

在这里插入图片描述

BodyT 接口定义了中医体质的五个核心属性:name 记录体质名称(如"平和质"、"气虚质"等)、pct 记录该体质在辨识结果中的占比百分比、desc 记录体质特征的简短描述、color 记录该体质对应的主题色值(使用十六进制颜色字符串)、emoji 记录表情符号图标。这五个字段共同描述了一条完整的体质数据,后续在体质 Tab 页的横条图中,每一条体质数据都会被渲染为一个带颜色条的列表项。

技术要点: ArkTS 的 interface 与 TypeScript 的 interface 在语法上一致,但在编译期会被擦除,不产生运行时代码。它的核心价值在于类型检查——当你在代码中尝试给 pct 赋值为字符串时,编译器会立即报错。这种"编译期安全"是 ArkTS 相比纯 JavaScript 的核心优势之一,尤其在中大型应用中能显著减少因类型错误导致的运行时崩溃。

1.2 药膳数据接口

interface HerbT {
  name: string
  func: string
  taste: string
  suit: string
  emoji: string
  fav: boolean
}

HerbT 接口定义了药膳条目的数据结构,包含名称、功效、性味、适宜体质、图标表情和收藏标记六个字段。其中 fav 字段类型为 boolean,用于标记用户是否收藏了该药膳,这是典型的"用户偏好"数据,后续会绑定到 UI 上的爱心图标,点击切换收藏状态。

1.3 穴位数据接口

interface PointT {
  name: string
  meridian: string
  effect: string
  min: number
  done: boolean
}

在这里插入图片描述

PointT 接口定义了穴位数据结构,meridian 字段记录穴位所属经络(如"足阳明胃经"、"任脉"等),min 字段记录建议按揉时长(分钟),done 字段标记今日是否已按揉打卡。这三个接口都体现了"业务实体 + 状态标记"的设计模式——将展示性数据与交互性状态封装在同一个数据结构中,在声明式 UI 中,当 donefav 变化时,框架自动刷新引用该数据的 UI 区域。

1.4 调理计划、医案与代茶饮接口

interface PlanT {
  title: string
  freq: string
  detail: string
  on: boolean
}
interface CaseT {
  date: string
  type: string
  result: string
  doc: string
}
interface TeaT {
  name: string
  mat: string
  effect: string
  season: string
  emoji: string
}

在这里插入图片描述

PlanT 接口定义调理计划,on 字段标记该计划是否处于启用状态,freq 记录执行频次(如"每日"、"每周 3 次"等)。CaseT 接口定义医案记录,doc 字段记录主治医师姓名,date 记录就诊日期。TeaT 接口定义代茶饮条目,mat 字段记录材料配比,season 记录适宜季节。

技术要点: interface 中每个字段都可以有不同的类型——string 用于文本、number 用于数值、boolean 用于开关状态。ArkTS 严格要求字段类型与赋值类型匹配,这种严格性贯穿整个开发链路,从数据定义到 UI 绑定再到事件处理,每一步都受类型系统保护。这正是鸿蒙原生应用相比 Web 前端开发在工程质量上的核心提升。


二、静态数据常量:Array 泛型集合与初始数据

在定义了接口契约之后,代码紧接着声明了七个 const 常量,每个都是 Array<T> 泛型数组,为应用提供初始数据。

2.1 九种体质数据

const BODIES5: Array<BodyT> = [
  { name: '平和质', pct: 42, desc: '阴阳气血调和,精力充沛', color: '#B03A2E', emoji: '☯️' },
  { name: '气虚质', pct: 18, desc: '元气不足,易疲乏气短', color: '#C0392B', emoji: '🍂' },
  { name: '阳虚质', pct: 12, desc: '畏寒怕冷,手足不温', color: '#D35400', emoji: '🔥' },
  { name: '阴虚质', pct: 8, desc: '口燥咽干,手足心热', color: '#8E44AD', emoji: '💧' },
  { name: '痰湿质', pct: 10, desc: '形体肥胖,腹部松软', color: '#16A085', emoji: '🌫️' },
  { name: '湿热质', pct: 5, desc: '面垢油光,口苦口干', color: '#F39C12', emoji: '🌶️' },
  { name: '血瘀质', pct: 3, desc: '肤色晦暗,易生斑痕', color: '#7B241C', emoji: '🩸' },
  { name: '气郁质', pct: 1, desc: '情绪低沉,敏感多虑', color: '#2E86C1', emoji: '🌾' },
  { name: '特禀质', pct: 1, desc: '过敏体质,易鼻塞喷嚏', color: '#6C3483', emoji: '🌸' }
]

BODIES5 常量包含了中医九种体质的完整数据,类型标注为 Array<BodyT>,即"元素类型为 BodyT 的数组"。每个元素都是一个对象字面量,严格匹配 BodyT 接口的字段定义。九种体质的百分比总和约为 100,构成了一个饼图式的分布数据。每种体质分配了独立的颜色值,用于在 UI 中区分不同体质的横条颜色。这些数据后续会在体质 Tab 页通过 ForEach 渲染为九条横条图。

技术要点: Array<BodyT> 是 ArkTS 泛型语法的体现。泛型使得数组的元素类型被精确约束——如果尝试向 BODIES5 中推入一个缺少 emoji 字段的对象,编译器会立即报错。这种类型安全在大型数据集操作时尤为关键,能防止因字段缺失导致的 UI 渲染异常。同时,const 关键字确保常量引用不可被重新赋值,但数组内部元素仍可被修改(因为 const 冻结的是引用绑定而非对象内容)。

2.2 药膳库数据

const HERBS5: Array<HerbT> = [
  { name: '山药茯苓粥', func: '健脾益气', taste: '甘淡平', suit: '气虚质', emoji: '🍚', fav: true },
  { name: '当归生姜羊肉汤', func: '温中散寒', taste: '甘温', suit: '阳虚质', emoji: '🍲', fav: false },
  { name: '银耳百合莲子羹', func: '滋阴润燥', taste: '甘微寒', suit: '阴虚质', emoji: '🍮', fav: true },
  { name: '薏米赤小豆汤', func: '利水渗湿', taste: '甘淡凉', suit: '痰湿质', emoji: '🥣', fav: false },
  { name: '绿豆苦瓜排骨汤', func: '清热祛湿', taste: '甘苦寒', suit: '湿热质', emoji: '🫕', fav: false },
  { name: '山楂桃仁露', func: '活血化瘀', taste: '酸甘', suit: '血瘀质', emoji: '🍶', fav: false },
  { name: '玫瑰花陈皮茶', func: '疏肝理气', taste: '甘微苦', suit: '气郁质', emoji: '🌹', fav: true },
  { name: '黄芪乌鸡汤', func: '补气固表', taste: '甘温', suit: '气虚质', emoji: '🍗', fav: false },
  { name: '枸杞菊花决明茶', func: '清肝明目', taste: '甘苦凉', suit: '阴虚质', emoji: '🍵', fav: false },
  { name: '芡实莲子猪肚汤', func: '健脾祛湿', taste: '甘涩平', suit: '痰湿质', emoji: '🍜', fav: false },
  { name: '秋梨膏', func: '润肺止咳', taste: '甘凉', suit: '阴虚质', emoji: '🍐', fav: false },
  { name: '八珍糕', func: '气血双补', taste: '甘平', suit: '平和质', emoji: '🍥', fav: false }
]

在这里插入图片描述

HERBS5 包含十二道药膳数据,每道药膳都标注了功效、性味、适宜体质。这里值得注意的设计是 fav 字段的初始值——部分药膳默认已收藏(fav: true),部分未收藏(fav: false)。这种"预置用户偏好"的做法在原型开发和功能演示中很常见,它让应用在首次打开时就呈现出有内容的状态,而不是空白列表。

2.3 穴位库与调理计划数据

const POINTS5: Array<PointT> = [
  { name: '足三里', meridian: '足阳明胃经', effect: '健脾和胃,扶正培元', min: 3, done: true },
  { name: '关元', meridian: '任脉', effect: '培元固本,温阳补气', min: 3, done: true },
  { name: '三阴交', meridian: '足太阴脾经', effect: '调补肝脾肾', min: 3, done: false },
  { name: '涌泉', meridian: '足少阴肾经', effect: '滋阴降火,安神助眠', min: 5, done: false },
  { name: '太冲', meridian: '足厥阴肝经', effect: '疏肝解郁,平肝潜阳', min: 3, done: true },
  { name: '中脘', meridian: '任脉', effect: '和胃健脾,消积化滞', min: 4, done: false },
  { name: '风池', meridian: '足少阳胆经', effect: '祛风散寒,清利头目', min: 3, done: false },
  { name: '合谷', meridian: '手阳明大肠经', effect: '疏风解表,通络止痛', min: 2, done: true },
  { name: '气海', meridian: '任脉', effect: '益气补虚,调理气机', min: 3, done: false },
  { name: '血海', meridian: '足太阴脾经', effect: '活血化瘀,调经统血', min: 3, done: false }
]
const PLANS5: Array<PlanT> = [
  { title: '卯时晨起梳头', freq: '每日', detail: '胆经循行,梳头 100 下升发阳气', on: true },
  { title: '辰时小米粥早餐', freq: '每日', detail: '胃经当令,温软养胃', on: true },
  { title: '午时小憩 15 分钟', freq: '每日', detail: '心经当令,养心护神', on: false },
  { title: '酉时揉按涌泉', freq: '每日', detail: '肾经当令,引火归元助眠', on: true },
  { title: '戌时散步 30 分钟', freq: '每周 5 次', detail: '心包经当令,舒缓情志', on: true },
  { title: '艾灸关元', freq: '每周 2 次', detail: '每次 15 分钟,温阳固本', on: false },
  { title: '八段锦练习', freq: '每周 3 次', detail: '完整两遍,调和气血', on: true },
  { title: '泡脚艾叶浴', freq: '每周 3 次', detail: '40℃ 温水 20 分钟,微微汗出', on: false }
]

在这里插入图片描述

POINTS5 包含十个常用穴位的按摩数据,done 字段标记今日是否已完成按揉打卡。PLANS5 包含八条调理计划,涵盖了从晨起梳头到泡脚艾叶浴的全天调理安排。这两组数据中,doneon 字段的初始值混合了 true 和 false,让 UI 在首屏就呈现出"部分已完成"的真实状态。

2.4 医案记录与代茶饮数据

const CASES5: Array<CaseT> = [
  { date: '08-26', type: '舌象分析', result: '舌淡红苔薄白,齿痕轻', doc: '贺兰山' },
  { date: '08-26', type: '脉象记录', result: '脉细缓,重按无力', doc: '贺兰山' },
  { date: '08-12', type: '体质复测', result: '气虚质为主,兼痰湿', doc: '秦艽' },
  { date: '07-29', type: '艾灸调理', result: '关元 + 足三里,施灸 30 分钟', doc: '白术' },
  { date: '07-15', type: '耳穴压豆', result: '神门 + 内分泌 + 脾', doc: '秦艽' },
  { date: '06-30', type: '拔罐记录', result: '背俞穴走罐,出痧淡红', doc: '贺兰山' },
  { date: '06-17', type: '体质初测', result: '气虚质 62 分,平和质 41 分', doc: '秦艽' },
  { date: '06-03', type: '膏方开方', result: '补中益气膏 250g × 2 瓶', doc: '贺兰山' },
  { date: '05-20', type: '节气养生指导', result: '小满:健脾利湿为主', doc: '白术' },
  { date: '05-06', type: '情志调理', result: '五音疗法:角调式乐曲', doc: '白术' }
]
const TEAS5: Array<TeaT> = [
  { name: '陈皮普洱', mat: '陈皮 3g + 熟普 5g', effect: '理气化痰', season: '四季', emoji: '🍵' },
  { name: '桂圆红枣', mat: '桂圆 5 颗 + 红枣 3 枚', effect: '养血安神', season: '秋冬', emoji: '🫖' },
  { name: '薄荷甘草', mat: '薄荷 2g + 甘草 3g', effect: '疏风利咽', season: '春夏', emoji: '🌿' },
  { name: '菊花枸杞', mat: '杭白菊 5 朵 + 枸杞 10 粒', effect: '清肝明目', season: '夏', emoji: '🌼' },
  { name: '生姜红糖', mat: '生姜 3 片 + 红糖 15g', effect: '温中散寒', season: '冬', emoji: '🫚' },
  { name: '酸梅汤', mat: '乌梅 + 山楂 + 陈皮', effect: '生津消食', season: '夏', emoji: '🥤' }
]
const MERIDIANHOURS5: Array<string> = ['寅时 肺经', '卯时 大肠', '辰时 胃经', '巳时 脾经', '午时 心经', '未时 小肠', '申时 膀胱', '酉时 肾经', '戌时 心包', '亥时 三焦', '子时 胆经', '丑时 肝经']

在这里插入图片描述

CASES5 包含十条医案记录,按时间倒序排列,覆盖舌象、脉象、体质测试、艾灸、耳穴、拔罐等多种中医诊疗方式。TEAS5 包含六种代茶饮配方。MERIDIANHOURS5 是一个字符串数组,记录了十二时辰与十二经络的对应关系——从寅时肺经到丑时肝经,这是中医子午流注理论的核心数据。

技术要点: 这里出现了两种数组声明方式——Array<BodyT>(泛型数组)和 Array<string>(基础类型数组)。MERIDIANHOURS5 虽然是简单字符串数组,但它服务于经络时辰流注的可视化渲染,在后续的 Scroll 横向滚动容器中会逐条展示。这种将业务知识编码为静态数据的方式,使得 UI 渲染逻辑与领域数据彻底解耦,便于后续维护和扩展。


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

3.1 组件入口声明

@Entry
@Component
struct Index {

这段代码定义了应用的根组件。struct Index 声明了一个结构体,这是 ArkTS 中定义组件的方式——不同于 React 的函数组件或类组件,ArkTS 使用结构体(struct)来组织组件。@Component 装饰器将该结构体标记为"可复用的 UI 组件",使其内部可以声明 @State@Prop@Link 等状态变量以及 @Builder 方法。@Entry 装饰器进一步将该组件标记为"页面入口组件",即应用启动后渲染的第一个组件。一个 .ets 文件中只能有一个 @Entry 组件,它是整个页面树的根节点。

技术要点: @Entry@Component 是两个不同层级的装饰器。@Component 是组件级别——任何可复用的 UI 单元都需要它;@Entry 是页面级别——只有页面根组件才需要它。如果你要抽取一个子组件在其他页面复用,只需 @Component 而不需要 @Entry。这种分层设计使得组件既可以独立复用,又能在特定页面作为入口承载全局状态。

3.2 核心状态变量声明

  @State selTab: number = 0
  @State herbs: Array<HerbT> = HERBS5.slice()
  @State points: Array<PointT> = POINTS5.slice()
  @State plans: Array<PlanT> = PLANS5.slice()
  @State cases: Array<CaseT> = CASES5.slice()
  @State selBody: string = '气虚质'

在这里插入图片描述

@State 是 ArkTS 中最核心的状态装饰器。被它标记的变量会被框架纳入"观察列表",任何对这些变量的修改都会自动触发 UI 的局部刷新。这里声明了六个核心状态:selTab 记录当前选中的 Tab 索引(0-5),初始值为 0;herbspointsplanscases 分别是药膳、穴位、计划、医案的可变数据副本——通过调用 .slice() 方法从常量数组创建浅拷贝,确保状态数据与原始常量隔离,修改状态不会影响常量数据。selBody 记录当前选中的体质名称,初始为"气虚质"。

技术要点: .slice() 的使用是一个关键细节。如果直接将 HERBS5 赋值给 @State herbs,那么 herbsHERBS5 会指向同一个数组引用,修改 herbs 中的元素时虽然 UI 会刷新(因为 @State 监听到了赋值),但原始常量数据也会被篡改。通过 .slice() 创建副本,实现了状态数据与常量数据的隔离。这是声明式 UI 中"数据不可变性"原则的实践——常量数据只读,可变数据通过状态变量管理。

3.3 体质测试状态组

  // 体质测试
  @State showTest: boolean = false
  @State qIdx: number = 0
  @State ansA: number = 0
  @State ansB: number = 0
  // 药膳详情
  @State showHerb: boolean = false
  @State selHerb: HerbT = HERBS5[0]
  // 穴位详情
  @State showPoint: boolean = false
  @State selPoint: PointT = POINTS5[0]
  // 编辑计划
  @State showEdit: boolean = false
  @State editIdx: number = 0
  @State editFreq: string = '每日'
  @State editTimes: number = 1
  @State editOn: boolean = true
  // 删除记录
  @State showDel: boolean = false
  @State delCase: CaseT = CASES5[0]
  @State delArmed: boolean = false
  // 代茶饮定制
  @State showTea: boolean = false
  @State teaBase: string = '陈皮普洱'
  @State teaSweet: string = '无糖'
  @State teaHot: string = '热饮'
  @State teaCnt: number = 7

在这里插入图片描述

这段代码集中声明了六个弹窗的控制状态。每一组弹窗都由若干个 @State 变量协同驱动:showTest/showHerb/showPoint/showEdit/showDel/showTea 这六个 boolean 变量分别控制六个弹窗的显示与隐藏;其余变量记录各自弹窗内的交互状态。例如体质测试弹窗中,qIdx 记录当前题目序号(0-5),ansAansB 分别记录两类得分维度的累积值。

技术要点: 弹窗的显示/隐藏完全由 @State boolean 变量驱动,这是声明式 UI 中"条件渲染"的典型模式。当 showTestfalse 变为 true 时,框架会自动在 build() 方法的 Stack 容器中插入弹窗组件;当变回 false 时,弹窗被移除。这种模式不需要开发者手动管理 DOM 节点的创建与销毁,框架负责全部的渲染调度。editFreqeditTimeseditOn 等变量则是弹窗内的"表单状态",记录用户在弹窗中的选择,点击保存时这些状态被提交到全局数据中。

3.4 特效动画状态

  // 特效
  @State qiY: number = 0
  @State yinOp: number = 0.4

qiYyinOp 是两个用于动画效果的状态变量。qiY 控制太极图标的垂直位移(通过 .offset({ y: this.qiY }) 绑定),yinOp 控制阴标的透明度(通过 .opacity(this.yinOp) 绑定)。这两个变量的初始值分别为 0 和 0.4,在 aboutToAppear 生命周期中会被 animateTo 动画驱动,形成持续循环的呼吸动效。在声明式 UI 中,动画的本质就是"修改状态变量值"——状态变化后框架自动插值过渡,无需开发者手动编写动画帧逻辑。


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

  aboutToAppear(): void {
    this.getUIContext().animateTo({ duration: 2800, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
      this.qiY = -12
    })
    this.getUIContext().animateTo({ duration: 1600, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
      this.yinOp = 1
    })
  }

aboutToAppear 是 ArkUI 组件生命周期的核心回调之一,它在组件创建后、build() 方法首次执行前被调用。这个时机非常适合做初始化工作——设置动画、发起网络请求、读取本地存储等。在本应用中,aboutToAppear 启动了两个循环动画。

animateTo 是 ArkUI 提供的显式动画 API。它接受两个参数:第一个是动画配置对象,第二个是闭包函数。闭包中只需修改 @State 变量的终值,框架会自动计算从当前值到终值的插值过程,并在 duration 指定的时间内平滑过渡。

第一个动画配置:duration: 2800(持续 2800 毫秒)、iterations: -1(无限循环)、playMode: PlayMode.Alternate(交替模式,即正向播放完毕后反向播放)、curve: Curve.EaseInOut(缓动曲线为先快后慢再快)。闭包中将 qiY 设为 -12,效果是太极图标从原位上移 12vp 再回到原位,循环往复,形成一种"漂浮"的视觉效果。

第二个动画配置:duration: 1600,同样无限循环交替播放。闭包中将 yinOp 设为 1,效果是阴标的透明度从 0.4 渐变到 1 再回到 0.4,形成"阴阳交替闪烁"的视觉效果,契合中医阴阳动态平衡的理念。

技术要点: PlayMode.Alternate 是动画播放模式枚举值之一。ArkUI 提供了三种播放模式:Normal(正向单次)、Reverse(反向单次)、Alternate(正反交替)。配合 iterations: -1(无限次),就能实现永不停的往复动画。Curve.EaseInOut 是缓动曲线,让动画在起始和结束阶段减速,中段加速,模拟自然运动节奏。getUIContext() 方法返回当前 UI 上下文,是调用 animateTo 的前置条件——在 aboutToAppear 中调用时,UI 上下文已经就绪。


五、业务逻辑方法:数据计算与状态变更

5.1 统计计算方法

  planRate5(): number {
    let n: number = 0
    this.plans.forEach((p: PlanT) => {
      if (p.on) {
        n += 1
      }
    })
    return Math.round(n * 100 / this.plans.length)
  }
  pointDone5(): number {
    let n: number = 0
    this.points.forEach((p: PointT) => {
      if (p.done) {
        n += 1
      }
    })
    return n
  }
  favHerb5(): number {
    let n: number = 0
    this.herbs.forEach((h: HerbT) => {
      if (h.fav) {
        n += 1
      }
    })
    return n
  }

这三个方法分别计算调理计划执行率、穴位按揉完成数和药膳收藏数。planRate5 通过遍历 plans 数组统计 on 为 true 的计划数量,再除以总数得到百分比,使用 Math.round 四舍五入。pointDone5 统计已按揉的穴位数量。favHerb5 统计已收藏的药膳数量。这三个方法不修改状态,只做纯计算,返回值直接嵌入 UI 的 Text 组件中展示。由于它们引用了 @State 变量(this.plansthis.pointsthis.herbs),当这些状态变化时,框架会重新调用方法并刷新对应 Text 的显示内容。

技术要点: 在声明式 UI 中,"计算属性"的概念是通过在 build() 方法或 @Builder 方法中调用这些计算方法来实现的。每次 @State 变量被修改,框架重新执行引用了该变量的 @Builder,计算方法随之被重新调用,返回最新结果。这与 Vue 的 computed 属性理念类似,但 ArkTS 中需要开发者自行保证计算方法不产生副作用——即只读不写。这里使用 forEach 遍历数组而非 filter + length 链式调用,是因为 ArkTS 对数组高阶函数的支持在某些版本下有性能差异,forEach + 计数器的方式更直观高效。

5.2 收藏切换方法

  toggleFav5(h: HerbT): void {
    this.herbs = this.herbs.map((x: HerbT) => {
      if (x === h) {
        const nh: HerbT = { name: x.name, func: x.func, taste: x.taste, suit: x.suit, emoji: x.emoji, fav: !x.fav }
        return nh
      }
      return x
    })
    if (this.selHerb === h) {
      const nh: HerbT = { name: h.name, func: h.func, taste: h.taste, suit: h.suit, emoji: h.emoji, fav: !h.fav }
      this.selHerb = nh
    }
  }

toggleFav5 方法实现了药膳收藏切换的核心逻辑。它使用了 Array.prototype.map 方法遍历 herbs 数组,对每个元素进行判断:如果该元素与传入的 h 是同一个引用(x === h),则创建一个新对象,复制原有字段但将 fav 取反(!x.fav);否则返回原对象不变。map 返回的新数组被重新赋值给 this.herbs,触发 @State 变更通知。此外,如果当前选中查看详情的药膳(this.selHerb)正是被切换收藏的药膳,也要同步更新 selHerb

技术要点: 这里体现了"不可变更新"(Immutable Update)模式。ArkTS 的 @State 对数组的状态监听基于引用变化——如果你直接修改数组元素的属性(如 this.herbs[0].fav = true),框架可能无法感知到变化。正确做法是用 map 创建一个新数组引用并赋值,这样 @State 才能确保触发 UI 刷新。每个修改后的元素都创建新对象(const nh = { ... }) 而非修改原对象,这是声明式 UI 中数据流管理的重要原则。同时,通过 x === h 的引用比较来定位目标元素,这种模式在处理对象数组的状态变更时非常通用。

5.3 穴位打卡方法

  doPoint5(p: PointT): void {
    this.points = this.points.map((x: PointT) => {
      if (x === p) {
        const np: PointT = { name: x.name, meridian: x.meridian, effect: x.effect, min: x.min, done: !x.done }
        return np
      }
      return x
    })
  }

doPoint5 的逻辑模式与 toggleFav5 完全一致——通过 map 创建新数组,对目标元素创建新对象并取反 done 字段,其余元素保持不变。当用户在穴位列表或穴位详情弹窗中点击"标记已按揉"时,调用此方法切换打卡状态。状态变更后,穴位列表中的打卡图标、进度环数据、统计文字都会自动刷新。

5.4 体质测试确认与计划编辑

  confirmTest5(): void {
    const score: number = this.ansA * 12 + this.ansB * 8
    const newBody: string = score >= 40 ? '气虚质' : (score >= 24 ? '平和质' : '阴虚质')
    const nc: CaseT = { date: '08-28', type: '体质测试', result: newBody + '倾向 ' + score.toString() + ' 分', doc: 'AI 辨识' }
    this.cases = [nc].concat(this.cases)
    this.selBody = newBody
    this.showTest = false
    this.qIdx = 0
    this.ansA = 0
    this.ansB = 0
  }
  confirmEdit5(): void {
    const list: Array<PlanT> = this.plans.slice()
    const np: PlanT = { title: list[this.editIdx].title, freq: this.editFreq + ' ' + this.editTimes.toString() + ' 次', detail: list[this.editIdx].detail, on: this.editOn }
    list[this.editIdx] = np
    this.plans = list
    this.showEdit = false
  }

confirmTest5 在体质测试完成后被调用。它根据答题累积的两个分数维度(ansAansB)计算总分:ansA 每分权重 12,ansB 每分权重 8。根据总分阈值判断体质倾向——40 分以上为气虚质,24-39 分为平和质,24 分以下为阴虚质。然后将结果封装为一条新的 CaseT 医案记录,通过 [nc].concat(this.cases) 将新记录插入到医案列表头部。同时更新当前选中体质(selBody)、关闭测试弹窗(showTest = false)、重置测试状态(qIdxansAansB 归零)。

confirmEdit5 在计划编辑弹窗中点击保存时调用。它先用 .slice() 创建 plans 数组的副本(保护原数组),然后在副本中替换指定索引位置的元素为新的 PlanT 对象(editIdx 指定位置),将编辑弹窗中选择的频次和次数组合为 freq 字符串。最后将修改后的副本赋值给 this.plans 并关闭弹窗。

技术要点: confirmTest5[nc].concat(this.cases) 的写法等价于在数组头部插入新元素,但创建了一个全新的数组引用。这种写法比 this.cases.unshift(nc) 更安全——unshift 修改原数组,可能不被 @State 正确监听。confirmEdit5 中使用 .slice() 创建副本再修改,是"拷贝-修改-替换"(Copy-On-Write)模式的体现,在 ArkTS 中这是操作数组状态的标准范式。

5.5 删除与答题流转方法

  doDel5(): void {
    this.cases = this.cases.filter((x: CaseT) => x !== this.delCase)
    this.showDel = false
  }
  nextQ5(a: number, b: number): void {
    this.ansA += a
    this.ansB += b
    if (this.qIdx >= 5) {
      this.confirmTest5()
    } else {
      this.qIdx += 1
    }
  }

doDel5 使用 Array.prototype.filter 方法过滤掉与 delCase 引用相等的元素,返回新数组赋值给 this.cases,实现删除医案记录的功能。nextQ5 是体质测试的答题流转方法——接收两个分数参数 ab,累加到 ansAansB 中,然后判断是否已答完第六题(qIdx >= 5)。如果是,调用 confirmTest5 生成结果;否则将题目索引 qIdx 递增 1,进入下一题。每道题有四个选项,分别对应不同的 ab 值组合,体现了一种简单的加权评分逻辑。


六、@Builder 公共组件:复用 UI 片段

6.1 Tab 按钮构建器

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

@Builder 装饰器将一个方法标记为"UI 构建器",使其能够像组件一样被调用。tabBtn5 接收三个参数:图标表情、标签文字、索引序号,构建一个 Tab 按钮。内部使用 Column 容器纵向排列图标和文字,通过三元运算符(idx === this.selTab ? ... : ...)根据当前选中 Tab 来切换样式——选中时图标更大、不透明、文字红色加粗;未选中时图标较小、半透明、文字灰色常规字重。

Column({ space: 3 }) 中的 space 参数设置子元素之间的纵向间距为 3vp。.layoutWeight(1) 将该按钮在父容器(Row)中的宽度设为弹性权重 1,使得一行中的多个 Tab 按钮等宽分布。.onClick 闭包将 selTab 设为当前按钮的索引,触发 Tab 切换。

技术要点: @Builder 是 ArkTS 中实现 UI 复用的核心机制。它类似于其他框架中的"渲染函数"或"模板片段",但有关键区别:@Builder 方法不能有返回值,它通过声明式描述直接将 UI 结构注入到调用位置。@Builder 方法可以接收参数,这使得它非常适合构建"参数化的重复 UI 单元"——如这里的 Tab 按钮,每次调用传入不同的图标、文字和索引,生成结构相同但内容不同的按钮。layoutWeight(1) 是弹性布局的核心属性——当一行中有三个 layoutWeight(1) 的元素时,它们各占三分之一宽度,无论屏幕尺寸如何变化。

6.2 底部导航栏构建器

  @Builder
  tabBar5() {
    Column() {
      Divider()
        .color('#E5DCC8')
      Row() {
        this.tabBtn5('☯️', '体质', 0)
        this.tabBtn5('🍵', '药膳', 1)
        this.tabBtn5('🧘', '经络', 2)
      }
      .width('100%')
      Row() {
        this.tabBtn5('📋', '医案', 3)
        this.tabBtn5('📅', '调理', 4)
        this.tabBtn5('🙋', '我的', 5)
      }
      .width('100%')
    }
    .width('100%')
    .backgroundColor('#FBF7F0')
  }

tabBar5 构建底部导航栏,采用双排三列布局。顶部是一条 Divider 分割线,颜色为浅米色。下方是两个 Row 容器,每个 Row 内调用三次 this.tabBtn5(...) 放置三个 Tab 按钮。六个 Tab 分别是体质、药膳、经络、医案、调理、我的。由于每个 tabBtn5 内部设置了 layoutWeight(1),三个按钮在一行内自动等宽分布。整个导航栏背景色为 #FBF7F0(米白色),与应用整体的中式宣纸风格一致。

技术要点: Divider 是 ArkUI 的内置分割线组件,默认为水平方向、全宽、1vp 厚度。通过 .color() 可自定义颜色。双排导航的设计在功能项较多时很实用——单排 6 个 Tab 会导致每个按钮过窄、文字溢出,而双排 3+3 布局保证了每个按钮有足够的触摸区域和文字显示空间。调用 this.tabBtn5(...) 时使用 this. 前缀,表明 @Builder 方法是组件实例方法,通过 this 访问。

6.3 头部信息栏构建器

  @Builder
  headerBar5() {
    Column({ space: 12 }) {
      Row() {
        Column({ space: 2 }) {
          Text('TCM BALANCE ☯️')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor('#3D3125')
          Text('上工治未病 · 辨体施养')
            .fontSize(11)
            .fontColor('#8C8378')
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Text('阴')
          .fontSize(14)
          .fontColor('#FBF7F0')
          .padding({ left: 9, right: 9, top: 5, bottom: 5 })
          .backgroundColor('#2E86C1')
          .borderRadius(12)
          .opacity(this.yinOp)
        Text('阳')
          .fontSize(14)
          .fontColor('#FBF7F0')
          .padding({ left: 9, right: 9, top: 5, bottom: 5 })
          .backgroundColor('#B03A2E')
          .borderRadius(12)
          .margin({ left: 6 })
      }
      .width('100%')

headerBar5 构建应用的头部信息区。第一行是一个 Row,左侧是应用标题"TCM BALANCE"和副标题"上工治未病",右侧是"阴"和"阳"两个标签。阴标使用蓝色背景(#2E86C1),透明度绑定到 this.yinOp 状态变量,在动画驱动下呼吸闪烁;阳标使用红色背景(#B03A2E),固定不透明。两个标签都使用 .borderRadius(12) 形成胶囊形圆角,并通过 .margin({ left: 6 }) 设置左侧间距。

.alignItems(HorizontalAlign.Start) 设置 Column 内子元素左对齐,使标题和副标题靠左排列。.layoutWeight(1) 让标题列占据 Row 中除阴阳标签外的全部剩余宽度,将阴阳标签挤到右侧。

      Row({ space: 8 }) {
        Text('🔎')
          .fontSize(15)
        Text('搜索体质 / 药膳 / 穴位 / 名医')
          .fontSize(13)
          .fontColor('#B3A897')
          .layoutWeight(1)
        Text('测体质')
          .fontSize(11)
          .fontColor('#FBF7F0')
          .padding({ left: 10, right: 10, top: 5, bottom: 5 })
          .backgroundColor('#B03A2E')
          .borderRadius(10)
          .onClick(() => {
            this.qIdx = 0
            this.ansA = 0
            this.ansB = 0
            this.showTest = true
          })
      }
      .width('100%')
      .padding({ left: 14, right: 14, top: 10, bottom: 10 })
      .backgroundColor('#FFFFFF')
      .borderRadius(20)

第二行是搜索栏和"测体质"按钮。搜索区域使用灰色占位文字(#B3A897),右侧"测体质"按钮为红色胶囊形,点击后重置测试状态(qIdxansAansB 归零)并打开体质测试弹窗(showTest = true)。搜索栏整体使用白色背景和 20vp 圆角,形成卡片式的搜索框视觉。

      Row({ space: 12 }) {
        Column() {
          Text('☯️')
            .fontSize(30)
            .offset({ y: this.qiY })
        }
        .padding(12)
        .backgroundColor('#F3ECDD')
        .borderRadius(16)
        Column({ space: 4 }) {
          Text('主体质:' + this.selBody)
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#3D3125')
          Text('辨识于 08-12 · 秦艽医师确认')
            .fontSize(11)
            .fontColor('#8C8378')
          Text('调理方向:补脾益气,佐以化湿')
            .fontSize(11)
            .fontColor('#B03A2E')
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
      }
      .width('100%')
      .padding(13)
      .backgroundColor('#FFFFFF')
      .borderRadius(18)
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 14, bottom: 10 })
    .backgroundColor('#FBF7F0')
  }

第三行是体质概览卡片:左侧是太极图标,其 .offset({ y: this.qiY }) 绑定到动画状态 qiY,在 aboutToAppear 中启动的动画驱动下持续上下浮动。右侧显示主体质名称、辨识日期和医师、调理方向。整体卡片白色背景、18vp 圆角,配合米白色页面背景形成层次感。

技术要点: .offset({ y: this.qiY }) 是位移属性,y 为负值时元素上移。由于 qiY@State 变量,animateTo 闭包中修改 qiY 时,框架自动对 .offset 属性执行动画插值,太极图标便产生了平滑的上下浮动效果。这就是声明式动画的精髓——开发者只声明"动画终态",框架负责所有中间帧的计算与渲染。.borderRadius 的圆角值越大越圆,当值达到元素尺寸一半时变为完整圆形。

6.4 区块标题构建器

  @Builder
  secTitle5(icon: string, title: string, extra: string) {
    Row() {
      Text(icon)
        .fontSize(15)
        .margin({ right: 6 })
      Text(title)
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor('#3D3125')
        .layoutWeight(1)
      Text(extra)
        .fontSize(12)
        .fontColor('#B03A2E')
    }
    .width('100%')
    .margin({ top: 18, bottom: 10 })
  }

secTitle5 是一个通用的区块标题构建器,接收图标、标题文字、附加文字三个参数。结构为 Row 容器内横向排列:图标 + 标题(layoutWeight(1) 占满中间空间)+ 附加文字(右对齐,红色)。上下 margin 提供区块间的呼吸空间。这个构建器在六个 Tab 页面中被反复调用,为每个内容区块提供统一的标题样式——如"九种体质倾向"、“体质药膳库”、"今日穴位按摩"等。


七、Tab 0 体质辨识页:ForEach 列表渲染与横条图

  @Builder
  bodyTab() {
    Column({ space: 0 }) {
      this.secTitle5('📊', '九种体质倾向', '总分 100')
      Column({ space: 9 }) {
        ForEach(BODIES5, (b: BodyT) => {
          Row({ space: 8 }) {
            Text(b.emoji)
              .fontSize(15)
            Text(b.name)
              .fontSize(12)
              .fontColor('#3D3125')
              .width(56)
            Column({ space: 3 }) {
              Row() {
                Row()
                  .width(b.pct.toString() + '%')
                  .height(8)
                  .backgroundColor(b.color)
                  .borderRadius(4)
                Row()
                  .layoutWeight(1)
                  .height(8)
                  .backgroundColor('#F3ECDD')
                  .borderRadius(4)
              }
              .width('100%')
              Text(b.desc)
                .fontSize(9)
                .fontColor('#B3A897')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Text(b.pct.toString() + ' 分')
              .fontSize(11)
              .fontWeight(FontWeight.Bold)
              .fontColor(b.color)
              .width(42)
          }
          .width('100%')
          .padding(10)
          .backgroundColor(this.selBody === b.name ? '#F9F1E3' : '#FFFFFF')
          .borderRadius(12)
          .border({ width: this.selBody === b.name ? 1 : 0, color: '#B03A2E' })
          .onClick(() => {
            this.selBody = b.name
          })
        }, (b: BodyT) => b.name)
      }
      .width('100%')

bodyTab 构建体质辨识页的主体内容。核心是使用 ForEach 遍历 BODIES5 常量数组,为每种体质渲染一条横条记录。ForEach 的第一个参数是数据源数组,第二个参数是项渲染函数,第三个参数是键值生成函数((b: BodyT) => b.name,以体质名称作为唯一键)。

每条记录的布局结构为:Row 容器内横向排列表情图标、体质名称(固定宽度 56vp)、进度条区域和分数。进度条区域使用嵌套的 Row——内层第一个 Row 的宽度设为百分比字符串(b.pct.toString() + '%'),背景色为体质颜色,形成彩色进度条;第二个 Row 使用 layoutWeight(1) 填充剩余空间,背景为浅米色,形成"未满"部分。两条拼接在一起,视觉上就是一条完整的进度条。这是用纯 Row 组件模拟进度条的经典技巧。

技术要点: ForEach 是 ArkUI 中处理列表渲染的核心组件。它的第三个参数(键值生成函数)至关重要——框架通过键值判断哪些项需要新增、删除或更新,从而实现高效的局部 Diff 更新。如果键值不唯一或缺失,可能导致渲染异常或性能下降。这里使用 b.name 作为键,因为九种体质名称天然唯一。.border({ width: ..., color: ... }) 同时设置边框宽度和颜色,选中体质的记录边框为 1vp 红色,未选中的为 0vp(无边框),实现选中态高亮。

      this.secTitle5('🍃', '五行养生要点', '对应五脏')
      Row({ space: 8 }) {
        Column({ space: 4 }) {
          Text('🌲')
            .fontSize(22)
          Text('木 · 肝')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor('#16A085')
          Text('舒达情志')
            .fontSize(9)
            .fontColor('#8C8378')
        }
        .layoutWeight(1)
        .padding({ top: 10, bottom: 10 })
        .backgroundColor('#FFFFFF')
        .borderRadius(12)
        Column({ space: 4 }) {
          Text('🔥')
            .fontSize(22)
          Text('火 · 心')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor('#B03A2E')
          Text('静养心神')
            .fontSize(9)
            .fontColor('#8C8378')
        }
        .layoutWeight(1)
        .padding({ top: 10, bottom: 10 })
        .backgroundColor('#FFFFFF')
        .borderRadius(12)
        Column({ space: 4 }) {
          Text('🏔️')
            .fontSize(22)
          Text('土 · 脾')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor('#D35400')
          Text('饮食有节')
            .fontSize(9)
            .fontColor('#8C8378')
        }
        .layoutWeight(1)
        .padding({ top: 10, bottom: 10 })
        .backgroundColor('#FFFFFF')
        .borderRadius(12)
        Column({ space: 4 }) {
          Text('⚔️')
            .fontSize(22)
          Text('金 · 肺')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor('#7D6608')
          Text('润燥益气')
            .fontSize(9)
            .fontColor('#8C8378')
        }
        .layoutWeight(1)
        .padding({ top: 10, bottom: 10 })
        .backgroundColor('#FFFFFF')
        .borderRadius(12)
        Column({ space: 4 }) {
          Text('💧')
            .fontSize(22)
          Text('水 · 肾')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor('#2E86C1')
          Text('藏精固本')
            .fontSize(9)
            .fontColor('#8C8378')
        }
        .layoutWeight(1)
        .padding({ top: 10, bottom: 10 })
        .backgroundColor('#FFFFFF')
        .borderRadius(12)
      }
      .width('100%')
      Text('🌸 处暑已过,白露将至:宜早睡早起,食酸润肺,减辛增甘')
        .fontSize(11)
        .fontColor('#8C8378')
        .margin({ top: 12 })
    }
    .width('100%')
    .padding({ left: 16, right: 16, bottom: 16 })
  }

体质页的第二部分是五行养生卡片,使用一个 Row 容器内嵌五个 Column,每个 Column 代表一行(木火土金水),包含表情、五行名称与对应脏腑、养生要点。五个 Column 都设置 layoutWeight(1),在 Row 中等宽分布。每张卡片使用不同的主题色——木为青绿、火为朱红、土为橙黄、金为暗金、水为蓝色,对应中医五色理论。最后是一条节气养生提示文字。

技术要点: 五等分布局是 layoutWeight 的典型应用场景。五个 Column 各设 layoutWeight(1),无论屏幕多宽,都自动等分。这比使用固定百分比宽度更灵活——如果将来从五个变为三个或六个卡片,只需增删 Column 即可,布局自动适配。.padding({ top: 10, bottom: 10 }) 只设置上下内边距,左右不设,使卡片内部文字垂直居中的同时卡片间靠 Row({ space: 8 })space 参数控制间距。


八、Tab 1 药膳库页:列表卡片与收藏交互

  @Builder
  herbTab() {
    Column({ space: 0 }) {
      this.secTitle5('🍵', '体质药膳库', '收藏 ' + this.favHerb5().toString() + ' 道')
      Row({ space: 8 }) {
        ForEach(['全部', '气虚质', '阳虚质', '阴虚质', '痰湿质'], (f: string) => {
          Text(f)
            .fontSize(11)
            .fontColor(this.selBody === f || (f === '全部' && false) ? '#FBF7F0' : '#8C8378')
            .padding({ left: 12, right: 12, top: 7, bottom: 7 })
            .backgroundColor(this.selBody === f ? '#B03A2E' : '#F3ECDD')
            .borderRadius(12)
            .onClick(() => {
              this.selBody = f
            })
        }, (f: string) => f)
      }
      .width('100%')
      Column({ space: 10 }) {
        ForEach(this.herbs, (h: HerbT) => {
          Row({ space: 10 }) {
            Text(h.emoji)
              .fontSize(24)
              .padding(8)
              .backgroundColor('#F9F1E3')
              .borderRadius(12)
            Column({ space: 3 }) {
              Text(h.name)
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor('#3D3125')
              Text(h.func + ' · ' + h.taste)
                .fontSize(11)
                .fontColor('#B03A2E')
              Text('宜:' + h.suit)
                .fontSize(10)
                .fontColor('#8C8378')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Text(h.fav ? '❤️' : '🤍')
              .fontSize(16)
              .onClick(() => {
                this.toggleFav5(h)
              })
            Text('详情')
              .fontSize(11)
              .fontColor('#B03A2E')
              .padding({ left: 10, right: 10, top: 5, bottom: 5 })
              .backgroundColor('#F9F1E3')
              .borderRadius(10)
              .onClick(() => {
                this.selHerb = h
                this.showHerb = true
              })
          }
          .width('100%')
          .padding(12)
          .backgroundColor('#FFFFFF')
          .borderRadius(14)
        }, (h: HerbT) => h.name + h.fav.toString())
      }
      .width('100%')
      .margin({ top: 12 })
    }
    .width('100%')
    .padding({ left: 16, right: 16, bottom: 16 })
  }

herbTab 构建药膳库页面。页面顶部是筛选标签栏,使用 ForEach 遍历体质名称数组,渲染一组可点击的筛选标签。选中态标签为红色背景白字,未选中为米色背景灰字。点击标签切换 selBody 状态,影响标签样式和后续列表筛选(虽然列表本身未做过滤,但标签状态联动已实现)。

药膳列表使用 ForEach 遍历 this.herbs 状态数组(而非常量 HERBS5),因为收藏状态需要动态变化。每条药膳记录是一个 Row 卡片:左侧表情图标(米色圆角背景)、中间药膳信息(名称、功效性味、适宜体质)、右侧收藏心和详情按钮。收藏心根据 h.fav 显示红心或空心,点击调用 toggleFav5(h) 切换收藏状态。详情按钮点击后设置 selHerb 并打开药膳详情弹窗。

技术要点: ForEach 的键值生成函数这里使用了 h.name + h.fav.toString(),将名称和收藏状态组合为键。这是因为收藏状态变化时,键值也会变化,强制框架将该条目视为"新项"进行完整重新渲染,确保收藏图标即时更新。如果仅使用 h.name 作为键,当 fav 从 false 变为 true 时,键值不变,框架可能复用旧节点而不刷新图标。这种"键值含状态字段"的技巧在需要强制刷新列表项时很有用。


九、Tab 2 经络穴位页:Stack 层叠、Progress 环形进度与 Scroll 横向滚动

  @Builder
  meridianTab() {
    Column({ space: 0 }) {
      this.secTitle5('🧘', '今日穴位按摩', this.pointDone5().toString() + '/' + this.points.length.toString() + ' 已按')
      Column({ space: 12 }) {
        Row({ space: 14 }) {
          Stack() {
            Progress({ value: this.pointDone5() * 100 / this.points.length, total: 100 })
              .width(82)
              .height(82)
              .style({ strokeWidth: 9 })
            Column({ space: 1 }) {
              Text('☯️')
                .fontSize(14)
                .offset({ y: this.qiY })
              Text(this.pointDone5().toString() + '/' + this.points.length.toString())
                .fontSize(15)
                .fontWeight(FontWeight.Bold)
                .fontColor('#3D3125')
            }
          }
          Column({ space: 5 }) {
            Text('经络调理进行中')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor('#3D3125')
            Text('每穴按揉 3 分钟,酸胀为度')
              .fontSize(11)
              .fontColor('#8C8378')
            Text('今日已积累 14 分钟')
              .fontSize(11)
              .fontColor('#16A085')
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
        }
        .width('100%')
      }
      .width('100%')
      .padding(14)
      .backgroundColor('#FFFFFF')
      .borderRadius(18)

meridianTab 经络页的核心亮点是 Stack 层叠布局配合 Progress 环形进度组件。Stack 容器将其子元素按层叠方式堆放——后声明的子元素覆盖在先声明的子元素之上。这里第一层是 Progress 环形进度条(82vp 见方),第二层是一个 Column,包含太极图标(同样绑定 qiY 动画浮动)和完成进度文字。两层叠放,形成"环形进度条中间显示数字和图标"的经典仪表盘效果。

Progress 组件通过 valuetotal 参数定义进度比例——value 为当前值,total 为总值,进度比例为 value/total。这里 value 为已完成穴位数乘以 100 除以总数,total 为 100,即百分比。.style({ strokeWidth: 9 }) 设置环形进度条的线宽为 9vp。

技术要点: Stack 是 ArkUI 三大布局容器之一,与 Column(纵向)、Row(横向)并列。Stack 的层叠特性使其非常适合构建"叠加视觉"——如进度环+中心文字、背景图+前景文字、地图+标记点等。默认情况下 Stack 的子元素居中对齐,可通过 .alignContent() 参数调整对齐方式。Progress 组件支持三种类型:Linear(线性条)、Ring/Circular(环形)、ScaleRing(刻度环),通过 type 参数或 ProgressType 枚举指定。默认为环形。

      this.secTitle5('⏳', '十二时辰经络流注', '顺时养生')
      Scroll() {
        Row({ space: 10 }) {
          ForEach(MERIDIANHOURS5, (m: string, i: number) => {
            Column({ space: 4 }) {
              Text(m.split(' ')[0])
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor(i === 8 ? '#FBF7F0' : '#3D3125')
              Text(m.split(' ')[1])
                .fontSize(10)
                .fontColor(i === 8 ? '#FBF7F0' : '#8C8378')
            }
            .padding({ left: 12, right: 12, top: 9, bottom: 9 })
            .backgroundColor(i === 8 ? '#B03A2E' : '#FFFFFF')
            .borderRadius(12)
          }, (m: string) => m)
        }
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .width('100%')
      Text('当前戌时 · 心包经当令,宜舒缓情志、准备入静')
        .fontSize(11)
        .fontColor('#B03A2E')
        .margin({ top: 10 })

十二时辰经络流注使用 Scroll 横向滚动容器展示。Scroll 内嵌一个 RowRow 中使用 ForEach 遍历 MERIDIANHOURS5 数组,为每个时辰渲染一个卡片。每个卡片通过 m.split(' ')[0]m.split(' ')[1] 分别提取时辰名称(如"寅时")和经络名称(如"肺经")。当索引为 8(戌时,当前时辰)时,卡片高亮为红色背景白字,其余为白底深色字。

.scrollable(ScrollDirection.Horizontal) 将滚动方向设为水平,.scrollBar(BarState.Off) 隐藏滚动条,使横向滚动更干净。在 ForEach 的渲染函数中,第二个参数 i: number 是当前项的索引,这是 ForEach 的可选第二参数,常用于需要根据位置做差异化渲染的场景。

技术要点: Scroll 是 ArkUI 的滚动容器组件,可容纳超出视口的内容并支持手势滚动。ScrollDirection 枚举提供 Horizontal(水平)、Vertical(垂直)和 Free(自由)三种方向。BarState 枚举控制滚动条显示:On(常显)、Off(隐藏)、Auto(自动)。横向滚动列表是移动端常见的 UI 模式,特别适合展示时间轴、标签栏、分类选择等有限项的横向排列。

      this.secTitle5('📍', '穴位库', '点击查看取穴')
      Column({ space: 10 }) {
        ForEach(this.points, (p: PointT) => {
          Row({ space: 10 }) {
            Column({ space: 3 }) {
              Text(p.name)
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor(p.done ? '#B3A897' : '#3D3125')
              Text(p.meridian + ' · ' + p.effect)
                .fontSize(10)
                .fontColor('#8C8378')
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Text(p.done ? '已按 ✅' : '未按')
              .fontSize(11)
              .fontColor(p.done ? '#16A085' : '#B03A2E')
              .padding({ left: 10, right: 10, top: 5, bottom: 5 })
              .backgroundColor(p.done ? '#EAF6F1' : '#F9F1E3')
              .borderRadius(10)
              .onClick(() => {
                this.doPoint5(p)
              })
            Text('取穴')
              .fontSize(11)
              .fontColor('#8C8378')
              .padding({ left: 10, right: 10, top: 5, bottom: 5 })
              .backgroundColor('#F3ECDD')
              .borderRadius(10)
              .onClick(() => {
                this.selPoint = p
                this.showPoint = true
              })
          }
          .width('100%')
          .padding(12)
          .backgroundColor('#FFFFFF')
          .borderRadius(14)
        }, (p: PointT) => p.name + p.done.toString())
      }
      .width('100%')
    }
    .width('100%')
    .padding({ left: 16, right: 16, bottom: 16 })
  }

穴位库列表使用 ForEach 遍历 this.points 状态数组。每条穴位记录卡片包含:穴位名称(已按的变灰、未按的深色)、经络与功效(.maxLines(1) 限制单行、.textOverflow({ overflow: TextOverflow.Ellipsis }) 超出部分显示省略号)、打卡按钮(已按绿色、未按红色,点击调用 doPoint5(p) 切换状态)、取穴按钮(点击打开穴位详情弹窗)。

技术要点: .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) 是处理文本溢出的黄金组合。maxLines 限制最大行数,textOverflow 设置溢出时的处理方式——Ellipsis 为省略号、Clip 为直接裁剪、None 为不处理。在列表项中限制文本行数可以防止过长描述撑破布局,保持列表项高度一致。键值函数 p.name + p.done.toString() 同样将状态纳入键值,确保打卡切换时列表项正确刷新。


十、Tab 3 医案页:时间轴布局与条件渲染

  @Builder
  caseTab() {
    Column({ space: 0 }) {
      this.secTitle5('📋', '调理医案', '共 ' + this.cases.length.toString() + ' 条')
      Column() {
        ForEach(this.cases, (c: CaseT, i: number) => {
          Row({ space: 10 }) {
            Column() {
              Circle({ width: 9, height: 9 })
                .fill('#B03A2E')
                .opacity(this.yinOp)
              if (i < this.cases.length - 1) {
                Row()
                  .width(2)
                  .layoutWeight(1)
                  .backgroundColor('#E5DCC8')
              }
            }
            .width(12)
            .constraintSize({ minHeight: 60 })
            Column({ space: 4 }) {
              Row() {
                Text(c.type)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#3D3125')
                  .layoutWeight(1)
                Text(c.date)
                  .fontSize(10)
                  .fontColor('#B3A897')
              }
              .width('100%')
              Text(c.result)
                .fontSize(11)
                .fontColor('#8C8378')
              Row({ space: 8 }) {
                Text('医师:' + c.doc)
                  .fontSize(10)
                  .fontColor('#B03A2E')
                  .layoutWeight(1)
                Text('删除')
                  .fontSize(10)
                  .fontColor('#B03A2E')
                  .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                  .backgroundColor('#F9E9E7')
                  .borderRadius(8)
                  .onClick(() => {
                    this.delCase = c
                    this.delArmed = false
                    this.showDel = true
                  })
              }
              .width('100%')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            .padding({ top: 2, bottom: 14 })
          }
          .width('100%')
          .alignItems(VerticalAlign.Top)
        }, (c: CaseT) => c.date + c.type + c.result)
      }
      .width('100%')
      .padding(14)
      .backgroundColor('#FFFFFF')
      .borderRadius(18)
      Text('📜 医案仅作调理参考,具体诊疗请至线下中医门诊')
        .fontSize(11)
        .fontColor('#B3A897')
        .margin({ top: 12 })
    }
    .width('100%')
    .padding({ left: 16, right: 16, bottom: 16 })
  }

caseTab 构建医案页面,采用时间轴(Timeline)布局。时间轴的核心结构是每条记录左侧的 Column:顶部是 Circle 圆点(绑定 yinOp 透明度动画,呈现呼吸闪烁效果),圆点下方是一根竖线(Row 宽 2vp,layoutWeight(1) 填充剩余高度,浅米色)。通过 if (i < this.cases.length - 1) 条件判断,最后一条记录不渲染竖线,形成完整的时间轴视觉。

右侧是医案内容:类型标题与日期横排、诊疗结果描述、医师名与删除按钮横排。删除按钮点击后设置 delCase 为当前记录、重置确认开关 delArmed 为 false、打开删除确认弹窗 showDel = true.constraintSize({ minHeight: 60 }) 设置最小高度为 60vp,确保即使内容很短,时间轴的间距也保持均匀。

技术要点: Circle 是 ArkUI 的内置图形组件,通过 widthheight 定义尺寸,.fill() 设置填充颜色。if/else 条件渲染是 ArkTS 在 build()@Builder 方法中支持的控制流语法——条件为 true 时渲染对应 UI,为 false 时跳过。这里用条件渲染实现了"最后一条无竖线"的效果。.alignItems(VerticalAlign.Top)Row 内子元素顶部对齐,使时间轴圆点与医案标题在同一水平线上。constraintSize 是约束尺寸属性,minHeight 确保最小高度,防止内容过少时布局塌缩。


十一、Tab 4 调理计划页:Progress 进度环与 Toggle 开关

  @Builder
  planTab() {
    Column({ space: 0 }) {
      this.secTitle5('📅', '调理计划', '执行率 ' + this.planRate5().toString() + '%')
      Column({ space: 12 }) {
        Row({ space: 14 }) {
          Stack() {
            Progress({ value: this.planRate5(), total: 100 })
              .width(80)
              .height(80)
              .style({ strokeWidth: 9 })
            Column({ space: 1 }) {
              Text('🍂')
                .fontSize(13)
                .offset({ y: this.qiY })
              Text(this.planRate5().toString() + '%')
                .fontSize(15)
                .fontWeight(FontWeight.Bold)
                .fontColor('#3D3125')
            }
          }
          Column({ space: 5 }) {
            Text('气虚质四周调理方案 · 第 3 周')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor('#3D3125')
            Text('补脾为主线,兼化痰湿,起居有常')
              .fontSize(11)
              .fontColor('#8C8378')
            Text('已完成 18/28 天打卡')
              .fontSize(11)
              .fontColor('#16A085')
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
        }
        .width('100%')
      }
      .width('100%')
      .padding(14)
      .backgroundColor('#FFFFFF')
      .borderRadius(18)

planTab 调理计划页的顶部与经络页类似,使用 Stack + Progress 构建环形进度仪表盘。进度值绑定到 planRate5() 方法的返回值,显示计划执行率百分比。中心叠加落叶表情(绑定 qiY 动画)和百分比文字。右侧是方案描述信息。

      this.secTitle5('🧾', '计划明细', '点击编辑')
      Column({ space: 10 }) {
        ForEach(this.plans, (p: PlanT, i: number) => {
          Row({ space: 10 }) {
            Column({ space: 3 }) {
              Text(p.title)
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor(p.on ? '#3D3125' : '#B3A897')
              Text(p.detail)
                .fontSize(10)
                .fontColor('#8C8378')
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
                .constraintSize({ maxWidth: 180 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Text(p.freq)
              .fontSize(10)
              .fontColor('#B03A2E')
              .padding({ left: 8, right: 8, top: 4, bottom: 4 })
              .backgroundColor('#F9F1E3')
              .borderRadius(8)
            Text('编辑')
              .fontSize(11)
              .fontColor('#FBF7F0')
              .padding({ left: 10, right: 10, top: 5, bottom: 5 })
              .backgroundColor('#B03A2E')
              .borderRadius(10)
              .onClick(() => {
                this.editIdx = i
                this.editFreq = '每日'
                this.editTimes = 1
                this.editOn = p.on
                this.showEdit = true
              })
          }
          .width('100%')
          .padding(12)
          .backgroundColor('#FFFFFF')
          .borderRadius(14)
        }, (p: PlanT, i: number) => p.title + i.toString())
      }
      .width('100%')
    }
    .width('100%')
    .padding({ left: 16, right: 16, bottom: 16 })
  }

计划明细列表使用 ForEach 遍历 this.plans 状态数组,ForEach 的第二参数同时接收项数据和索引 i: number。每条计划卡片包含标题(启用深色、禁用灰色)、详情描述(单行省略,.constraintSize({ maxWidth: 180 }) 限制最大宽度防溢出)、频次标签、编辑按钮。编辑按钮点击时将索引 i 和默认编辑参数写入状态变量,打开编辑弹窗。

技术要点: ForEach 的项渲染函数签名为 (item: T, index?: number) => void,第二个参数是可选的索引。当需要根据位置做差异化处理(如交替背景色、首尾特殊样式)时,索引非常有用。键值函数 p.title + i.toString() 将标题和索引组合为键——包含索引是为了处理多条同名计划的情况,确保每条记录的键值唯一。.constraintSize({ maxWidth: 180 }) 限制文本最大宽度,配合 maxLines(1) 和省略号,确保长描述不会撑破卡片布局。


十二、Tab 5 我的页:用户卡片与设置列表

  @Builder
  mineTab() {
    Column({ space: 0 }) {
      Column({ space: 8 }) {
        Text('🧑‍⚕️')
          .fontSize(42)
          .padding(14)
          .backgroundColor('#F9F1E3')
          .borderRadius(30)
        Text('李小天')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor('#3D3125')
        Text('气虚质调理第 68 天 · 平和质目标进度 62%')
          .fontSize(12)
          .fontColor('#B03A2E')
        Row({ space: 8 }) {
          Text('药膳收藏 ' + this.favHerb5().toString() + ' 道')
            .fontSize(11)
            .fontColor('#D35400')
            .padding({ left: 10, right: 10, top: 4, bottom: 4 })
            .backgroundColor('#F9F1E3')
            .borderRadius(10)
          Text('打卡 46 天')
            .fontSize(11)
            .fontColor('#16A085')
            .padding({ left: 10, right: 10, top: 4, bottom: 4 })
            .backgroundColor('#EAF6F1')
            .borderRadius(10)
        }
      }
      .width('100%')
      .padding({ top: 22, bottom: 18 })
      .backgroundColor('#FFFFFF')
      .borderRadius(20)

mineTab 我的页面顶部是用户信息卡片:大头像(42vp 的表情图标,30vp 圆角的米色背景块)、姓名、调理进度描述、两个统计标签(药膳收藏数、打卡天数)。收藏数动态绑定到 favHerb5() 方法返回值,随收藏状态实时更新。

      this.secTitle5('🫖', '我的代茶饮', '定制养生茶')
      Column({ space: 10 }) {
        ForEach(TEAS5, (t: TeaT) => {
          Row({ space: 10 }) {
            Text(t.emoji)
              .fontSize(20)
            Column({ space: 3 }) {
              Text(t.name)
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor('#3D3125')
              Text(t.mat + ' · ' + t.effect + ' · 宜' + t.season)
                .fontSize(10)
                .fontColor('#8C8378')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Text('定制')
              .fontSize(11)
              .fontColor('#FBF7F0')
              .padding({ left: 10, right: 10, top: 5, bottom: 5 })
              .backgroundColor('#B03A2E')
              .borderRadius(10)
              .onClick(() => {
                this.teaBase = t.name
                this.showTea = true
              })
          }
          .width('100%')
          .padding(12)
          .backgroundColor('#FFFFFF')
          .borderRadius(14)
        }, (t: TeaT) => t.name)
      }
      .width('100%')

代茶饮列表使用 ForEach 遍历 TEAS5 常量数组,每条记录展示配方名、材料配比、功效、适宜季节,右侧"定制"按钮点击后设置 teaBase 为当前茶饮名并打开代茶饮定制弹窗。

      this.secTitle5('⚙️', '设置', '')
      Column({ space: 0 }) {
        ForEach(['🔔 节气与时辰提醒', '☯️ 体质档案管理', '🍵 药膳收藏夹', '👨‍👩‍👧 家庭体质档案', '📅 调理计划通知', '📞 咨询执业中医师'], (s: string) => {
          Row({ space: 8 }) {
            Text(s)
              .fontSize(13)
              .fontColor('#3D3125')
              .layoutWeight(1)
            Text('›')
              .fontSize(16)
              .fontColor('#B3A897')
          }
          .width('100%')
          .padding({ top: 13, bottom: 13 })
          .backgroundColor('#FFFFFF')
        }, (s: string) => s)
      }
      .width('100%')
      .borderRadius(14)
      .clip(true)
      Text('TCM BALANCE · 中医体质辨识 v5.0.1')
        .fontSize(10)
        .fontColor('#B3A897')
        .margin({ top: 18, bottom: 6 })
    }
    .width('100%')
    .padding({ left: 16, right: 16, bottom: 16 })
  }

设置列表使用 ForEach 遍历字符串数组,渲染一组设置项。每项左侧是设置名称(layoutWeight(1) 占满空间),右侧是箭头符号"›"。整个设置列表容器使用 .borderRadius(14).clip(true)——clip(true) 是裁剪属性,使子元素的圆角不超出容器边界,形成顶部和底部行方角、整体容器圆角的分组列表效果。底部是应用版本号文字。

技术要点: .clip(true) 在 ArkUI 中用于裁剪溢出内容。当一个圆角容器内有多个子元素时,如果不裁剪,内部子元素的直角会超出容器的圆角边界。.clip(true) 强制将超出部分裁剪掉,实现"外圆内方"的分组列表视觉。这是 iOS 风格设置列表的经典实现方式。ForEach 也可以遍历普通字符串数组(Array<string>),不一定需要复杂对象——当数据本身就是一个简单值时,直接使用该值作为渲染内容即可。


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

13.1 弹层架构模式

本应用的六个弹层采用了统一的架构模式:每个弹层由两部分组成——遮罩层和内容层。遮罩层是一个全屏半透明背景,点击可关闭弹层;内容层是实际的弹窗卡片,通过 position 绝对定位放置在屏幕特定位置。两者通过 zIndex 控制层叠顺序,遮罩在下(zIndex: 999)、内容在上(zIndex: 1000)。

技术要点: position 是绝对定位属性,通过 { x, y } 参数指定元素相对于父容器左上角的偏移。使用 position 后,元素脱离正常文档流,不再影响其他元素的布局。zIndex 控制层叠顺序——数值越大越在上层。在 Stack 容器中,zIndex 用于精确控制子元素的堆叠顺序。遮罩 999、内容 1000 的搭配确保弹层内容始终在遮罩之上,遮罩又在所有页面内容之上。这种"遮罩+内容+绝对定位+zIndex"的组合是 ArkTS 中实现自定义弹层的标准范式,比使用系统 Dialog 组件更灵活,可完全控制样式和位置。

13.2 体质测试弹层(居中大卡)

  @Builder
  testModalOverlay() {
    Column() {
      Column()
        .width('100%')
        .height('100%')
        .backgroundColor('#663D3125')
        .onClick(() => {
          this.showTest = false
        })
    }
    .width('100%')
    .height('100%')
    .position({ x: 0, y: 0 })
    .zIndex(999)
    Column({ space: 14 }) {
      Text('☯️ 中医体质快速测试')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#3D3125')
      Text('第 ' + (this.qIdx + 1).toString() + ' / 6 题')
        .fontSize(11)
        .fontColor('#8C8378')
      Progress({ value: (this.qIdx + 1) * 100 / 6, total: 100, type: ProgressType.Linear })
        .width('100%')
        .style({ strokeWidth: 8 })

体质测试弹层的内容卡片居中显示(通过 position({ x: '7%', y: '12%' }) 定位),宽度为屏幕的 86%。卡片内包含标题、题号进度文字、线性进度条(ProgressType.Linear),以及题目内容和四个选项按钮。

线性进度条通过 value: (this.qIdx + 1) * 100 / 6 计算当前题目的进度百分比——第一题为 16.7%,第六题为 100%。题号变化时进度条自动前进。

      Column({ space: 10 }) {
        if (this.qIdx === 0) {
          Text('您容易感到疲乏、说话有气无力吗?')
            .fontSize(13)
            .fontColor('#3D3125')
            .width('100%')
        } else if (this.qIdx === 1) {
          Text('您比别人容易怕冷,手脚发凉吗?')
            .fontSize(13)
            .fontColor('#3D3125')
            .width('100%')
        } else if (this.qIdx === 2) {
          Text('您感到口干咽燥、总想喝水吗?')
            .fontSize(13)
            .fontColor('#3D3125')
            .width('100%')
        } else if (this.qIdx === 3) {
          Text('您感到身体沉重不轻松、腹部肥满吗?')
            .fontSize(13)
            .fontColor('#3D3125')
            .width('100%')
        } else if (this.qIdx === 4) {
          Text('您容易情绪低落、多愁善感吗?')
            .fontSize(13)
            .fontColor('#3D3125')
            .width('100%')
        } else {
          Text('您容易对药物、食物、花粉过敏吗?')
            .fontSize(13)
            .fontColor('#3D3125')
            .width('100%')
        }
        Row({ space: 8 }) {
          Text('没有')
            .fontSize(12)
            .fontColor('#8C8378')
            .padding({ left: 14, right: 14, top: 8, bottom: 8 })
            .backgroundColor('#F3ECDD')
            .borderRadius(12)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .onClick(() => {
              this.nextQ5(0, 0)
            })
          Text('偶尔')
            .fontSize(12)
            .fontColor('#D35400')
            .padding({ left: 14, right: 14, top: 8, bottom: 8 })
            .backgroundColor('#F9F1E3')
            .borderRadius(12)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .onClick(() => {
              this.nextQ5(1, 0)
            })
        }
        .width('100%')
        Row({ space: 8 }) {
          Text('经常')
            .fontSize(12)
            .fontColor('#B03A2E')
            .padding({ left: 14, right: 14, top: 8, bottom: 8 })
            .backgroundColor('#F9E9E7')
            .borderRadius(12)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .onClick(() => {
              this.nextQ5(0, 2)
            })
          Text('总是')
            .fontSize(12)
            .fontColor('#FBF7F0')
            .padding({ left: 14, right: 14, top: 8, bottom: 8 })
            .backgroundColor('#B03A2E')
            .borderRadius(12)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
            .onClick(() => {
              this.nextQ5(1, 3)
            })
        }
        .width('100%')
      }
      .width('100%')
      if (this.qIdx >= 5) {
        Text('完成本题即可生成辨识结果')
          .fontSize(11)
          .fontColor('#16A085')
      } else {
        Text('作答后自动进入下一题')
          .fontSize(11)
          .fontColor('#B3A897')
      }
    }
    .width('86%')
    .padding(18)
    .backgroundColor('#FBF7F0')
    .borderRadius(22)
    .position({ x: '7%', y: '12%' })
    .zIndex(1000)
  }

题目内容通过 if/else if/else 条件渲染链实现——根据 qIdx 的值显示对应的题目文本。六个题目分别对应气虚、阳虚、阴虚、痰湿、气郁、特禀六种体质倾向。四个选项"没有"“偶尔”“经常”"总是"分别传入不同的评分参数:(0,0)(1,0)(0,2)(1,3),对应 nextQ5ab 参数。四个选项使用 layoutWeight(1) 等宽分布,.textAlign(TextAlign.Center) 使文字居中。

技术要点: if/else if/else 条件渲染链在 ArkTS 中是合法的 UI 描述语法。每条分支内声明的 UI 组件只在条件满足时被渲染。这与 JavaScript 的 if 语句在形式上一致,但在 ArkTS 中它运行在 UI 描述层而非逻辑层——框架会根据条件增删 UI 节点。TextAlign.Center 是文本对齐枚举值,使 Text 内容在自身宽度内居中。配合 layoutWeight(1),四个选项按钮等宽分布且文字居中,形成整齐的选项网格。(this.qIdx + 1) * 100 / 6 的计算方式确保进度条在第一题时就显示约 16.7% 而非 0%,给用户"已开始"的心理暗示。

13.3 药膳详情弹层(底部抽屉)

  @Builder
  herbModalOverlay() {
    Column() {
      Column()
        .width('100%')
        .height('100%')
        .backgroundColor('#663D3125')
        .onClick(() => {
          this.showHerb = false
        })
    }
    .width('100%')
    .height('100%')
    .position({ x: 0, y: 0 })
    .zIndex(999)
    Column({ space: 14 }) {
      Row()
        .width(44)
        .height(5)
        .backgroundColor('#E5DCC8')
        .borderRadius(3)
        .margin({ top: 10 })
      Row({ space: 12 }) {
        Text(this.selHerb.emoji)
          .fontSize(38)
        Column({ space: 3 }) {
          Text(this.selHerb.name)
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#3D3125')
          Text(this.selHerb.func + ' · ' + this.selHerb.taste)
            .fontSize(11)
            .fontColor('#B03A2E')
          Text('宜:' + this.selHerb.suit + ' 人群')
            .fontSize(11)
            .fontColor('#8C8378')
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Text(this.selHerb.fav ? '❤️' : '🤍')
          .fontSize(20)
          .onClick(() => {
            this.toggleFav5(this.selHerb)
          })
      }
      .width('100%')
      Column({ space: 7 }) {
        Text('做法')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor('#3D3125')
          .width('100%')
        Text('食材冷水浸泡 30 分钟,武火煮沸后转文火慢炖 40 分钟,空腹温服为佳。')
          .fontSize(11)
          .fontColor('#8C8378')
          .lineHeight(17)
        Text('宜忌')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor('#3D3125')
          .width('100%')
          .margin({ top: 4 })
        Text('感冒发热期间停服;服用人参类方剂时忌萝卜与浓茶。')
          .fontSize(11)
          .fontColor('#8C8378')
          .lineHeight(17)
      }
      .width('100%')
      .padding(12)
      .backgroundColor('#F9F1E3')
      .borderRadius(14)
      Row({ space: 10 }) {
        Text('关闭')
          .fontSize(13)
          .fontColor('#8C8378')
          .padding({ left: 14, right: 14, top: 9, bottom: 9 })
          .backgroundColor('#F3ECDD')
          .borderRadius(14)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .onClick(() => {
            this.showHerb = false
          })
        Text('加入一周食谱')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FBF7F0')
          .padding({ left: 14, right: 14, top: 9, bottom: 9 })
          .backgroundColor('#B03A2E')
          .borderRadius(14)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .onClick(() => {
            this.showHerb = false
          })
      }
      .width('100%')
      .margin({ top: 2, bottom: 14 })
    }
    .width('100%')
    .padding({ left: 18, right: 18 })
    .backgroundColor('#FBF7F0')
    .borderRadius({ topLeft: 24, topRight: 24 })
    .position({ x: 0, y: '46%' })
    .zIndex(1000)
  }

药膳详情弹层采用底部抽屉样式——内容卡片通过 .position({ x: 0, y: '46%' }) 定位在屏幕下方 46% 处,顶部圆角(borderRadius({ topLeft: 24, topRight: 24 })),形成从底部滑出的抽屉视觉效果。卡片顶部是一个 44vp 宽、5vp 高的拖拽指示条(圆角矩形),这是移动端底部弹窗的标准设计语言。卡片内容展示选中药膳的详细信息:表情图标、名称、功效性味、适宜人群、收藏按钮、做法说明、宜忌提示,底部是"关闭"和"加入一周食谱"两个操作按钮。

技术要点: .borderRadius({ topLeft: 24, topRight: 24 }) 是 ArkUI 的部分圆角语法——可以分别为四个角设置不同的圆角值。参数为 topLefttopRightbottomLeftbottomRight。底部抽屉通常只有顶部两角为圆角,底部为直角(因为紧贴屏幕底边)。.lineHeight(17) 设置文本行高为 17vp,使多行描述文字有舒适的行间距。底部双按钮使用 layoutWeight(1) 等宽分布、textAlign(TextAlign.Center) 文字居中,形成对称的操作栏。

13.4 穴位取穴弹层(底部抽屉)

  @Builder
  pointModalOverlay() {
    Column() {
      Column()
        .width('100%')
        .height('100%')
        .backgroundColor('#663D3125')
        .onClick(() => {
          this.showPoint = false
        })
    }
    .width('100%')
    .height('100%')
    .position({ x: 0, y: 0 })
    .zIndex(999)
    Column({ space: 14 }) {
      Row()
        .width(44)
        .height(5)
        .backgroundColor('#E5DCC8')
        .borderRadius(3)
        .margin({ top: 10 })
      Row({ space: 12 }) {
        Column() {
          Text('🧘')
            .fontSize(30)
            .offset({ y: this.qiY })
        }
        .padding(12)
        .backgroundColor('#F9F1E3')
        .borderRadius(16)
        Column({ space: 3 }) {
          Text(this.selPoint.name)
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#3D3125')
          Text(this.selPoint.meridian)
            .fontSize(11)
            .fontColor('#B03A2E')
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Text(this.selPoint.done ? '今日已按' : '今日未按')
          .fontSize(10)
          .fontColor(this.selPoint.done ? '#16A085' : '#8C8378')
          .padding({ left: 8, right: 8, top: 4, bottom: 4 })
          .backgroundColor(this.selPoint.done ? '#EAF6F1' : '#F3ECDD')
          .borderRadius(10)
      }
      .width('100%')
      Column({ space: 7 }) {
        Text('功效')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor('#3D3125')
          .width('100%')
        Text(this.selPoint.effect)
          .fontSize(11)
          .fontColor('#8C8378')
        Text('取穴方法')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor('#3D3125')
          .width('100%')
          .margin({ top: 4 })
        Text('拇指指腹按压,以酸胀为度,顺时针揉按 ' + this.selPoint.min.toString() + ' 分钟,早晚各一次。')
          .fontSize(11)
          .fontColor('#8C8378')
          .lineHeight(17)
      }
      .width('100%')
      .padding(12)
      .backgroundColor('#F9F1E3')
      .borderRadius(14)
      Row({ space: 10 }) {
        Text('关闭')
          .fontSize(13)
          .fontColor('#8C8378')
          .padding({ left: 14, right: 14, top: 9, bottom: 9 })
          .backgroundColor('#F3ECDD')
          .borderRadius(14)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .onClick(() => {
            this.showPoint = false
          })
        Text('标记已按揉')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FBF7F0')
          .padding({ left: 14, right: 14, top: 9, bottom: 9 })
          .backgroundColor('#16A085')
          .borderRadius(14)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .onClick(() => {
            this.doPoint5(this.selPoint)
            this.showPoint = false
          })
      }
      .width('100%')
      .margin({ top: 2, bottom: 14 })
    }
    .width('100%')
    .padding({ left: 18, right: 18 })
    .backgroundColor('#FBF7F0')
    .borderRadius({ topLeft: 24, topRight: 24 })
    .position({ x: 0, y: '44%' })
    .zIndex(1000)
  }

穴位取穴弹层与药膳详情弹层结构一致,都是底部抽屉。内容展示选中穴位的名称、所属经络、打卡状态标签、功效说明和取穴方法(动态拼接 selPoint.min 分钟数)。底部"关闭"按钮关闭弹层,"标记已按揉"按钮调用 doPoint5(this.selPoint) 切换打卡状态并关闭弹层。穴位图标同样绑定 qiY 浮动动画。

13.5 编辑计划弹层(居中卡片)与 Toggle 组件

  @Builder
  editModalOverlay() {
    Column() {
      Column()
        .width('100%')
        .height('100%')
        .backgroundColor('#663D3125')
        .onClick(() => {
          this.showEdit = false
        })
    }
    .width('100%')
    .height('100%')
    .position({ x: 0, y: 0 })
    .zIndex(999)
    Column({ space: 13 }) {
      Text('✏️ 编辑调理计划')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#3D3125')
      Text('「' + this.plans[this.editIdx].title + '」')
        .fontSize(12)
        .fontColor('#8C8378')
      Column({ space: 8 }) {
        Text('执行频次')
          .fontSize(12)
          .fontColor('#8C8378')
          .width('100%')
        Row({ space: 8 }) {
          ForEach(['每日', '每周 2 次', '每周 3 次', '每周 5 次'], (f: string) => {
            Text(f)
              .fontSize(11)
              .fontColor(this.editFreq === f ? '#FBF7F0' : '#8C8378')
              .padding({ left: 12, right: 12, top: 8, bottom: 8 })
              .backgroundColor(this.editFreq === f ? '#B03A2E' : '#F3ECDD')
              .borderRadius(12)
              .onClick(() => {
                this.editFreq = f
              })
          }, (f: string) => f)
        }
        .width('100%')
      }
      .width('100%')

编辑计划弹层采用居中卡片样式(position({ x: '9%', y: '15%' }),宽度 82%)。卡片内容包含:标题、计划名称、执行频次选择器(ForEach 遍历四个频次选项,选中红色背景)、每日次数步进器(减号按钮 + 数字显示 + 加号按钮)、开启提醒开关(Toggle 组件)、取消和保存按钮。

      Column({ space: 8 }) {
        Text('每日次数:' + this.editTimes.toString() + ' 次')
          .fontSize(12)
          .fontColor('#8C8378')
          .width('100%')
        Row({ space: 14 }) {
          Text('-')
            .fontSize(16)
            .fontColor('#B03A2E')
            .padding({ left: 14, right: 14, top: 6, bottom: 6 })
            .backgroundColor('#F9E9E7')
            .borderRadius(12)
            .onClick(() => {
              if (this.editTimes > 1) {
                this.editTimes -= 1
              }
            })
          Text(this.editTimes.toString() + ' 次')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#3D3125')
          Text('+')
            .fontSize(16)
            .fontColor('#16A085')
            .padding({ left: 14, right: 14, top: 6, bottom: 6 })
            .backgroundColor('#EAF6F1')
            .borderRadius(12)
            .onClick(() => {
              if (this.editTimes < 4) {
                this.editTimes += 1
              }
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.Center)
      }
      .width('100%')
      Row({ space: 8 }) {
        Text('开启提醒')
          .fontSize(13)
          .fontColor('#3D3125')
          .layoutWeight(1)
        Toggle({ type: ToggleType.Switch, isOn: this.editOn })
          .onChange((v: boolean) => {
            this.editOn = v
          })
      }
      .width('100%')
      Row({ space: 10 }) {
        Text('取消')
          .fontSize(13)
          .fontColor('#8C8378')
          .padding({ left: 14, right: 14, top: 9, bottom: 9 })
          .backgroundColor('#F3ECDD')
          .borderRadius(14)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .onClick(() => {
            this.showEdit = false
          })
        Text('保存')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FBF7F0')
          .padding({ left: 14, right: 14, top: 9, bottom: 9 })
          .backgroundColor('#B03A2E')
          .borderRadius(14)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .onClick(() => {
            this.confirmEdit5()
          })
      }
      .width('100%')
      .margin({ top: 2 })
    }
    .width('82%')
    .padding(18)
    .backgroundColor('#FBF7F0')
    .borderRadius(22)
    .position({ x: '9%', y: '15%' })
    .zIndex(1000)
  }

步进器通过两个 Text 按钮("-“和"+”)实现,分别绑定 editTimes 的递减和递增。递减有下限保护(if (this.editTimes > 1)),递增有上限保护(if (this.editTimes < 4)),防止值超出合理范围。.justifyContent(FlexAlign.Center) 使步进器内部三个元素水平居中排列。

Toggle 组件是 ArkUI 的开关控件,通过 type: ToggleType.Switch 指定为滑动开关样式,isOn 绑定到 editOn 状态变量。.onChange 回调在开关状态变化时被调用,参数 v 是新的布尔值,更新到 editOn 中。

技术要点: Toggle 是 ArkUI 的内置开关组件,支持两种类型:ToggleType.Switch(滑动开关)和 ToggleType.Checkbox(复选框)和 ToggleType.Button(按钮式)。isOn 参数设置初始开关状态,.onChange 回调监听状态变化。.justifyContent(FlexAlign.Center)Row/Column 的主轴对齐属性,FlexAlign 枚举提供 StartCenterEndSpaceBetweenSpaceAroundSpaceEvenly 等对齐方式。步进器的上下限保护是交互设计的基本原则——防止用户输入超出业务逻辑允许范围的值。

13.6 删除医案弹层(危险操作确认)

  @Builder
  delModalOverlay() {
    Column() {
      Column()
        .width('100%')
        .height('100%')
        .backgroundColor('#663D3125')
        .onClick(() => {
          this.showDel = false
        })
    }
    .width('100%')
    .height('100%')
    .position({ x: 0, y: 0 })
    .zIndex(999)
    Column({ space: 12 }) {
      Text('⚠️')
        .fontSize(28)
      Text('删除医案记录')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#B03A2E')
      Text('「' + this.delCase.date + ' ' + this.delCase.type + '」删除后不可恢复,请谨慎操作。')
        .fontSize(12)
        .fontColor('#8C8378')
        .textAlign(TextAlign.Center)
        .lineHeight(18)
      Row({ space: 8 }) {
        Text('我已知晓')
          .fontSize(12)
          .fontColor('#8C8378')
          .layoutWeight(1)
        Toggle({ type: ToggleType.Switch, isOn: this.delArmed })
          .onChange((v: boolean) => {
            this.delArmed = v
          })
      }
      .width('100%')
      .padding({ left: 10, right: 10, top: 8, bottom: 8 })
      .backgroundColor('#F9E9E7')
      .borderRadius(12)
      Row({ space: 10 }) {
        Text('再想想')
          .fontSize(13)
          .fontColor('#8C8378')
          .padding({ left: 16, right: 16, top: 9, bottom: 9 })
          .backgroundColor('#F3ECDD')
          .borderRadius(14)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .onClick(() => {
            this.showDel = false
          })
        Text('确认删除')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FBF7F0')
          .padding({ left: 16, right: 16, top: 9, bottom: 9 })
          .backgroundColor(this.delArmed ? '#B03A2E' : '#D9A7A2')
          .borderRadius(14)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .onClick(() => {
            if (this.delArmed) {
              this.doDel5()
            }
          })
      }
      .width('100%')
    }
    .width('74%')
    .padding(18)
    .backgroundColor('#FBF7F0')
    .borderRadius(20)
    .position({ x: '13%', y: '27%' })
    .zIndex(1000)
  }

删除医案弹层是一个窄幅居中卡片(宽度 74%),用于危险操作的二次确认。设计亮点是"我已知晓"开关——用户必须先打开 Toggle 开关(delArmed 变为 true),"确认删除"按钮的背景色才会从浅红(#D9A7A2,不可用态)变为深红(#B03A2E,可用态)。点击"确认删除"时检查 if (this.delArmed),只有开关已打开才执行 doDel5()。这种"二次确认+武装开关"的双重保险机制,在删除类不可逆操作中是标准的安全设计模式。

技术要点: 按钮的"可用/不可用"态通过条件背景色实现:backgroundColor(this.delArmed ? '#B03A2E' : '#D9A7A2')。当 delArmed 为 false 时,按钮显示为浅红色,视觉上传达"不可点击"的信号;为 true 时变为深红色,传达"可执行"的信号。配合 onClick 中的 if 检查,形成了视觉和逻辑双重保护。这种模式比简单的 AlertDialog 确认框更细致,提供了更好的用户引导和安全保障。#663D3125 是带透明度的颜色——前两位 66 是 Alpha 通道值(十六进制的 66 约等于十进制的 102,即 40% 不透明度),后六位是 RGB 颜色值。

13.7 代茶饮定制弹层(底部抽屉)

  @Builder
  teaModalOverlay() {
    Column() {
      Column()
        .width('100%')
        .height('100%')
        .backgroundColor('#663D3125')
        .onClick(() => {
          this.showTea = false
        })
    }
    .width('100%')
    .height('100%')
    .position({ x: 0, y: 0 })
    .zIndex(999)
    Column({ space: 14 }) {
      Row()
        .width(44)
        .height(5)
        .backgroundColor('#E5DCC8')
        .borderRadius(3)
        .margin({ top: 10 })
      Text('🫖 定制「' + this.teaBase + '」')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#3D3125')
      Column({ space: 8 }) {
        Text('甜度')
          .fontSize(12)
          .fontColor('#8C8378')
          .width('100%')
        Row({ space: 8 }) {
          ForEach(['无糖', '微糖', '半糖', '罗汉果甜'], (s: string) => {
            Text(s)
              .fontSize(12)
              .fontColor(this.teaSweet === s ? '#FBF7F0' : '#8C8378')
              .padding({ left: 12, right: 12, top: 8, bottom: 8 })
              .backgroundColor(this.teaSweet === s ? '#16A085' : '#F3ECDD')
              .borderRadius(12)
              .onClick(() => {
                this.teaSweet = s
              })
          }, (s: string) => s)
        }
        .width('100%')
      }
      .width('100%')
      Column({ space: 8 }) {
        Text('饮用温度')
          .fontSize(12)
          .fontColor('#8C8378')
          .width('100%')
        Row({ space: 8 }) {
          ForEach(['热饮', '温饮', '常温'], (s: string) => {
            Text(s)
              .fontSize(12)
              .fontColor(this.teaHot === s ? '#FBF7F0' : '#8C8378')
              .padding({ left: 14, right: 14, top: 8, bottom: 8 })
              .backgroundColor(this.teaHot === s ? '#D35400' : '#F3ECDD')
              .borderRadius(12)
              .onClick(() => {
                this.teaHot = s
              })
          }, (s: string) => s)
        }
        .width('100%')
      }
      .width('100%')
      Column({ space: 8 }) {
        Text('配送周期:' + this.teaCnt.toString() + ' 天量')
          .fontSize(12)
          .fontColor('#8C8378')
          .width('100%')
        Row({ space: 14 }) {
          Text('-')
            .fontSize(16)
            .fontColor('#D35400')
            .padding({ left: 14, right: 14, top: 6, bottom: 6 })
            .backgroundColor('#F9F1E3')
            .borderRadius(12)
            .onClick(() => {
              if (this.teaCnt > 3) {
                this.teaCnt -= 1
              }
            })
          Text(this.teaCnt.toString() + ' 天')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#3D3125')
          Text('+')
            .fontSize(16)
            .fontColor('#B03A2E')
            .padding({ left: 14, right: 14, top: 6, bottom: 6 })
            .backgroundColor('#F9E9E7')
            .borderRadius(12)
            .onClick(() => {
              if (this.teaCnt < 30) {
                this.teaCnt += 1
              }
            })
          Text('¥' + (this.teaCnt * 6).toString())
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor('#B03A2E')
            .margin({ left: 6 })
        }
        .width('100%')
      }
      .width('100%')
      Row({ space: 10 }) {
        Text('取消')
          .fontSize(14)
          .fontColor('#8C8378')
          .padding({ left: 16, right: 16, top: 10, bottom: 10 })
          .backgroundColor('#F3ECDD')
          .borderRadius(16)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .onClick(() => {
            this.showTea = false
          })
        Text('确认定制')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FBF7F0')
          .padding({ left: 16, right: 16, top: 10, bottom: 10 })
          .backgroundColor('#B03A2E')
          .borderRadius(16)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .onClick(() => {
            this.showTea = false
          })
      }
      .width('100%')
      .margin({ top: 2, bottom: 14 })
    }
    .width('100%')
    .padding({ left: 18, right: 18 })
    .backgroundColor('#FBF7F0')
    .borderRadius({ topLeft: 24, topRight: 24 })
    .position({ x: 0, y: '42%' })
    .zIndex(1000)
  }

代茶饮定制弹层是功能最丰富的底部抽屉,包含三组选择器和一组步进器:甜度选择(无糖/微糖/半糖/罗汉果甜,选中绿色)、饮用温度选择(热饮/温饮/常温,选中橙色)、配送周期步进器(3-30 天,下限 3、上限 30)和实时价格显示(teaCnt * 6 元)。三组选择器的选中态使用不同颜色——甜度绿色、温度橙色、步进器红色——通过颜色区分不同配置维度。价格随天数实时计算并显示,为用户提供了直观的金额反馈。

技术要点: 多个 ForEach 在同一弹层中使用,每个遍历不同的字符串数组,生成不同维度的选择器。每个选择器的选中态颜色不同(绿、橙、红),这通过在各自的 backgroundColor 三元表达式中使用不同色值实现。步进器的价格计算 this.teaCnt * 6 嵌入在 Text 的字符串拼接中,由于 teaCnt@State 变量,每次步进时价格自动更新。这种"计算值嵌入 UI"的模式在声明式框架中非常自然——无需手动刷新,状态变化自动触发重渲染。


十四、根布局 build():Stack 层叠与条件渲染弹层

  build() {
    Stack() {
      Column() {
        this.headerBar5()
        Scroll() {
          Column() {
            if (this.selTab === 0) {
              this.bodyTab()
            } else if (this.selTab === 1) {
              this.herbTab()
            } else if (this.selTab === 2) {
              this.meridianTab()
            } else if (this.selTab === 3) {
              this.caseTab()
            } else if (this.selTab === 4) {
              this.planTab()
            } else {
              this.mineTab()
            }
          }
          .width('100%')
        }
        .layoutWeight(1)
        .scrollBar(BarState.Off)
        .width('100%')
        .backgroundColor('#FBF7F0')
        this.tabBar5()
      }
      .width('100%')
      .height('100%')
      if (this.showTest) {
        this.testModalOverlay()
      }
      if (this.showHerb) {
        this.herbModalOverlay()
      }
      if (this.showPoint) {
        this.pointModalOverlay()
      }
      if (this.showEdit) {
        this.editModalOverlay()
      }
      if (this.showDel) {
        this.delModalOverlay()
      }
      if (this.showTea) {
        this.teaModalOverlay()
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FBF7F0')
  }
}

build() 是每个 @Component 组件必须实现的方法,它返回组件的 UI 描述。本应用的 build() 使用 Stack 作为根容器,内部包含两个层级:第一层是主界面(Column 内嵌头部栏、内容滚动区和底部导航栏),第二层是六个弹层的条件渲染。

主界面 Column 的结构为:headerBar5() 头部栏(固定不滚动)、Scroll 内容滚动区(layoutWeight(1) 占满中间空间)、tabBar5() 底部导航栏(固定不滚动)。Scroll 内部通过 if/else if/else 条件链,根据 selTab 的值渲染对应的 Tab 内容构建器。.scrollBar(BarState.Off) 隐藏滚动条,使内容区滚动更干净。

六个弹层在 Stack 的第二层级通过六个独立的 if 条件渲染——if (this.showTest)if (this.showHerb) 等。每个弹层的显示/隐藏完全由对应的 @State boolean 变量驱动。由于这些弹层在 Stack 中,它们通过 position 绝对定位浮于主界面之上,zIndex 确保层级正确。当多个弹层同时打开时(虽然本应用的业务逻辑不会同时打开多个),后声明的弹层会覆盖在先声明的弹层之上。

技术要点: Stack 作为根容器是弹层系统的理想选择。Stack 的层叠特性使得弹层可以自然地覆盖在主界面之上,无需额外的容器嵌套。Scroll + layoutWeight(1) 的组合是经典的"头部固定+内容滚动+底部固定"布局模式——layoutWeight(1)Scroll 占据头部和底部之间的全部剩余空间。六个 if 条件渲染是独立而非互斥的(不是 if/else if 链),这意味着理论上可以同时渲染多个弹层。但在本应用的交互逻辑中,弹层是互斥打开的(打开一个弹层时不会同时打开另一个),这种设计既保证了灵活性(未来可以支持多弹层叠加)又避免了当前场景的冲突。


十五、应用架构流程图

15.1 整体架构与数据流

.slice 拷贝

true

false

静态常量数据
BODIES5 / HERBS5 / POINTS5
PLANS5 / CASES5 / TEAS5

State 状态变量
herbs / points / plans / cases

selTab / selBody
show弹窗布尔组
edit编辑状态组

业务逻辑方法
planRate5 / pointDone5 / favHerb5
toggleFav5 / doPoint5 / confirmTest5

Builder UI 构建器
tabBar5 / headerBar5 / secTitle5
bodyTab / herbTab / meridianTab
caseTab / planTab / mineTab

六个弹层 Overlay
testModal / herbModal / pointModal
editModal / delModal / teaModal

show 布尔状态

渲染弹层

隐藏弹层

build 根布局
Stack 根容器

界面渲染输出

上图展示了应用从静态数据到界面渲染的完整数据流。静态常量通过 .slice() 拷贝为状态变量,状态变量驱动业务逻辑方法的计算,计算结果和状态变量共同传入 @Builder UI 构建器,最终汇聚到 build()Stack 根容器中渲染输出。六个弹层通过独立的 show 布尔状态控制显示/隐藏,在 Stack 中条件渲染叠加于主界面之上。

15.2 用户交互与状态流转

切换索引

用户操作

点击 Tab

selTab 变更

条件渲染对应 Tab

点击测体质

showTest = true

答题 nextQ5

qIdx >= 5?

qIdx + 1 下一题

confirmTest5 计算结果

新增医案记录

selBody 更新

showTest = false

点击收藏

toggleFav5

herbs 数组重建

列表刷新

点击编辑计划

showEdit = true

选择频次/次数/开关

confirmEdit5 保存

plans 数组更新

计划列表刷新

点击删除医案

showDel = true

打开武装开关 delArmed

doDel5 删除

cases 数组过滤

医案列表刷新

上图展示了用户主要交互路径的状态流转。Tab 切换修改 selTab 触发条件渲染;体质测试通过 nextQ5 逐题流转,最终由 confirmTest5 计算结果并更新体质和医案;收藏通过 toggleFav5 重建 herbs 数组触发列表刷新;计划编辑通过 confirmEdit5 更新 plans 数组;删除通过 doDel5 过滤 cases 数组。每条路径都遵循"用户操作 → 状态变更 → UI 自动刷新"的声明式 UI 响应链路。


十六、核心概念对比表格

下表对本应用中涉及的各类数据结构、组件、状态变量、装饰器、布局属性等进行系统对比归纳:

类别 名称 类型/签名 作用说明 使用位置/场景
装饰器 @Entry 组件级 标记页面入口组件,应用启动后首个渲染 struct Index
装饰器 @Component 组件级 标记结构体为可复用 UI 组件 struct Index
装饰器 @State 状态级 声明可观察状态变量,变更触发 UI 刷新 selTabherbsshowTest
装饰器 @Builder 方法级 标记 UI 构建器方法,可参数化复用 tabBtn5headerBar5bodyTab
接口 BodyT interface 定义体质数据契约(5 字段) BODIES5 常量
接口 HerbT interface 定义药膳数据契约(6 字段含 fav) HERBS5 常量、herbs 状态
接口 PointT interface 定义穴位数据契约(5 字段含 done) POINTS5 常量、points 状态
接口 PlanT interface 定义调理计划契约(4 字段含 on) PLANS5 常量、plans 状态
接口 CaseT interface 定义医案记录契约(4 字段含 doc) CASES5 常量、cases 状态
接口 TeaT interface 定义代茶饮契约(5 字段含 season) TEAS5 常量
容器组件 Column 内置 纵向排列子元素的容器 几乎所有布局
容器组件 Row 内置 横向排列子元素的容器 标题栏、列表项、按钮组
容器组件 Stack 内置 层叠堆放子元素的容器 进度环+文字、根布局+弹层
容器组件 Scroll 内置 可滚动内容容器 Tab 内容区、时辰横滚
基础组件 Text 内置 文本显示 全局文字展示
基础组件 Progress 内置 进度条(环形/线性) 穴位进度环、计划进度环、测试进度条
基础组件 Toggle 内置 开关组件 编辑计划提醒、删除武装确认
基础组件 Divider 内置 分割线 底部导航栏顶部
基础组件 Circle 内置 圆形图形 医案时间轴圆点
渲染控制 ForEach 内置 列表渲染,支持键值 Diff 体质列表、药膳列表等所有列表
渲染控制 if/else 语法 条件渲染,增删 UI 节点 Tab 切换、弹层显示、题目切换
布局属性 layoutWeight 通用属性 弹性分配剩余空间 Tab 按钮等宽、卡片内容填充
布局属性 position 通用属性 绝对定位偏移 弹层遮罩与内容定位
布局属性 zIndex 通用属性 控制层叠顺序 弹层遮罩 999/内容 1000
布局属性 padding 通用属性 内边距 卡片内间距
布局属性 margin 通用属性 外边距 区块间距
布局属性 borderRadius 通用属性 圆角半径 卡片圆角、胶囊按钮
布局属性 border 通用属性 边框宽度和颜色 选中体质高亮边框
布局属性 constraintSize 通用属性 约束尺寸(min/max) 时间轴最小高度、文本最大宽度
布局属性 justifyContent 通用属性 主轴对齐方式 步进器居中
布局属性 alignItems 通用属性 交叉轴对齐方式 Column 左对齐
布局属性 clip 通用属性 裁剪溢出内容 设置列表外圆内方
样式属性 fontSize 通用属性 字体大小 全局文字
样式属性 fontColor 通用属性 字体颜色 全局文字
样式属性 fontWeight 通用属性 字体粗细 标题加粗
样式属性 backgroundColor 通用属性 背景颜色 卡片背景、按钮背景
样式属性 opacity 通用属性 不透明度 阴标呼吸、未选中态
样式属性 offset 通用属性 位移偏移 太极图标浮动动画
动画 API animateTo 实例方法 显式动画,闭包定义终态 aboutToAppear 中启动循环动画
枚举 PlayMode 枚举 动画播放模式 Alternate 交替循环
枚举 Curve 枚举 缓动曲线 EaseInOut 缓入缓出
枚举 ProgressType 枚举 进度条类型 Linear 线性、Ring 环形
枚举 ToggleType 枚举 开关类型 Switch 滑动开关
枚举 ScrollDirection 枚举 滚动方向 Horizontal 水平
枚举 BarState 枚举 滚动条状态 Off 隐藏
枚举 TextAlign 枚举 文本对齐 Center 居中
枚举 TextOverflow 枚举 文本溢出处理 Ellipsis 省略号
枚举 FontWeight 枚举 字体粗细 Bold 加粗、Normal 常规
枚举 HorizontalAlign 枚举 水平对齐 Start 左对齐
枚举 VerticalAlign 枚举 垂直对齐 Top 顶部对齐
枚举 FlexAlign 枚举 弹性对齐 Center 居中
生命周期 aboutToAppear 回调 组件创建后首次渲染前 初始化动画

安装DevEco Studio程序

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

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

  }
}
.width('100%')
.height('100%')
.backgroundColor('#FBF7F0')

}
}


---
![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/57584d15e68c4062a782ad738bafb2ff.png#pic_center)

## 十七、总结

通过对这份中医体质辨识应用 ArkTS 代码的逐段剖析,我们可以清晰地看到鸿蒙声明式 UI 开发的完整技术图景。整个应用以 `@Entry @Component struct Index` 为根组件,通过数十个 `@State` 状态变量驱动六个 Tab 页面和六个弹层的交互逻辑,所有 UI 结构通过 `@Builder` 方法参数化复用,最终汇聚到 `build()` 方法的 `Stack` 根容器中统一渲染。这种架构使得"状态变更"成为驱动界面更新的唯一动力源——开发者只需修改状态变量值,框架自动完成 UI 的差异计算和局部刷新,无需任何手动 DOM 操作。

在数据层面,应用通过六个 `interface` 接口定义了严格的类型契约,确保药膳、穴位、计划、医案、代茶饮、体质六类业务实体在数据流转中字段完整、类型正确。七个 `const` 常量数组提供了初始数据源,通过 `.slice()` 拷贝为可变状态副本,实现了"常量只读、状态可变"的数据隔离原则。不可变更新模式贯穿所有状态变更操作——`map` 创建新数组、对象字面量创建新元素、`filter` 过滤删除项——每次状态变更都返回全新引用,确保 `@State` 监听器正确捕获变化并触发 UI 刷新。这种数据管理范式虽然相比直接修改属性稍显繁琐,但它从根本上消除了状态追踪的歧义,是声明式 UI 框架高效运作的基石。

在 UI 构建层面,`@Builder` 装饰器是实现组件复用的核心机制。`tabBtn5`、`secTitle5`、`headerBar5` 等公共构建器在多个页面中反复调用,避免了代码重复;六个 Tab 内容构建器(`bodyTab`、`herbTab`、`meridianTab`、`caseTab`、`planTab`、`mineTab`)各自封装一个功能页面的 UI 描述;六个弹层构建器通过统一的"遮罩+内容+position+zIndex"模式实现自定义弹窗。`ForEach` 列表渲染配合键值生成函数实现高效的 Diff 更新,`if/else` 条件渲染实现 Tab 切换和弹层显隐。`Column`、`Row`、`Stack` 三大容器组件配合 `layoutWeight`、`position`、`zIndex`、`borderRadius`、`clip` 等布局属性,构建出从简单列表到层叠弹窗、从横向滚动到时间轴布局的丰富界面结构。这种"容器+属性+组件"的组合方式赋予了开发者极强的布局控制力,同时也要求开发者对每个属性的作用范围和优先级有清晰认知。

在交互动画层面,`animateTo` 显式动画 API 配合 `@State` 变量实现了声明式动画效果。`aboutToAppear` 生命周期中启动了两个无限循环动画——太极图标的垂直浮动和阴标的透明度呼吸——它们分别通过 `.offset({ y: this.qiY })` 和 `.opacity(this.yinOp)` 绑定到 UI 属性。这种"状态驱动属性变化、框架自动插值过渡"的动画范式,使开发者无需关心动画帧率和插值算法,只需声明"从当前值到目标值"即可获得平滑的过渡效果。`PlayMode.Alternate` 交替模式让动画在正向和反向之间往复,模拟出"阴阳动态平衡"的中医理念,是技术与业务语义融合的典范。

在弹窗系统层面,六个弹层采用统一的架构模式但呈现三种不同的视觉形态:居中大卡(体质测试、编辑计划)、底部抽屉(药膳详情、穴位详情、代茶饮定制)、窄幅危险卡(删除确认)。这三种形态通过 `position` 的不同 `y` 值实现——居中卡片 `y` 值在 12%-27% 区间、底部抽屉 `y` 值在 42%-46% 区间、危险卡居中偏上。所有弹层都使用半透明遮罩(`#663D3125`,即 40% 不透明度的深褐色)覆盖背景,点击遮罩关闭弹层。删除弹层的"武装开关"二次确认机制通过 `Toggle` 组件和条件背景色实现,为不可逆操作提供了安全保障。这种弹层架构虽然完全基于 `position` + `zIndex` 手动实现,但它提供了比系统 `Dialog` 组件更高的自定义灵活度,是复杂交互场景下的优选方案。

从工程实践角度审视,这份代码展现了 ArkTS 在构建中大型应用时的几项核心能力。首先是类型安全——六个接口覆盖了全部业务实体,所有数组都有泛型约束,所有方法参数和返回值都有类型标注,编译器在每一步都提供类型保护。其次是状态管理——数十个 `@State` 变量按功能分组(Tab 状态、测试状态、编辑状态、弹窗状态、动画状态),每个弹窗拥有独立的状态组,互不干扰。再者是渲染控制——`ForEach` 的键值生成函数精心设计,将状态字段纳入键值(如 `h.name + h.fav.toString()`)确保状态变化时列表项正确刷新。最后是布局表达——从 `Column` 的 `space` 间距到 `Row` 的 `layoutWeight` 权重,从 `Stack` 的层叠到 `Scroll` 的滚动方向,每种布局属性都在恰当的场景发挥作用。这些能力共同构成了鸿蒙原生应用开发的技术底座。

综观全文,这份代码以中医体质辨识为业务载体,完整呈现了 ArkTS 声明式 UI 开发的方法论。从 `interface` 类型定义到 `const` 常量数据,从 `@State` 状态管理到 `@Builder` UI 复用,从 `animateTo` 动画到 `ForEach` 列表渲染,从 `Stack` 层叠布局到 `position`/`zIndex` 弹层系统,每一个技术点都在实际业务场景中得到了落地应用。九种体质横条图用 `Row` 拼接模拟进度条,十二时辰经络流注用 `Scroll` 横向滚动展示,医案时间轴用 `Circle` + 条件渲染竖线构建,删除确认用 `Toggle` 武装开关双重保障——这些实现细节充分说明,掌握 ArkTS 的关键不在于记忆多少 API,而在于理解"状态驱动 UI"的核心思维模式,并能将容器组件、布局属性、渲染控制、状态管理灵活组合,解决具体的业务交互需求。这正是鸿蒙原生开发的精髓所在。
Logo

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

更多推荐