一、鸿蒙开发背景与ArkTS语言概览

鸿蒙操作系统(HarmonyOS)是华为面向万物互联时代打造的分布式操作系统,其应用开发框架ArkUI提供了一套全新的声明式UI开发范式。在鸿蒙的生态体系中,ArkTS作为主力开发语言,在TypeScript的基础上进行了深度定制与扩展,既保留了TypeScript的静态类型检查优势,又融入了鸿蒙特有的装饰器系统和状态管理机制。ArkTS并非简单的TypeScript超集,它通过@Entry、@Component、@State、@Builder、@Prop、@Link、@Provide、@Consume等一系列装饰器,构建出了一套面向声明式UI的编程模型,让开发者能够以数据驱动的方式描述界面结构,从而大幅提升开发效率和代码可维护性。

声明式UI范式的核心思想是"状态驱动视图"。在传统的命令式UI开发中,开发者需要手动获取控件引用、手动更新控件属性,代码中充斥着大量的findViewById和setText调用。而在ArkTS的声明式范式下,开发者只需描述界面"应该是什么样子",框架会自动跟踪状态变量的变化,并在状态发生改变时智能地重新渲染受影响的部分。这种数据与视图的自动绑定机制,使得复杂的交互逻辑变得清晰可控,尤其在构建多Tab、多弹框、多列表的复杂业务页面时,优势更为明显。本案例中的听力健康中心页面,正是一个集成了6个底部Tab、6个弹框、多种列表渲染和动画效果的典型复杂场景,非常适合用来深入剖析ArkTS的各种高级特性。

ArkUI组件体系是鸿蒙声明式UI的基石。它提供了丰富的基础容器组件(如Column、Row、Stack、Flex、Scroll)、基础显示组件(如Text、Image、Progress、Toggle、TextInput)、以及强大的交互能力(如onClick、onChange事件回调)。Column是垂直线性布局容器,子元素按从上到下顺序排列;Row是水平线性布局容器,子元素按从左到右顺序排列;Stack是层叠布局容器,子元素可以堆叠在一起,通过zIndex控制层级;Flex是弹性布局容器,支持换行等高级特性;Scroll是滚动容器,当内容超出可视区域时提供滚动能力。这些容器组件通过链式调用的方式配置属性,形成了一种高度可读的DSL(领域特定语言)风格,让UI代码既像代码又像设计稿,极大地降低了UI开发的认知负担。

ArkTS的状态管理体系是理解整个声明式UI运作机制的关键。@State是最基础的状态装饰器,被它修饰的变量一旦发生变化,框架会自动触发该组件的重新渲染。当数据结构较为复杂时,ArkTS还提供了@Prop(单向同步)、@Link(双向同步)、@Provide/@Consume(跨层级传递)、@Observed/@ObjectLink(嵌套对象观察)等更高级的状态管理装饰器。在本案例中,页面通过大量的@State变量管理着当前选中的Tab索引、各类业务数据数组、多个弹框的显示开关、各种表单的选中状态以及动画驱动的数值,这些状态共同构成了整个页面的数据模型,驱动着界面的每一次更新。掌握这些状态变量的设计方法,是构建高质量鸿蒙应用的核心技能。


二、数据结构设计:interface接口的定义与应用

2.1 听力频率数据结构

interface FreqT11 {
  id: number
  hz: string
  left: number
  right: number
}

在这里插入图片描述

这段代码定义了一个名为FreqT11的接口,用于描述纯音测听中每个频率点的听力数据。在ArkTS中,interface是定义数据结构的主要方式,它与TypeScript中的interface语法基本一致,但在鸿蒙的编译器约束下更加严格——所有属性必须显式声明类型,且不支持可选属性在运行时为undefined的情况(除非显式标注)。这里定义了四个属性:id是唯一标识符,hz是频率值(用字符串表示,因为包含’1k’、'2k’这样的缩写),left和right分别代表左耳和右耳在该频率下的听阈值(单位dB HL)。

技术要点:interface与状态管理的关系。 在ArkTS中,interface定义的数据结构本身不参与响应式追踪,但当interface类型的数组被@State修饰时,整个数组的引用变化会触发UI更新。需要注意的是,直接修改数组内某对象的属性不会触发更新,必须通过生成新数组的方式(如map、filter、concat)来实现响应式刷新。这是鸿蒙声明式UI与传统命令式UI在数据处理思维上的重大差异。

听力学中,纯音测听是评估听力损失程度的金标准。测听频率通常覆盖250Hz到8000Hz,这个范围涵盖了人类语音的主要频率成分。正常的听阈值应低于25dB HL,26-40dB为轻度听力损失,41-60dB为中度损失。本数据结构设计巧妙地用left和right两个字段分别记录双耳数据,使得在UI层可以通过双柱状图直观对比双耳听力差异,这种数据结构与可视化呈现的紧密配合,是医疗健康类应用设计的核心原则。

2.2 助听器商品数据结构

interface AidT11 {
  id: number
  brand: string
  model: string
  type: string
  price: number
  score: number
  feature: string
  stock: boolean
}

在这里插入图片描述

AidT11接口定义了助听器商品的数据模型。这里值得关注的几个字段设计:type字段用字符串描述助听器的外形类型(如"耳背式RIC"、“深耳道CIC”、"耳内式ITC"等),这些是听力学和助听器行业的专业术语;score字段是评分,使用number类型保留小数位,在UI层通过toFixed(1)格式化显示;stock字段使用boolean类型表示现货状态,这在电商类场景中非常常见,UI层会根据该值显示"现货"或"预订2周"的不同提示。

技术要点:boolean类型在条件渲染中的价值。 stock字段使用boolean而非字符串,体现了数据建模的严谨性。在ArkTS的条件表达式中,boolean值可以直接用于三元运算符判断,如a.stock ? '现货' : '预订 2 周',代码简洁且不易出错。如果用字符串"有"/"无"则容易因大小写、空格等问题导致判断失败,这是初学者常犯的错误。

2.3 训练课程与噪声场景数据结构

interface CourseT11 {
  id: number
  name: string
  mins: number
  days: number
  done: number
  icon: string
}

interface NoiseT11 {
  id: number
  scene: string
  db: number
  mins: number
  safe: boolean
  icon: string
}

在这里插入图片描述

CourseT11描述的是听觉康复训练课程,其中mins表示每日训练时长(分钟),days表示课程总周期天数,done表示已完成的进度。这三个数值字段配合,可以在UI层计算出完成百分比(done/days*100),并用进度条可视化展示。icon字段使用emoji字符串,这是一种轻量级的图标方案,避免了引入图片资源包的复杂性,同时保持了良好的视觉表现力。

NoiseT11描述的是噪声暴露场景记录。db字段记录分贝值,这是噪声评估的核心指标;safe字段是一个计算属性,表示该次暴露是否在安全上限内。在听力学中,85dB以上的持续暴露超过8小时即可能造成不可逆的感音神经性听力损失,因此safe字段的判定逻辑通常围绕这个阈值展开。mins字段记录暴露时长,与db值共同决定了风险等级。

技术要点:emoji作为图标资源的替代方案。 在鸿蒙ArkUI中,Text组件可以直接渲染emoji字符,这是一种零成本、跨平台的图标方案。相较于Image组件需要引入png/svg资源文件,emoji方案省去了资源管理和路径处理的负担,特别适合快速原型开发和概念验证。但需要注意,不同设备对emoji的渲染样式可能有细微差异,正式生产环境中建议替换为统一的图标字体或矢量资源。

2.4 复查记录与订单数据结构

interface RevT11 {
  id: number
  date: string
  place: string
  result: string
  avg: number
  next: string
}

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

在这里插入图片描述

RevT11是复查记录的数据结构,date是检查日期,place是检查地点,result是检查结论描述,avg是平均听阈值,next是下次建议复查时间。这些字段共同构成了一条完整的复查历史记录,在UI层将以时间轴的形式呈现,让用户能够清晰地追踪听力变化趋势。

OrdT11是订单数据结构,state字段使用字符串描述订单状态(如"已完成"、“调试中”、“已发货”、"进行中"等)。这里有一个设计上的取舍:state本可以定义为枚举(enum)或联合字面量类型以获得更好的类型安全,但使用string提供了更大的灵活性,允许后端返回任意状态文本。在UI层,通过ordColor11工具函数将状态字符串映射为颜色值,实现了状态的可视化区分。

技术要点:数据结构的字段命名规范。 本案例采用了简洁的短字段名(如mins、db、avg),这在数据量大的场景下能减少代码体积。但在团队协作的大型项目中,建议采用更具语义性的完整命名(如minutes、decibel、averageThreshold),以提升代码可读性。ArkTS的编译器会在构建时进行tree-shaking优化,长字段名不会影响最终包体积。


三、静态数据初始化与数组操作

3.1 听力频率数据初始化

const FREQS11: Array<FreqT11> = [
  { id: 1, hz: '250', left: 15, right: 15 },
  { id: 2, hz: '500', left: 15, right: 20 },
  { id: 3, hz: '1k', left: 20, right: 25 },
  { id: 4, hz: '2k', left: 30, right: 30 },
  { id: 5, hz: '3k', left: 40, right: 40 },
  { id: 6, hz: '4k', left: 45, right: 50 },
  { id: 7, hz: '6k', left: 40, right: 45 },
  { id: 8, hz: '8k', left: 35, right: 40 }
]

在这里插入图片描述

这段代码使用const关键字声明了一个名为FREQS11的常量数组,类型标注为Array<FreqT11>,表示这是一个元素类型为FreqT11的数组。数组中包含8个频率点的双耳听阈数据,从250Hz到8000Hz,覆盖了纯音测听的标准频率范围。可以看到,4kHz处左耳45dB、右耳50dB,属于轻度到中度听力损失的临界区域,这符合噪声性听力损失的典型特征——通常首先在4kHz附近出现"V型"凹陷。

技术要点:const与@State的协作模式。 代码中使用const声明了静态原始数据(FREQS11等),然后在@State变量中通过slice()方法创建副本(freqs11: Array<FreqT11> = FREQS11.slice())。这种"原始数据不可变 + 状态数据可变副本"的设计模式非常重要:const数组作为数据源保持稳定,@State数组作为可变状态参与UI响应式更新。slice()方法创建的是浅拷贝,对于本案例的对象数组来说足够使用,因为UI更新时总是替换整个对象而非修改对象内部属性。

观察这组数据可以发现,低频段(250Hz-500Hz)双耳听阈均在15-20dB,属于正常范围;中频段(1kHz-2kHz)开始轻微上升;高频段(3kHz-8kHz)明显升高,4kHz达到峰值45-50dB。这种"高频下降"的曲线模式,是噪声性听力损伤的标志性特征。在UI渲染时,每个频率点会渲染为一对柱子(左耳蓝色、右耳黄色),柱子的高度与听阈值成正比,直观地呈现了听力损失的程度和频率分布。

3.2 助听器商品数据

const AIDS11: Array<AidT11> = [
  { id: 1, brand: '峰力', model: 'Lumity L90', type: '耳背式 RIC', price: 32800, score: 4.9, feature: '双耳直连 iPhone · 噪声下言语识别提升 30%', stock: true },
  { id: 2, brand: '奥迪康', model: 'Intent 1', type: '受话器外置', price: 29800, score: 4.8, feature: '4D 传感器场景自动切换', stock: true },
  { id: 3, brand: '瑞声达', model: '浩听 96', type: '耳背式 BTE', price: 26800, score: 4.8, feature: 'AI 言语聚焦 · 风噪抑制', stock: true },
  { id: 4, brand: '西嘉', model: 'Pure 7IX', type: '深耳道 CIC', price: 35800, score: 4.9, feature: '隐形佩戴 · 原声处理芯片', stock: false },
  { id: 5, brand: '斯达克', model: 'Evolve AI 20', type: '耳内式 ITC', price: 21800, score: 4.7, feature: '中文语境优化', stock: true },
  { id: 6, brand: '唯听', model: 'Moment 440', type: '受话器外置', price: 24800, score: 4.8, feature: '双压缩通道零延迟', stock: true },
  { id: 7, brand: '优利康', model: '蓝鲸 900', type: '耳背式', price: 16800, score: 4.6, feature: '高性价比入门首选', stock: true },
  { id: 8, brand: '新声', model: '灵犀 Pro', type: '耳内式', price: 9800, score: 4.5, feature: '国产旗舰 · 自研芯片', stock: true },
  { id: 9, brand: '爱沃福', model: '微风 8C', type: '深耳道', price: 7800, score: 4.4, feature: '轻中度听损适用', stock: false },
  { id: 10, brand: '耳魔王', model: 'AV-300', type: '耳背式', price: 4800, score: 4.2, feature: '老人简易操作款', stock: true }
]

在这里插入图片描述

这组助听器数据涵盖了从4800元到35800元的不同价位段,包含了峰力、奥迪康、瑞声达、西嘉、斯达克、唯听等国际一线品牌,以及优利康、新声、爱沃福、耳魔王等高性价比品牌。每条记录的feature字段用简短的一句话概括了该产品的核心卖点,这些卖点反映了当前助听器行业的技术趋势:蓝牙直连手机、AI场景识别、风噪抑制、零延迟处理、中文语境优化等。

技术要点:Array泛型类型的显式标注。 在ArkTS中,数组类型的声明推荐使用Array<T>泛型形式而非T[]简写形式。虽然两者在语义上等价,但Array<T>形式更加显式,在阅读复杂类型(如Array<Array<FreqT11>>)时更清晰。此外,ArkTS编译器对类型推断有严格限制,不像TypeScript那样支持隐式any,因此所有变量都需要显式类型标注。

3.3 训练课程与噪声场景数据

const COURSES11: Array<CourseT11> = [
  { id: 1, name: '听觉再适应 · 声音辨识', mins: 20, days: 21, done: 12, icon: '👂' },
  { id: 2, name: '嘈杂环境言语训练', mins: 15, days: 14, done: 14, icon: '🎙️' },
  { id: 3, name: '音乐频率感知修复', mins: 25, days: 28, done: 8, icon: '🎵' },
  { id: 4, name: '耳鸣声治疗(白噪声)', mins: 40, days: 30, done: 22, icon: '🌊' },
  { id: 5, name: '方向听觉定位训练', mins: 12, days: 10, done: 10, icon: '🧭' },
  { id: 6, name: '言语速度适应(快语速)', mins: 18, days: 21, done: 5, icon: '⚡' },
  { id: 7, name: '电话语音强化训练', mins: 10, days: 14, done: 9, icon: '📞' },
  { id: 8, name: '安静环境语句复述', mins: 15, days: 12, done: 12, icon: '📝' }
]

训练课程数据体现了听觉康复的科学体系。从"听觉再适应"到"嘈杂环境言语训练",从"音乐频率感知修复"到"耳鸣声治疗",再到"方向听觉定位训练"和"言语速度适应",这些课程覆盖了听力康复的多个维度。每门课程的done字段记录了已完成天数,UI层通过done/days的比值计算完成率,并用不同颜色(蓝色表示进行中、绿色表示已完成)的进度条直观展示。

const NOISES11: Array<NoiseT11> = [
  { id: 1, scene: '地铁通勤(车厢内)', db: 88, mins: 45, safe: false, icon: '🚇' },
  { id: 2, scene: '办公室工位', db: 55, mins: 480, safe: true, icon: '🏢' },
  { id: 3, scene: '健身房团课', db: 92, mins: 60, safe: false, icon: '🏋️' },
  { id: 4, scene: '演唱会内场', db: 105, mins: 150, safe: false, icon: '🎤' },
  { id: 5, scene: '咖啡馆闲聊', db: 62, mins: 90, safe: true, icon: '☕' },
  { id: 6, scene: '马路骑行', db: 78, mins: 35, safe: true, icon: '🚴' },
  { id: 7, scene: '电钻装修(邻居家)', db: 96, mins: 120, safe: false, icon: '🛠️' },
  { id: 8, scene: '影院 IMAX 厅', db: 85, mins: 130, safe: false, icon: '🎬' },
  { id: 9, scene: 'KTV 包厢', db: 98, mins: 120, safe: false, icon: '🎤' },
  { id: 10, scene: '居家夜晚', db: 38, mins: 600, safe: true, icon: '🏠' }
]

在这里插入图片描述

噪声场景数据展示了日常生活中常见的噪声暴露源。从数据中可以看到,演唱会内场高达105dB,KTV包厢98dB,健身房团课92dB,这些场景都远超85dB的安全阈值,被标记为safe: false。而办公室工位55dB、居家夜晚38dB虽然时长很长,但因为分贝值低,仍被标记为安全。这组数据为UI层的分贝标尺柱状图提供了可视化素材。

技术要点:safe字段的预计算策略。 噪声数据的safe字段在数据初始化时就已计算好,而非在UI渲染时动态判断。这种"预计算"策略减少了UI层的逻辑复杂度,但也带来了数据一致性的风险——如果后续修改了db值或mins值,safe字段不会自动更新。在更严谨的设计中,safe应该是一个getter计算属性,但在ArkTS的interface中不支持getter,因此需要通过工具函数在运行时计算。本案例中dbColor11和噪声记录弹框中的n.db < 85 || this.nProtect11判断,正是对这种预计算策略的补充。

3.4 复查记录与订单数据

const REVS11: Array<RevT11> = [
  { id: 1, date: '2026-08-20', place: '爱康听力学中心·望京', result: '高频轻度下降 4kHz/45dB', avg: 28, next: '2027-02-20' },
  { id: 2, date: '2026-02-21', place: '爱康听力学中心·望京', result: '双耳高频临界 4kHz/40dB', avg: 26, next: '2026-08-20' },
  { id: 3, date: '2025-08-18', place: '爱康国宾·国贸分院', result: '正常范围,注意防护', avg: 22, next: '2026-02-21' },
  { id: 4, date: '2025-02-15', place: '爱康国宾·国贸分院', result: '正常范围', avg: 20, next: '2025-08-18' },
  { id: 5, date: '2024-09-02', place: '社区听力筛查车', result: '正常范围', avg: 18, next: '2025-02-15' }
]

复查记录按时间倒序排列(最新在前),记录了从2024年9月到2026年8月的5次听力检查。可以清晰地看到听力变化的趋势:avg从18dB逐步上升到28dB,从"正常范围"演变到"高频轻度下降"。这种时间轴式的数据展示,让用户能够直观感知到听力健康的变化趋势,体现了健康档案类应用的核心价值——“记录历史,发现趋势,预警风险”。

const ORDS11: Array<OrdT11> = [
  { id: 1, name: '纯音测听 + 声导抗复查', state: '已完成', date: '2026-08-20', price: 120 },
  { id: 2, name: '峰力 Lumity L90 双耳验配', state: '调试中', date: '2026-08-25', price: 32800 },
  { id: 3, name: '定制耳模(软耳膜)', state: '已完成', date: '2026-08-23', price: 680 },
  { id: 4, name: '助听器干燥盒', state: '已发货', date: '2026-08-22', price: 89 },
  { id: 5, name: '听觉训练季卡', state: '进行中', date: '2026-07-01', price: 398 },
  { id: 6, name: '耳鸣声治疗评估', state: '已完成', date: '2026-06-12', price: 260 },
  { id: 7, name: '音乐节定制耳塞', state: '已完成', date: '2026-05-20', price: 1280 },
  { id: 8, name: '听力健康年卡', state: '生效中', date: '2026-01-01', price: 680 }
]

在这里插入图片描述

订单数据涵盖了服务类(测听、验配、训练)和商品类(耳模、干燥盒、耳塞)两大类型,state字段的值非常多样:已完成、调试中、已发货、进行中、生效中。这种多状态的订单管理,对UI层提出了"状态颜色映射"的需求,后面的ordColor11工具函数正是为此而生。

技术要点:日期字符串的存储格式。 本案例中所有日期均采用’YYYY-MM-DD’的ISO 8601标准格式存储为字符串。这种格式的优势在于字符串排序即等价于时间排序,无需解析为Date对象即可比较大小。但在需要计算日期差、显示相对时间(如"3天前")时,仍需通过new Date(dateStr)解析。在ArkTS中,Date对象的使用与标准JavaScript一致,但需注意时区处理。

3.5 常量选项数组

const SLOT11: Array<string> = ['09:00', '10:15', '11:30', '13:45', '15:00', '16:15']
const EARS11: Array<string> = ['双耳', '左耳', '右耳']
const BUDGET11: Array<string> = ['1万内', '1-2万', '2-3万', '3万以上']

这三个常量数组分别定义了预约时段选项、耳别选项和预算区间选项。它们将被弹框中的ForEach渲染为可点击的标签按钮。这种将选项数据与UI渲染分离的设计,使得选项内容的维护(增删改)只需修改常量数组,而无需触碰UI代码,实现了数据与视图的解耦。


四、工具函数:颜色映射与数值转换

4.1 分贝颜色映射函数

function dbColor11(db: number): string {
  if (db >= 95) {
    return '#F87171'
  }
  if (db >= 80) {
    return '#F59E0B'
  }
  return '#34D399'
}

dbColor11是一个顶层函数(不属于任何类或结构体),接收一个分贝数值,返回对应的颜色十六进制字符串。函数采用分段映射策略:95dB及以上返回红色(#F87171,表示危险),80-94dB返回橙色(#F59E0B,表示警告),80dB以下返回绿色(#34D399,表示安全)。这种"分贝-颜色"映射在UI层多处使用:噪声柱状图的柱体颜色、场景列表中的分贝数值颜色等,确保了整个应用中分贝值颜色语义的一致性。

技术要点:顶层函数与组件方法的区别。 在ArkTS中,使用function关键字声明的顶层函数是纯函数,不依赖组件状态,可以在任何地方调用。而使用@Builder修饰的方法虽然也能复用UI结构,但它绑定在特定组件实例上,可以访问this.state。工具类函数(如颜色映射、数值转换)推荐定义为顶层函数,以保证其纯粹性和可测试性。

4.2 分贝转高度函数

function dbToH11(db: number): number {
  return Math.max((db - 60) * 1.6, 6)
}

dbToH11函数将分贝值转换为柱状图的高度值(像素)。公式(db - 60) * 1.6的含义是:以60dB为基准(0高度),每增加1dB高度增加1.6像素。Math.max(…, 6)确保最小高度为6像素,避免低分贝场景下柱子完全不可见。例如,88dB对应高度(88-60)*1.6=44.8像素,105dB对应高度(105-60)*1.6=72像素,38dB对应高度6像素(取最小值)。这种线性映射简化了可视化逻辑,让柱子高度直观反映分贝大小。

技术要点:Math对象在ArkTS中的可用性。 ArkTS保留了标准JavaScript的Math对象,包括Math.max、Math.min、Math.round、Math.floor、Math.ceil等常用方法。但需注意,ArkTS对动态特性有限制,Math对象的扩展或重写不被允许。在进行浮点数运算时,建议使用toFixed进行格式化以避免精度问题。

4.3 订单状态颜色映射函数

function ordColor11(s: string): string {
  if (s === '调试中' || s === '进行中') {
    return '#38BDF8'
  }
  if (s === '已发货') {
    return '#F59E0B'
  }
  return '#34D399'
}

ordColor11函数将订单状态字符串映射为颜色。调试中和进行中返回蓝色(#38BDF8),表示进行中状态;已发货返回橙色(#F59E0B),表示待收货状态;其他状态(已完成、生效中、待到店、待回电等)返回绿色(#34D399),表示已完成或稳定状态。这种基于字符串相等性判断的映射方式简单直接,但如果状态种类增多,建议改为基于Map或对象的查找表,以获得更好的可维护性。

技术要点:字符串相等性比较。 ArkTS中使用=进行严格相等比较,既比较值也比较类型。对于字符串,=比较的是字符序列是否完全一致。需要注意的是,ArkTS不支持的隐式类型转换,所有相等比较都应使用=。在处理包含中文的字符串时,===能正确处理Unicode字符序列。


五、页面入口与状态变量设计

5.1 @Entry与@Component装饰器

@Entry
@Component
struct Index {

这三行代码是鸿蒙ArkTS页面的标准入口结构。@Entry装饰器标记该组件为页面入口组件,意味着它会被注册到路由系统中,可以通过页面路由直接访问。@Component装饰器声明这是一个自定义组件,编译器会为其生成组件工厂代码。struct关键字定义了一个结构体(在ArkTS中,struct替代了class作为组件的载体),Index是组件名称。

技术要点:@Entry的唯一性。 在一个页面文件中,@Entry修饰的组件有且只能有一个,它是整个页面的根组件。@Component修饰的组件可以有多个(通过子组件方式引入),但@Entry是页面级别的标记。如果不添加@Entry,该struct虽然能被编译,但无法作为独立页面被路由系统识别。

5.2 核心业务状态变量

@State curTab11: number = 0
@State freqs11: Array<FreqT11> = FREQS11.slice()
@State courses11: Array<CourseT11> = COURSES11.slice()
@State noises11: Array<NoiseT11> = NOISES11.slice()
@State revs11: Array<RevT11> = REVS11.slice()
@State orders11: Array<OrdT11> = ORDS11.slice()

这组@State变量构成了页面的核心数据状态。curTab11初始值为0,表示默认显示第一个Tab(听力图)。其余5个变量都是业务数据数组的可变副本,通过slice()方法从对应的const常量数组中复制而来。这些@State变量的任何重新赋值都会触发UI的重新渲染。

技术要点:@State的响应式触发条件。 @State变量的响应式更新并非"深度监听",而是"引用监听"。对于数组类型,只有当整个数组引用被替换时(如this.freqs11 = newArray),UI才会更新。直接通过this.freqs11[0].left = 20修改数组元素属性不会触发更新。这就是为什么后面的代码中大量使用map、filter、concat等返回新数组的方法来更新状态。

5.3 弹框开关状态

// 弹框开关
@State showTest11: boolean = false
@State showReport11: boolean = false
@State showNoise11: boolean = false
@State showRev11: boolean = false
@State showCourseDel11: boolean = false
@State showFit11: boolean = false

六个boolean类型的@State变量分别控制六个弹框的显示与隐藏。初始值均为false,表示页面加载时所有弹框都是隐藏状态。当用户点击触发按钮时,将对应的变量设为true,弹框便通过条件渲染显示出来。这种"布尔开关 + 条件渲染"是ArkUI中实现弹框/抽屉/模态层的标准模式。

技术要点:boolean状态与条件渲染的配合。 在build方法中,通过if (this.showTest11) { this.testOverlay11() }的方式控制弹框渲染。当showTest11从false变为true时,框架会执行testOverlay11构建器并挂载到组件树;当从true变为false时,框架会卸载该组件。这种基于条件的挂载/卸载比通过visibility属性控制显示隐藏更高效,因为不可见的组件不占用渲染资源。

5.4 表单状态变量

// 测听预约
@State tSlot11: number = 1
@State tEar11: number = 0

// 噪声记录
@State nScene11: number = 0
@State nMins11: number = 60
@State nProtect11: boolean = true

// 复查计划
@State revMonths11: number = 6

// 课程删除
@State courseId11: number = 0
@State armed11: boolean = false

// 验配咨询
@State fModel11: number = 0
@State fEar11: number = 0
@State fBudget11: number = 2

这些@State变量分别服务于各个弹框中的表单交互。tSlot11和tEar11是预约测听弹框中的选中索引(时段和耳别);nScene11、nMins11、nProtect11是噪声记录弹框中的场景索引、时长和防护开关;revMonths11是复查周期选择;courseId11和armed11是课程删除弹框的目标ID和二次确认开关;fModel11、fEar11、fBudget11是验配咨询弹框中的型号、耳别和预算选中索引。

这种将表单状态集中管理在页面组件中的设计,在小型应用中是合理的。但如果弹框数量继续增加,建议将每个弹框拆分为独立的子组件,通过@Prop/@Link或@Provide/@Consume传递状态,以避免状态变量过多导致的可维护性下降。

技术要点:索引型状态变量的设计。 tSlot11、tEar11等变量使用number类型存储选中项的索引(而非选中项的值),这是一种高效的设计。索引比较(this.tSlot11 === i)比字符串比较更快,且索引可以直接用于从数组中取值(SLOT11[this.tSlot11])。但需要注意索引越界问题——当选项数组动态变化时,索引可能失效。

5.5 动画驱动状态

// 特效
@State waveX11: number = -20
@State earOp11: number = 0.3

waveX11驱动头部声波装饰条的左右移动动画,初始值-20表示向左偏移20像素。earOp11驱动头部耳机图标的透明度脉动动画,初始值0.3表示30%不透明度。这两个变量将在aboutToAppear生命周期中被animateTo动画API持续修改,从而产生循环往复的视觉效果。

技术要点:动画驱动状态与UI绑定。 动画驱动的@State变量与普通状态变量在机制上完全相同,区别仅在于变化来源不同。普通状态变量由用户交互(点击、输入)触发变化,而动画状态变量由animateTo的回调函数在每一帧自动修改。框架在每一帧检测到@State值变化后,会自动重新渲染依赖该状态的UI部分,形成流畅的动画效果。


六、生命周期与动画初始化

6.1 aboutToAppear生命周期

aboutToAppear(): void {
  this.getUIContext().animateTo({ duration: 2400, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
    this.waveX11 = 20
  })
  this.getUIContext().animateTo({ duration: 1300, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
    this.earOp11 = 0.9
  })
}

aboutToAppear是ArkUI组件的生命周期回调函数,在组件创建后、build方法执行前被调用。它通常用于初始化数据、启动动画、发起网络请求等准备工作。本案例中,aboutToAppear启动了两个无限循环动画。

技术要点:aboutToAppear的执行时机。 aboutToAppear在组件实例创建之后、首次build之前执行。此时组件的@State变量已完成初始化,但UI尚未渲染。因此,在aboutToAppear中修改@State变量不会触发额外的重新渲染(因为首次渲染尚未发生),但启动的动画会在build完成后立即生效。需要注意的是,aboutToAppear只执行一次,组件的后续更新不会再次触发该回调。

6.2 animateTo动画API详解

animateTo是ArkUI提供的显式动画API,它接收两个参数:动画选项对象和闭包函数。动画选项对象包含以下关键字段:

  • duration:动画持续时间,单位毫秒。本案例中两个动画分别是2400ms和1300ms。
  • iterations:迭代次数,-1表示无限循环。这是实现循环动画的关键参数。
  • playMode:播放模式。PlayMode.Alternate表示交替播放,即正向播完后反向播放,形成"来回"的往复效果。其他模式包括PlayMode.Normal(正向一次)、PlayMode.Reverse(反向一次)、PlayMode.Alternate(正反交替)。
  • curve:动画曲线。Curve.EaseInOut表示先加速后减速的缓动曲线,让动画看起来更自然。

闭包函数内定义了动画的终止状态。对于waveX11动画,闭包内将waveX11设为20,框架会自动在初始值-20和目标值20之间进行插值,配合PlayMode.Alternate模式,实现声波条在-20到20之间的往复移动。earOp11同理,在0.3到0.9之间往复变化,形成透明度的脉动效果。

技术要点:getUIContext()的必要性。 在较新的ArkUI API版本中,animateTo需要通过getUIContext()获取UI上下文后调用,而非直接调用全局animateTo函数。这种设计将动画与具体的UI上下文绑定,避免了多实例场景下的上下文混淆。getUIContext()返回当前组件所在的UIContext实例,它还提供了getAnimator、getFont、getPromptAction等丰富的UI能力。

组件创建

aboutToAppear回调

启动waveX11动画
2400ms 无限循环 交替模式

启动earOp11动画
1300ms 无限循环 交替模式

waveX11在-20到20间往复变化

earOp11在0.3到0.9间往复变化

声波装饰条左右移动

耳机图标透明度脉动

首次build渲染

UI挂载完成 动画生效

持续视觉动效

这个流程图展示了aboutToAppear生命周期中两个动画的启动过程和最终效果。可以看到,动画在aboutToAppear中声明后,会在build完成后自动生效,持续驱动UI的视觉变化,为页面增添了动态生命力。


七、头部构建器:声波动画与搜索栏

7.1 @Builder装饰器与headerBar11

@Builder headerBar11() {
  Column() {
    Row() {
      Column() {
        Text('👂 HEARING LAB').fontSize(17).fontWeight(700).fontColor('#7DD3FC').letterSpacing(1)
        Text('纯音测听 · 助听验配 · 听觉训练').fontSize(10).fontColor('#64809C').margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)

      Text('🎧').fontSize(22).opacity(this.earOp11)
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 14, bottom: 8 })

@Builder是ArkTS中用于定义UI构建片段的装饰器。被@Builder修饰的方法返回一段UI描述,可以在build方法或其他@Builder方法中通过this.methodName()的方式调用。它的作用类似于其他框架中的"局部组件"或"渲染函数",用于将复杂的UI拆分为可复用的模块。

headerBar11构建了页面的顶部头部区域。整体结构是一个Column容器(垂直布局),内部包含三行内容:第一行是标题行(Row容器),包含应用名称"HEARING LAB"和副标题"纯音测听·助听验配·听觉训练",以及右侧的耳机图标。

技术要点:@Builder与@Component子组件的区别。 @Builder定义的是当前组件内部的UI片段,它共享当前组件的this和状态,不需要参数传递就能访问@State变量。而@Component子组件是独立的组件实例,需要通过@Prop/@Link显式传递数据。@Builder更轻量,适合页面内的UI拆分;@Component更独立,适合跨页面复用。本案例中所有Tab和弹框都用@Builder实现,因为它们紧密依赖页面的@State状态,无需独立复用。

标题行使用Row水平布局,左侧的Column(layoutWeight(1))占据剩余空间,右侧的耳机图标固定大小。耳机图标的opacity绑定到this.earOp11,这是aboutToAppear中启动的透明度脉动动画的目标变量,因此耳机图标会呈现0.3到0.9之间往复变化的透明度效果,形成"呼吸"般的视觉脉动。

技术要点:layoutWeight权重分配。 layoutWeight是ArkUI中Flex/Row/Column容器的权重分配属性。当容器内有多个子元素时,layoutWeight值为正数的子元素会按比例分配容器的剩余空间(总空间减去固定尺寸子元素占用的空间)。本例中左侧Column设置了layoutWeight(1),右侧Text没有设置layoutWeight(默认为0,不参与剩余空间分配),因此左侧Column占据100%的剩余宽度,右侧Text保持内容自适应宽度。

7.2 声波装饰条与ForEach渲染

    // 静态声波装饰条
    Row() {
      ForEach([6, 14, 22, 30, 22, 14, 6, 14, 22, 30, 22, 14, 6, 14, 22, 30, 22, 14], (h: number, i: number) => {
        Column() {
        }
        .width(4)
        .height(h)
        .borderRadius(2)
        .backgroundColor(i % 3 === 0 ? '#38BDF8' : '#1E5A8A')
        .margin({ right: 6 })
        .translate({ x: i % 2 === 0 ? this.waveX11 : -this.waveX11 })
      }, (h: number, i: number) => 'wv' + i.toString() + '_' + h.toString())
    }
    .width('100%')
    .justifyContent(FlexAlign.Center)
    .padding({ top: 6, bottom: 10 })

声波装饰条是头部的一个视觉亮点。它通过ForEach渲染了18个不同高度的细长柱子,高度数组[6, 14, 22, 30, 22, 14, ...]呈现出"低-高-中-低"的波峰波谷形态,模拟声波的视觉形象。每个柱子宽4像素,高度从数组中取值,圆角2像素,柱子间右间距6像素。

颜色采用交替策略:i % 3 === 0时为亮蓝色(#38BDF8),其余为深蓝色(#1E5A8A),形成每隔3个柱子一个亮色的节奏感。translate属性绑定了this.waveX11,但通过i % 2 === 0 ? this.waveX11 : -this.waveX11实现了相邻柱子反向移动——偶数索引柱子向右移动,奇数索引柱子向左移动,配合animateTo的往复动画,营造出声波"扩散"的动态效果。

技术要点:ForEach的三个参数。 ForEach是ArkUI的列表渲染组件,接收三个参数:数据源数组、子项生成函数、键值生成函数。子项生成函数(h: number, i: number) => { ... }接收元素值和索引两个参数。键值生成函数(h: number, i: number) => 'wv' + i.toString() + '_' + h.toString()为每个项生成唯一键,框架通过键值判断哪些项需要新增、删除或重排,从而实现高效的差异化更新。键值的唯一性非常重要,如果键值重复会导致渲染异常。

技术要点:translate位移变换。 translate是ArkUI的图形变换属性,用于在x/y方向平移组件。与position绝对定位不同,translate不影响布局流——组件在布局中仍然占据原始位置,只是视觉上发生了位移。这使得translate非常适合动画场景,因为位移不会触发布局重排,性能优于修改left/top属性。本案例中声波柱子的左右移动动画正是利用translate实现的。

7.3 搜索栏

      Row() {
        Text('🔎').fontSize(13)
        Text('查助听器型号 / 听力术语 / 门店').fontSize(11).fontColor('#64809C').margin({ left: 6 }).layoutWeight(1).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        Text('📊').fontSize(16).onClick(() => {
          this.showReport11 = true
        })
      }
      .width('100%')
      .padding({ left: 12, right: 12, top: 9, bottom: 9 })
      .borderRadius(20)
      .backgroundColor('#0E2038')
      .margin({ left: 16, right: 16, bottom: 12 })
    }
    .width('100%')
    .backgroundColor('#081527')
    .borderRadius({ bottomLeft: 20, bottomRight: 20 })
  }

搜索栏是一个Row容器,包含搜索图标、占位文本和报告图标。占位文本设置了maxLines(1)和textOverflow({ overflow: TextOverflow.Ellipsis }),确保文本过长时只显示一行并以省略号结尾,避免布局被撑开。报告图标的onClick事件将showReport11设为true,触发完整报告弹框的显示。

技术要点:textOverflow文本溢出处理。 TextOverflow.Ellipsis是ArkUI提供的文本溢出处理策略,当文本超出可用宽度时以省略号"…"结尾。其他策略包括TextOverflow.Clip(直接截断)和TextOverflow.None(不处理)。配合maxLines限制行数,可以有效控制文本组件的尺寸,防止长文本破坏布局。需要注意的是,textOverflow只在maxLines或width约束生效时才有意义。


八、底部Tab栏:多Tab切换架构

8.1 tabItem11构建器

@Builder tabItem11(icon: string, label: string, idx: number) {
  Column() {
    Text(icon).fontSize(19)
    Text(label).fontSize(9).fontColor(this.curTab11 === idx ? '#38BDF8' : '#5A7590').margin({ top: 3 })
  }
  .alignItems(HorizontalAlign.Center)
  .padding({ top: 8, bottom: 6 })
  .layoutWeight(1)
  .onClick(() => {
    this.curTab11 = idx
  })
}

tabItem11是一个带参数的@Builder方法,接收三个参数:icon(图标emoji)、label(标签文字)、idx(Tab索引)。这种带参数的@Builder是ArkUI中实现UI复用的重要手段。每个Tab项是一个Column容器,垂直排列图标和文字,文字颜色根据this.curTab11 === idx判断——如果当前选中Tab与该项索引一致,文字为亮蓝色(#38BDF8),否则为灰蓝色(#5A7590),实现了选中态的视觉反馈。

技术要点:@Builder的参数传递。 @Builder方法可以接收参数,参数类型可以是基本类型(string、number、boolean)或对象类型。参数在方法内部作为局部变量使用,与@State变量的区别在于:参数是只读的(在方法内不应修改),而@State变量是响应式的。当@State变量变化时,引用该变量的@Builder会自动重新渲染。

每个Tab项设置了layoutWeight(1),使得6个Tab项在Row容器中均分宽度。onClick事件回调将curTab11设为该Tab的索引,触发状态更新后,所有Tab项的文字颜色会重新计算,选中的Tab变为高亮色,未选中的恢复灰色,同时build方法中的条件渲染逻辑会切换显示对应的Tab内容。

8.2 tabBar11构建器

@Builder tabBar11() {
  Row() {
    this.tabItem11('📈', '听力图', 0)
    this.tabItem11('🎧', '助听器', 1)
    this.tabItem11('🎓', '训练', 2)
    this.tabItem11('🔊', '防护', 3)
    this.tabItem11('🗓️', '复查', 4)
    this.tabItem11('👤', '我的', 5)
  }
  .width('100%')
  .backgroundColor('#0B1A2E')
  .border({ width: 0.5, color: '#3338BDF8' })
}

tabBar11将6个tabItem11组织在一个Row容器中,形成了底部Tab栏。6个Tab分别是:听力图(📈)、助听器(🎧)、训练(🎓)、防护(🔊)、复查(🗓️)、我的(👤)。Tab栏设置了深色背景(#0B1A2E)和顶部边框(0.5像素、半透明蓝色#3338BDF8),与上方内容区域形成视觉分隔。

技术要点:颜色值的透明度表示。 ArkUI中的颜色值支持8位十六进制格式#RRGGBBAA,其中后两位AA表示Alpha通道(透明度)。例如#3338BDF8表示颜色#38BDF8的约20%透明度(0x33/0xFF ≈ 0.2)。这种格式在需要半透明效果时非常方便,无需使用rgba()函数。但需注意,部分旧版本ArkUI编译器可能不支持8位格式,建议确认目标API级别。


九、Tab0 听力图:双柱状图与Progress组件

9.1 听力概览卡片

@Builder tabAudi11() {
  Column() {
    Row() {
      Stack() {
        Progress({ value: 30, total: 100 })
          .width(66)
          .height(66)
          .style({ strokeWidth: 7 })
          .color('#38BDF8')
        Column() {
          Text('28dB').fontSize(13).fontWeight(700).fontColor('#7DD3FC')
          Text('平均听阈').fontSize(8).fontColor('#64809C')
        }
        .alignItems(HorizontalAlign.Center)
      }

      Column() {
        Text('2026-08-20 纯音测听').fontSize(13).fontWeight(700).fontColor('#E2EDF7')
        Text('双耳高频轻度下降(4kHz 45dB),语频区正常,建议防护+半年复查。').fontSize(10).fontColor('#64809C').margin({ top: 5 }).lineHeight(14)
        Row() {
          Text('预约测听').fontSize(10).fontColor('#081527').fontWeight(700).padding({ left: 12, right: 12, top: 5, bottom: 5 }).borderRadius(14).backgroundColor('#38BDF8').onClick(() => {
            this.showTest11 = true
          })
          Text('完整报告').fontSize(10).fontColor('#38BDF8').padding({ left: 12, right: 12, top: 5, bottom: 5 }).borderRadius(14).border({ width: 0.8, color: '#38BDF8' }).margin({ left: 8 }).onClick(() => {
            this.showReport11 = true
          })
        }
        .margin({ top: 9 })
      }
      .alignItems(HorizontalAlign.Start)
      .margin({ left: 14 })
      .layoutWeight(1)
    }
    .width('100%')
    .alignItems(VerticalAlign.Center)
    .padding(14)
    .borderRadius(16)
    .backgroundColor('#0E2038')
    .margin({ left: 14, right: 14, top: 12 })

听力概览卡片是Tab0的第一块内容,整体是一个Row容器,左右两部分:左侧是圆形进度环,右侧是检查信息和操作按钮。

左侧使用Stack层叠布局,将Progress圆形进度组件和文字内容堆叠在一起。Progress组件设置为环形进度(默认type为ProgressType.Circular),value为30表示30%进度,total为100。style({ strokeWidth: 7 })设置环线宽度为7像素,color(‘#38BDF8’)设置进度色为蓝色。在Progress之上叠叠了一个Column,显示"28dB"(平均听阈值)和"平均听阈"标签,形成"环形进度 + 中心文字"的经典仪表盘视觉效果。

技术要点:Progress进度组件。 Progress是ArkUI的进度展示组件,支持三种类型:ProgressType.Linear(线性进度条)、ProgressType.Circular(圆形进度环)、ProgressType.Ring(圆环进度,较细)。通过构造参数{ value, total, type }初始化。value表示当前值,total表示总值,百分比 = value/total*100。style方法用于设置类型相关的样式,如环形进度的strokeWidth(线宽)。

技术要点:Stack层叠布局。 Stack是ArkUI的层叠布局容器,子元素默认居中堆叠。可以通过alignContent参数设置默认对齐方式。Stack中后添加的子元素覆盖在先添加的子元素之上。本例中Progress先添加(底层),Column后添加(上层),因此文字显示在进度环之上。Stack常用于"背景+前景"的层叠场景,如头像+角标、图片+遮罩+文字等。

右侧Column包含检查日期标题、检查结论描述和两个操作按钮。"预约测听"按钮是实心按钮(深色文字+蓝色背景),点击触发showTest11弹框;"完整报告"按钮是描边按钮(蓝色文字+蓝色边框+透明背景),点击触发showReport11弹框。两种按钮风格的对比,遵循了"主操作实心、次操作描边"的设计规范。

9.2 双耳频率双柱状图

      // 双耳频率柱
      Column() {
        Text('📈 气导听阈(dB HL,柱越低越好)').fontSize(13).fontWeight(700).fontColor('#E2EDF7')
        Row() {
          ForEach(this.freqs11, (f: FreqT11) => {
            Column() {
              Text(f.left.toString()).fontSize(7).fontColor('#7DD3FC')
              Column() {
                Column() {
                }
                .width(11)
                .height(f.left * 0.9)
                .borderRadius({ topLeft: 2, topRight: 2 })
                .backgroundColor('#38BDF8')
              }
              .justifyContent(FlexAlign.End)
              .height(46)
              Text(f.hz).fontSize(7).fontColor('#64809C').margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Center)
            .layoutWeight(1)

            Column() {
              Text(f.right.toString()).fontSize(7).fontColor('#FBBF24')
              Column() {
                Column() {
                }
                .width(11)
                .height(f.right * 0.9)
                .borderRadius({ topLeft: 2, topRight: 2 })
                .backgroundColor('#FBBF24')
              }
              .justifyContent(FlexAlign.End)
              .height(46)
              Text(' ').fontSize(7)
            }
            .alignItems(HorizontalAlign.Center)
            .layoutWeight(1)
          }, (f: FreqT11) => 'fq' + f.id.toString())
        }
        .width('100%')
        .margin({ top: 14 })

双耳频率双柱状图是听力图Tab的核心可视化组件。整体结构是一个Row容器,通过ForEach遍历freqs11数组,为每个频率点渲染一对柱子(左耳蓝色+右耳黄色)。

每个柱子的结构值得仔细分析。以左耳柱子为例:最外层是一个Column(layoutWeight(1)均分宽度),内部从上到下包含三部分——听阈数值文本(如"15")、柱体容器、频率标签(如"1k")。柱体容器是一个固定高度46像素的Column,设置了justifyContent(FlexAlign.End)使内部子元素底部对齐。柱体本身是一个空Column,宽度11像素,高度为f.left * 0.9(听阈值乘以0.9缩放系数),顶部圆角2像素。

技术要点:justifyContent对齐方式。 FlexAlign是ArkUI中Flex/Row/Column容器的子元素主轴对齐方式枚举。FlexAlign.End表示子元素在主轴末端对齐。对于Column容器,主轴是垂直方向,FlexAlign.End使子元素堆叠在容器底部。本例中柱体容器高度46像素,内部柱体高度为f.left * 0.9(如15*0.9=13.5像素),通过FlexAlign.End使柱体"长"在容器底部,形成"从底向上生长"的柱状图效果。

技术要点:柱状图的纯CSS实现。 本案例的柱状图完全由Column容器和height属性实现,没有使用任何图表库。这种"原生组件拼装"的方式虽然代码量较大,但优势是高度可定制、性能优秀(无需引入第三方库),且每个柱子都可以独立绑定事件和动画。对于简单的柱状图需求,这种方式比引入图表库更轻量。

右耳柱子的结构与左耳完全对称,区别仅在于颜色(#FBBF24黄色)和数值来源(f.right)。每对柱子之间没有显式间距,而是通过layoutWeight(1)均分宽度实现自然间隔。频率标签只在左耳柱子下方显示(右耳柱子下方显示一个空格文本),避免了重复显示。图例区通过两个小色块+文字说明了蓝色代表左耳、黄色代表右耳,并标注了听力损失分级标准。

9.3 耳鸣评估卡片

      // 耳鸣评估
      Column() {
        Text('🌀 耳鸣匹配评估').fontSize(13).fontWeight(700).fontColor('#E2EDF7')
        Row() {
          Column() {
            Text('4kHz').fontSize(16).fontWeight(700).fontColor('#F87171')
            Text('匹配频率').fontSize(9).fontColor('#64809C').margin({ top: 3 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          Column() {
            Text('12dB').fontSize(16).fontWeight(700).fontColor('#F59E0B')
            Text('匹配响度').fontSize(9).fontColor('#64809C').margin({ top: 3 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          Column() {
            Text('轻度').fontSize(16).fontWeight(700).fontColor('#34D399')
            Text('THI 分级').fontSize(9).fontColor('#64809C').margin({ top: 3 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
        }
        .width('100%')
        .margin({ top: 12 })
      }
      .width('100%')
      .alignItems(HorizontalAlign.Start)
      .padding(14)
      .borderRadius(16)
      .backgroundColor('#0E2038')
      .margin({ left: 14, right: 14, top: 10 })
    }
    .width('100%')
    .padding({ bottom: 12 })
  }

耳鸣评估卡片展示了耳鸣匹配测试的三个关键指标:匹配频率(4kHz,红色)、匹配响度(12dB,橙色)、THI分级(轻度,绿色)。三个指标使用三个等宽的Column(各layoutWeight(1))水平排列,形成三列等宽的统计面板。每个指标包含大号数值(fontSize(16)、fontWeight(700))和小号标签(fontSize(9)),数值颜色根据严重程度递减——红色最严重、橙色次之、绿色最轻,与前面dbColor11函数的颜色语义保持一致。

技术要点:三列等宽布局的layoutWeight技巧。 三个Column各设置layoutWeight(1),在Row容器中均分宽度。这种"等分布局"是响应式设计的基础——无论屏幕宽度如何变化,三列始终等宽,无需硬编码宽度值。如果需要不等宽的布局,只需调整各列的layoutWeight值即可,如[2, 1, 1]表示第一列占50%。


十、Tab1 助听器:商品列表与验配进度

10.1 验配进度卡片

@Builder tabAid11() {
  Column() {
    Column() {
      Row() {
        Text('🎧 验配进度').fontSize(13).fontWeight(700).fontColor('#E2EDF7')
        Text('3/5 步').fontSize(10).fontColor('#38BDF8')
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      Text('已完成:听力检查 → 耳模取型 → 机器选型;待完成:编程调试 → 效果评估').fontSize(10).fontColor('#64809C').margin({ top: 8 }).lineHeight(15)
      Row() {
        Progress({ value: 60, total: 100, type: ProgressType.Linear })
          .layoutWeight(1)
          .height(6)
          .style({ strokeWidth: 6 })
          .color('#38BDF8')
          .backgroundColor('#081527')
        Text('60%').fontSize(10).fontWeight(700).fontColor('#38BDF8').margin({ left: 10 })
      }
      .width('100%')
      .margin({ top: 10 })
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
    .padding(14)
    .borderRadius(16)
    .backgroundColor('#0E2038')
    .margin({ left: 14, right: 14, top: 12 })

验配进度卡片展示了助听器验配流程的五步进度。标题行使用Row容器,左侧标题和右侧"3/5步"通过justifyContent(FlexAlign.SpaceBetween)分散两端对齐。描述文本说明了已完成的三个步骤和待完成的两步骤,形成清晰的流程说明。

进度条使用Progress组件的Linear类型(线性进度条),value为60表示60%进度。与Tab0中的Circular类型不同,Linear类型显示为水平条状。通过layoutWeight(1)使进度条占据剩余宽度,右侧显示"60%"百分比文本。进度条高度6像素,线宽6像素,进度色蓝色,背景色深蓝,视觉上精致而清晰。

技术要点:FlexAlign.SpaceBetween两端对齐。 FlexAlign.SpaceBetween使子元素在主轴上均匀分布,首尾元素贴边,中间元素间距相等。这是Row容器中实现"左右两端对齐"的常用方式。其他对齐方式包括FlexAlign.Start(起始端对齐)、FlexAlign.End(末端对齐)、FlexAlign.Center(居中对齐)、FlexAlign.SpaceAround(等间距环绕)、FlexAlign.SpaceEvenly(等间距均匀分布)。

10.2 助听器商品列表

      Row() {
        Text('🎧 助听器精选(10 款)').fontSize(13).fontWeight(700).fontColor('#E2EDF7')
        Text('验配咨询 >').fontSize(10).fontColor('#38BDF8').onClick(() => {
          this.showFit11 = true
        })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      .padding({ left: 16, right: 16, top: 16, bottom: 4 })

      ForEach(AIDS11, (a: AidT11) => {
        Column() {
          Row() {
            Text('🎧').fontSize(26)
            Column() {
              Row() {
                Text(a.brand + ' ' + a.model).fontSize(12).fontWeight(700).fontColor('#E2EDF7')
                Text(a.type).fontSize(8).fontColor('#38BDF8').padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(6).backgroundColor('#12314F').margin({ left: 8 })
              }
              .width('100%')
              Text(a.feature).fontSize(9).fontColor('#64809C').margin({ top: 5 }).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
              Row() {
                Text('⭐ ' + a.score.toFixed(1)).fontSize(9).fontColor('#FBBF24')
                Text(a.stock ? ' 现货' : ' 预订 2 周').fontSize(9).fontColor(a.stock ? '#34D399' : '#F59E0B').margin({ left: 10 })
              }
              .margin({ top: 4 })
            }
            .alignItems(HorizontalAlign.Start)
            .margin({ left: 12 })
            .layoutWeight(1)

            Column() {
              Text('¥' + (a.price / 10000).toFixed(1).toString() + '万').fontSize(13).fontWeight(700).fontColor('#38BDF8')
            }
            .alignItems(HorizontalAlign.End)
          }
          .width('100%')
          .alignItems(VerticalAlign.Center)

          Text('咨询验配').fontSize(10).fontColor('#081527').fontWeight(700).padding({ left: 14, right: 14, top: 6, bottom: 6 }).borderRadius(13).backgroundColor('#38BDF8').alignSelf(ItemAlign.End).margin({ top: 8 }).onClick(() => {
            this.fModel11 = Math.max(0, AIDS11.findIndex((x: AidT11) => x.id === a.id))
            this.showFit11 = true
          })
        }
        .width('100%')
        .padding(13)
        .borderRadius(14)
        .backgroundColor('#0E2038')
        .border({ width: 0.6, color: '#22385A' })
        .margin({ left: 14, right: 14, top: 8 })
      }, (a: AidT11) => 'ad' + a.id.toString())
    }
    .width('100%')
    .padding({ bottom: 12 })
  }

助听器商品列表通过ForEach遍历AIDS11数组,为每款助听器渲染一个商品卡片。每个卡片是一个Column容器,内部包含商品信息行和"咨询验配"按钮。

商品信息行是一个Row,从左到右依次是:助听器图标(🎧)、商品信息Column(品牌型号+类型标签+特色描述+评分库存)、价格Column。商品信息Column设置了layoutWeight(1)占据剩余空间,价格Column靠右对齐。

技术要点:toFixed格式化浮点数。 a.score.toFixed(1)将评分格式化为一位小数的字符串,如4.9。(a.price / 10000).toFixed(1)将价格从元转换为万元并保留一位小数,如32800元变为"3.3万"。toFixed是Number原型方法,返回字符串,需要注意其在ArkTS中的行为与标准JavaScript一致——会进行四舍五入。

技术要点:alignSelf交叉轴单独对齐。 alignSelf属性允许单个子元素覆盖父容器的默认交叉轴对齐方式。本例中"咨询验配"按钮设置了alignSelf(ItemAlign.End),使其在父Column(默认居中对齐)中单独靠右对齐。ItemAlign.End表示交叉轴末端对齐,在Column中即水平靠右。这种"整体居中、个别特例"的对齐方式在实际开发中非常实用。

"咨询验配"按钮的onClick事件中,AIDS11.findIndex((x: AidT11) => x.id === a.id)查找当前助听器在AIDS11数组中的索引,并通过Math.max(0, …)确保索引非负(findIndex未找到时返回-1,Math.max将其修正为0)。然后将fModel11设为该索引,并打开验配咨询弹框。这样用户点击某款助听器的"咨询验配"时,弹框中会自动预选该型号,提供了良好的用户体验连贯性。


十一、Tab2 训练:课程列表与进度环

11.1 训练总览卡片

@Builder tabTrain11() {
  Column() {
    Row() {
      Stack() {
        Progress({ value: 66, total: 100 })
          .width(66)
          .height(66)
          .style({ strokeWidth: 7 })
          .color('#34D399')
        Column() {
          Text('66%').fontSize(14).fontWeight(700).fontColor('#6EE7B7')
          Text('总完成率').fontSize(8).fontColor('#64809C')
        }
        .alignItems(HorizontalAlign.Center)
      }

      Column() {
        Text('🎓 听觉训练计划').fontSize(13).fontWeight(700).fontColor('#E2EDF7')
        Text('8 门课程 · 今日待练 2 门 · 连续打卡 23 天').fontSize(10).fontColor('#64809C').margin({ top: 5 })
      }
      .alignItems(HorizontalAlign.Start)
      .margin({ left: 14 })
      .layoutWeight(1)
    }
    .width('100%')
    .alignItems(VerticalAlign.Center)
    .padding(14)
    .borderRadius(16)
    .backgroundColor('#0E2038')
    .margin({ left: 14, right: 14, top: 12 })

训练总览卡片与听力概览卡片结构类似,左侧是66%完成率的圆形进度环(绿色#34D399),右侧是训练计划标题和统计信息。66%的完成率意味着8门课程中大部分已完成或进行中,但仍有提升空间。

11.2 课程列表与进度条

      ForEach(this.courses11, (c: CourseT11) => {
        Row() {
          Text(c.icon).fontSize(24)
          Column() {
            Text(c.name).fontSize(12).fontWeight(700).fontColor('#E2EDF7')
            Text('每日 ' + c.mins.toString() + ' 分钟 · ' + c.days.toString() + ' 天周期').fontSize(9).fontColor('#64809C').margin({ top: 4 })
            Row() {
              Column() {
                Column() {
                }
                .width((c.done / c.days * 100).toFixed(0) + '%')
                .height('100%')
                .borderRadius(3)
                .backgroundColor(c.done >= c.days ? '#34D399' : '#38BDF8')
              }
              .layoutWeight(1)
              .height(5)
              .borderRadius(3)
              .backgroundColor('#081527')
              Text(c.done >= c.days ? '已完成' : c.done.toString() + '/' + c.days.toString()).fontSize(8).fontColor(c.done >= c.days ? '#34D399' : '#64809C').margin({ left: 8 }).width(38)
            }
            .width('100%')
            .margin({ top: 8 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .padding({ left: 12 })

          Column() {
            Text('▶').fontSize(16).fontColor(c.done >= c.days ? '#5A7590' : '#38BDF8').onClick(() => {
              if (c.done < c.days) {
                this.courses11 = this.courses11.map((x: CourseT11) => {
                  if (x.id === c.id) {
                    return { id: x.id, name: x.name, mins: x.mins, days: x.days, done: x.done + 1, icon: x.icon }
                  }
                  return x
                })
              }
            })
            Text('🗑️').fontSize(13).margin({ top: 10 }).onClick(() => {
              this.courseId11 = c.id
              this.armed11 = false
              this.showCourseDel11 = true
            })
          }
          .alignItems(HorizontalAlign.Center)
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        .padding(13)
        .borderRadius(14)
        .backgroundColor('#0E2038')
        .margin({ left: 14, right: 14, top: 8 })
      }, (c: CourseT11) => 'cs' + c.id.toString() + '_' + c.done.toString())
    }
    .width('100%')
    .padding({ bottom: 12 })
  }

课程列表是Tab2的核心内容。每个课程卡片是一个Row,从左到右包含:课程图标、课程信息Column(名称+时长周期+进度条)、操作Column(播放按钮+删除按钮)。

进度条的实现值得重点关注。外层Column设置了layoutWeight(1)、height(5)、borderRadius(3)、backgroundColor(‘#081527’),作为进度条的背景轨道。内部嵌套一个Column,其width通过(c.done / c.days * 100).toFixed(0) + '%'计算——例如done=12、days=21时,(12/21*100).toFixed(0) = “57”,所以内部柱体宽度为"57%"。height(‘100%’)使内部柱体填满轨道高度。背景色根据完成状态切换:已完成(done >= days)为绿色,进行中为蓝色。

技术要点:map函数实现不可变状态更新。 点击播放按钮时,通过this.courses11.map(...)生成新数组。map遍历每个课程对象,当x.id === c.id时返回一个新对象(done属性+1),否则返回原对象。这种"生成新对象而非修改原对象"的方式是ArkTS响应式更新的关键——@State数组需要引用变化才能触发UI更新。新对象展开了原对象的所有属性并仅修改done,确保了数据完整性。

技术要点:ForEach键值包含done值。 注意键值生成函数'cs' + c.id.toString() + '_' + c.done.toString(),键值中包含了done值。这意味着当某课程的done变化时,该项的键值也变化,框架会将其视为"新项"进行重新创建而非更新。这种设计虽然会增加少量渲染开销,但确保了进度条动画的正确性——框架不会尝试在旧进度条上做差异化更新,而是直接创建新的进度条组件。

删除按钮(🗑️)的onClick事件设置了courseId11为目标课程ID,armed11为false(重置确认开关),然后打开删除确认弹框。这种"两步确认"的删除流程,通过armed11布尔开关强制用户先勾选"我已知晓进度清零"才能点击确认移除,有效防止了误操作。


十二、Tab3 防护:分贝标尺柱与场景列表

12.1 噪声暴露柱状图

@Builder tabNoise11() {
  Column() {
    Column() {
      Text('🔊 今日噪声暴露').fontSize(13).fontWeight(700).fontColor('#E2EDF7')
      Text('累计 96 分贝·分钟,占日安全上限 31%').fontSize(10).fontColor('#64809C').margin({ top: 5 })
      Row() {
        ForEach(NOISES11.slice(0, 8), (n: NoiseT11) => {
          Column() {
            Text(n.db.toString()).fontSize(7).fontColor(dbColor11(n.db)).margin({ bottom: 2 })
            Column() {
              Column() {
              }
              .width(12)
              .height(dbToH11(n.db))
              .borderRadius({ topLeft: 2, topRight: 2 })
              .backgroundColor(dbColor11(n.db))
            }
            .width('100%')
            .height(64)
            .justifyContent(FlexAlign.End)
            Text(n.icon).fontSize(10).margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Center)
          .layoutWeight(1)
        }, (n: NoiseT11) => 'nz' + n.id.toString())
      }
      .width('100%')
      .margin({ top: 12 })
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
    .padding(14)
    .borderRadius(16)
    .backgroundColor('#0E2038')
    .margin({ left: 14, right: 14, top: 12 })

噪声暴露柱状图展示了8个场景的分贝值柱状图。ForEach遍历NOISES11.slice(0, 8)(取前8项),每个场景渲染为一个柱子。柱体高度通过dbToH11函数计算(分贝值转像素高度),柱体颜色通过dbColor11函数计算(分贝值转颜色)。柱体容器高度64像素,使用justifyContent(FlexAlign.End)使柱体从底部向上生长。

技术要点:slice方法的分页/截取用途。 NOISES11.slice(0, 8)取数组前8项,返回新数组。这种截取在需要"只展示部分数据"的场景中非常实用。slice不修改原数组,而是返回浅拷贝的子数组。在ArkTS中,数组方法(slice、map、filter、concat等)均遵循函数式编程的不可变原则,返回新数组而非修改原数组。

每个柱子上方显示分贝数值(颜色与柱体一致),下方显示场景emoji图标,形成了"数值-柱体-图标"的三段式信息展示。通过颜色编码(红/橙/绿),用户一眼就能识别出哪些场景的噪声暴露超出了安全范围。

12.2 噪声场景记录列表

      Row() {
        Text('🗺️ 噪声场景记录(' + this.noises11.length.toString() + ')').fontSize(13).fontWeight(700).fontColor('#E2EDF7')
        Text('记录 +').fontSize(10).fontColor('#38BDF8').onClick(() => {
          this.nScene11 = 0
          this.nMins11 = 60
          this.showNoise11 = true
        })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      .padding({ left: 16, right: 16, top: 16, bottom: 4 })

      ForEach(this.noises11, (n: NoiseT11) => {
        Row() {
          Text(n.icon).fontSize(22)
          Column() {
            Text(n.scene).fontSize(12).fontWeight(700).fontColor('#E2EDF7')
            Text('暴露 ' + n.mins.toString() + ' 分钟 · ' + (n.safe ? '安全上限内' : '超出安全上限')).fontSize(9).fontColor(n.safe ? '#34D399' : '#F87171').margin({ top: 4 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .padding({ left: 12 })

          Column() {
            Text(n.db.toString() + 'dB').fontSize(13).fontWeight(700).fontColor(dbColor11(n.db))
            Text(n.db >= 85 ? '建议佩戴耳塞' : '无需防护').fontSize(8).fontColor('#64809C').margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.End)
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        .padding(13)
        .borderRadius(14)
        .backgroundColor('#0E2038')
        .margin({ left: 14, right: 14, top: 8 })
        .onClick(() => {
          this.nScene11 = Math.max(0, NOISES11.findIndex((x: NoiseT11) => x.id === n.id))
          this.nMins11 = n.mins
          this.showNoise11 = true
        })
      }, (n: NoiseT11) => 'nz2' + n.id.toString())

噪声场景记录列表遍历noises11数组(注意这是@State变量而非NOISES11常量,因此支持动态添加新记录)。每个场景卡片展示场景名称、暴露时长、安全状态、分贝值和防护建议。点击任一场景卡片会打开噪声记录弹框,并预填该场景的数据(场景索引和时长),方便用户编辑。

技术要点:@State数组与const数组的配合使用。 noises11是@State数组(可变,用于UI渲染和动态更新),NOISES11是const数组(不可变,作为数据源)。弹框中的场景选择列表使用NOISES11渲染(固定的场景选项),而主页面的场景记录列表使用noises11渲染(可动态添加)。这种"选项源 + 记录列表"的双数组设计,清晰地区分了"可选选项"和"已选记录"两种数据角色。

列表底部的安全提示"85dB以上暴露超过8小时即可能造成永久性听损,演唱会请务必佩戴降噪耳塞",用通俗易懂的语言传达了听力学核心知识,体现了健康类应用的健康教育功能。


十三、Tab4 复查:时间轴布局

13.1 下次复查卡片

@Builder tabRev11() {
  Column() {
    Column() {
      Text('🗓️ 下次复查').fontSize(13).fontWeight(700).fontColor('#E2EDF7')
      Text('2027-02-20 · 爱康听力学中心(望京)· 纯音测听+声导抗').fontSize(10).fontColor('#64809C').margin({ top: 6 })
      Row() {
        Text('调整复查计划').fontSize(10).fontColor('#081527').fontWeight(700).padding({ left: 12, right: 12, top: 5, bottom: 5 }).borderRadius(14).backgroundColor('#38BDF8').onClick(() => {
          this.showRev11 = true
        })
        Text('立即约测听').fontSize(10).fontColor('#38BDF8').padding({ left: 12, right: 12, top: 5, bottom: 5 }).borderRadius(14).border({ width: 0.8, color: '#38BDF8' }).margin({ left: 8 }).onClick(() => {
          this.showTest11 = true
        })
      }
      .margin({ top: 10 })
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
    .padding(14)
    .borderRadius(16)
    .backgroundColor('#0E2038')
    .margin({ left: 14, right: 14, top: 12 })

下次复查卡片展示下一次预约的复查信息,包括日期、地点和检查项目。两个操作按钮分别打开复查计划弹框(调整周期)和测听预约弹框(立即预约)。

13.2 时间轴历史记录

      Row() {
        Text('📜 历史复查(' + this.revs11.length.toString() + ')').fontSize(13).fontWeight(700).fontColor('#E2EDF7')
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 16, bottom: 4 })

      ForEach(this.revs11, (r: RevT11) => {
        Row() {
          Column() {
            Circle().width(10).height(10).fill('#38BDF8')
            Column() {
            }
            .width(2)
            .layoutWeight(1)
            .backgroundColor('#1E3A5C')
          }
          .width(12)
          .alignItems(HorizontalAlign.Center)
          .margin({ top: 2 })

          Column() {
            Row() {
              Text(r.date).fontSize(12).fontWeight(700).fontColor('#E2EDF7')
              Text('平均 ' + r.avg.toString() + 'dB').fontSize(9).fontColor(r.avg >= 26 ? '#F59E0B' : '#34D399').margin({ left: 10 })
            }
            .width('100%')
            Text(r.place + ' · ' + r.result).fontSize(9).fontColor('#64809C').margin({ top: 4 }).lineHeight(13)
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .padding({ left: 10, bottom: 14 })
        }
        .width('100%')
        .alignItems(VerticalAlign.Top)
        .margin({ left: 16, right: 16 })
      }, (r: RevT11) => 'rv' + r.id.toString())
    }
    .width('100%')
    .padding({ bottom: 12 })
  }

历史复查时间轴是Tab4的核心可视化设计。每条记录是一个Row,左侧是时间轴轴线(Column容器),右侧是记录内容(Column容器)。

时间轴轴线的实现非常巧妙:左侧Column宽12像素,内部从上到下包含一个Circle组件(10x10像素的蓝色圆点,代表时间节点)和一个空Column(宽2像素、layoutWeight(1)撑满剩余高度、深蓝色背景,代表连接线)。由于外层Row设置了alignItems(VerticalAlign.Top),左侧Column的高度由右侧内容的高度决定,layoutWeight(1)使连接线Column撑满左侧Column的剩余高度,形成"圆点+竖线"的时间轴节点效果。

技术要点:Circle基础图形组件。 Circle是ArkUI的基础图形绘制组件,用于绘制圆形。通过width和height设置尺寸,fill设置填充颜色。与通过borderRadius: 50%实现的"伪圆形"不同,Circle是真正的矢量图形,在任何分辨率下都保持清晰。ArkUI还提供了Rect(矩形)、Ellipse(椭圆)、Path(路径)、Polyline(折线)等基础图形组件,可通过这些组件绘制自定义的可视化图形。

技术要点:时间轴布局的layoutWeight技巧。 时间轴连接线使用layoutWeight(1)撑满圆点下方的剩余空间。这种设计使得每条记录的连接线长度自动适配内容高度,无需硬编码高度值。最后一条记录的连接线会延伸到其内容的底部,虽然视觉上可能略长,但整体时间轴效果依然连贯。如果需要隐藏最后一条的连接线,可以通过条件渲染或动态样式来处理。

右侧内容区展示检查日期、平均听阈值(颜色根据avg >= 26判断——超过26dB为橙色警告,否则为绿色正常)和检查地点+结论。通过平均听阈值的颜色变化,用户可以直观追踪听力从18dB到28dB的变化趋势,体现了"历史数据可视化"的健康档案设计理念。


十四、Tab5 我的:用户档案与订单列表

14.1 用户信息卡片

@Builder tabMine11() {
  Column() {
    Row() {
      Stack() {
        Circle().width(52).height(52).fill('#12314F')
        Text('🧑').fontSize(26)
      }
      Column() {
        Text('王先生 · 听力档案 HL-08829').fontSize(14).fontWeight(700).fontColor('#E2EDF7')
        Text('高频轻度下降 · 已验配双耳 · 训练打卡 23 天').fontSize(9).fontColor('#64809C').margin({ top: 5 })
      }
      .alignItems(HorizontalAlign.Start)
      .margin({ left: 12 })
      .layoutWeight(1)

      Text('预约测听').fontSize(10).fontColor('#081527').padding({ left: 10, right: 10, top: 6, bottom: 6 }).borderRadius(12).backgroundColor('#38BDF8').onClick(() => {
        this.showTest11 = true
      })
    }
    .width('100%')
    .alignItems(VerticalAlign.Center)
    .padding(14)
    .borderRadius(16)
    .backgroundColor('#0E2038')
    .margin({ left: 14, right: 14, top: 12 })

用户信息卡片使用Stack层叠布局实现了"圆形头像底色+emoji人物图标"的 avatar 效果。Stack中先放置一个52x52像素的深蓝色Circle作为底色,再叠加一个fontSize(26)的人物emoji,形成简洁的用户头像。右侧是用户姓名、档案编号和健康摘要信息,最右侧是"预约测听"快捷按钮。

技术要点:Stack的层叠顺序。 Stack中子元素的层叠顺序遵循"后添加者在上"的原则。本例中Circle先添加(底层),Text后添加(上层),因此emoji显示在Circle之上。如果需要动态调整层级,可以使用zIndex属性。Stack还支持alignContent参数设置默认对齐方式,默认为Alignment.Center(居中)。

14.2 订单列表

      Row() {
        Text('📦 我的订单(' + this.orders11.length.toString() + ')').fontSize(13).fontWeight(700).fontColor('#E2EDF7')
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 16, bottom: 4 })

      ForEach(this.orders11, (o: OrdT11) => {
        Row() {
          Column() {
            Text(o.name).fontSize(11).fontColor('#E2EDF7').maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
            Row() {
              Text(o.state).fontSize(9).fontColor(ordColor11(o.state))
              Text(o.date).fontSize(9).fontColor('#64809C').margin({ left: 10 })
            }
            .margin({ top: 6 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)

          Text('¥' + o.price.toString()).fontSize(11).fontWeight(700).fontColor('#38BDF8')
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        .padding(12)
        .borderRadius(12)
        .backgroundColor('#0B1A2E')
        .margin({ left: 14, right: 14, top: 5 })
      }, (o: OrdT11) => 'od' + o.id.toString())

订单列表遍历orders11数组,每条订单是一个Row,左侧是订单信息Column(名称+状态行),右侧是价格文本。状态文本的颜色通过ordColor11函数动态计算,不同状态显示不同颜色,使订单状态一目了然。价格直接显示原始数值(如¥32800),没有做万元转换,因为订单列表中的价格跨度大(从89元到32800元),统一用元更清晰。

技术要点:列表项的视觉层级设计。 订单卡片使用了较深的背景色(#0B1A2E)和较小的圆角(12),与前面的卡片(#0E2038背景、14/16圆角)形成微妙差异。这种细微的视觉差异帮助用户在长列表中区分不同类型的卡片。同时,订单卡片的padding(12)比内容卡片padding(14)更紧凑,使列表项在视觉上更密集,符合"列表项比独立卡片更紧凑"的设计惯例。


十五、弹框1:预约测听底部抽屉

15.1 底部抽屉容器结构

@Builder testOverlay11() {
  Column() {
    Column() {
      Column() {
        Text('').fontSize(4).width(40).borderRadius(2).backgroundColor('#1E5A8A')
      }
      .width('100%')
      .alignItems(HorizontalAlign.Center)
      .padding({ top: 10, bottom: 4 })

      Row() {
        Text('📅 预约纯音测听').fontSize(15).fontWeight(700).fontColor('#E2EDF7')
        Text('✕').fontSize(15).fontColor('#5A7590').onClick(() => {
          this.showTest11 = false
        })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      .padding({ left: 16, right: 16, top: 4 })

预约测听弹框采用"底部抽屉"模式。最外层是一个全屏Column(width 100%、height 100%),设置了justifyContent(FlexAlign.End)使内容贴底,半透明深色背景(#99081527,约60%透明度)作为遮罩层。内层Column是实际的抽屉面板,设置了顶部圆角(topLeft: 22, topRight: 22)和不透明背景色。

抽屉顶部的拖拽指示条是一个40像素宽、4像素fontSize的空文本(实际由width和backgroundColor渲染为一条小横条),居中显示,是Material Design中底部抽屉的标准视觉元素,提示用户可以从底部拖拽关闭。

技术要点:zIndex层级控制。 弹框的最外层Column设置了zIndex(999),确保它显示在所有其他内容之上。在ArkUI的Stack布局中,zIndex值越大的子元素显示在越上层。本案例的主布局使用Stack作为根容器,弹框和主内容都是Stack的子元素,通过zIndex确保弹框覆盖主内容。如果不设置zIndex,Stack中后添加的子元素默认在上层,但显式设置zIndex更安全。

技术要点:半透明遮罩的颜色编码。 #99081527是8位十六进制颜色,前6位#081527是RGB颜色,后2位#99是Alpha透明度(十进制153,约60%)。这种格式在ArkUI中合法且常用。遮罩层的作用是遮挡底层内容、聚焦用户注意力,同时保持底层内容的可见性,形成"模态"效果。点击遮罩层(onClick将showTest11设为false)关闭弹框,是标准的交互模式。

15.2 耳别选择与时段选择

        Text('测听耳别').fontSize(11).fontColor('#64809C').margin({ top: 14 })
        Row() {
          ForEach(EARS11, (e: string, i: number) => {
            Text(e).fontSize(10).fontColor(this.tEar11 === i ? '#081527' : '#64809C').padding({ left: 14, right: 14, top: 7, bottom: 7 }).borderRadius(12).backgroundColor(this.tEar11 === i ? '#38BDF8' : '#12314F').margin({ right: 8 }).onClick(() => {
              this.tEar11 = i
            })
          }, (e: string) => 'te' + e)
        }
        .margin({ top: 6 })

        Text('时段').fontSize(11).fontColor('#64809C').margin({ top: 14 })
        Flex({ wrap: FlexWrap.Wrap }) {
          ForEach(SLOT11, (t: string, i: number) => {
            Text(t).fontSize(10).fontColor(this.tSlot11 === i ? '#081527' : '#64809C').padding({ left: 13, right: 13, top: 7, bottom: 7 }).borderRadius(12).backgroundColor(this.tSlot11 === i ? '#38BDF8' : '#12314F').margin({ right: 8, bottom: 8 }).onClick(() => {
              this.tSlot11 = i
            })
          }, (t: string) => 'tt' + t)
        }
        .width('100%')
        .margin({ top: 6 })

耳别选择使用Row容器(不换行),3个选项横向排列。时段选择使用Flex容器(wrap: FlexWrap.Wrap允许换行),6个时段在一行放不下时自动换到下一行。每个选项是一个Text组件,通过三元运算符动态设置选中态样式:选中时文字深色(#081527)、背景亮蓝(#38BDF8);未选中时文字灰色(#64809C)、背景深蓝(#12314F)。

技术要点:Flex容器与wrap换行。 Flex是ArkUI的弹性布局容器,比Row/Column更灵活。通过wrap参数设置换行模式:FlexWrap.NoWrap(不换行,默认)、FlexWrap.Wrap(自动换行)。本例中时段选择使用Flex+Wrap,因为6个时段在窄屏上可能一行放不下,需要自动换行。Flex还支持direction(主轴方向)、justifyContent(主轴对齐)、alignItems(交叉轴对齐)等参数,功能比Row/Column更全面。

15.3 确认预约与订单生成

        Text('确认预约')
          .fontSize(13)
          .fontWeight(700)
          .fontColor('#081527')
          .width('100%')
          .textAlign(TextAlign.Center)
          .padding({ top: 13, bottom: 13 })
          .borderRadius(22)
          .backgroundColor('#38BDF8')
          .margin({ top: 16, bottom: 16 })
          .onClick(() => {
            const no: OrdT11 = { id: Date.now(), name: '纯音测听(' + EARS11[this.tEar11] + ')', state: '待到店', date: '明天 ' + SLOT11[this.tSlot11], price: 120 }
            this.orders11 = [no].concat(this.orders11)
            this.showTest11 = false
          })
      }
      .width('100%')
      .padding({ left: 16, right: 16 })
      .borderRadius({ topLeft: 22, topRight: 22 })
      .backgroundColor('#0E2038')
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.End)
    .backgroundColor('#99081527')
    .zIndex(999)
    .onClick(() => {
      this.showTest11 = false
    })
  }

确认预约按钮是全宽实心按钮,onClick事件中执行了三个关键操作:创建新订单对象(使用Date.now()生成唯一ID,从EARS11和SLOT11中取出选中的耳别和时段拼装订单名和日期)、通过concat将新订单插入orders11数组头部([no].concat(this.orders11)生成新数组)、关闭弹框(showTest11 = false)。

技术要点:Date.now()作为唯一ID生成器。 Date.now()返回当前时间的毫秒数时间戳,在单次操作中几乎不可能重复,因此常被用作临时唯一ID。但需注意,如果在同一毫秒内连续创建多条记录,可能产生重复ID。在生产环境中,建议使用UUID库或后端分配的ID。在ArkTS中,Date.now()的行为与标准JavaScript一致。

技术要点:concat数组合并。 [no].concat(this.orders11)将新订单数组[no]与现有订单数组合并,返回新数组。新订单在数组头部,符合"最新记录显示在最前"的展示需求。concat不修改原数组,而是返回新数组,这符合ArkTS响应式更新要求——通过赋值新数组触发@State的UI更新。


十六、弹框2:完整报告居中大卡

16.1 居中弹框容器

@Builder reportOverlay11() {
  Column() {
    Column() {
      Row() {
        Text('📈 听力报告解读').fontSize(15).fontWeight(700).fontColor('#E2EDF7')
        Text('✕').fontSize(15).fontColor('#5A7590').onClick(() => {
          this.showReport11 = false
        })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)

与底部抽屉不同,完整报告弹框采用"居中大卡"模式。最外层Column设置justifyContent(FlexAlign.Center)使内容垂直居中,内层卡片设置了width(‘86%’)和constraintSize({ maxHeight: ‘78%’ }),既限制了宽度(不铺满屏幕),又限制了最大高度(超出时由内部Scroll处理)。

技术要点:constraintSize约束尺寸。 constraintSize用于设置组件的尺寸约束,包括minWidth、maxWidth、minHeight、maxHeight四个维度。本例中maxHeight: '78%'限制卡片最大高度为屏幕的78%,防止内容过多时卡片超出屏幕。与固定height不同,constraintSize只设置上下限,组件仍会根据内容自适应尺寸,在内容少时不会强制撑满。

16.2 报告内容与双进度条

        Column() {
          Text('双耳语频(500-2kHz)平均听阈 28dB,属于正常范围;4kHz 高频段 45-50dB 轻度下降,符合噪声性听损早期曲线特征。').fontSize(11).fontColor('#E2EDF7').lineHeight(18)
        }
        .width('100%')
        .alignItems(HorizontalAlign.Start)
        .padding(13)
        .borderRadius(12)
        .backgroundColor('#12314F')
        .margin({ top: 12 })

        Column() {
          Text('言语识别率(安静)').fontSize(11).fontColor('#64809C')
          Row() {
            Progress({ value: 96, total: 100, type: ProgressType.Linear })
              .layoutWeight(1)
              .height(6)
              .style({ strokeWidth: 6 })
              .color('#34D399')
              .backgroundColor('#081527')
            Text('96%').fontSize(10).fontWeight(700).fontColor('#34D399').margin({ left: 10 })
          }
          .width('100%')
          .margin({ top: 6 })
          Text('言语识别率(噪声 +5dB SNR)').fontSize(11).fontColor('#64809C').margin({ top: 12 })
          Row() {
            Progress({ value: 68, total: 100, type: ProgressType.Linear })
              .layoutWeight(1)
              .height(6)
              .style({ strokeWidth: 6 })
              .color('#F59E0B')
              .backgroundColor('#081527')
            Text('68%').fontSize(10).fontWeight(700).fontColor('#F59E0B').margin({ left: 10 })
          }
          .width('100%')
          .margin({ top: 6 })
        }
        .width('100%')
        .alignItems(HorizontalAlign.Start)
        .padding(13)
        .borderRadius(12)
        .backgroundColor('#12314F')
        .margin({ top: 10 })

报告内容包含三个信息块:诊断描述、言语识别率双进度条、建议。言语识别率展示了两个场景的对比——安静环境下96%(绿色,优秀)和噪声环境下68%(橙色,一般)。这种对比直观地揭示了听力损失对实际交流能力的影响:虽然安静环境下言语识别率接近正常,但在噪声环境中显著下降,这是感音神经性听力损失的典型表现。

每个进度条都是Row容器,Progress组件通过layoutWeight(1)占据剩余宽度,右侧显示百分比数值。双进度条使用不同颜色(绿色和橙色)形成视觉对比,与数值的高低形成语义关联。

技术要点:lineHeight行高设置。 文本组件的lineHeight属性设置行高,单位为像素。本例中诊断描述文本设置了lineHeight(18),使fontSize(11)的文本行间距更宽,提升可读性。在长文本展示中,适当增加lineHeight(通常为fontSize的1.4-1.6倍)能显著改善阅读体验。

16.3 建议与关闭按钮

        Column() {
          Text('建议').fontSize(11).fontColor('#64809C')
          Text('1. 高频暴露场景坚持佩戴降噪耳塞;2. 半年后复查纯音测听;3. 若耳鸣加重或出现耳闷胀感应及时就诊。').fontSize(11).fontColor('#E2EDF7').lineHeight(18).margin({ top: 6 })
        }
        .width('100%')
        .alignItems(HorizontalAlign.Start)
        .padding(13)
        .borderRadius(12)
        .backgroundColor('#0B1A2E')
        .margin({ top: 10 })

        Text('关闭').fontSize(12).fontColor('#081527').fontWeight(700).width('100%').textAlign(TextAlign.Center).padding({ top: 12, bottom: 12 }).borderRadius(20).backgroundColor('#38BDF8').margin({ top: 14 }).onClick(() => {
          this.showReport11 = false
        })
      }
      .width('86%')
      .padding(18)
      .borderRadius(18)
      .backgroundColor('#0E2038')
      .border({ width: 0.8, color: '#3338BDF8' })
      .constraintSize({ maxHeight: '78%' })
      .onClick(() => {
      })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#99081527')
    .zIndex(999)
    .onClick(() => {
      this.showReport11 = false
    })
  }

建议信息块使用了更深的背景色(#0B1A2E),与上方的诊断描述(#12314F)形成层次区分。关闭按钮是全宽实心按钮,与底部抽屉的确认按钮风格一致。值得注意的是,内层卡片设置了空onClick(onClick(() => {})),这是为了阻止点击事件冒泡到外层遮罩——如果不阻止冒泡,点击卡片内容区域也会触发外层的关闭逻辑。这是一种常见的事件冒泡阻断技巧。

技术要点:事件冒泡与阻断。 在ArkUI中,父组件和子组件都可以绑定onClick事件。当点击子组件时,事件会先触发子组件的onClick,然后"冒泡"到父组件触发其onClick。如果不想让父组件响应子组件的点击事件,可以在子组件上设置一个空onClick来"吸收"事件。本例中内层卡片的空onClick正是起此作用——点击卡片内容不会关闭弹框,只有点击遮罩层才会关闭。


十七、弹框3:噪声记录与Toggle组件

17.1 场景选择与时长调节

@Builder noiseOverlay11() {
  Column() {
    Column() {
      Text('🔊 记录噪声暴露').fontSize(15).fontWeight(700).fontColor('#E2EDF7')
      Text('记录场景与时长,自动折算安全上限占比').fontSize(10).fontColor('#64809C').margin({ top: 6 })

      Text('场景').fontSize(11).fontColor('#64809C').margin({ top: 14 })
      Flex({ wrap: FlexWrap.Wrap }) {
        ForEach(NOISES11, (n: NoiseT11, i: number) => {
          Text(n.icon + n.scene).fontSize(9).fontColor(this.nScene11 === i ? '#081527' : '#64809C').padding({ left: 9, right: 9, top: 7, bottom: 7 }).borderRadius(12).backgroundColor(this.nScene11 === i ? '#38BDF8' : '#12314F').margin({ right: 8, bottom: 8 }).onClick(() => {
            this.nScene11 = i
          })
        }, (n: NoiseT11) => 'ns' + n.id.toString())
      }
      .width('100%')
      .margin({ top: 6 })

      Text('暴露时长(分钟)').fontSize(11).fontColor('#64809C').margin({ top: 10 })
      Row() {
        Text('−').fontSize(18).fontColor('#38BDF8').width(36).height(36).borderRadius(18).backgroundColor('#12314F').textAlign(TextAlign.Center).onClick(() => {
          if (this.nMins11 > 15) {
            this.nMins11 -= 15
          }
        })
        Text(this.nMins11.toString() + ' 分钟').fontSize(13).fontWeight(700).fontColor('#E2EDF7').layoutWeight(1).textAlign(TextAlign.Center)
        Text('+').fontSize(18).fontColor('#38BDF8').width(36).height(36).borderRadius(18).backgroundColor('#12314F').textAlign(TextAlign.Center).onClick(() => {
          if (this.nMins11 < 480) {
            this.nMins11 += 15
          }
        })
      }
      .width('70%')
      .alignItems(VerticalAlign.Center)
      .margin({ top: 8 })

噪声记录弹框是居中卡片模式。场景选择使用Flex+Wrap渲染10个场景选项(emoji+场景名),每个选项fontSize(9)比较小以适应较多选项。时长调节器是一个Row,包含减号按钮、数值显示和加号按钮,宽度70%居中。加减按钮每次步进15分钟,范围限制在15-480分钟之间。

技术要点:步进器的实现模式。 时长调节器通过"减号Text + 数值Text + 加号Text"的Row组合实现了步进器(Stepper)效果。减号按钮的onClick中先判断this.nMins11 > 15再减15,加号按钮先判断this.nMins11 < 480再加15,确保数值在合理范围内。这种"按钮+条件判断+状态修改"是数值调节器的标准实现模式。ArkUI也提供了Stepper组件,但自定义按钮组合在样式上更灵活。

17.2 Toggle开关与保存逻辑

        Row() {
          Text('🎧 当时佩戴了防护耳塞').fontSize(11).fontColor('#E2EDF7').layoutWeight(1)
          Toggle({ type: ToggleType.Switch, isOn: this.nProtect11 })
            .selectedColor('#38BDF8')
            .onChange((on: boolean) => {
              this.nProtect11 = on
            })
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        .margin({ top: 14 })

        Row() {
          Text('取消').fontSize(12).fontColor('#64809C').layoutWeight(1).textAlign(TextAlign.Center).padding({ top: 11, bottom: 11 }).borderRadius(20).border({ width: 0.8, color: '#1E5A8A' }).onClick(() => {
            this.showNoise11 = false
          })
          Text('保存记录').fontSize(12).fontColor('#081527').fontWeight(700).layoutWeight(1).textAlign(TextAlign.Center).padding({ top: 11, bottom: 11 }).borderRadius(20).backgroundColor('#38BDF8').margin({ left: 10 }).onClick(() => {
            const n: NoiseT11 = NOISES11[this.nScene11]
            const nn: NoiseT11 = { id: Date.now(), scene: n.scene, db: n.db, mins: this.nMins11, safe: n.db < 85 || this.nProtect11, icon: n.icon }
            this.noises11 = [nn].concat(this.noises11)
            this.showNoise11 = false
          })
        }
        .width('100%')
        .margin({ top: 18 })

Toggle开关组件是ArkUI的开关控件,type设置为ToggleType.Switch(滑动开关样式),isOn绑定到nProtect11状态。selectedColor设置开启状态的滑块颜色为蓝色。onChange回调在开关状态变化时触发,参数on为新的布尔值,将其赋给nProtect11完成状态同步。

保存记录按钮的onClick中,从NOISES11中取出选中场景的模板数据(n),然后构建新的NoiseT11对象(nn):id用Date.now()生成,scene/db/icon从模板复制,mins用用户调节的时长,safe通过n.db < 85 || this.nProtect11计算——如果分贝值低于85(本身安全)或者用户佩戴了防护耳塞,则标记为安全。最后通过concat将新记录插入noises11数组头部并关闭弹框。

技术要点:Toggle组件详解。 Toggle是ArkUI的开关组件,支持两种类型:ToggleType.Switch(滑动开关,类似iOS的UISwitch)和ToggleType.Checkbox(复选框)。构造参数{ type, isOn }指定类型和初始状态。selectedColor设置开启状态的选中色,通过onChange回调监听状态变化。Toggle在表单中常用于布尔值的切换,如"是否开启通知"、"是否接受协议"等场景。

技术要点:安全状态的动态计算。 safe字段的计算逻辑n.db < 85 || this.nProtect11体现了听力学知识:85dB是安全暴露的阈值,低于85dB的场景无论如何都是安全的;高于85dB的场景如果佩戴了防护耳塞(nProtect11为true),也视为安全。这种"分贝阈值 OR 防护措施"的逻辑,准确地反映了听力防护的科学原则。


十八、弹框4:复查计划底部抽屉

@Builder revOverlay11() {
  Column() {
    Column() {
      Row() {
        Text('🗓️ 调整复查计划').fontSize(15).fontWeight(700).fontColor('#E2EDF7')
        Text('✕').fontSize(15).fontColor('#5A7590').onClick(() => {
          this.showRev11 = false
        })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      .padding({ top: 16, bottom: 4 })

      Text('复查周期').fontSize(11).fontColor('#64809C').margin({ top: 12 })
      Row() {
        ForEach([3, 6, 12], (m: number, i: number) => {
          Text(m.toString() + ' 个月').fontSize(10).fontColor(this.revMonths11 === m ? '#081527' : '#64809C').padding({ left: 13, right: 13, top: 7, bottom: 7 }).borderRadius(12).backgroundColor(this.revMonths11 === m ? '#38BDF8' : '#12314F').margin({ right: 8 }).onClick(() => {
            this.revMonths11 = m
          })
        }, (m: number) => 'rm' + m.toString())
      }
      .margin({ top: 6 })

      Text('当前听力曲线稳定,系统建议 6 个月;若耳鸣或耳闷胀感出现请提前。').fontSize(9).fontColor('#64809C').margin({ top: 12 }).lineHeight(14)

      Row() {
        Text('复查前一天短信+电话双提醒').fontSize(11).fontColor('#E2EDF7').layoutWeight(1)
        Toggle({ type: ToggleType.Switch, isOn: true })
          .selectedColor('#38BDF8')
          .onChange((on: boolean) => {
          })
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)
      .margin({ top: 10 })

      Text('保存计划')
        .fontSize(13)
        .fontWeight(700)
        .fontColor('#081527')
        .width('100%')
        .textAlign(TextAlign.Center)
        .padding({ top: 13, bottom: 13 })
        .borderRadius(22)
        .backgroundColor('#38BDF8')
        .margin({ top: 16, bottom: 16 })
        .onClick(() => {
          this.revs11 = this.revs11.map((r: RevT11, i: number) => {
            if (i === 0) {
              return { id: r.id, date: r.date, place: r.place, result: r.result, avg: r.avg, next: '按 ' + this.revMonths11.toString() + ' 个月周期自动排期' }
            }
            return r
          })
          this.showRev11 = false
        })
    }
    .width('100%')
    .padding({ left: 16, right: 16 })
    .borderRadius({ topLeft: 22, topRight: 22 })
    .backgroundColor('#0E2038')
  }
  .width('100%')
  .height('100%')
  .justifyContent(FlexAlign.End)
  .backgroundColor('#99081527')
  .zIndex(999)
  .onClick(() => {
    this.showRev11 = false
  })
}

复查计划弹框是底部抽屉模式,内容较为简单:复查周期选择(3/6/12个月,ForEach直接遍历数字数组[3, 6, 12])、提醒开关(Toggle固定开启状态)、保存按钮。

保存按钮的onClick通过map函数更新revs11数组:遍历每条记录,当索引i为0(即最新一条记录)时,修改其next字段为"按 X 个月周期自动排期",其他记录保持不变。这种"只修改第一条记录"的逻辑,体现了"最新记录的下次复查计划是可调整的"业务规则。

技术要点:ForEach遍历数字数组。 ForEach([3, 6, 12], (m: number, i: number) => {...})直接遍历数字字面量数组。ArkTS的ForEach支持任意类型的数组,包括基本类型(number、string)和对象类型。键值生成函数'rm' + m.toString()使用数值本身作为键的一部分,确保唯一性。注意这里用m(值)而非i(索引)生成键值,因为值本身就是唯一的。


十九、弹框5:删除课程危险确认卡

@Builder courseDelOverlay11() {
  Column() {
    Column() {
      Text('⚠️').fontSize(30)
      Text('移除这门训练课程?').fontSize(15).fontWeight(700).fontColor('#E2EDF7').margin({ top: 10 })
      Text('移除后训练进度将清零,总完成率会重新计算。').fontSize(11).fontColor('#64809C').margin({ top: 10 }).textAlign(TextAlign.Center).lineHeight(17)

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

      Row() {
        Text('再想想').fontSize(12).fontColor('#64809C').layoutWeight(1).textAlign(TextAlign.Center).padding({ top: 11, bottom: 11 }).borderRadius(20).border({ width: 0.8, color: '#1E5A8A' }).onClick(() => {
          this.showCourseDel11 = false
        })
        Text(this.armed11 ? '确认移除' : '请先打开确认')
          .fontSize(12)
          .fontWeight(700)
          .fontColor(this.armed11 ? '#E2EDF7' : '#5A7590')
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .padding({ top: 11, bottom: 11 })
          .borderRadius(20)
          .backgroundColor(this.armed11 ? '#7E2B22' : '#12314F')
          .margin({ left: 10 })
          .onClick(() => {
            if (this.armed11) {
              this.courses11 = this.courses11.filter((c: CourseT11) => c.id !== this.courseId11)
              this.showCourseDel11 = false
            }
          })
      }
      .width('100%')
      .margin({ top: 18 })
    }
    .width('74%')
    .padding(18)
    .borderRadius(16)
    .backgroundColor('#231714')
    .border({ width: 1, color: '#4DF87171' })
    .onClick(() => {
    })
  }
  .width('100%')
  .height('100%')
  .justifyContent(FlexAlign.Center)
  .backgroundColor('#990E0B08')
  .zIndex(999)
  .onClick(() => {
    this.showCourseDel11 = false
  })
}

删除课程弹框是居中窄卡片模式(width 74%),整体采用红色警示色调——背景#231714(深红褐)、边框#4DF87171(半透明红)、遮罩#990E0B08(半透明深红)。这种"全红"的视觉设计传达了"危险操作"的强烈信号。

弹框的核心交互是"二次确认"机制:用户必须先打开"我已知晓进度清零"的Toggle开关(armed11变为true),确认移除按钮才会从灰色禁用态(“请先打开确认”、#12314F背景)变为红色激活态(“确认移除”、#7E2B22背景)。按钮文本、文字颜色、背景色都通过三元运算符动态切换,形成了完整的"禁用-激活"状态反馈。

技术要点:危险操作的二次确认设计。 对于不可逆操作(如删除数据),业界通用的最佳实践是"二次确认"。本案例采用了"Toggle开关 + 按钮状态联动"的方案,比简单的"确认/取消"对话框更严格——用户必须显式勾选知晓后果才能执行删除。这种设计在医疗、金融等高风险场景中尤为重要。armed(武装)变量的命名也颇具意味——只有"解除保险"才能"开火"。

确认移除按钮的onClick中,先判断if (this.armed11),只有armed为true时才执行删除操作。删除通过this.courses11.filter((c: CourseT11) => c.id !== this.courseId11)实现——filter返回排除目标课程后的新数组,然后关闭弹框。

技术要点:filter函数的删除模式。 filter是函数式编程中实现"删除"的标准方法。与splice(直接修改原数组)不同,filter返回新数组,不修改原数组。array.filter(item => item.id !== targetId)的含义是"保留所有id不等于targetId的元素",等价于"删除id等于targetId的元素"。这种"保留不匹配的"思维方式是函数式编程的典型范式。


二十、弹框6:验配咨询底部抽屉与Scroll滚动容器

20.1 Scroll容器与内容

@Builder fitOverlay11() {
  Column() {
    Column() {
      Column() {
        Text('').fontSize(4).width(40).borderRadius(2).backgroundColor('#1E5A8A')
      }
      .width('100%')
      .alignItems(HorizontalAlign.Center)
      .padding({ top: 10, bottom: 4 })

      Row() {
        Text('🎧 助听器验配咨询').fontSize(15).fontWeight(700).fontColor('#E2EDF7')
        Text('✕').fontSize(15).fontColor('#5A7590').onClick(() => {
          this.showFit11 = false
        })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      .padding({ left: 16, right: 16, top: 4 })

      Scroll() {
        Column() {
          Text('意向型号').fontSize(11).fontColor('#64809C').margin({ top: 12 })
          Flex({ wrap: FlexWrap.Wrap }) {
            ForEach(AIDS11, (a: AidT11, i: number) => {
              Text(a.brand + '·' + a.model).fontSize(9).fontColor(this.fModel11 === i ? '#081527' : '#64809C').padding({ left: 9, right: 9, top: 7, bottom: 7 }).borderRadius(12).backgroundColor(this.fModel11 === i ? '#38BDF8' : '#12314F').margin({ right: 8, bottom: 8 }).onClick(() => {
                this.fModel11 = i
              })
            }, (a: AidT11) => 'fa' + a.id.toString())
          }
          .width('100%')

          Text('验配耳别').fontSize(11).fontColor('#64809C').margin({ top: 6 })
          Row() {
            ForEach(EARS11, (e: string, i: number) => {
              Text(e).fontSize(10).fontColor(this.fEar11 === i ? '#081527' : '#64809C').padding({ left: 14, right: 14, top: 7, bottom: 7 }).borderRadius(12).backgroundColor(this.fEar11 === i ? '#38BDF8' : '#12314F').margin({ right: 8 }).onClick(() => {
                this.fEar11 = i
              })
            }, (e: string) => 'fe' + e)
          }
          .margin({ top: 6 })

          Text('预算区间').fontSize(11).fontColor('#64809C').margin({ top: 14 })
          Row() {
            ForEach(BUDGET11, (b: string, i: number) => {
              Text(b).fontSize(10).fontColor(this.fBudget11 === i ? '#081527' : '#64809C').padding({ left: 12, right: 12, top: 7, bottom: 7 }).borderRadius(12).backgroundColor(this.fBudget11 === i ? '#38BDF8' : '#12314F').margin({ right: 8 }).onClick(() => {
                this.fBudget11 = i
              })
            }, (b: string) => 'fb' + b)
          }
          .margin({ top: 6 })

验配咨询弹框是底部抽屉模式,但内容较多(型号选择有10项),因此中间内容区使用了Scroll滚动容器。Scroll内部是一个Column,包含意向型号选择(Flex+Wrap,10款助听器)、验配耳别选择(Row,3项)、预算区间选择(Row,4项)。

技术要点:Scroll滚动容器。 Scroll是ArkUI的滚动容器组件,当子内容超出Scroll的可视区域时,提供滚动能力。Scroll默认垂直滚动,可通过scrollable参数设置滚动方向(Vertical/Horizontal)。本例中Scroll设置了layoutWeight(1)占据抽屉面板的剩余高度,align(Alignment.Top)使内容从顶部开始排列,edgeEffect(EdgeEffect.Spring)设置边缘弹性效果(iOS般的回弹动画)。

意向型号选择使用Flex+Wrap,因为10款助听器的名称长度不一,需要自动换行。每项显示"品牌·型号"(如"峰力·Lumity L90"),fontSize(9)确保每项不会太宽。点击选中时,fModel11更新为该项索引,同时下方的"机型参考价"会通过AIDS11[this.fModel11].price动态显示对应型号的价格。

20.2 费用信息与提交逻辑

            Column() {
              Row() {
                Text('验配服务费').fontSize(10).fontColor('#64809C')
                Text('含 3 次免费调试').fontSize(10).fontColor('#34D399')
              }
              .width('100%')
              .justifyContent(FlexAlign.SpaceBetween)
              Row() {
                Text('机型参考价').fontSize(10).fontColor('#64809C')
                Text('¥' + AIDS11[this.fModel11].price.toString()).fontSize(11).fontWeight(700).fontColor('#38BDF8')
              }
              .width('100%')
              .justifyContent(FlexAlign.SpaceBetween)
              .margin({ top: 8 })
              Row() {
                Text('试听体验').fontSize(10).fontColor('#64809C')
                Text('门店免费 30 分钟').fontSize(10).fontColor('#34D399')
              }
              .width('100%')
              .justifyContent(FlexAlign.SpaceBetween)
              .margin({ top: 8 })
            }
            .width('100%')
            .padding(12)
            .borderRadius(12)
            .backgroundColor('#12314F')
            .margin({ top: 14 })

            Text('提交后验配师将在 1 小时内回电,安排试听。').fontSize(9).fontColor('#64809C').margin({ top: 10 })
          }
          .width('100%')
          .padding({ left: 16, right: 16, bottom: 12 })
        }
        .layoutWeight(1)
        .align(Alignment.Top)
        .edgeEffect(EdgeEffect.Spring)

        Text('提交咨询')
          .fontSize(13)
          .fontWeight(700)
          .fontColor('#081527')
          .width('100%')
          .textAlign(TextAlign.Center)
          .padding({ top: 13, bottom: 13 })
          .borderRadius(22)
          .backgroundColor('#38BDF8')
          .margin({ left: 16, right: 16, bottom: 14 })
          .onClick(() => {
            const a: AidT11 = AIDS11[this.fModel11]
            const no: OrdT11 = { id: Date.now(), name: a.brand + ' ' + a.model + '(' + EARS11[this.fEar11] + ')验配咨询', state: '待回电', date: '1 小时内', price: 0 }
            this.orders11 = [no].concat(this.orders11)
            this.showFit11 = false
          })

费用信息区是一个Column卡片,包含三行信息:验配服务费(含3次免费调试)、机型参考价(动态显示选中型号的价格)、试听体验(门店免费30分钟)。每行使用Row+justifyContent(FlexAlign.SpaceBetween)实现左右两端对齐——左侧标签灰色、右侧值蓝色或绿色。

提交咨询按钮的onClick从AIDS11中取出选中型号(a),构建新订单对象(名称为"品牌 型号(耳别)验配咨询",状态"待回电",日期"1小时内",价格0因为咨询本身免费),插入orders11数组头部并关闭弹框。

技术要点:edgeEffect弹性效果。 EdgeEffect是Scroll组件的边缘滑动效果枚举:EdgeEffect.Spring(弹性回弹,类似iOS效果)、EdgeEffect.None(无效果)、EdgeEffect.Fade(渐隐)。本例使用Spring效果,当内容滚动到边缘时会产生回弹动画,提供了流畅自然的滚动体验。这是提升应用"手感"的重要细节。


二十一、主布局build方法:Stack层叠与条件渲染

21.1 主内容布局

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

      Scroll() {
        Column() {
          if (this.curTab11 === 0) {
            this.tabAudi11()
          }
          if (this.curTab11 === 1) {
            this.tabAid11()
          }
          if (this.curTab11 === 2) {
            this.tabTrain11()
          }
          if (this.curTab11 === 3) {
            this.tabNoise11()
          }
          if (this.curTab11 === 4) {
            this.tabRev11()
          }
          if (this.curTab11 === 5) {
            this.tabMine11()
          }
        }
        .width('100%')
      }
      .layoutWeight(1)
      .scrollBar(BarState.Off)
      .edgeEffect(EdgeEffect.Spring)
      .width('100%')
      .backgroundColor('#071426')

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

build方法是每个@Component组件必须实现的方法,它返回整个组件的UI描述。本案例的build方法使用Stack作为根容器,内部包含两个层级:底层是主内容(Column:头部+滚动内容区+底部Tab栏),上层是六个弹框(通过条件渲染控制显示)。

主内容Column从上到下依次是:headerBar11头部、Scroll滚动内容区、tabBar11底部Tab栏。Scroll设置了layoutWeight(1)占据头部和Tab栏之间的所有剩余空间,scrollBar(BarState.Off)隐藏滚动条(因为内容区通过Tab切换,不需要可见的滚动条),edgeEffect(EdgeEffect.Spring)提供弹性滚动效果。

技术要点:build方法的唯一性。 每个@Component组件有且只有一个build方法,它是组件UI的入口。build方法中通过声明式语法描述UI结构,框架会在组件首次创建时执行build生成组件树,后续在@State变量变化时重新执行build进行差异化更新。build方法中不应执行耗时的同步操作(如网络请求、复杂计算),以免阻塞UI线程。

21.2 Tab内容条件渲染

滚动内容区内部使用6个if语句根据curTab11的值渲染对应的Tab内容。这种"if条件渲染"模式是ArkUI中实现Tab切换的简洁方案——每次只有一个Tab的内容被渲染到组件树中,切换Tab时旧Tab被卸载、新Tab被挂载。

技术要点:if条件渲染与Tab切换的性能考量。 使用if条件渲染实现Tab切换的特点是"每次只渲染当前Tab",内存占用低但切换时需要重新构建组件。另一种方案是使用Tabs组件(ArkUI内置的Tab容器),所有Tab内容同时渲染,切换时通过动画过渡,体验更流畅但内存占用更高。对于内容复杂、Tab较多的场景,if条件渲染是更轻量的选择;对于Tab少、切换频繁的场景,Tabs组件更合适。

21.3 弹框条件渲染

      if (this.showTest11) {
        this.testOverlay11()
      }
      if (this.showReport11) {
        this.reportOverlay11()
      }
      if (this.showNoise11) {
        this.noiseOverlay11()
      }
      if (this.showRev11) {
        this.revOverlay11()
      }
      if (this.showCourseDel11) {
        this.courseDelOverlay11()
      }
      if (this.showFit11) {
        this.fitOverlay11()
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#071426')
  }
}

六个弹框通过if条件渲染挂在Stack的上层。每个弹框的显示由对应的@State布尔变量控制,当变量为true时弹框被渲染到组件树并显示在最上层(通过zIndex(999)),当变量为false时弹框被卸载。由于弹框都设置了全屏尺寸和半透明遮罩背景,同时只有一个弹框显示时,遮罩覆盖整个屏幕,弹框内容居中或贴底显示。

技术要点:Stack作为弹框容器的优势。 使用Stack作为根容器,将主内容和弹框作为Stack的子元素,通过zIndex控制层级,是ArkUI中实现模态弹框的标准方案。Stack的层叠特性天然适合"主内容在下、弹框在上"的需求,无需额外的Overlay机制。多个弹框可以共存于Stack中,通过各自的布尔开关独立控制显隐。

0

1

2

3

4

5

true

true

true

true

true

true

build方法

Stack根容器

底层: 主内容Column

headerBar11 头部

Scroll 滚动内容区

curTab11值判断

tabAudi11 听力图

tabAid11 助听器

tabTrain11 训练

tabNoise11 防护

tabRev11 复查

tabMine11 我的

tabBar11 底部Tab栏

上层: 弹框条件渲染

showTest11?

testOverlay11 预约测听

showReport11?

reportOverlay11 完整报告

showNoise11?

noiseOverlay11 噪声记录

showRev11?

revOverlay11 复查计划

showCourseDel11?

courseDelOverlay11 删除课程

showFit11?

fitOverlay11 验配咨询

这个流程图展示了build方法的完整渲染结构。Stack根容器将主内容和弹框分为两个层级,主内容通过curTab11的值切换显示6个Tab之一,弹框通过6个布尔开关独立控制显隐。这种架构清晰地将"主导航"和"模态交互"分离,使得代码结构易于理解和维护。


二十二、核心技术点对比汇总

下面通过表格对本案例中涉及的各种数据结构、组件、状态变量、装饰器、工具函数等进行系统对比,帮助读者建立完整的知识图谱。

类别 名称 类型/返回值 作用描述 使用场景 技术要点
数据结构 FreqT11 interface 听力频率数据(频率、双耳听阈) 听力图Tab柱状图渲染 含hz/left/right字段,支持双耳对比
数据结构 AidT11 interface 助听器商品(品牌、型号、价格等) 助听器Tab商品列表 含stock布尔字段控制现货/预订显示
数据结构 CourseT11 interface 训练课程(时长、周期、进度) 训练Tab课程列表 done/days比值计算完成率
数据结构 NoiseT11 interface 噪声场景(分贝、时长、安全标记) 防护Tab场景列表 safe字段为预计算的布尔值
数据结构 RevT11 interface 复查记录(日期、地点、结论) 复查Tab时间轴 next字段可动态更新
数据结构 OrdT11 interface 订单(名称、状态、日期、价格) 我的Tab订单列表 state字符串映射颜色
装饰器 @Entry 标记页面入口组件 页面根组件 每个页面只能有一个@Entry
装饰器 @Component 声明自定义组件 struct声明 生成组件工厂代码
装饰器 @State 响应式状态变量 状态管理 引用变化触发UI更新
装饰器 @Builder UI构建片段 UI复用 支持参数传递,共享组件this
状态变量 curTab11 number 当前选中Tab索引 Tab切换 0-5对应6个Tab
状态变量 showTest11 boolean 预约测听弹框开关 弹框显隐控制 true时渲染弹框组件
状态变量 showReport11 boolean 完整报告弹框开关 弹框显隐控制 居中大卡模式
状态变量 showNoise11 boolean 噪声记录弹框开关 弹框显隐控制 居中卡片模式
状态变量 showRev11 boolean 复查计划弹框开关 弹框显隐控制 底部抽屉模式
状态变量 showCourseDel11 boolean 删除课程弹框开关 弹框显隐控制 居中危险确认卡模式
状态变量 showFit11 boolean 验配咨询弹框开关 弹框显隐控制 底部抽屉含Scroll滚动
状态变量 tSlot11 number 预约时段选中索引 预约表单 0-5对应6个时段
状态变量 tEar11 number 测听耳别选中索引 预约表单 0-2对应双耳/左耳/右耳
状态变量 nScene11 number 噪声场景选中索引 噪声记录表单 0-9对应10个场景
状态变量 nMins11 number 暴露时长(分钟) 噪声记录表单 15-480步进15
状态变量 nProtect11 boolean 是否佩戴防护耳塞 噪声记录表单 影响safe字段计算
状态变量 revMonths11 number 复查周期(月) 复查计划表单 3/6/12三个选项
状态变量 courseId11 number 待删除课程ID 删除确认 配合armed11使用
状态变量 armed11 boolean 删除确认武装开关 删除确认 false时按钮禁用
状态变量 fModel11 number 验配型号选中索引 验配咨询表单 0-9对应10款助听器
状态变量 fEar11 number 验配耳别选中索引 验配咨询表单 0-2对应双耳/左耳/右耳
状态变量 fBudget11 number 预算区间选中索引 验配咨询表单 0-3对应4个预算档
状态变量 waveX11 number 声波动画位移值 头部装饰条动画 -20到20往复
状态变量 earOp11 number 耳机图标透明度 头部图标动画 0.3到0.9往复
容器组件 Column 垂直线性布局容器 贯穿全页 子元素从上到下排列
容器组件 Row 水平线性布局容器 贯穿全页 子元素从左到右排列
容器组件 Stack 层叠布局容器 头部/弹框根 子元素堆叠,zIndex控层级
容器组件 Flex 弹性布局容器 弹框选项渲染 支持wrap换行
容器组件 Scroll 滚动容器 主内容区/验配弹框 edgeEffect弹性效果
显示组件 Text 文本显示 贯穿全页 支持fontSize/fontColor/fontWeight
显示组件 Progress 进度组件 听力概览/验配进度/报告 支持Circular/Linear/Ring类型
显示组件 Toggle 开关组件 噪声记录/复查/删除弹框 支持Switch/Checkbox类型
显示组件 Circle 圆形图形组件 复查时间轴/用户头像 矢量图形,fill填充
工具函数 dbColor11 string 分贝值映射颜色 噪声柱状图/场景列表 95+红/80+橙/其余绿
工具函数 dbToH11 number 分贝值映射像素高度 噪声柱状图 (db-60)*1.6,最小6
工具函数 ordColor11 string 订单状态映射颜色 订单列表 调试中/进行中蓝,已发货橙
布局属性 layoutWeight number 权重分配剩余空间 贯穿全页 正数按比例分配
布局属性 justifyContent FlexAlign 主轴对齐方式 贯穿全页 SpaceBetween/Center/End等
布局属性 alignItems HorizontalAlign/VerticalAlign 交叉轴对齐 贯穿全页 Start/Center/End
布局属性 zIndex number 层级控制 弹框 999确保在最上层
布局属性 constraintSize 对象 尺寸约束 报告弹框 maxHeight限制最大高度
动画API animateTo void 显式动画驱动 aboutToAppear duration/iterations/playMode/curve
列表渲染 ForEach 数组遍历渲染 所有列表场景 需提供键值生成函数
数组方法 slice Array 截取子数组 @State初始化 返回新数组不修改原数组
数组方法 map Array 映射新数组 课程进度更新 不可变更新模式
数组方法 filter Array 过滤新数组 课程删除 保留不匹配的元素
数组方法 concat Array 合并新数组 订单插入头部 新元素在前,旧元素在后
生命周期 aboutToAppear void 组件创建后回调 动画初始化 build前执行,只触发一次

二十三、总结

本案例作为一个听力健康中心的完整业务页面,充分展示了鸿蒙ArkTS声明式UI的开发范式和技术深度。从整体架构来看,页面采用Stack根容器将主内容层与弹框层分离,主内容通过Column实现"头部-滚动内容区-底部Tab栏"的经典三段式布局,弹框通过6个独立的@State布尔开关控制显隐,这种"主内容+模态弹框"的双层架构是复杂业务页面的标准设计模式。整个页面共包含6个Tab、6个弹框、10个数据结构字段定义、5个数组方法操作、3个工具函数和2个循环动画,体量和复杂度都达到了真实业务场景的水平。

在数据建模层面,本案例通过6个interface定义了听力频率、助听器商品、训练课程、噪声场景、复查记录、订单等核心数据结构。每个interface的字段设计都紧密结合了听力学领域知识——频率的双耳听阈、助听器的型号类型、噪声的分贝安全阈值、复查的平均听阈等,体现了"数据模型反映业务领域"的设计原则。静态数据通过const数组初始化,可变状态通过@State数组的slice()副本管理,"原始数据不可变+状态数据可变副本"的模式确保了数据流的单向性和可追踪性。

在状态管理层面,页面共使用了22个@State变量,覆盖了Tab索引、弹框开关、表单选中值、动画驱动值等多种状态类型。这些状态变量的设计遵循了"单一数据源"原则——每个UI状态都有且只有一个@State变量驱动,避免了状态冗余和不一致。数组状态的更新严格采用不可变模式——通过map、filter、concat等函数返回新数组而非直接修改原数组,这是ArkTS响应式更新机制的基石。理解并熟练运用这种不可变状态更新模式,是从传统命令式编程转向声明式编程的关键思维转变。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// ============================================================
// 声学深蓝 · 6底部Tab · 6弹框
// 布局亮点:听力图Tab双耳频率分组双柱;助听Tab商品列表+验配进度;
//           训练Tab课程卡+完成环;防护Tab分贝标尺柱+场景列表;复查Tab时间轴
// ============================================================

// ---------------- 数据结构 ----------------
interface FreqT11 {
  id: number
  hz: string
  left: number
  right: number
}

interface AidT11 {
  id: number
  brand: string
  model: string
  type: string
  price: number
  score: number
  feature: string
  stock: boolean
}

interface CourseT11 {
  id: number
  name: string
  mins: number
  days: number
  done: number
  icon: string
}

interface NoiseT11 {
  id: number
  scene: string
  db: number
  mins: number
  safe: boolean
  icon: string
}

interface RevT11 {
  id: number
  date: string
  place: string
  result: string
  avg: number
  next: string
}

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

// ---------------- 写死数据 ----------------
const FREQS11: Array<FreqT11> = [
  { id: 1, hz: '250', left: 15, right: 15 },
  { id: 2, hz: '500', left: 15, right: 20 },
  { id: 3, hz: '1k', left: 20, right: 25 },
  { id: 4, hz: '2k', left: 30, right: 30 },
  { id: 5, hz: '3k', left: 40, right: 40 },
  { id: 6, hz: '4k', left: 45, right: 50 },
  { id: 7, hz: '6k', left: 40, right: 45 },
  { id: 8, hz: '8k', left: 35, right: 40 }
]

const AIDS11: Array<AidT11> = [
  { id: 1, brand: '峰力', model: 'Lumity L90', type: '耳背式 RIC', price: 32800, score: 4.9, feature: '双耳直连 iPhone · 噪声下言语识别提升 30%', stock: true },
  { id: 2, brand: '奥迪康', model: 'Intent 1', type: '受话器外置', price: 29800, score: 4.8, feature: '4D 传感器场景自动切换', stock: true },
  { id: 3, brand: '瑞声达', model: '浩听 96', type: '耳背式 BTE', price: 26800, score: 4.8, feature: 'AI 言语聚焦 · 风噪抑制', stock: true },
  { id: 4, brand: '西嘉', model: 'Pure 7IX', type: '深耳道 CIC', price: 35800, score: 4.9, feature: '隐形佩戴 · 原声处理芯片', stock: false },
  { id: 5, brand: '斯达克', model: 'Evolve AI 20', type: '耳内式 ITC', price: 21800, score: 4.7, feature: '中文语境优化', stock: true },
  { id: 6, brand: '唯听', model: 'Moment 440', type: '受话器外置', price: 24800, score: 4.8, feature: '双压缩通道零延迟', stock: true },
  { id: 7, brand: '优利康', model: '蓝鲸 900', type: '耳背式', price: 16800, score: 4.6, feature: '高性价比入门首选', stock: true },
  { id: 8, brand: '新声', model: '灵犀 Pro', type: '耳内式', price: 9800, score: 4.5, feature: '国产旗舰 · 自研芯片', stock: true },
  { id: 9, brand: '爱沃福', model: '微风 8C', type: '深耳道', price: 7800, score: 4.4, feature: '轻中度听损适用', stock: false },
  { id: 10, brand: '耳魔王', model: 'AV-300', type: '耳背式', price: 4800, score: 4.2, feature: '老人简易操作款', stock: true }
]

const COURSES11: Array<CourseT11> = [
  { id: 1, name: '听觉再适应 · 声音辨识', mins: 20, days: 21, done: 12, icon: '👂' },
  { id: 2, name: '嘈杂环境言语训练', mins: 15, days: 14, done: 14, icon: '🎙️' },
  { id: 3, name: '音乐频率感知修复', mins: 25, days: 28, done: 8, icon: '🎵' },
  { id: 4, name: '耳鸣声治疗(白噪声)', mins: 40, days: 30, done: 22, icon: '🌊' },
  { id: 5, name: '方向听觉定位训练', mins: 12, days: 10, done: 10, icon: '🧭' },
  { id: 6, name: '言语速度适应(快语速)', mins: 18, days: 21, done: 5, icon: '⚡' },
  { id: 7, name: '电话语音强化训练', mins: 10, days: 14, done: 9, icon: '📞' },
  { id: 8, name: '安静环境语句复述', mins: 15, days: 12, done: 12, icon: '📝' }
]

const NOISES11: Array<NoiseT11> = [
  { id: 1, scene: '地铁通勤(车厢内)', db: 88, mins: 45, safe: false, icon: '🚇' },
  { id: 2, scene: '办公室工位', db: 55, mins: 480, safe: true, icon: '🏢' },
  { id: 3, scene: '健身房团课', db: 92, mins: 60, safe: false, icon: '🏋️' },
  { id: 4, scene: '演唱会内场', db: 105, mins: 150, safe: false, icon: '🎤' },
  { id: 5, scene: '咖啡馆闲聊', db: 62, mins: 90, safe: true, icon: '☕' },
  { id: 6, scene: '马路骑行', db: 78, mins: 35, safe: true, icon: '🚴' },
  { id: 7, scene: '电钻装修(邻居家)', db: 96, mins: 120, safe: false, icon: '🛠️' },
  { id: 8, scene: '影院 IMAX 厅', db: 85, mins: 130, safe: false, icon: '🎬' },
  { id: 9, scene: 'KTV 包厢', db: 98, mins: 120, safe: false, icon: '🎤' },
  { id: 10, scene: '居家夜晚', db: 38, mins: 600, safe: true, icon: '🏠' }
]

const REVS11: Array<RevT11> = [
  { id: 1, date: '2026-08-20', place: '爱康听力学中心·望京', result: '高频轻度下降 4kHz/45dB', avg: 28, next: '2027-02-20' },
  { id: 2, date: '2026-02-21', place: '爱康听力学中心·望京', result: '双耳高频临界 4kHz/40dB', avg: 26, next: '2026-08-20' },
  { id: 3, date: '2025-08-18', place: '爱康国宾·国贸分院', result: '正常范围,注意防护', avg: 22, next: '2026-02-21' },
  { id: 4, date: '2025-02-15', place: '爱康国宾·国贸分院', result: '正常范围', avg: 20, next: '2025-08-18' },
  { id: 5, date: '2024-09-02', place: '社区听力筛查车', result: '正常范围', avg: 18, next: '2025-02-15' }
]

const ORDS11: Array<OrdT11> = [
  { id: 1, name: '纯音测听 + 声导抗复查', state: '已完成', date: '2026-08-20', price: 120 },
  { id: 2, name: '峰力 Lumity L90 双耳验配', state: '调试中', date: '2026-08-25', price: 32800 },
  { id: 3, name: '定制耳模(软耳膜)', state: '已完成', date: '2026-08-23', price: 680 },
  { id: 4, name: '助听器干燥盒', state: '已发货', date: '2026-08-22', price: 89 },
  { id: 5, name: '听觉训练季卡', state: '进行中', date: '2026-07-01', price: 398 },
  { id: 6, name: '耳鸣声治疗评估', state: '已完成', date: '2026-06-12', price: 260 },
  { id: 7, name: '音乐节定制耳塞', state: '已完成', date: '2026-05-20', price: 1280 },
  { id: 8, name: '听力健康年卡', state: '生效中', date: '2026-01-01', price: 680 }
]

const SLOT11: Array<string> = ['09:00', '10:15', '11:30', '13:45', '15:00', '16:15']
const EARS11: Array<string> = ['双耳', '左耳', '右耳']
const BUDGET11: Array<string> = ['1万内', '1-2万', '2-3万', '3万以上']

// ---------------- 工具函数 ----------------
function dbColor11(db: number): string {
  if (db >= 95) {
    return '#F87171'
  }
  if (db >= 80) {
    return '#F59E0B'
  }
  return '#34D399'
}

function dbToH11(db: number): number {
  return Math.max((db - 60) * 1.6, 6)
}

function ordColor11(s: string): string {
  if (s === '调试中' || s === '进行中') {
    return '#38BDF8'
  }
  if (s === '已发货') {
    return '#F59E0B'
  }
  return '#34D399'
}

// ================= 页面 =================
@Entry
@Component
struct Index {
  @State curTab11: number = 0
  @State freqs11: Array<FreqT11> = FREQS11.slice()
  @State courses11: Array<CourseT11> = COURSES11.slice()
  @State noises11: Array<NoiseT11> = NOISES11.slice()
  @State revs11: Array<RevT11> = REVS11.slice()
  @State orders11: Array<OrdT11> = ORDS11.slice()

  // 弹框开关
  @State showTest11: boolean = false
  @State showReport11: boolean = false
  @State showNoise11: boolean = false
  @State showRev11: boolean = false
  @State showCourseDel11: boolean = false
  @State showFit11: boolean = false

  // 测听预约
  @State tSlot11: number = 1
  @State tEar11: number = 0

  // 噪声记录
  @State nScene11: number = 0
  @State nMins11: number = 60
  @State nProtect11: boolean = true

  // 复查计划
  @State revMonths11: number = 6

  // 课程删除
  @State courseId11: number = 0
  @State armed11: boolean = false

  // 验配咨询
  @State fModel11: number = 0
  @State fEar11: number = 0
  @State fBudget11: number = 2

  // 特效
  @State waveX11: number = -20
  @State earOp11: number = 0.3

  aboutToAppear(): void {
    this.getUIContext().animateTo({ duration: 2400, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
      this.waveX11 = 20
    })
    this.getUIContext().animateTo({ duration: 1300, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut }, () => {
      this.earOp11 = 0.9
    })
  }

  // ---------------- 头部(无动画 · 深色声学风) ----------------
  @Builder headerBar11() {
    Column() {
      Row() {
        Column() {
          Text('👂 HEARING LAB').fontSize(17).fontWeight(700).fontColor('#7DD3FC').letterSpacing(1)
          Text('纯音测听 · 助听验配 · 听觉训练').fontSize(10).fontColor('#64809C').margin({ top: 3 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)

        Text('🎧').fontSize(22).opacity(this.earOp11)
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 14, bottom: 8 })

      // 静态声波装饰条
      Row() {
        ForEach([6, 14, 22, 30, 22, 14, 6, 14, 22, 30, 22, 14, 6, 14, 22, 30, 22, 14], (h: number, i: number) => {
          Column() {
          }
          .width(4)
          .height(h)
          .borderRadius(2)
          .backgroundColor(i % 3 === 0 ? '#38BDF8' : '#1E5A8A')
          .margin({ right: 6 })
          .translate({ x: i % 2 === 0 ? this.waveX11 : -this.waveX11 })
        }, (h: number, i: number) => 'wv' + i.toString() + '_' + h.toString())
      }
      .width('100%')
      .justifyContent(FlexAlign.Center)
      .padding({ top: 6, bottom: 10 })

      Row() {
        Text('🔎').fontSize(13)
        Text('查助听器型号 / 听力术语 / 门店').fontSize(11).fontColor('#64809C').margin({ left: 6 }).layoutWeight(1).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        Text('📊').fontSize(16).onClick(() => {
          this.showReport11 = true
        })
      }
      .width('100%')
      .padding({ left: 12, right: 12, top: 9, bottom: 9 })
      .borderRadius(20)
      .backgroundColor('#0E2038')
      .margin({ left: 16, right: 16, bottom: 12 })
    }
    .width('100%')
    .backgroundColor('#081527')
    .borderRadius({ bottomLeft: 20, bottomRight: 20 })
  }

  // ---------------- 底部Tab(一排6个) ----------------
  @Builder tabItem11(icon: string, label: string, idx: number) {
    Column() {
      Text(icon).fontSize(19)
      Text(label).fontSize(9).fontColor(this.curTab11 === idx ? '#38BDF8' : '#5A7590').margin({ top: 3 })
    }
    .alignItems(HorizontalAlign.Center)
    .padding({ top: 8, bottom: 6 })
    .layoutWeight(1)
    .onClick(() => {
      this.curTab11 = idx
    })
  }

  @Builder tabBar11() {
    Row() {
      this.tabItem11('📈', '听力图', 0)
      this.tabItem11('🎧', '助听器', 1)
      this.tabItem11('🎓', '训练', 2)
      this.tabItem11('🔊', '防护', 3)
      this.tabItem11('🗓️', '复查', 4)
      this.tabItem11('👤', '我的', 5)
    }
    .width('100%')
    .backgroundColor('#0B1A2E')
    .border({ width: 0.5, color: '#3338BDF8' })
  }

  // ============ Tab0 听力图(双耳频率双柱) ============
  @Builder tabAudi11() {
    Column() {
      Row() {
        Stack() {
          Progress({ value: 30, total: 100 })
            .width(66)
            .height(66)
            .style({ strokeWidth: 7 })
            .color('#38BDF8')
          Column() {
            Text('28dB').fontSize(13).fontWeight(700).fontColor('#7DD3FC')
            Text('平均听阈').fontSize(8).fontColor('#64809C')
          }
          .alignItems(HorizontalAlign.Center)
        }

        Column() {
          Text('2026-08-20 纯音测听').fontSize(13).fontWeight(700).fontColor('#E2EDF7')
          Text('双耳高频轻度下降(4kHz 45dB),语频区正常,建议防护+半年复查。').fontSize(10).fontColor('#64809C').margin({ top: 5 }).lineHeight(14)
          Row() {
            Text('预约测听').fontSize(10).fontColor('#081527').fontWeight(700).padding({ left: 12, right: 12, top: 5, bottom: 5 }).borderRadius(14).backgroundColor('#38BDF8').onClick(() => {
              this.showTest11 = true
            })
            Text('完整报告').fontSize(10).fontColor('#38BDF8').padding({ left: 12, right: 12, top: 5, bottom: 5 }).borderRadius(14).border({ width: 0.8, color: '#38BDF8' }).margin({ left: 8 }).onClick(() => {
              this.showReport11 = true
            })
          }
          .margin({ top: 9 })
        }
        .alignItems(HorizontalAlign.Start)
        .margin({ left: 14 })
        .layoutWeight(1)
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)
      .padding(14)
      .borderRadius(16)
      .backgroundColor('#0E2038')
      .margin({ left: 14, right: 14, top: 12 })

      // 双耳频率柱
      Column() {
        Text('📈 气导听阈(dB HL,柱越低越好)').fontSize(13).fontWeight(700).fontColor('#E2EDF7')
        Row() {
          ForEach(this.freqs11, (f: FreqT11) => {
            Column() {
              Text(f.left.toString()).fontSize(7).fontColor('#7DD3FC')
              Column() {
                Column() {
                }
                .width(11)
                .height(f.left * 0.9)
                .borderRadius({ topLeft: 2, topRight: 2 })
                .backgroundColor('#38BDF8')
              }
              .justifyContent(FlexAlign.End)
              .height(46)
              Text(f.hz).fontSize(7).fontColor('#64809C').margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Center)
            .layoutWeight(1)

            Column() {
              Text(f.right.toString()).fontSize(7).fontColor('#FBBF24')
              Column() {
                Column() {
                }
                .width(11)
                .height(f.right * 0.9)
                .borderRadius({ topLeft: 2, topRight: 2 })
                .backgroundColor('#FBBF24')
              }
              .justifyContent(FlexAlign.End)
              .height(46)
              Text(' ').fontSize(7)
            }
            .alignItems(HorizontalAlign.Center)
            .layoutWeight(1)
          }, (f: FreqT11) => 'fq' + f.id.toString())
        }
        .width('100%')
        .margin({ top: 14 })

        Row() {
          Row() {
            Column() {
            }
            .width(9)
            .height(9)
            .borderRadius(2)
            .backgroundColor('#38BDF8')
            Text('左耳').fontSize(9).fontColor('#64809C').margin({ left: 5 })
          }
          .alignItems(VerticalAlign.Center)
          Row() {
            Column() {
            }
            .width(9)
            .height(9)
            .borderRadius(2)
            .backgroundColor('#FBBF24')
            Text('右耳').fontSize(9).fontColor('#64809C').margin({ left: 5 })
          }
          .alignItems(VerticalAlign.Center)
          .margin({ left: 16 })
          Text('正常 < 26dB · 轻度 26-40 · 中度 41-60').fontSize(8).fontColor('#64809C').layoutWeight(1).textAlign(TextAlign.End)
        }
        .width('100%')
        .margin({ top: 10 })
      }
      .width('100%')
      .alignItems(HorizontalAlign.Start)
      .padding(14)
      .borderRadius(16
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#071426')
  }
}


在这里插入图片描述

在UI构建层面,@Builder装饰器是本案例的核心组织手段。15个@Builder方法将庞大的UI拆分为可管理的模块——6个Tab构建器、6个弹框构建器、头部构建器、Tab栏构建器、Tab项构建器。每个@Builder方法聚焦于一个独立的UI区域,通过参数传递实现复用(如tabItem11接收icon/label/idx三个参数生成6个Tab项)。@Builder与@Component子组件的区别在于:@Builder更轻量、共享当前组件状态,适合页面内的UI模块化;@Component更独立、需要显式传参,适合跨页面复用。本案例全部使用@Builder实现,因为所有UI模块都紧密依赖页面的@State状态,无需独立复用。

在可视化层面,本案例没有使用任何第三方图表库,完全通过ArkUI的基础组件(Column、Row、Text、Circle、Progress等)和属性(height、backgroundColor、justifyContent等)手工搭建了双耳频率双柱状图、噪声分贝标尺柱、训练进度条、复查时间轴、圆形进度环、线性进度条等多种数据可视化组件。这种"原生组件拼装"的方式虽然代码量较大,但优势在于高度可定制、性能优秀、无第三方依赖。每个柱子、每条进度条都可以独立绑定事件和动画,灵活性远超封装好的图表库。对于柱状图、进度条、时间轴等常见可视化需求,掌握这种原生实现方式是非常有价值的技能。

在交互动画层面,本案例通过animateTo显式动画API实现了两个无限循环动画——声波装饰条的左右移动和耳机图标的透明度脉动。animateTo的iterations: -1实现无限循环,playMode: PlayMode.Alternate实现正反交替播放,curve: Curve.EaseInOut实现先加速后减速的自然缓动。这两个动画为页面增添了动态生命力,避免了纯静态界面的沉闷感。在交互层面,6个弹框涵盖了底部抽屉、居中大卡、居中窄卡三种模式,分别适用于不同场景——底部抽屉适合表单输入(预约、验配咨询),居中大卡适合信息展示(报告),居中窄卡适合警示确认(删除)。删除课程的"Toggle开关+按钮状态联动"二次确认机制,体现了对不可逆操作的安全设计意识。

在事件处理层面,本案例展示了ArkUI事件系统的多种用法:onClick点击事件用于按钮和卡片的交互响应;onChange回调用于Toggle开关的状态同步;事件冒泡阻断通过空onClick实现,防止点击弹框内容时触发遮罩层的关闭逻辑。这些事件处理技巧虽然细微,但对用户体验的影响显著——正确的事件冒泡处理确保了"点击内容不关闭、点击遮罩才关闭"的符合直觉的交互行为。总体而言,本案例是一个涵盖数据建模、状态管理、UI构建、可视化、动画、交互、事件处理等全方位技术点的综合性实践范例,对深入理解和掌握鸿蒙ArkTS声明式UI开发具有重要的参考价值。

Logo

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

更多推荐