在移动互联网与全场景智慧生活深度融合的当下,鸿蒙操作系统以其独特的分布式架构和统一的开发范式,正在重新定义跨设备应用的开发边界。ArkTS作为鸿蒙生态的核心编程语言,在TypeScript的基础上进行了深度的领域定制与语法增强,使其既能承载复杂的前端交互逻辑,又能紧密贴合底层的渲染引擎与系统能力。本文将以一款功能完备的乐器学习应用为研究对象,逐行逐段剖析其ArkTS源码实现,从接口定义、数据模型、装饰器机制、状态管理、布局系统、组件通信到弹窗架构,全方位解读鸿蒙原生开发的工程实践与技术精髓。

鸿蒙开发的核心思想可以概括为"一次开发,多端部署"。这不仅仅是一句口号,而是渗透在ArkTS语言设计每一个角落的工程哲学。从@Entry和@Component的装饰器体系,到@State的状态管理驱动UI刷新,再到@Builder的UI构建函数复用机制,每一层抽象都服务于"声明式UI编程"这一核心理念。声明式UI与传统命令式UI的根本区别在于:开发者只需描述界面"应该是什么样子",而非"如何一步步变成那个样子"。当状态数据发生变化时,框架的差分算法会自动计算出最小变更集,并精确地将变更应用到对应的DOM节点上,从而实现高效的UI更新。

ArkTS的组件化开发思想借鉴了现代前端框架的先进经验,同时针对鸿蒙的运行时环境做了深度优化。每个自定义组件(Custom Component)都是一个独立的、可复用的UI单元,拥有自己的状态空间、生命周期和渲染逻辑。组件之间通过属性传值、回调函数和事件机制进行通信,形成清晰的依赖关系树。这种设计模式不仅降低了代码的耦合度,还使得大规模应用的开发、测试和维护变得可行。在本文剖析的音乐学习应用中,我们可以清晰地看到这种组件化架构的实践:一个根组件管理全局Tab导航状态,五个子组件分别承载课程、曲库、练琴房、乐理工具和个人中心五个功能模块,每个子组件内部又进一步拆分出多个@Builder函数来复用弹窗、列表项等UI片段。

一、类型系统与接口定义:构建数据契约的基石

在任何严肃的软件工程实践中,类型系统都是保障代码质量的第一道防线。ArkTS继承了TypeScript强大的静态类型检查能力,同时针对鸿蒙的编译工具链做了针对性优化。通过interface关键字定义的接口类型,在本应用中扮演着数据契约的角色,它们精确地规定了各个业务实体应该包含哪些字段、每个字段的类型是什么,从而在编译期就能捕获大量的类型错误。

1.1 课程项接口定义

interface CourseItem {
  id: number;
  title: string;
  teacher: string;
  level: string;
  lessons: number;
  price: number;
  originPrice: number;
  students: number;
  rating: string;
  tag: string;
  purchased: boolean;
}

在这里插入图片描述

这段代码定义了CourseItem接口,它是课程业务模块的核心数据契约。接口中声明了十一个字段,涵盖了课程展示所需的全部信息维度。其中id字段使用number类型,作为课程的唯一标识符,在列表渲染时也会作为ForEach的key生成依据。titleteacher字段均为string类型,分别存储课程名称和讲师姓名,这两个字段将直接呈现在UI卡片的标题区域。level字段以字符串形式存储难度等级,取值包括"入门"、“进阶”、"高级"三个层级,这个字段在后续的筛选逻辑中发挥着关键作用。

lessons字段记录课程包含的课时总数,类型为numberpriceoriginPrice分别表示当前售价和原始价格,两者通常配合使用,通过在UI上对原价添加删除线装饰来突出折扣力度。students字段记录已报名学员人数,这个值通常较大(动辄上万),因此在后续的格式化工具函数中会进行"万"单位的转换处理。rating字段使用字符串类型存储评分值而非数字类型,这是一个值得注意的设计选择——在UI展示中评分通常需要与星标符号拼接显示,使用字符串可以避免不必要的类型转换。tag字段存储课程的标签文字,如"镇店之宝"、"热销"等营销文案。purchased字段是布尔类型,标记用户是否已购买该课程,这个字段直接决定了详情弹窗中底部按钮的显示文案和交互行为。

在ArkTS中,接口(interface)与类型别名(type alias)都可以用于定义对象的形状,但接口在某些场景下具有更灵活的特性,如声明合并。在本应用中,所有数据模型统一使用interface来定义,体现了团队对一致性和可扩展性的追求。当后续需要为某个接口添加新字段时,只需在接口定义中追加声明即可,编译器会在所有实现了该接口的位置提示补全新字段,这种"编译驱动开发"的体验极大提升了重构的安全性。

1.2 曲谱接口定义

interface SongSheet {
  id: number;
  name: string;
  artist: string;
  instrument: string;
  keySignature: string;
  difficulty: number;
  bars: number;
  bpm: number;
  chordProgression: string;
  hot: boolean;
}

SongSheet接口定义了曲库模块的数据结构。与CourseItem不同,曲谱的属性更加偏向音乐专业领域。instrument字段标识曲目适用的乐器类型,取值包括"吉他"、“钢琴”、“尤克里里"等,这个字段在曲库页面的乐器筛选器中被用作过滤条件。keySignature字段存储调号信息,如"G”、“C”、"A小调"等,调号在音乐理论中决定了乐曲的基准音高体系。

difficulty字段使用number类型存储难度等级,取值范围为1到5的整数,这与课程接口中使用字符串存储难度形成了鲜明对比。这种差异并非随意为之,而是基于业务需求的精心设计:曲谱的难度需要用圆点数量直观可视化,数值类型更便于在ForEach循环中进行条件判断和颜色映射。bars字段记录乐曲的小节总数,bpm字段存储每分钟节拍数,这两个数值字段共同描述了乐曲的基本规模和速度特征。chordProgression字段以字符串形式存储和弦进行序列,如"G-D-Em-C",使用连字符分隔各个和弦名称,在UI中会以等宽字体呈现以增强可读性。hot布尔字段标记是否为热门曲目,热门曲目会在列表中显示橙色的"HOT"标签。

1.3 和弦形状与练习计划接口

interface ChordShape {
  id: number;
  name: string;
  type: string;
  frets: string;
  fingers: string;
  difficulty: string;
}

interface PlanItem {
  id: number;
  name: string;
  minutes: number;
  days: string;
  done: boolean;
}

在这里插入图片描述

ChordShape接口定义了和弦指法图的数据模型。其中frets字段是一个编码字符串,记录了和弦在吉他指板上的按弦位置。例如"C"和弦的frets值为"x32010",其中每个字符对应一根弦(从低音弦到高音弦),'x’表示该弦不弹奏,'0’表示空弦,数字表示按住第几品。fingers字段记录对应的手指标记,如"13241"表示分别用食指、中指、无名指、小指和无名指来按弦。这种编码方式来源于国际通用的和弦图谱记谱法,在UI层需要通过专门的解码工具函数将其转化为可视化的指法网格。

PlanItem接口定义了练琴计划项的数据结构,设计得极其简洁。minutes字段记录单次练习的目标时长,days字段以字符串形式存储重复周期描述(如"每天"、“周三·周五”),done字段标记该计划项是否已完成。这种轻量级的数据结构使得计划的增删改查操作非常高效,同时字符串形式的周期描述也比结构化的日期规则更加灵活和直观。

二、静态配置与数据模型的工程化实现

2.1 常量定义

const DIFFICULTY_DOTS: number = 5;

这行代码定义了一个全局常量DIFFICULTY_DOTS,值为5,表示难度圆点的最大数量。在ArkTS中,使用const关键字声明的常量在编译期即可确定其值,且不可被重新赋值。将这种魔法数字提取为命名常量是经典的工程实践,它增强了代码的可读性和可维护性——当后续需要调整难度等级的最大值时,只需修改一处即可,而不必在整个代码库中搜索替换。

在鸿蒙应用开发中,合理的常量管理策略可以显著降低维护成本。推荐的实践是将业务常量集中在一个或几个配置文件中管理,按业务域分组。对于本应用这种规模的单文件应用,直接在文件顶部定义全局常量是一种可接受的轻量级方案。但在大型项目中,更推荐使用模块化的方式导出常量,以便在不同组件间共享和复用。

2.2 课程数据类与构造函数

class CourseItemData implements CourseItem {
  id: number = 0;
  title: string = '';
  teacher: string = '';
  level: string = '';
  lessons: number = 0;
  price: number = 0;
  originPrice: number = 0;
  students: number = 0;
  rating: string = '';
  tag: string = '';
  purchased: boolean = false;

  constructor(id: number, title: string, teacher: string, level: string, lessons: number,
    price: number, originPrice: number, students: number, rating: string, tag: string, purchased: boolean) {
    this.id = id;
    this.title = title;
    this.teacher = teacher;
    this.level = level;
    this.lessons = lessons;
    this.price = price;
    this.originPrice = originPrice;
    this.students = students;
    this.rating = rating;
    this.tag = tag;
    this.purchased = purchased;
  }
}

在这里插入图片描述

这里定义了CourseItemData类,它通过implements关键字实现了CourseItem接口。implements关键字表明该类承诺遵守接口定义的类型契约——类中必须包含接口声明的所有属性,且类型必须一致。值得注意的是,类中每个属性都有初始默认值,这是ArkTS的强制要求:与TypeScript不同,ArkTS不允许声明未初始化的类属性,这一设计选择从源头上杜绝了运行时因访问undefined属性而引发的错误。

构造函数接收所有字段作为参数,并在函数体内逐一赋值。这种写法虽然看起来冗长,但具有几个显著优势。首先,参数的顺序和类型一目了然,IDE可以提供精确的参数提示。其次,所有字段都在构造函数中显式赋值,不存在遗漏某个字段未初始化的风险。最后,这种模式生成的代码非常容易进行自动化测试——只需按照构造函数签名传入测试数据即可创建对象实例。

ArkTS对类属性初始化的严格要求,体现了"安全优先"的语言设计理念。在传统的JavaScript/TypeScript中,类属性可以声明而不初始化,其值默认为undefined,这往往是运行时错误的根源。ArkTS通过编译期检查强制开发者提供初始值,虽然增加了少量样板代码,但大幅提升了应用的运行时稳定性。这种设计哲学贯穿于ArkTS的诸多语法约束之中,如禁止使用any类型、要求函数参数和返回值的显式类型标注等。

2.3 模拟课程数据集

const mockCourses: CourseItem[] = [
  new CourseItemData(1, '吉他入门:从零到弹唱', '老周吉他教室', '入门', 42, 199, 399, 28640, '4.9', '镇店之宝', true),
  new CourseItemData(2, '民谣指弹进阶训练营', '南窗音乐', '进阶', 36, 299, 499, 12310, '4.8', '口碑王', true),
  new CourseItemData(3, '钢琴即兴伴奏速成', '白鹭琴房', '入门', 30, 259, 459, 19870, '4.9', '热销', false),
  // ... 更多课程数据
  new CourseItemData(18, '编曲制作入门到上架', '家庭工作室', '进阶', 38, 499, 799, 8120, '4.8', '全链路', false)
];

在这里插入图片描述

这段代码创建了一个包含18条课程记录的静态数据数组,类型标注为CourseItem[],即接口类型的数组。注意这里使用的是接口类型而非具体类类型来声明数组,这是一个经典的"面向接口编程"实践——数组中存储的元素在类型系统看来是CourseItem接口的实例,具体的运行时类型CourseItemData被隐藏在接口之后。这种设计使得如果未来需要更换数据源(例如从网络请求获取数据),只需确保返回的数据符合CourseItem接口即可,无需修改使用该数据的组件代码。

数据内容覆盖了吉他、钢琴、尤克里里、贝斯、架子鼓、口琴、小提琴、萨克斯等多种乐器类型的课程,难度从入门到高级均有分布。每条数据都包含完整的11个字段值,构造函数的参数顺序与接口定义的字段顺序保持一致。这种大规模的模拟数据集在开发阶段非常有用,它可以让开发者在没有后端API支持的情况下快速搭建和调试UI界面,验证布局效果和交互逻辑。

implements

implements

implements

implements

实例化

实例化

实例化

实例化

数据层架构

接口定义 interface

实现类 class implements

模拟数据 const mockXxx

CourseItem

SongSheet

ChordShape

PlanItem

CourseItemData

SongSheetData

ChordShapeData

PlanItemData

mockCourses 18条

mockSongs 24条

mockChords 16条

mockPlans 8条

2.4 曲库与和弦数据的实现模式

class SongSheetData implements SongSheet {
  id: number = 0;
  name: string = '';
  artist: string = '';
  instrument: string = '';
  keySignature: string = '';
  difficulty: number = 0;
  bars: number = 0;
  bpm: number = 0;
  chordProgression: string = '';
  hot: boolean = false;

  constructor(id: number, name: string, artist: string, instrument: string, keySignature: string,
    difficulty: number, bars: number, bpm: number, chordProgression: string, hot: boolean) {
    this.id = id;
    this.name = name;
    this.artist = artist;
    this.instrument = instrument;
    this.keySignature = keySignature;
    this.difficulty = difficulty;
    this.bars = bars;
    this.bpm = bpm;
    this.chordProgression = chordProgression;
    this.hot = hot;
  }
}

在这里插入图片描述

SongSheetData类的实现模式与CourseItemData完全一致,遵循相同的工程范式:实现接口、初始化所有属性、提供全参数构造函数。这种一致性使得整个数据层的代码具有高度的可预测性——开发者一旦理解了一个数据类的模式,就能立即理解所有其他数据类。

曲库数据集包含了24首经典曲目,从周杰伦的《晴天》到贝多芬的《致爱丽丝》,从吉他和声到钢琴独奏,从流行歌曲到爵士标准曲,覆盖了多种风格和乐器。每首曲目的数据都精心调校了难度值、小节数和BPM值,这些数值在UI展示时会直接影响圆点数量、尺寸大小和文字描述的呈现效果。

和弦数据集则收录了16个常用和弦,从基础的C、G、Am三和弦,到进阶的Bm、B7横按和弦,再到高级的F#m7b5半减七和弦。每个和弦的fretsfingers字段都严格按照国际通用的和弦图谱编码标准填写,这些编码将在后续的getChordDot工具函数中被解码为指法网格中的圆点标记。

2.5 练习计划数据

class PlanItemData implements PlanItem {
  id: number = 0;
  name: string = '';
  minutes: number = 0;
  days: string = '';
  done: boolean = false;

  constructor(id: number, name: string, minutes: number, days: string, done: boolean) {
    this.id = id;
    this.name = name;
    this.minutes = minutes;
    this.days = days;
    this.done = done;
  }
}

const mockPlans: PlanItem[] = [
  new PlanItemData(1, '爬格子热身 120bpm', 10, '每天', true),
  new PlanItemData(2, 'C-G-Am-F 和弦转换', 15, '每天', true),
  new PlanItemData(3, '《晴天》主歌段落', 20, '每天', false),
  new PlanItemData(4, '指弹基础:靠弦法', 15, '周三·周五', false),
  new PlanItemData(5, '乐理:调式速记', 10, '每周一', true),
  new PlanItemData(6, '扫弦节奏型 4/4', 15, '每天', false),
  new PlanItemData(7, '《卡农》右手旋律', 25, '周末', false),
  new PlanItemData(8, '听力训练:单音模唱', 10, '每天', false)
];

在这里插入图片描述

练习计划数据集包含8条记录,每条记录模拟了一个真实的练琴计划项。这些计划项的内容设计得非常贴近真实的乐器学习场景:从基础的"爬格子热身"(一种吉他手指练习方法)到具体的曲目练习段落,从和弦转换练习到乐理知识记忆。done字段的值有真有假,模拟了部分已完成、部分待完成的真实使用状态。days字段则展示了多种重复周期模式,包括"每天"、“周三·周五”、“每周一”、"周末"等灵活的时间安排。

值得关注的是,这些模拟数据虽然被称为"mock",但其数据质量和结构设计已经达到了可生产级别的水平。在真实项目中,这些数据通常会在开发阶段使用,待后端API就绪后通过HTTP请求替换。在ArkTS中,数据获取层与UI展示层的解耦是通过接口类型系统天然实现的——只要API返回的数据符合接口定义,UI层的代码无需任何修改即可正常工作。

三、工具函数:业务逻辑的原子化封装

工具函数是应用中不可或缺的基础设施,它们将复杂的业务逻辑封装为可复用的原子操作。在本应用中,工具函数承担了颜色映射、图标映射、数字格式化和和弦网格解码等核心职责。

3.1 难度颜色映射函数

function getDiffColor(difficulty: number): string {
  if (difficulty >= 5) {
    return '#C2410C';
  } else if (difficulty >= 4) {
    return '#B45309';
  } else if (difficulty >= 3) {
    return '#1E3A2F';
  } else {
    return '#3F6212';
  }
}

在这里插入图片描述

getDiffColor函数接收一个数值类型的难度值,返回对应的十六进制颜色字符串。函数内部使用阶梯式的条件判断,将1到5的难度值映射到四种颜色:最高难度(5级)使用赭石色#C2410C(一种深橙红色),高难度(4级)使用棕色#B45309,中等难度(3级)使用墨绿色#1E3A2F(应用的主色调),低难度(1-2级)使用草绿色#3F6212

这个颜色映射策略体现了深思熟虑的视觉设计:随着难度递增,颜色从冷色调的绿色逐渐过渡到暖色调的橙红色,利用色彩心理学中"冷色=平静/安全,暖色=警觉/挑战"的原理,让用户在浏览曲库时能够通过颜色直觉感知到曲目的难度梯度。这种数据驱动颜色的设计模式在信息可视化领域非常常见,它使得UI的视觉呈现能够随着数据的变化而自动调整,无需在模板中编写大量的条件样式。

在ArkTS的函数定义中,参数和返回值都必须显式标注类型,这是与TypeScript的一个显著差异。TypeScript允许利用类型推断省略返回值类型标注,但ArkTS要求完全显式。这一约束虽然增加了少量代码量,但换来了更好的类型安全性和IDE工具支持——编译器可以在编译期完整地检查函数的类型一致性,IDE也能基于完整的类型信息提供更精准的代码补全和错误提示。

3.2 乐器图标映射函数

function getInstrumentIcon(instrument: string): string {
  if (instrument === '吉他') {
    return '🎸';
  } else if (instrument === '钢琴') {
    return '🎹';
  } else if (instrument === '尤克里里') {
    return '🪕';
  } else {
    return '🎼';
  }
}

在这里插入图片描述

getInstrumentIcon函数将乐器名称字符串映射为对应的emoji图标字符。这种映射方式简单直接,通过一系列的字符串严格相等比较来返回不同的图标。当传入的乐器名称不匹配任何已知类型时,函数返回一个通用的乐谱emoji作为默认值。这种防御性编程的实践确保了即使出现未预料到的乐器类型,UI也不会显示空白或错误内容。

在鸿蒙的ArkTS渲染环境中,emoji字符可以直接作为文本内容在Text组件中显示,框架会利用系统内置的emoji字体进行渲染。这种方式避免了使用图片资源文件带来的包体积增加和加载延迟问题,是一种轻量级的图标方案。不过需要注意,不同设备上的emoji渲染效果可能存在细微差异,在要求像素级一致的生产环境中可能需要替换为字体图标或SVG矢量图标。

3.3 数字格式化函数

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

formatWan2函数实现了中文环境下常见的"万"单位数字格式化。当输入的数字大于或等于一万时,函数将其除以一万并保留一位小数,然后拼接"万"字后缀。例如,输入28640将返回"2.9万"。当数字不足一万时,直接调用toString()方法将其转为字符串。

这种格式化处理在中文互联网产品中非常普遍,它能够将较大的数字压缩为更易读的简短形式。在UI层面,"2.9万人已学"比"28640人已学"更加简洁且符合中文阅读习惯。函数名中的"2"后缀暗示这是一个经过迭代优化的版本,可能在之前存在一个返回不同格式或精度不够的版本。

3.4 和弦指法网格解码函数

function getChordDot(frets: string, str: number, fret: number): string {
  if (frets.length <= str) {
    return '';
  }
  const ch: string = frets.charAt(str);
  if (ch === 'x' || ch === '0') {
    return '';
  }
  if (parseInt(ch) === fret) {
    return '●';
  }
  return '';
}

getChordDot函数是整个应用中最精妙的工具函数之一。它负责将和弦的编码字符串解码为指法网格中某个特定位置的显示内容。函数接收三个参数:frets是和弦的按弦编码字符串(如"x32010"),str是弦的索引(0表示最低音弦),fret是品格索引(1表示第一品)。

函数首先检查弦索引是否超出编码字符串的长度范围,这是必要的边界检查。然后使用charAt方法取出指定弦位置的编码字符。如果该字符是’x’(表示该弦不弹奏)或’0’(表示空弦),函数返回空字符串,表示该位置不需要显示任何标记。如果字符是一个表示品格数字的字符,函数使用parseInt将其转为整数并与传入的fret参数比较,如果相等则返回圆点符号’●’,表示在该弦该品的位置需要按弦,否则返回空字符串。

这个函数在乐理页的和弦指法弹窗中被反复调用,配合嵌套的ForEach循环构建出一个6弦×4品的指法网格。每次循环迭代时,外层循环遍历品格,内层循环遍历琴弦,将网格的坐标(品格、弦序)传入此函数,由函数决定该位置是显示圆点还是空白。这种数据驱动渲染的模式使得和弦指法图的展示完全由数据决定,新增一个和弦只需在数据集中添加一条记录即可自动生成对应的指法图。

字符为'x'

字符为'0'

字符为数字

相等

不等

frets编码字符串
例: x32010

getChordDot函数

charAt获取字符

返回空字符串
该弦不弹奏

返回空字符串
该弦为空弦

parseInt后
与fret比较

返回'●'
显示按弦圆点

返回空字符串
该品不按

指法网格渲染

6弦×4品可视化网格

四、Tab枚举与主入口组件

4.1 Tab枚举定义

enum ChordTab {
  COURSE,
  SHEET,
  PRACTICE,
  THEORY,
  ME
}

ChordTab是一个枚举类型,定义了应用的五个底部导航标签。枚举(enum)是ArkTS中用于定义一组命名常量的数据结构,它为有限的离散值提供了类型安全的表达方式。在没有枚举的情况下,开发者可能会使用字符串常量或数字常量来表示Tab类型,但这两种方式都有缺陷:字符串容易拼写错误且无自动补全,数字缺乏语义可读性。枚举类型完美解决了这些问题,IDE可以提供完整的自动补全,编译器能在编译期检查枚举值的合法性。

枚举成员COURSE对应课程页,SHEET对应曲库页,PRACTICE对应练琴房页,THEORY对应乐理工具页,ME对应个人中心页。这些枚举值在内部以从0开始的整数表示,但在使用时始终通过枚举成员名引用,代码的可读性得到了充分保障。

4.2 主入口组件的定义与状态管理

@Entry
@Component
struct ChordxApp {
  @State activeTab: ChordTab = ChordTab.COURSE

这三行代码是整个应用架构的核心。@Entry装饰器标记ChordxApp为应用的入口组件,每个鸿蒙应用有且仅有一个被@Entry标记的组件,它对应应用启动时加载的第一个UI页面。@Component装饰器标记ChordxApp为一个自定义组件,使其具备声明式UI的构建能力。struct关键字定义了一个结构体,在ArkTS中,自定义组件以struct而非class的形式定义,这是因为struct具有值语义和栈分配的特性,在UI组件的创建和销毁过程中具有更好的性能表现。

@State是ArkTS状态管理的基础装饰器。被@State标记的变量被称为"状态变量",当其值发生变化时,框架会自动触发组件的重新渲染,将变更反映到UI上。activeTab变量的初始值为ChordTab.COURSE,这意味着应用启动时默认显示课程页面。当用户点击底部导航栏的其他Tab时,activeTab的值会被更新,框架检测到状态变化后会重新执行build方法,渲染出对应Tab的页面内容。

@State的状态管理机制是ArkTS声明式UI的核心引擎。它背后的工作原理可以概括为"观察者模式+差分渲染"。@State变量在被赋值时,会通知框架其依赖的UI部分需要更新。框架随后执行一个"差分"过程,比较新旧UI描述的差异,并将最小化的变更集应用到实际的渲染树上。这种机制确保了UI更新既准确又高效——只有真正受影响的UI部分才会被重新渲染,而非整个页面。在本应用中,当activeTab从COURSE切换到SHEET时,只有contentArea函数的输出会发生变化,底部导航栏的渲染结果(除了选中状态的视觉变化外)不会受到不必要的影响。

4.3 内容区域构建器

  @Builder contentArea() {
    Column() {
      if (this.activeTab === ChordTab.COURSE) {
        CourseContent()
      } else if (this.activeTab === ChordTab.SHEET) {
        SheetContent()
      } else if (this.activeTab === ChordTab.PRACTICE) {
        PracticeContent()
      } else if (this.activeTab === ChordTab.THEORY) {
        TheoryContent()
      } else {
        MeContent()
      }
    }
    .layoutWeight(1)
  }

@Builder装饰器标记的函数是一种特殊的UI构建函数。与build方法类似,@Builder函数内部使用声明式语法定义UI结构,但它可以被多次调用以复用UI片段。contentArea函数根据activeTab的当前值,条件性地渲染五个子组件中的一个。这种模式是一种简化版的路由机制——通过状态变量控制组件的显示与隐藏,实现了页面切换的效果。

if-else条件链在这里充当了路由表的角色。每个分支判断activeTab是否等于某个枚举值,如果是则渲染对应的子组件。最后一个else分支处理ME的情况,这种"else兜底"的写法比显式判断=== ChordTab.ME更加健壮——即使未来枚举新增了值但忘记在此处添加对应的分支,应用也不会崩溃,而是默认显示个人中心页。

Column是最基本的垂直布局容器,它将其子元素在垂直方向上从上到下依次排列。在这里,Column包裹了条件渲染的子组件,并应用了layoutWeight(1)属性。layoutWeight是ArkTS布局系统中的一个重要概念,它指定了组件在父容器剩余空间中所占的权重比例。值为1表示占据所有剩余空间,这使得内容区域能够填充底部导航栏以上的全部空间。

4.4 底部导航项构建器

  @Builder bottomTabItem(icon: string, label: string, tab: ChordTab) {
    Column() {
      Text(icon)
        .fontSize(20)
        .opacity(this.activeTab === tab ? 1.0 : 0.4)
        .scale({ x: this.activeTab === tab ? 1.18 : 1.0, y: this.activeTab === tab ? 1.18 : 1.0 })
        .animation({ duration: 180, curve: Curve.EaseOut })
      Text(label)
        .fontSize(9)
        .fontColor(this.activeTab === tab ? '#1E3A2F' : '#A8A29E')
        .fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
        .margin({ top: 2 })
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Center)
    .padding({ top: 6, bottom: 6 })
    .onClick(() => { this.activeTab = tab })
  }

bottomTabItem构建器是底部导航栏的复用单元。它接收三个参数:icon是Tab图标(emoji字符),label是Tab文字标签,tab是该Tab项对应的枚举值。构建器内部使用Column布局将图标和文字垂直排列。

这个构建器最精妙之处在于其状态驱动的视觉表现。图标和文字的样式都通过三元条件表达式与this.activeTab === tab的判断结果绑定:当当前Tab等于该项的tab值时,图标完全不透明(opacity: 1.0)并放大到1.18倍,文字使用主色调#1E3A2F并加粗显示;当不等于时,图标半透明(opacity: 0.4),文字使用灰色#A8A29E并常规显示。这种选中态与未选中态的视觉差异设计,为用户提供了清晰的当前位置反馈。

animation属性为图标的变化添加了过渡动画。duration: 180指定动画持续180毫秒,curve: Curve.EaseOut指定使用"先快后慢"的缓动曲线。当用户在Tab之间切换时,旧Tab的图标会平滑地缩小并变淡,新Tab的图标会平滑地放大并变亮,营造出流畅自然的切换体验。这种微交互细节是高质量应用的重要标志。

onClick事件处理器将activeTab的值设置为当前Tab项对应的枚举值。由于activeTab@State变量,赋值后框架会自动触发UI更新,contentArea函数会重新执行并渲染新的页面内容,所有bottomTabItem也会重新评估其选中状态。整个切换过程从用户点击到UI更新,全部由框架的状态管理机制自动驱动。

4.5 根组件的build方法

  build() {
    Column() {
      this.contentArea()
      Row() {
        this.bottomTabItem('📚', '课程', ChordTab.COURSE)
        this.bottomTabItem('🎼', '曲库', ChordTab.SHEET)
        this.bottomTabItem('⏱️', '练琴房', ChordTab.PRACTICE)
        this.bottomTabItem('和弦', '乐理', ChordTab.THEORY)
        this.bottomTabItem('👤', '我的', ChordTab.ME)
      }
      .width('100%')
      .backgroundColor('#FFFBF2')
      .padding({ top: 5, bottom: 7 })
      .shadow({ radius: 14, color: '#261E3A2F', offsetY: -4 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FAF6EF')
  }
}

build方法是每个自定义组件必须实现的方法,它定义了组件的UI结构。根组件的build方法使用了一个垂直的Column作为最外层容器,内部依次放置内容区域和底部导航栏。这个结构构成了应用最基础的布局骨架:上方是可变的内容区域,下方是固定的导航栏。

底部导航栏使用Row容器水平排列五个bottomTabItem构建器调用。Row是最基本的水平布局容器,与Column对应,它将子元素在水平方向上从左到右依次排列。每个Tab项调用时传入了不同的emoji图标和对应的枚举值,使得五个导航项具有各自独立的标识。

导航栏的样式设计体现了"奶油纸感"的设计风格:backgroundColor设置为#FFFBF2(一种非常浅的暖白色),padding提供了上下方向的留白,shadow属性在导航栏上方投射出一道带有颜色的阴影。阴影的color值为#261E3A2F,其中前两位26是透明度的十六进制表示(约15%不透明度),1E3A2F是阴影色值,这种半透明的主题色阴影比纯黑阴影更加柔和自然。offsetY: -4使阴影向上偏移4vp,模拟从导航栏上方投射的光影效果。

最外层Column设置了width('100%')height('100%'),确保应用占满整个屏幕。backgroundColor设置为#FAF6EF(米色纸底色),这是整个应用的背景基调。

COURSE

SHEET

PRACTICE

THEORY

else/ME

onClick

onClick

onClick

onClick

onClick

@Entry @Component
ChordxApp 根组件

@State activeTab
当前激活的Tab枚举

build方法

Column 垂直布局容器

@Builder contentArea
内容区域

Row 底部导航栏容器

activeTab判断

CourseContent
课程页组件

SheetContent
曲库页组件

PracticeContent
练琴房页组件

TheoryContent
乐理页组件

MeContent
我的页组件

@Builder bottomTabItem
课程Tab

@Builder bottomTabItem
曲库Tab

@Builder bottomTabItem
练琴房Tab

@Builder bottomTabItem
乐理Tab

@Builder bottomTabItem
我的Tab

五、公共头部组件:组件通信与回调机制

5.1 NoteHeader组件定义

@Component
struct NoteHeader {
  title: string = '琴弦手册';
  subtitle: string = '';
  streakDays: number = 42;
  onStreakTap: () => void = () => {};
  onSearchTap: () => void = () => {};

NoteHeader是一个公共头部组件,被五个Tab页面共享复用。该组件没有被标记为@Entry,因为它只是一个子组件,需要被其他组件引用才能渲染。组件定义了五个成员变量,其中titlesubtitlestreakDays是普通的可变属性(非@State),用于接收外部传入的显示数据。onStreakTaponSearchTap是函数类型的属性,初始值为空箭头函数,它们的作用是接收外部传入的回调函数。

这种通过函数类型属性传递回调的设计模式,是ArkTS中实现子组件向父组件通信的标准方式。当子组件内部发生特定事件(如点击)时,它调用自身持有的回调函数,而回调函数的具体逻辑由父组件在创建子组件时传入。这种模式确保了组件的高内聚低耦合——子组件不需要知道点击后应该做什么,只需"报告"事件发生即可,具体的行为由父组件决定。

ArkTS的组件通信机制与React的props/回调模式非常相似,但也有显著区别。在React中,props是只读的,子组件不能直接修改父组件传入的props值。而在ArkTS中,普通成员变量(非@State、@Prop、@Link)可以在组件内部被修改,但这种修改不会触发UI刷新。在本应用中,NoteHeader的成员变量都是"配置型"属性——由父组件在创建时一次性设置,之后不再变化,这种用法与React的props最为接近。如果需要实现父子组件间的双向数据绑定,ArkTS提供了@Prop(单向同步)和@Link(双向同步)等专门的装饰器。

5.2 头部布局结构

  build() {
    Column() {
      Row() {
        Column() {
          Text('🔥 连续练习 ' + this.streakDays + ' 天')
            .fontSize(13)
            .fontColor('#FAF6EF')
            .fontWeight(FontWeight.Medium)
          Text('别断签哦')
            .fontSize(8)
            .fontColor('#D6D3D1')
            .margin({ top: 1 })
        }
        .alignItems(HorizontalAlign.Start)
        .onClick(() => { this.onStreakTap() })

        Row() {
          Text('🔍')
            .fontSize(13)
            .margin({ left: 10 })
          Text('搜曲谱 / 课程 / 和弦')
            .fontSize(11)
            .fontColor('#78716C')
            .margin({ left: 6 })
        }
        .layoutWeight(1)
        .height(34)
        .backgroundColor('#FAF6EFCC')
        .borderRadius(17)
        .margin({ left: 12 })
        .onClick(() => { this.onSearchTap() })

NoteHeader的build方法构建了一个层次分明的头部布局。最外层是Column容器,内部第一个子元素是Row——这是头部的第一行,包含连续练习天数的显示和搜索栏。

左侧的Column展示了用户的连续练习天数,使用HorizontalAlign.Start将文本左对齐。streakDays变量通过字符串拼接嵌入到显示文本中(“🔥 连续练习 42 天”),这种动态文本构建方式是ArkTS中最基本的数据绑定形式。下方的"别断签哦"是一个温馨提示文字,使用小字号(8)和浅灰色,作为主文本的辅助说明。

右侧的搜索栏是一个Row容器,包含放大镜emoji和提示文字。它使用layoutWeight(1)占据剩余空间,height(34)设置了固定高度,backgroundColor使用带透明度的颜色值#FAF6EFCC(CC是约80%不透明度的十六进制表示),borderRadius(17)将圆角半径设置为高度的一半,形成完美的圆角矩形(药丸形)。

5.3 头部标题与渐变背景

        Text('📖')
          .fontSize(17)
          .width(34)
          .height(34)
          .textAlign(TextAlign.Center)
          .backgroundColor('#FAF6EFCC')
          .borderRadius(17)
          .margin({ left: 8 })
          .onClick(() => { this.onStreakTap() })
      }
      .width('100%')
      .padding({ left: 14, right: 14, top: 10 })

      Row() {
        Column() {
          Text(this.title)
            .fontSize(22)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FAF6EF')
          if (this.subtitle !== '') {
            Text(this.subtitle)
              .fontSize(10)
              .fontColor('#D6D3D1')
              .margin({ top: 2 })
          }
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
      }
      .width('100%')
      .padding({ left: 14, right: 14, top: 12, bottom: 10 })
    }
    .width('100%')
    .linearGradient({
      angle: 135,
      colors: [['#1E3A2F', 0], ['#0F2620', 1]]
    })
  }
}

头部的第二行是标题区域,显示页面标题和可选的副标题。title属性的默认值为’琴弦手册’,但各Tab页面在创建NoteHeader时会传入不同的标题值。副标题使用条件渲染——只有当subtitle不为空字符串时才显示副标题Text组件。这种if条件渲染是ArkTS中控制UI元素显示隐藏的推荐方式,它比通过visibility属性控制具有更好的性能,因为条件为假时组件根本不会被创建。

整个头部最外层Column应用了linearGradient线性渐变背景。angle: 135指定渐变方向为135度(从左上到右下),colors数组定义了渐变的色标:起始位置(0%处)使用#1E3A2F(墨绿色),结束位置(100%处)使用#0F2620(更深的墨绿色)。这种深色渐变背景为头部上的浅色文字提供了良好的对比度,同时营造出沉稳而不失质感的视觉效果。

线性渐变是ArkTS中丰富视觉表现的重要手段。通过linearGradient属性,开发者可以定义任意方向、任意色标的多色渐变。在本应用的设计体系中,墨绿色渐变被用于头部、计时大卡和每日一题入口卡等"主视觉"区域,与米色纸底背景形成层次分明的视觉对比,这种深浅交替的布局节奏使得应用的整体观感既统一又富有变化。

六、课程页面:列表渲染与多级弹窗架构

6.1 状态变量与模态遮罩

@Component
struct CourseContent {
  @State pickedLevel: string = '全部'
  @State showDetailModal: boolean = false
  @State showBuyModal: boolean = false
  @State showCouponModal: boolean = false
  @State selectedCourse: CourseItem | null = null
  @State pickedCoupon: string = ''
  levelList: string[] = ['全部', '入门', '进阶', '高级']

  @Builder modalOverlay(onClose: () => void) {
    Column()
      .width('100%')
      .height('100%')
      .backgroundColor('rgba(50,45,35,0.5)')
      .onClick(onClose)
  }

课程页面是应用中功能最复杂的Tab页面之一。它定义了六个状态变量来管理页面状态:pickedLevel跟踪当前选中的难度筛选值(默认为"全部"),showDetailModalshowBuyModalshowCouponModal三个布尔值分别控制三个弹窗的显示与隐藏,selectedCourse存储当前被点击选中的课程对象(使用联合类型CourseItem | null,初始为null表示未选中),pickedCoupon存储用户在优惠券弹窗中选择的券标识。

levelList是一个非状态的普通数组属性,存储可选的难度筛选选项。它不使用@State标记是因为筛选选项是固定的,不会在运行时发生变化。

modalOverlay是一个公共的@Builder函数,用于创建弹窗的半透明遮罩层。它接收一个onClose回调函数作为参数,在遮罩被点击时调用。遮罩本身是一个填满整个屏幕的Column,背景色使用RGBA格式的半透明颜色(rgba(50,45,35,0.5)表示50%不透明度的深棕色)。这种"点击遮罩关闭弹窗"的交互模式是移动端弹窗的标准实践。

本应用采用"状态变量+条件渲染"的方式管理弹窗,而非使用ArkTS原生的CustomDialog控制器。这种选择有其工程考量:状态变量驱动的弹窗可以更灵活地组合多个弹窗的显示逻辑(如课程详情弹窗关闭后立即打开购买弹窗),且弹窗的状态(如选中的课程、选中的优惠券)可以直接存储在组件的状态变量中,无需额外的数据传递。代价是弹窗的动画效果不如CustomDialog丰富,但对于本应用的功能需求而言已经足够。

6.2 课程详情弹窗

  @Builder courseModal() {
    Column() {
      this.modalOverlay(() => { this.showDetailModal = false })
      Column() {
        Text('📘 ' + (this.selectedCourse === null ? '' : this.selectedCourse.title))
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1C1917')
          .margin({ top: 18 })
        Text(this.selectedCourse === null ? '' : this.selectedCourse.teacher + ' · ' +
          this.selectedCourse.level + ' · ' + this.selectedCourse.lessons + '课时')
          .fontSize(10)
          .fontColor('#78716C')
          .margin({ top: 4 })

课程详情弹窗是课程页面的第一个弹窗,它在用户点击课程卡片时显示。弹窗的结构分为两层:外层Column包含遮罩层和弹窗主体,内层Column是弹窗的实际内容容器。

弹窗顶部显示课程标题和元信息。注意到每次访问this.selectedCourse的属性之前都先判断是否为null,如果为null则返回空字符串。这种防御性编程模式贯穿了整个应用的弹窗代码,确保即使状态变量处于初始的null值时也不会触发运行时错误。三元表达式 this.selectedCourse === null ? '' : this.selectedCourse.title 是ArkTS中处理可能为null的对象属性的标准模式。

6.3 课程目录与Scroll组件

        Scroll() {
          Column() {
            ForEach(['01 认识你的乐器与坐姿', '02 第一组开放和弦', '03 节拍与拨弦练习',
              '04 和弦转换的三个诀窍', '05 第一首完整弹唱', '06 扫弦节奏入门',
              '07 常见问题与纠错', '08 阶段综合练习曲'], (l: string) => {
              Row() {
                Text('▶')
                  .fontSize(10)
                  .fontColor('#1E3A2F')
                  .width(20)
                Text(l)
                  .fontSize(11)
                  .fontColor('#44403C')
                  .layoutWeight(1)
                Text('免费试看')
                  .fontSize(9)
                  .fontColor('#C2410C')
              }
              .width('100%')
              .padding({ top: 10, bottom: 10 })
              .backgroundColor('#FFFBF2')
              .borderRadius(10)
              .margin({ top: 6 })
            }, (l: string) => l)
          }
          .width('100%')
          .padding({ left: 16, right: 16 })
        }
        .constraintSize({ maxHeight: '42%' })
        .margin({ top: 8 })

这段代码展示了课程目录列表的实现。Scroll组件是ArkTS中的滚动容器,当其内容超出可视区域时允许用户滚动查看。在这里,Scroll被用来包裹课程目录的列表,并通过constraintSize属性的maxHeight: '42%'限制了最大高度为弹窗高度的42%,防止目录过长导致弹窗超出屏幕。

ForEach是ArkTS中实现列表渲染的核心组件。它接收三个参数:第一个是要遍历的数据数组(这里是一个字符串数组,包含8个课程章节名称),第二个是每个数据项的渲染函数(接收一个字符串参数,返回一个Row组件),第三个是key生成函数(接收一个字符串参数,返回该字符串本身作为唯一标识)。

key生成函数在ForEach中扮演着至关重要的角色。框架通过key来标识列表中的每一项,当数据发生变化时(如插入、删除、重排),框架利用key来判断哪些项是新增的、哪些项被移除了、哪些项只是位置发生了变化。合理设置key可以避免不必要的组件销毁和重建,显著提升列表的更新性能。在本例中,直接使用章节名称字符串作为key是合理的,因为每个章节名称都是唯一的。

Scroll组件在ArkTS中是唯一允许内容溢出滚动的容器。与Column和Row不同,后者在内容超出容器尺寸时会直接溢出显示(不提供滚动),而Scroll会自动添加滚动能力。需要注意的是,Scroll组件只能包含一个直接子组件——这是ArkTS的语法约束。如果需要在Scroll内放置多个子组件,必须用一个容器(如Column或Row)包裹它们。在本例中,Scroll内的Column充当了这个"单一子组件"的角色,内部的ForEach生成的多个Row都是Column的子组件,而非Scroll的直接子组件。

6.4 购买按钮与条件渲染

        Row() {
          Column() {
            Text('¥' + (this.selectedCourse === null ? 0 : this.selectedCourse.price))
              .fontSize(20)
              .fontWeight(FontWeight.Bold)
              .fontColor('#C2410C')
            Text('原价 ¥' + (this.selectedCourse === null ? 0 : this.selectedCourse.originPrice))
              .fontSize(9)
              .fontColor('#A8A29E')
              .decoration({ type: TextDecorationType.LineThrough })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)

          Text(this.selectedCourse !== null && this.selectedCourse.purchased ? '已购买 · 继续学习' : '立即报名')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FAF6EF')
            .padding({ left: 26, right: 26, top: 11, bottom: 11 })
            .backgroundColor(this.selectedCourse !== null && this.selectedCourse.purchased ? '#1E3A2F' : '#C2410C')
            .borderRadius(20)
            .onClick(() => {
              if (this.selectedCourse !== null && this.selectedCourse.purchased) {
                this.showDetailModal = false
              } else {
                this.showDetailModal = false
                this.showBuyModal = true
              }
            })
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 14, bottom: 20 })

弹窗底部的购买区域展示了价格信息和操作按钮。左侧Column显示当前价格(大字号、赭石色、加粗)和原价(小字号、灰色、带删除线)。decoration属性配合TextDecorationType.LineThrough为原价添加了删除线效果,这是电商类应用中表达折扣的通用视觉语言。

右侧的按钮文字和背景色都通过三元表达式与purchased属性绑定:已购买的课程显示"已购买 · 继续学习",按钮背景为墨绿色;未购买的课程显示"立即报名",按钮背景为赭石色。点击事件处理器也根据购买状态执行不同逻辑:已购买则直接关闭详情弹窗,未购买则关闭详情弹窗并打开购买确认弹窗。这种"关闭一个弹窗、打开另一个弹窗"的级联操作在状态变量驱动的弹窗架构中非常自然——只需修改对应的状态变量值即可。

6.5 购买确认弹窗

  @Builder buyModal() {
    Column() {
      this.modalOverlay(() => { this.showBuyModal = false })
      Column() {
        Text('确认报名')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1C1917')
          .margin({ top: 18 })
        Row() {
          Text('📘')
            .fontSize(28)
            .width(54)
            .height(54)
            .textAlign(TextAlign.Center)
            .backgroundColor('#FFFBF2')
            .borderRadius(14)
          Column() {
            Text(this.selectedCourse === null ? '' : this.selectedCourse.title)
              .fontSize(12)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1C1917')
              .maxLines(1)
              .textOverflow({ overflow: TextOverflow.Ellipsis })
            Text(this.selectedCourse === null ? '' : this.selectedCourse.teacher + ' · ' +
              this.selectedCourse.lessons + '课时')
              .fontSize(9)
              .fontColor('#78716C')
              .margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .margin({ left: 12 })
        }
        .width('100%')
        .padding(14)
        .backgroundColor('#FFFBF2')
        .borderRadius(14)
        .margin({ top: 14 })

购买确认弹窗展示了一个标准的订单确认界面。弹窗顶部是"确认报名"标题,下方是一个订单信息卡片,使用Row布局将课程图标和信息水平排列。课程标题Text组件使用了两个重要的文本控制属性:maxLines(1)限制文本最多显示一行,textOverflow({ overflow: TextOverflow.Ellipsis })指定当文本溢出时以省略号结尾。这两个属性配合使用,确保过长的课程标题不会破坏弹窗的布局结构。

6.6 优惠券入口与弹窗级联

        Row() {
          Text('优惠券')
            .fontSize(12)
            .fontColor('#78716C')
            .layoutWeight(1)
          Text(this.pickedCoupon === '' ? '2 张可用 ›' : this.pickedCoupon)
            .fontSize(12)
            .fontColor('#C2410C')
            .onClick(() => {
              this.showBuyModal = false
              this.showCouponModal = true
            })
        }
        .width('100%')
        .padding(14)
        .backgroundColor('#FFFBF2')
        .borderRadius(14)
        .margin({ top: 10 })

优惠券入口行展示了状态变量驱动UI的另一个精彩案例。当用户尚未选择优惠券时(pickedCoupon为空字符串),右侧文字显示"2 张可用 ›",提示用户有优惠券可选;当用户在优惠券弹窗中选择了某张券后(pickedCoupon被设置为券标识),再次打开购买弹窗时,右侧文字会显示已选的券名称。这种"选择状态回显"的设计模式为用户提供了清晰的操作反馈。

点击优惠券入口会关闭购买弹窗并打开优惠券弹窗,形成第三个层级的弹窗。这种多级弹窗的级联关闭和打开完全由状态变量驱动,逻辑清晰且易于维护。用户可以从优惠券弹窗中选择券后返回购买弹窗,也可以点击遮罩直接关闭优惠券弹窗回到购买弹窗(因为关闭优惠券弹窗后,如果购买弹窗的状态变量仍为true则它仍然可见——但实际上本例中关闭购买弹窗时已将showBuyModal设为false,所以用户需要重新触发购买流程)。

6.7 优惠券弹窗与票券样式

  @Builder couponModal() {
    Column() {
      this.modalOverlay(() => { this.showCouponModal = false })
      Column() {
        Text('🎟️ 选择优惠券')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1C1917')
          .margin({ top: 18 })

        ForEach([['立减 ¥50', '满 ¥199 可用 · 全场通用', '50券'], ['立减 ¥30', '满 ¥99 可用 · 限入门课', '30券'], ['7.5 折券', '最高抵 ¥80 · 限进阶课', '75折']], (c: string[]) => {
          Row() {
            Column() {
              Text(c[0])
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#C2410C')
              Text(c[1])
                .fontSize(9)
                .fontColor('#78716C')
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Text(this.pickedCoupon === c[2] ? '已选 ✓' : '选择')
              .fontSize(11)
              .fontColor(this.pickedCoupon === c[2] ? '#1E3A2F' : '#C2410C')
              .padding({ left: 16, right: 16, top: 8, bottom: 8 })
              .backgroundColor(this.pickedCoupon === c[2] ? '#D6E5DC' : '#FFF7ED')
              .borderRadius(14)
              .onClick(() => { this.pickedCoupon = c[2] })
          }
          .width('100%')
          .padding(14)
          .backgroundColor('#FFFBF2')
          .border({ width: 1, color: '#F5E9D9' })
          .borderRadius(14)
          .margin({ top: 10 })
        }, (c: string[]) => c[2])

优惠券弹窗使用ForEach遍历一个二维数组(string[][]),每个内层数组包含三个元素:优惠描述、使用条件和券标识。这种将数据内联在ForEach调用中的方式适用于少量且固定的数据集。每个优惠券项使用Row布局,左侧展示优惠信息,右侧是选择按钮。

选择按钮的文案和样式都通过this.pickedCoupon === c[2]的判断来决定:已选中的券显示"已选 ✓"并使用墨绿色文字和浅绿背景,未选中的券显示"选择"并使用赭石色文字和浅橙背景。点击选择按钮会将pickedCoupon设为对应券的标识值,由于pickedCoupon是@State变量,赋值后所有优惠券项的选中态会自动更新——已选的变为未选,新选的变为已选,整个列表的视觉状态会实时刷新。

key生成函数返回c[2](券标识),确保每个优惠券项有唯一的key,框架可以精确地追踪每项的选中状态变化。

点击课程卡片

点击"立即报名"

点击遮罩/已购买

点击"优惠券"

点击"取消"

点击"去支付"

选择券后确定

点击遮罩

课程列表

详情弹窗

购买弹窗

优惠券弹窗

6.8 课程页面主体布局

  build() {
    Stack() {
      Scroll() {
        Column() {
          NoteHeader({ title: '课程', subtitle: '今日推荐 · 好课上新 8 折起', onStreakTap: () => {}, onSearchTap: () => {} })

          Text('🏆 口碑精选')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#1C1917')
            .width('100%')
            .padding({ left: 16, top: 14 })

课程页面的build方法使用Stack作为最外层容器。Stack是ArkTS中的堆叠布局容器,它将子元素在Z轴方向上堆叠——后放入的子元素会覆盖在先放入的子元素之上。在这里,Stack的第一层是可滚动的内容区域(Scroll+Column),第二层(条件渲染的弹窗)会覆盖在内容区域之上。这种"Stack+弹窗"的模式是本应用所有Tab页面的统一架构。

Stack内的Scroll组件包裹了一个Column,Column内依次放置了NoteHeader头部组件、"口碑精选"标题、横向滚动卡片区、难度筛选器和课程列表。NoteHeader在创建时传入了title: '课程'subtitle: '今日推荐 · 好课上新 8 折起',这些参数会覆盖NoteHeader定义时的默认值。onStreakTaponSearchTap都传入空箭头函数,表示在课程页面点击这两个区域时不执行任何特殊操作。

6.9 横向滚动卡片与条件过滤

          Scroll() {
            Row() {
              ForEach(mockCourses, (c: CourseItem) => {
                if (c.rating === '4.9') {
                  Column() {
                    Text('📘')
                      .fontSize(34)
                      .width(110)
                      .height(80)
                      .textAlign(TextAlign.Center)
                      .backgroundColor('#E7EFE9')
                      .borderRadius(14)
                    Text(c.title)
                      .fontSize(12)
                      .fontWeight(FontWeight.Bold)
                      .fontColor('#1C1917')
                      .margin({ top: 8 })
                      .maxLines(1)
                      .width(110)
                    Text(c.teacher + ' · ⭐' + c.rating)
                      .fontSize(9)
                      .fontColor('#78716C')
                      .margin({ top: 3 })
                    Text('¥' + c.price)
                      .fontSize(13)
                      .fontWeight(FontWeight.Bold)
                      .fontColor('#C2410C')
                      .margin({ top: 4 })
                  }
                  .width(118)
                  .alignItems(HorizontalAlign.Start)
                  .padding(10)
                  .backgroundColor('#FFFFFF')
                  .borderRadius(16)
                  .margin({ left: 6, right: 6 })
                  .onClick(() => {
                    this.selectedCourse = c
                    this.showDetailModal = true
                  })
                }
              }, (c: CourseItem) => 'top' + c.id.toString())
            }
            .padding({ left: 10, right: 10 })
          }
          .scrollable(ScrollDirection.Horizontal)
          .scrollBar(BarState.Off)
          .width('100%')

这段代码实现了"口碑精选"区的横向滚动卡片。关键点在于ForEach内部使用了if (c.rating === '4.9')条件判断,只渲染评分为4.9的课程——这是一种"数据过滤+列表渲染"的组合模式。横向滚动通过设置scrollable(ScrollDirection.Horizontal)实现,scrollBar(BarState.Off)隐藏了滚动条,使界面更加简洁。

每张卡片使用Column布局,包含课程图标、标题、讲师和评分、价格四个信息层级。width(110)限定了卡片的固定宽度,这是横向滚动列表中子项的常见做法——固定宽度的卡片在横向滚动时具有稳定的布局表现。点击卡片会将selectedCourse设为当前课程对象,同时将showDetailModal设为true,从而触发详情弹窗的显示。

key生成函数返回'top' + c.id.toString(),通过添加前缀"top"来与下方主列表中可能使用相同id的项区分,确保key的全局唯一性。

6.10 难度筛选器

          Row() {
            ForEach(this.levelList, (l: string) => {
              Text(l)
                .fontSize(11)
                .fontColor(this.pickedLevel === l ? '#FAF6EF' : '#78716C')
                .fontWeight(this.pickedLevel === l ? FontWeight.Bold : FontWeight.Normal)
                .padding({ left: 14, right: 14, top: 7, bottom: 7 })
                .backgroundColor(this.pickedLevel === l ? '#1E3A2F' : '#FFFFFF')
                .borderRadius(15)
                .margin({ right: 8 })
                .onClick(() => { this.pickedLevel = l })
            }, (l: string) => l)
          }
          .width('100%')
          .padding({ left: 14, right: 14, top: 14 })

难度筛选器是一个水平排列的标签选择器。ForEach遍历levelList数组([‘全部’, ‘入门’, ‘进阶’, ‘高级’]),为每个难度级别渲染一个Text标签。标签的样式通过三元表达式与pickedLevel === l的判断结果绑定:选中的标签使用深色背景和浅色文字,未选中的标签使用白色背景和灰色文字。

点击标签会将pickedLevel设为对应的难度值。由于pickedLevel是@State变量,赋值后筛选器和下方的课程列表都会自动更新。这种"状态驱动筛选"的模式极其简洁高效——无需手动操作DOM来切换标签的active样式,也无需手动过滤和重新渲染列表,一切由框架的响应式系统自动完成。

6.11 课程列表与筛选逻辑

          ForEach(mockCourses, (c: CourseItem) => {
            if (this.pickedLevel === '全部' || c.level === this.pickedLevel) {
              Row() {
                Column() {
                  Text('📘')
                    .fontSize(30)
                    .width(78)
                    .height(78)
                    .textAlign(TextAlign.Center)
                    .backgroundColor('#E7EFE9')
                    .borderRadius(14)
                  Text(c.tag)
                    .fontSize(8)
                    .fontColor('#C2410C')
                    .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                    .backgroundColor('#FFF7ED')
                    .borderRadius(8)
                    .margin({ top: 6 })
                }
                .alignItems(HorizontalAlign.Center)

主课程列表使用ForEach遍历mockCourses数组,但在渲染函数内部使用了条件判断if (this.pickedLevel === '全部' || c.level === this.pickedLevel)来过滤课程。当pickedLevel为"全部"时,所有课程都会被渲染;当pickedLevel为某个具体级别时,只有匹配该级别的课程才会被渲染。

key生成函数返回c.id.toString() + this.pickedLevel,这是一个精妙的设计——将筛选状态拼接到key中,确保当筛选条件变化时,列表项的key也会变化,从而触发框架完全重建列表项而非复用。虽然在某些场景下这种做法会损失一些性能(因为无法复用已存在的组件实例),但它确保了筛选切换时列表状态的完全重置,避免了可能出现的视觉残留或状态错乱问题。

每张课程卡片使用Row布局,左侧Column放置课程图标和标签,右侧Column(通过layoutWeight(1)占据剩余空间)放置课程标题、讲师信息、评分和价格。已购买的课程会在价格行末尾显示一个绿色的"已购 ✓"标签,通过if (c.purchased)条件渲染控制其显示。

七、曲库页面:乐器筛选与节拍器交互

7.1 曲库页状态与弹窗架构

@Component
struct SheetContent {
  @State pickedInstrument: string = '全部'
  @State showSheetModal: boolean = false
  @State showErrataModal: boolean = false
  @State showMetronomeModal: boolean = false
  @State bpmValue: number = 72
  @State selectedSong: SongSheet | null = null
  @State errataText: string = ''
  instrumentList: string[] = ['全部', '吉他', '钢琴', '尤克里里']

曲库页面的状态变量设计与课程页面类似,但针对曲谱业务做了调整。pickedInstrument跟踪选中的乐器筛选值,showSheetModalshowErrataModalshowMetronomeModal分别控制曲谱详情、纠错和节拍器三个弹窗。bpmValue存储节拍器的当前BPM值,初始为72(一个适合初学者的中慢速度)。errataText存储用户在纠错表单中输入的文本内容。

instrumentList数组定义了可选的乐器筛选选项,与难度筛选器的设计模式一致。

7.2 曲谱详情弹窗

  @Builder sheetModal() {
    Column() {
      this.modalOverlay(() => { this.showSheetModal = false })
      Column() {
        Row() {
          Text(getInstrumentIcon(this.selectedSong === null ? '' : this.selectedSong.instrument))
            .fontSize(28)
            .width(54)
            .height(54)
            .textAlign(TextAlign.Center)
            .backgroundColor('#E7EFE9')
            .borderRadius(14)
          Column() {
            Text(this.selectedSong === null ? '' : this.selectedSong.name)
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1C1917')
            Text(this.selectedSong === null ? '' : this.selectedSong.artist + ' · ' +
              this.selectedSong.keySignature + '调 · ' + this.selectedSong.bpm + ' BPM')
              .fontSize(9)
              .fontColor('#78716C')
              .margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .margin({ left: 12 })
          Text('❌')
            .fontSize(14)
            .fontColor('#A8A29E')
            .onClick(() => { this.showSheetModal = false })
        }
        .width('100%')
        .padding(16)

曲谱详情弹窗的头部使用三栏Row布局:左侧是乐器图标,中间是曲名和元信息(通过layoutWeight(1)占据中间空间),右侧是关闭按钮。与课程详情弹窗不同,这里增加了一个显式的关闭按钮(❌ emoji),为用户提供了除点击遮罩外的另一种关闭方式。这种设计考虑到了弹窗内容较多时用户可能需要快速关闭的场景。

7.3 和弦进行展示与简谱预览

        Column() {
          Text('和弦进行')
            .fontSize(11)
            .fontColor('#78716C')
            .width('100%')
          Text(this.selectedSong === null ? '' : this.selectedSong.chordProgression)
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor('#1E3A2F')
            .fontFamily('monospace')
            .margin({ top: 6 })
        }
        .width('100%')
        .padding(14)
        .backgroundColor('#E7EFE9')
        .borderRadius(14)

        Column() {
          Text('简谱预览(前 8 小节)')
            .fontSize(11)
            .fontColor('#78716C')
            .width('100%')
          Text('3 3 4 5 | 5 4 3 2 | 1 1 2 3 | 3 - - -\n3 3 4 5 | 5 4 3 2 | 1 1 2 1 | 1 - - -')
            .fontSize(14)
            .fontColor('#44403C')
            .fontFamily('monospace')
            .margin({ top: 8 })
            .width('100%')
        }
        .width('100%')
        .padding(14)
        .backgroundColor('#FFFBF2')
        .borderRadius(14)
        .margin({ top: 10 })

和弦进行展示区使用了fontFamily('monospace')设置等宽字体。等宽字体使得和弦名称(如G-D-Em-C)中的每个字符占用相同宽度,连字符对齐整齐,视觉上更加工整。这种字体选择在展示代码、乐谱和表格数据时非常常见。

简谱预览区展示了一段静态的简谱文本,使用\n换行符分隔两行乐谱。简谱中的数字代表音级(1=do, 2=re, 3=mi…),竖线|是小节线,连字符-表示音的延长。这段简谱内容是《欢乐颂》的旋律片段,作为预览展示给用户。

7.4 难度圆点与ForEach条件渲染

          Row() {
            ForEach([1, 2, 3, 4, 5], (d: number) => {
              Text('●')
                .fontSize(12)
                .fontColor(this.selectedSong !== null && d <= this.selectedSong.difficulty ?
                  getDiffColor(this.selectedSong.difficulty) : '#E7E5E4')
            }, (d: number) => d.toString())
          }
          .margin({ top: 4 })

难度圆点的实现非常精妙。ForEach遍历数字数组[1, 2, 3, 4, 5],为每个数字渲染一个圆点字符。圆点的颜色通过条件表达式决定:如果当前数字d小于等于曲目的难度值selectedSong.difficulty,则使用getDiffColor函数返回的难度颜色;否则使用浅灰色#E7E5E4表示未达到的难度等级。

例如,一首难度为3的曲目,前三个圆点会显示为墨绿色(3级对应的颜色),后两个圆点显示为浅灰色。这种可视化方式让用户一眼就能直观感知曲目的难度等级,比纯文字描述更加直观高效。

7.5 节拍器弹窗与交互逻辑

  @Builder metronomeModal() {
    Column() {
      this.modalOverlay(() => { this.showMetronomeModal = false })
      Column() {
        Row() {
          Text('⏱️ 节拍器')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#1C1917')
          Text('❌')
            .fontSize(14)
            .fontColor('#A8A29E')
            .onClick(() => { this.showMetronomeModal = false })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        .padding({ left: 16, right: 16, top: 16 })

        Text(this.bpmValue.toString() + ' BPM')
          .fontSize(38)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1E3A2F')
          .margin({ top: 10 })

节拍器弹窗是一个底部抽屉式的交互组件。弹窗顶部使用justifyContent(FlexAlign.SpaceBetween)将标题和关闭按钮分别推向左右两端。FlexAlign.SpaceBetween是Flex布局的对齐模式之一,它使子元素之间的间距均匀分布,首尾元素紧贴容器边缘。

BPM数值使用大字号(38)居中显示,字体颜色为墨绿色。this.bpmValue.toString() + ' BPM'将数值转换为字符串并与"BPM"拼接,形成完整的速度标注。

7.6 BPM调节按钮

        Row() {
          Text('−')
            .fontSize(20)
            .fontColor('#FAF6EF')
            .width(44)
            .height(44)
            .textAlign(TextAlign.Center)
            .backgroundColor('#1E3A2F')
            .borderRadius(22)
            .onClick(() => {
              if (this.bpmValue > 40) {
                this.bpmValue -= 2
              }
            })
          Text('+')
            .fontSize(20)
            .fontColor('#FAF6EF')
            .width(44)
            .height(44)
            .textAlign(TextAlign.Center)
            .backgroundColor('#C2410C')
            .borderRadius(22)
            .margin({ left: 16 })
            .onClick(() => {
              if (this.bpmValue < 220) {
                this.bpmValue += 2
              }
            })
        }
        .margin({ top: 12 })

BPM调节使用两个圆形按钮,减号按钮使用墨绿色背景,加号按钮使用赭石色背景。按钮的widthheight都设置为44,borderRadius设置为22(半径=直径/2),形成完美的圆形。点击减号按钮时,BPM值减2,但通过if (this.bpmValue > 40)确保不会低于40;点击加号按钮时,BPM值加2,通过if (this.bpmValue < 220)确保不会超过220。这些边界检查防止了不合理的BPM值,40-220的范围覆盖了从极慢板到极急板的全部音乐速度。

7.7 拍号指示器

        Row() {
          ForEach([1, 2, 3, 4], (b: number) => {
            Column() {
              Text(b.toString())
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(b === 1 ? '#C2410C' : '#A8A29E')
                .scale({
                  x: b === 1 ? 1.2 : 1.0,
                  y: b === 1 ? 1.2 : 1.0
                })
                .animation({ duration: 200, curve: Curve.EaseOut })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
          }, (b: number) => b.toString())
        }
        .width('100%')
        .padding({ left: 30, right: 30, top: 18 })

拍号指示器使用ForEach渲染4/4拍的四个拍点。第一拍(b === 1)使用赭石色和1.2倍放大显示,强调其在4/4拍中的"强拍"地位;其余拍点使用灰色和原始大小。animation属性为拍点的缩放变化添加了200毫秒的过渡动画,当节拍器运行时(虽然本例中未实现实际的节拍切换逻辑),拍点之间的视觉切换会呈现出流畅的脉动感。

7.8 曲库列表与筛选

        Scroll() {
          Column() {
            ForEach(mockSongs, (s: SongSheet) => {
              if (this.pickedInstrument === '全部' || s.instrument === this.pickedInstrument) {
                Row() {
                  Column() {
                    Text(getInstrumentIcon(s.instrument))
                      .fontSize(22)
                      .width(44)
                      .height(44)
                      .textAlign(TextAlign.Center)
                      .backgroundColor('#E7EFE9')
                      .borderRadius(12)
                    if (s.hot) {
                      Text('HOT')
                        .fontSize(7)
                        .fontColor('#C2410C')
                        .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                        .backgroundColor('#FFF7ED')
                        .borderRadius(6)
                        .margin({ top: 4 })
                    }
                  }
                  .alignItems(HorizontalAlign.Center)

曲库列表的渲染逻辑与课程列表类似,使用ForEach遍历mockSongs数组,通过if条件判断实现乐器筛选。每张曲谱卡片左侧的Column同时包含了乐器图标和热门标签——热门标签通过if (s.hot)条件渲染控制显示,只有hot为true的曲目才会显示橙色的"HOT"标签。

key生成函数返回s.id.toString() + this.pickedInstrument,与课程列表一样将筛选状态拼入key中,确保筛选切换时列表的正确重建。

匹配

不匹配

点击纠错

点击节拍器

曲库页面

NoteHeader 公共头部

乐器筛选器 Row

曲谱列表 Scroll

弹窗层 Stack

全部

吉他

钢琴

尤克里里

ForEach: mockSongs

pickedInstrument
筛选判断

曲谱卡片 Row

不渲染

点击 → selectedSong赋值
showSheetModal = true

曲谱详情弹窗

纠错弹窗

节拍器弹窗

八、练琴房页面:计时器与柱状图可视化

8.1 练琴房状态管理

@Component
struct PracticeContent {
  @State practiceMinutes: number = 0
  @State isPracticing: boolean = false
  @State showPlanModal: boolean = false
  @State showEditModal: boolean = false
  @State showDeleteModal: boolean = false
  @State pickedPlan: PlanItem | null = null
  @State planName: string = ''
  @State planMinutes: string = ''

练琴房页面定义了八个状态变量,是所有Tab页面中状态最多的。practiceMinutes记录当前练习时长(分钟数),isPracticing标记是否正在练习中。三个弹窗状态变量分别控制新建计划、编辑计划和删除计划弹窗的显示。pickedPlan存储当前选中操作的练习计划项,planNameplanMinutes存储新建计划表单中的输入值。

注意planMinutes虽然是时长数值,但使用string类型——这是因为ArkTS的TextInput组件的text属性要求字符串类型,将数字存储为字符串可以避免在输入和显示之间的频繁类型转换。

8.2 计时大卡与渐变背景

          Column() {
            Text(this.isPracticing ? '练习中…' : '准备开始')
              .fontSize(12)
              .fontColor('#D6E5DC')
            Text(this.practiceMinutes.toString())
              .fontSize(72)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FAF6EF')
              .fontFamily('monospace')
            Text('分钟')
              .fontSize(11)
              .fontColor('#D6E5DC')
            Row() {
              Text(this.isPracticing ? '⏸ 暂停' : '▶ 开始练习')
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor('#1E3A2F')
                .padding({ left: 32, right: 32, top: 12, bottom: 12 })
                .backgroundColor('#FAF6EF')
                .borderRadius(22)
                .scale({
                  x: this.isPracticing ? 1.0 : 1.05,
                  y: this.isPracticing ? 1.0 : 1.05
                })
                .animation({ duration: 200, curve: Curve.EaseOut })
                .onClick(() => {
                  this.isPracticing = !this.isPracticing
                  if (this.isPracticing) {
                    this.practiceMinutes += 1
                  }
                })
              Text('⏹ 结束')
                .fontSize(13)
                .fontColor('#F5C6A5')
                .padding({ left: 22, right: 22, top: 12, bottom: 12 })
                .borderRadius(22)
                .margin({ left: 12 })
                .border({ width: 1, color: '#F5C6A5' })
                .onClick(() => {
                  this.isPracticing = false
                  this.practiceMinutes = 0
                })
            }
            .margin({ top: 18 })
          }
          .width('100%')
          .padding({ top: 26, bottom: 26 })
          .linearGradient({
            angle: 135,
            colors: [['#1E3A2F', 0], ['#2D5A47', 1]]
          })
          .borderRadius(20)
          .alignItems(HorizontalAlign.Center)
          .margin({ top: 10, left: 12, right: 12 })

计时大卡是练琴房页面的视觉中心。卡片使用墨绿色渐变背景(从#1E3A2F#2D5A47),与头部组件的渐变方向一致但色标不同,形成视觉上的呼应和层次。练习时长使用超大字号(72)的等宽字体显示,等宽字体确保数字变化时不会出现宽度的跳动——例如从"9"变为"10"时,两个字符的总宽度与一位数字时相同(因为等宽字体中每位数字宽度相等),这在计时器类UI中非常重要。

"开始练习"按钮在非练习状态下使用1.05倍的放大效果和200毫秒的过渡动画,给用户一种"按钮在主动邀请你点击"的视觉暗示。点击按钮会切换isPracticing状态,如果切换为true则同时将practiceMinutes加1。结束按钮使用描边样式(border属性)而非填充背景,在视觉上弱化其重要性,符合"开始"是主要操作、"结束"是次要操作的设计原则。

8.3 练习计划列表与完成态切换

          ForEach(mockPlans, (p: PlanItem) => {
            Row() {
              Text(p.done ? '✅' : '⬜')
                .fontSize(17)
                .onClick(() => { p.done = !p.done })
              Column() {
                Text(p.name)
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(p.done ? '#A8A29E' : '#1C1917')
                  .decoration({ type: p.done ? TextDecorationType.LineThrough : TextDecorationType.None })
                Text(p.minutes + ' 分钟 · ' + p.days)
                  .fontSize(9)
                  .fontColor('#78716C')
                  .margin({ top: 3 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              .margin({ left: 12 })

              Text('✏️')
                .fontSize(14)
                .onClick(() => {
                  this.pickedPlan = p
                  this.showEditModal = true
                })
              Text('🗑️')
                .fontSize(14)
                .margin({ left: 16 })
                .onClick(() => {
                  this.pickedPlan = p
                  this.showDeleteModal = true
                })
            }
            .width('100%')
            .padding(13)
            .backgroundColor('#FFFFFF')
            .borderRadius(14)
            .margin({ top: 8, left: 12, right: 12 })
          }, (p: PlanItem) => p.id.toString())

练习计划列表使用ForEach遍历mockPlans数组。每条计划项使用Row布局:左侧是完成状态勾选框(✅/⬜ emoji),中间是计划名称和时长信息,右侧是编辑和删除按钮。

完成状态的切换通过点击勾选框实现,p.done = !p.done直接修改了数据对象的属性。这里有一个值得注意的技术细节:mockPlans数组中的元素是PlanItemData类的实例(class实例),而ForEach遍历时将其声明为PlanItem接口类型。在ArkTS中,直接修改通过ForEach传入的数据对象的属性不会自动触发UI刷新(因为p不是@State变量),但如果p是某个@State数组的元素引用,则可能触发刷新。在实际的鸿蒙运行时中,class实例的属性修改行为取决于其是否被状态管理系统追踪。

已完成计划项的视觉表现通过多个条件样式实现:标题文字变灰色、添加删除线、勾选框变为打勾状态。这种"完成态"的视觉降级设计帮助用户快速区分已完成和待完成的任务。

8.4 周练习柱状图

          Column() {
            Text('📊 本周练习时长(分钟)')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1C1917')
              .width('100%')
            Row() {
              ForEach([25, 40, 15, 55, 30, 90, 45], (v: number) => {
                Column() {
                  Column()
                    .width(16)
                    .height((v * 1.1).toFixed(0) + 'vp')
                    .backgroundColor(v >= 80 ? '#C2410C' : '#1E3A2F')
                    .borderRadius({ topLeft: 4, topRight: 4 })
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Center)
                .justifyContent(FlexAlign.End)
                .height(105)
              }, (v: number) => v.toString())
            }
            .width('100%')
            .alignItems(VerticalAlign.Bottom)
            .margin({ top: 8 })
            Row() {
              ForEach(['一', '二', '三', '四', '五', '六', '日'], (d: string) => {
                Text(d)
                  .fontSize(8)
                  .fontColor('#A8A29E')
                  .layoutWeight(1)
                  .textAlign(TextAlign.Center)
              }, (d: string) => d)
            }
            .width('100%')
            .margin({ top: 4 })
          }
          .width('100%')
          .padding(16)
          .backgroundColor('#FFFFFF')
          .borderRadius(18)
          .margin({ top: 16, left: 12, right: 12, bottom: 16 })

周练习柱状图是一个纯ArkTS实现的简易数据可视化组件,无需引入任何图表库。柱状图使用Row布局水平排列七个柱子,每个柱子是一个嵌套的Column结构:外层Column提供固定高度(105vp)和底部对齐(justifyContent(FlexAlign.End)),内层空Column作为实际的柱体,其高度通过height属性动态计算。

柱体高度的计算公式是(v * 1.1).toFixed(0) + 'vp',其中v是练习时长(分钟),乘以1.1是缩放系数,toFixed(0)将结果四舍五入为整数,最后拼接’vp’单位字符串。例如,时长90分钟的柱子高度为99vp。这种将数值计算结果拼接为尺寸字符串的方式是ArkTS中实现动态尺寸的常用模式。

柱体颜色通过v >= 80条件判断:练习时长达到或超过80分钟的柱子使用赭石色(高亮强调),其余使用墨绿色。这种条件着色让用户一眼就能识别出哪些天的练习量较大。borderRadius只设置了上方两个圆角(topLefttopRight),使柱子底部紧贴基线,视觉上更加稳固。

柱状图下方使用另一个ForEach渲染星期标签(一至日),每个标签通过layoutWeight(1)等分宽度,与上方的柱子一一对齐。

这个柱状图的实现展示了ArkTS的"声明式UI即数据可视化"能力。传统前端开发中,柱状图通常需要借助Canvas或第三方图表库来实现,但在ArkTS中,通过ForEach+条件样式+动态尺寸的组合,就能用纯声明式语法构建出可接受的数据可视化效果。当然,对于更复杂的图表需求(如折线图、饼图、散点图),仍然推荐使用专业的图表组件库或Canvas绘制。但在"够用就好"的快速原型开发中,这种纯声明式的实现方式具有极低的开发成本和极好的可维护性。

九、乐理页面:和弦网格与知识测验

9.1 乐理页状态与每日一题入口

@Component
struct TheoryContent {
  @State showChordModal: boolean = false
  @State showQuizModal: boolean = false
  @State pickedChord: ChordShape | null = null
  @State quizPicked: string = ''
  @State quizScore: number = 0

乐理页面定义了五个状态变量。showChordModalshowQuizModal控制和弦指法弹窗和每日一题弹窗的显示。pickedChord存储当前选中的和弦对象。quizPicked存储用户在每日一题中选择的答案选项。quizScore记录用户的答题积分。

          Row() {
            Column() {
              Text('🧠 每日一题')
                .fontSize(15)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FAF6EF')
              Text('今日还未作答 · 连对 6 天')
                .fontSize(10)
                .fontColor('#D6E5DC')
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Text('去答题 >')
              .fontSize(12)
              .fontColor('#1E3A2F')
              .fontWeight(FontWeight.Bold)
              .padding({ left: 16, right: 16, top: 9, bottom: 9 })
              .backgroundColor('#FAF6EF')
              .borderRadius(15)
              .onClick(() => { this.showQuizModal = true })
          }
          .width('100%')
          .padding(16)
          .linearGradient({
            angle: 135,
            colors: [['#1E3A2F', 0], ['#C2410C', 1]]
          })
          .borderRadius(18)
          .margin({ top: 10, left: 12, right: 12 })

每日一题入口卡使用了双色渐变背景——从墨绿色#1E3A2F渐变到赭石色#C2410C,这种双色渐变比单色渐变更具视觉冲击力,能够有效吸引用户的注意力。卡片左侧是标题和状态描述,右侧是"去答题"按钮。点击按钮会打开每日一题弹窗。

9.2 和弦速查宫格与Flex布局

          Flex({ wrap: FlexWrap.Wrap }) {
            ForEach(mockChords, (c: ChordShape) => {
              Column() {
                Text(c.name)
                  .fontSize(17)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#1E3A2F')
                  .fontFamily('monospace')
                Text(c.type)
                  .fontSize(8)
                  .fontColor('#78716C')
                  .margin({ top: 3 })
                Text(c.difficulty)
                  .fontSize(8)
                  .fontColor(c.difficulty === '高级' ? '#C2410C' : '#3F6212')
                  .padding({ left: 7, right: 7, top: 2, bottom: 2 })
                  .backgroundColor(c.difficulty === '高级' ? '#FFF7ED' : '#E7EFE9')
                  .borderRadius(7)
                  .margin({ top: 4 })
              }
              .width('28%')
              .alignItems(HorizontalAlign.Center)
              .padding({ top: 13, bottom: 13 })
              .backgroundColor('#FFFFFF')
              .borderRadius(14)
              .margin(7)
              .scale({ x: 1.0, y: 1.0 })
              .animation({ duration: 120, curve: Curve.EaseOut })
              .onClick(() => {
                this.pickedChord = c
                this.showChordModal = true
              })
            }, (c: ChordShape) => c.id.toString())
          }
          .width('100%')
          .padding({ left: 6, right: 6, top: 8 })

和弦速查宫格使用了Flex布局容器,这是本应用中唯一使用Flex的地方。Flex是ArkTS中最灵活的布局容器,它结合了Row和Column的能力,支持子元素的换行排列。wrap: FlexWrap.Wrap指定当子元素在一行中放不下时自动换行,形成网格状的排列效果。

每个和弦卡片设置了width('28%'),这意味着一行可以容纳约3个卡片(3×28%=84%,加上margin间距后恰好填满一行)。margin(7)为卡片四周添加了7vp的外边距,使卡片之间保持适当的间距。点击和弦卡片会设置pickedChord并打开和弦指法弹窗。

和弦名称使用等宽字体显示,和弦类型和难度标签使用小字号。难度标签的颜色和背景通过c.difficulty === '高级'条件判断,高级和弦使用赭石色系(警示色),入门和进阶和弦使用绿色系(安全色),这种颜色编码帮助用户快速识别和弦的难度等级。

Flex布局是ArkTS布局系统中最强大的容器。它不仅支持主轴和交叉轴的双向排列,还支持子元素的自动换行、弹性伸缩、对齐控制等高级特性。在本应用中,Flex主要用于需要"网格化自动排列"的场景。Flex的wrap属性是实现网格布局的关键——当子元素的总宽度超过Flex容器宽度时,超出的子元素会自动换到下一行。这种自适应排列使得和弦宫格在不同屏幕尺寸下都能合理展示,无需手动计算每行应放置多少个卡片。

9.3 和弦掌握度进度条

            ForEach([['入门和弦', 90], ['大横按', 55], ['七和弦', 30], ['挂留与加音', 15]], (s: string[]) => {
              Column() {
                Row() {
                  Text(s[0])
                    .fontSize(11)
                    .fontColor('#44403C')
                    .layoutWeight(1)
                  Text(s[1] + '%')
                    .fontSize(11)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#1E3A2F')
                }
                .width('100%')
                Column() {
                  Column()
                    .width(s[1] + '%')
                    .height(7)
                    .backgroundColor(s[1] >= '60' ? '#1E3A2F' : '#C2410C')
                    .borderRadius(4)
                }
                .width('100%')
                .height(7)
                .backgroundColor('#FFFBF2')
                .borderRadius(4)
                .margin({ top: 5 })
              }
              .width('100%')
              .margin({ top: 10 })
            }, (s: string[]) => s[0])

和弦掌握度进度条使用ForEach遍历一个包含四个子数组的二维数组,每个子数组包含类别名称和百分比数值。进度条的实现非常巧妙:外层Column作为轨道,设置固定高度(7vp)和浅色背景;内层空Column作为填充条,其width通过s[1] + '%'动态设置为百分比字符串。

这里有一个值得分析的技术细节:s[1]的值来自数组[90, 55, 30, 15]等,它们是number类型。在ArkTS中,将number与字符串拼接时会自动调用toString()转换,所以s[1] + '%'的结果是"90%"、"55%"等有效的百分比尺寸字符串。

进度条颜色通过s[1] >= '60'判断——这里实际上是在进行字符串比较而非数值比较。在JavaScript/ArkTS中,字符串之间的比较是按字典序进行的,‘90’ >= ‘60’为true(因为’9’的字符编码大于’6’),‘55’ >= '60’为false,‘30’ >= '60’为false,‘15’ >= '60’为false。虽然结果碰巧是正确的(90%用绿色,其余用赭石色),但这种基于字符串而非数值的比较是一种潜在的技术债务,在更复杂的场景中可能导致错误。

9.4 和弦指法网格弹窗

  @Builder chordModal() {
    Column() {
      this.modalOverlay(() => { this.showChordModal = false })
      Column() {
        Text(this.pickedChord === null ? '' : this.pickedChord.name)
          .fontSize(28)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1E3A2F')
          .fontFamily('monospace')
          .margin({ top: 20 })
        Text(this.pickedChord === null ? '' : this.pickedChord.type + ' · ' + this.pickedChord.difficulty)
          .fontSize(11)
          .fontColor('#78716C')
          .margin({ top: 4 })

        Column() {
          ForEach([0, 1, 2, 3, 4], (fret: number) => {
            Row() {
              ForEach([0, 1, 2, 3, 4, 5], (str: number) => {
                Column() {
                  if (fret === 0) {
                    Text('—')
                      .fontSize(12)
                      .fontColor('#1C1917')
                  } else {
                    Text(getChordDot(this.pickedChord === null ? '' : this.pickedChord.frets, str, fret))
                      .fontSize(15)
                      .fontColor('#C2410C')
                  }
                }
                .width(34)
                .height(34)
                .alignItems(HorizontalAlign.Center)
                .justifyContent(FlexAlign.Center)
              }, (str: number) => str.toString())
            }
          }, (fret: number) => fret.toString())
        }
        .padding(12)
        .backgroundColor('#FFFBF2')
        .borderRadius(14)
        .margin({ top: 14 })

和弦指法弹窗的核心是指法网格,这是一个使用嵌套ForEach构建的二维网格。外层ForEach遍历品格数组[0, 1, 2, 3, 4](5个品),内层ForEach遍历弦序数组[0, 1, 2, 3, 4, 5](6根弦),共生成5×6=30个网格单元。每个单元是一个34×34vp的Column,内部通过条件判断显示不同的内容。

fret === 0(第一行,即开放弦位置)时,显示横线’—‘表示弦的开放位置;当fret > 0时,调用getChordDot函数获取该位置应该显示的标记——如果该弦该品需要按弦则返回圆点’●’,否则返回空字符串。整个网格通过函数计算动态生成,完全由和弦数据的frets字段驱动。

和弦指法网格渲染流程

外层ForEach: 品格
0,1,2,3,4

内层ForEach: 琴弦
0,1,2,3,4,5

fret === 0?

显示'—'
开放弦标记

调用getChordDot
(frets, str, fret)

该位置需按弦?

显示'●'
赭石色圆点

显示空字符串
无标记

34x34vp网格单元

5行×6列指法网格
可视化呈现

9.5 每日一题弹窗

  @Builder quizModal() {
    Column() {
      this.modalOverlay(() => { this.showQuizModal = false })
      Column() {
        Text('🧠 乐理每日一题')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1C1917')
          .margin({ top: 18 })
        Text('答对 +5 积分 · 当前积分 ' + this.quizScore.toString())
          .fontSize(10)
          .fontColor('#C2410C')
          .margin({ top: 4 })

        Text('C 大调的属七和弦是?')
          .fontSize(14)
          .fontColor('#1C1917')
          .margin({ top: 14 })
          .padding({ left: 20, right: 20 })
          .textAlign(TextAlign.Center)

        ForEach(['G7', 'Dm7', 'Cmaj7', 'Em7'], (o: string) => {
          Text(o)
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(this.quizPicked === o ? '#FAF6EF' : '#1C1917')
            .width('70%')
            .textAlign(TextAlign.Center)
            .padding({ top: 11, bottom: 11 })
            .backgroundColor(this.quizPicked === o ? '#1E3A2F' : '#FFFBF2')
            .borderRadius(14)
            .margin({ top: 8 })
            .scale({
              x: this.quizPicked === o ? 1.03 : 1.0,
              y: this.quizPicked === o ? 1.03 : 1.0
            })
            .animation({ duration: 130, curve: Curve.EaseOut })
            .onClick(() => {
              this.quizPicked = o
              if (o === 'G7') {
                this.quizScore += 5
              }
            })
        }, (o: string) => o)

每日一题弹窗展示了一道选择题。题目"C 大调的属七和弦是?“是一道乐理知识题,正确答案是"G7”(C大调的属音是G,属七和弦即G7)。四个选项使用ForEach渲染,每个选项的选中态通过this.quizPicked === o判断:选中项使用深色背景和浅色文字,并放大到1.03倍,配合130毫秒的过渡动画形成轻微的"弹出"效果。

点击选项时,quizPicked被设为该选项的值,如果选中的是正确答案"G7",则quizScore增加5分。积分值的变化会实时反映在弹窗顶部的"当前积分"文字中,形成即时的正反馈。这种"答对即加分、实时显示积分"的设计为用户提供了即时的学习激励。

十、个人中心页面:资料卡与功能列表

10.1 资料卡布局

          Column() {
            Row() {
              Text('🎸')
                .fontSize(32)
                .width(66)
                .height(66)
                .textAlign(TextAlign.Center)
                .backgroundColor('#E7EFE9')
                .borderRadius(33)
              Column() {
                Text('六弦学徒小陈')
                  .fontSize(17)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#1C1917')
                Text('琴龄 142 天 · 已连续练习 42 天')
                  .fontSize(10)
                  .fontColor('#78716C')
                  .margin({ top: 3 })
                Text('🎓 已完成 3 门课程 · 在学 2 门')
                  .fontSize(9)
                  .fontColor('#C2410C')
                  .margin({ top: 3 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              .margin({ left: 14 })
            }
            .width('100%')

个人中心页面的资料卡使用Row布局将头像和信息水平排列。头像使用emoji字符配合borderRadius(33)(半径=直径/2=66/2=33)形成圆形背景。右侧的Column通过layoutWeight(1)占据剩余空间,依次显示用户名、练习统计和课程进度。注意三行文字使用了递减的字号(17→10→9),形成了清晰的信息层级——最重要(用户名)最大,次要信息逐渐缩小。

10.2 统计数据栏

            Row() {
              Column() {
                Text('142h')
                  .fontSize(15)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#1E3A2F')
                Text('累计练习')
                  .fontSize(9)
                  .fontColor('#78716C')
                  .margin({ top: 2 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
              Column() {
                Text('24')
                  .fontSize(15)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#1E3A2F')
                Text('曲谱收藏')
                  .fontSize(9)
                  .fontColor('#78716C')
                  .margin({ top: 2 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
              Column() {
                Text('16')
                  .fontSize(15)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#1E3A2F')
                Text('掌握和弦')
                  .fontSize(9)
                  .fontColor('#78716C')
                  .margin({ top: 2 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
              Column() {
                Text('1260')
                  .fontSize(15)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#C2410C')
                Text('积分')
                  .fontSize(9)
                  .fontColor('#78716C')
                  .margin({ top: 2 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
            }
            .width('100%')
            .margin({ top: 18 })

统计数据栏使用Row布局水平排列四组数据,每组数据是一个Column,包含大字号的数值和小字号的标签。四组数据通过layoutWeight(1)等分宽度,alignItems(HorizontalAlign.Center)使内容居中对齐。前三组数据(累计练习、曲谱收藏、掌握和弦)使用墨绿色,最后一组(积分)使用赭石色,通过色彩区分强调了积分的特殊性——积分是可消费的虚拟货币,与其他纯展示性数据有本质区别。

10.3 功能列表与Divider分隔线

          Column() {
            Row() {
              Text('👑')
                .fontSize(16)
              Text('开通会员')
                .fontSize(13)
                .fontColor('#1C1917')
                .margin({ left: 12 })
                .layoutWeight(1)
              Text('未开通')
                .fontSize(10)
                .fontColor('#B45309')
              Text('>')
                .fontSize(13)
                .fontColor('#D6D3D1')
                .margin({ left: 8 })
            }
            .width('100%')
            .padding({ top: 15, bottom: 15 })
            .onClick(() => { this.showVipModal = true })
            Divider().color('#F5F5F4')

功能列表的每一项使用Row布局:左侧emoji图标,中间文字标签(layoutWeight(1)占据剩余空间),右侧状态文字和箭头符号。各项之间使用Divider组件添加分隔线,Divider是ArkTS内置的分割线组件,通过.color('#F5F5F4')设置极浅的灰色,既能区分各功能项又不会过于显眼。

功能列表共包含五项:开通会员、我的订单、我的成就、意见反馈和关于。每项的点击行为各不相同:开通会员打开VIP弹窗,意见反馈打开反馈表单弹窗,关于打开关于弹窗,其余项的onClick传入空函数(预留接口)。这种"先搭建UI骨架、后填充业务逻辑"的开发模式在原型阶段非常高效。

10.4 会员开通弹窗

  @Builder vipModal() {
    Column() {
      this.modalOverlay(() => { this.showVipModal = false })
      Column() {
        Text('👑 CHORDX 会员')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#B45309')
          .margin({ top: 18 })
        Text('全部曲谱免费看 · 会员课程 6 折')
          .fontSize(10)
          .fontColor('#78716C')
          .margin({ top: 4 })

        Row() {
          ForEach([['连续包月', '¥18'], ['季卡', '¥48'], ['年卡', '¥158']], (p: string[]) => {
            Column() {
              Text(p[1])
                .fontSize(17)
                .fontWeight(FontWeight.Bold)
                .fontColor('#B45309')
              Text(p[0])
                .fontSize(10)
                .fontColor('#78716C')
                .margin({ top: 3 })
              if (p[0] === '年卡') {
                Text('立省 ¥58')
                  .fontSize(8)
                  .fontColor('#C2410C')
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                  .backgroundColor('#FFF7ED')
                  .borderRadius(6)
                  .margin({ top: 4 })
              }
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
            .padding({ top: 16, bottom: 16 })
            .backgroundColor('#FFFBF2')
            .borderRadius(14)
            .margin({ left: 5, right: 5 })
            .onClick(() => {})
          }, (p: string[]) => p[0])
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 16 })

会员弹窗展示了三种套餐选项(包月、季卡、年卡),使用ForEach遍历二维数组渲染。每个套餐使用layoutWeight(1)等分宽度,年卡套餐额外显示"立省 ¥58"的促销标签(通过if (p[0] === '年卡')条件渲染)。所有价格使用棕色#B45309,与VIP主题色保持一致。

10.5 意见反馈表单弹窗

  @Builder feedbackModal() {
    Column() {
      this.modalOverlay(() => { this.showFeedbackModal = false })
      Column() {
        Text('📮 意见反馈')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1C1917')
          .margin({ top: 18 })

        Column() {
          Text('反馈类型')
            .fontSize(11)
            .fontColor('#78716C')
            .width('100%')
          Row() {
            ForEach(['功能建议', '曲谱问题', '播放异常', '其他'], (t: string) => {
              Text(t)
                .fontSize(10)
                .fontColor('#1E3A2F')
                .padding({ left: 10, right: 10, top: 7, bottom: 7 })
                .backgroundColor('#E7EFE9')
                .borderRadius(12)
                .margin({ right: 6 })
                .onClick(() => {})
            }, (t: string) => t)
          }
          .margin({ top: 8 })
        }
        .width('100%')
        .padding(13)
        .backgroundColor('#FFFFFF')
        .borderRadius(14)
        .margin({ top: 14 })

        Column() {
          Text('详细描述')
            .fontSize(11)
            .fontColor('#78716C')
            .width('100%')
          TextInput({ placeholder: '说说你的想法…', text: this.feedbackText })
            .fontSize(12)
            .fontColor('#1C1917')
            .placeholderColor('#C8C2BC')
            .backgroundColor('#FFFBF2')
            .borderRadius(10)
            .padding({ left: 12, right: 12 })
            .height(56)
            .margin({ top: 6 })
            .onChange((v: string) => { this.feedbackText = v })
        }

反馈弹窗包含两个表单区域:反馈类型选择器(使用ForEach渲染四个可点击的标签)和详细描述文本输入框。TextInput是ArkTS的文本输入组件,通过placeholder设置占位提示文字,text绑定状态变量实现双向数据同步。onChange回调在用户输入时被触发,将输入值同步到feedbackText状态变量。height(56)为输入框提供了较大的高度,适合多行文本的输入。

10.6 关于弹窗

  @Builder aboutModal() {
    Column() {
      this.modalOverlay(() => { this.showAboutModal = false })
      Column() {
        Text('🎼')
          .fontSize(44)
          .margin({ top: 22 })
        Text('琴弦手册 CHORDX')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1C1917')
          .margin({ top: 10 })
        Text('Version 1.9.3 (Build 872)')
          .fontSize(10)
          .fontColor('#A8A29E')
          .margin({ top: 4 })
        Text('让每个人都能读懂音乐\n已陪伴 380 万学员开始他们的第一课')
          .fontSize(11)
          .fontColor('#78716C')
          .textAlign(TextAlign.Center)
          .margin({ top: 12 })
        Text('用户协议 · 隐私政策 · 教师入驻')
          .fontSize(10)
          .fontColor('#C2410C')
          .margin({ top: 14 })
        Text('关闭')
          .fontSize(13)
          .fontColor('#78716C')
          .padding({ left: 30, right: 30, top: 9, bottom: 9 })
          .backgroundColor('#FFFBF2')
          .borderRadius(16)
          .margin({ top: 14, bottom: 20 })
          .onClick(() => { this.showAboutModal = false })
      }
      .width('78%')
      .backgroundColor('#FFFFFF')
      .borderRadius(20)
      .alignItems(HorizontalAlign.Center)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
  }

关于弹窗是应用中最简洁的弹窗,居中展示应用图标、名称、版本号、品牌口号和法律链接。版本号"Version 1.9.3 (Build 872)"使用灰色小字显示,品牌口号使用\n换行符分为两行,居中对齐。底部的"用户协议 · 隐私政策 · 教师入驻"链接使用赭石色,与应用的主题强调色一致。关闭按钮使用浅色背景的药丸形按钮,点击后关闭弹窗。

onClick

onClick

onClick

个人中心页面 MeContent

资料卡区域

功能列表区域

弹窗层 Stack

头像与昵称 Row

统计数据栏 Row
4个等分Column

开通会员 → vipModal

我的订单 → 预留

我的成就 → 预留

意见反馈 → feedbackModal

关于 → aboutModal

VIP弹窗
套餐选择+权益说明

反馈弹窗
类型选择+文本输入

关于弹窗
应用信息+版本号

十一、组件架构与数据流总览

11.1 组件树结构

整个应用的组件树以ChordxApp为根节点,向下分为内容区域和底部导航栏两大分支。内容区域根据activeTab状态值动态切换五个子组件,每个子组件内部又包含NoteHeader公共头部、主内容区和弹窗层。这种"根组件管理全局状态、子组件管理局部状态"的分层架构是中型应用推荐的组织方式。

ChordxApp (根组件)
@Entry @Component
@State: activeTab

@Builder contentArea

Row 底部导航栏

CourseContent
课程页

SheetContent
曲库页

PracticeContent
练琴房页

TheoryContent
乐理页

MeContent
我的页

NoteHeader
公共头部

Scroll 主内容

3个弹窗
详情/购买/优惠券

NoteHeader

Scroll 主内容

3个弹窗
曲谱/纠错/节拍器

NoteHeader

Scroll 主内容

3个弹窗
新建/编辑/删除计划

NoteHeader

Scroll 主内容

2个弹窗
和弦指法/每日一题

资料卡

功能列表

3个弹窗
VIP/反馈/关于

bottomTabItem 课程

bottomTabItem 曲库

bottomTabItem 练琴房

bottomTabItem 乐理

bottomTabItem 我的

11.2 状态管理数据流

activeTab

pickedLevel

pickedInstrument

showXxxModal

selectedXxx

bpmValue

isPracticing

quizScore

用户交互

onClick事件

@State变量赋值

框架检测状态变化

差分渲染算法

UI自动更新

Tab切换

课程筛选

曲谱筛选

弹窗开关

选中数据回显

节拍器数值

练习计时状态

答题积分

11.3 弹窗管理系统

本应用共有14个弹窗(@Builder函数),分布在五个Tab页面中。所有弹窗都采用相同的架构模式:Stack布局 + 状态变量控制 + modalOverlay遮罩。这种统一的弹窗架构模式使得弹窗的创建、显示、关闭和级联切换都具有高度一致的代码结构和交互行为。

在实际的大型项目中,当弹窗数量超过一定阈值时,通常会考虑提取一个公共的弹窗管理器或使用ArkTS原生的CustomDialog控制器来统一管理弹窗的生命周期、动画和层级关系。但在本应用的单文件架构中,每个组件内部管理自己的弹窗状态是一种合理的工程选择——它保持了代码的局部性和自包含性,使得修改某个页面的弹窗逻辑时不会影响其他页面。

十二、核心技术点深度总结

回顾整篇代码,我们从类型系统的接口定义开始,逐步分析了数据模型的类实现与构造函数模式、工具函数的业务逻辑封装、枚举类型的路由控制、装饰器体系的状态管理与UI构建、Stack/Column/Row/Flex四种布局容器的组合运用、ForEach列表渲染与条件过滤、线性渐变与动画效果的视觉增强、TextInput表单输入与双向数据绑定、多级弹窗的级联架构等核心技术点。每一个技术点都不是孤立存在的,它们相互配合、协同工作,共同构建了一个功能完备、交互丰富、视觉精致的乐器学习应用。

ArkTS的@State装饰器是整个响应式系统的基石。它通过 Proxy 拦截或 setter 拦截机制,在变量被赋值时通知框架执行差分渲染。在本应用中,从Tab切换到筛选过滤,从弹窗开关到计时器数值,从答题积分到表单输入,所有交互行为都最终通过修改@State变量来触发UI更新。这种"状态即真理源"的编程范式使得开发者只需关注"状态应该是什么"和"UI如何根据状态渲染",而无需手动操作DOM或管理渲染流程,极大地简化了复杂交互的开发。

@Component装饰器定义了可复用的自定义组件。每个自定义组件拥有独立的build方法、独立的状态空间和独立的生命周期。在本应用中,六个自定义组件(ChordxApp、NoteHeader、CourseContent、SheetContent、PracticeContent、TheoryContent、MeContent)各司其职,通过属性传值和回调函数进行通信。这种组件化架构使得每个组件可以独立开发、独立测试、独立维护,代码的内聚性和可维护性都得到了充分保障。

@Builder装饰器提供了一种轻量级的UI片段复用机制。与@Component不同,@Builder函数不需要定义独立的struct,它更类似于"渲染函数"——接收参数,返回UI描述。在本应用中,contentArea、bottomTabItem和modalOverlay三个@Builder函数分别实现了内容区域路由、导航项复用和弹窗遮罩复用的功能。@Builder的参数传递是值传递(按值捕获),这意味着在函数内部对参数的修改不会影响外部,这一特性在处理事件回调时需要特别注意。

ForEach是ArkTS实现列表渲染的核心组件。它通过key生成函数追踪列表项的身份,通过渲染函数定义每个列表项的UI结构,通过条件判断实现数据过滤。在本应用中,ForEach被广泛用于课程列表、曲谱列表、练习计划列表、和弦宫格、难度圆点、柱状图、拍号指示器、选项按钮等各种列表和网格场景。合理设置key是优化ForEach性能的关键——key应该唯一且稳定,避免使用数组索引作为key(因为数组重排时索引会变化,导致框架无法正确追踪项的身份)。

Stack布局是弹窗架构的基础。Stack的堆叠特性使得弹窗可以覆盖在内容区域之上,后放入的子元素(弹窗)自然位于先放入的子元素(内容)之上。配合条件渲染,弹窗的显示和隐藏完全由状态变量控制,无需手动操作z-index或DOM层级。这种"Stack+条件渲染+状态变量"的三位一体模式,是本应用所有14个弹窗的统一实现方案。

Flex布局提供了最灵活的排列能力。通过wrap属性实现自动换行,Flex可以创建自适应的网格布局,无需手动计算行列数。在本应用中,和弦速查宫格使用了Flex+Wrap的方案,每行自动容纳3-4个卡片,在不同屏幕宽度下都能合理展示。

Column和Row是最基础的布局容器。Column实现垂直排列,Row实现水平排列,它们通过layoutWeight、alignItems、justifyContent等属性控制子元素的尺寸分配和对齐方式。在本应用中,几乎所有的UI结构都是Column和Row的嵌套组合——从最外层的页面骨架到最内层的卡片内容,层层嵌套的Column和Row构建了整个应用的布局体系。

linearGradient线性渐变和animation动画属性是视觉增强的两大手段。渐变背景为关键视觉区域(头部、计时卡、入口卡)营造了层次感和品质感;动画属性为状态切换(Tab切换、按钮点击、选项选中)添加了流畅的过渡效果。这些视觉细节虽然不影响功能逻辑,但对用户体验的感知质量有着显著影响。

十三、全面对比表

以下是本应用中涉及的所有关键组件、属性、方法和装饰器的特性对比:

序号 名称 类别 用途 关键特性 使用场景 性能考量
1 @Entry 装饰器 标记应用入口组件 每个应用仅一个 根组件ChordxApp 无额外开销
2 @Component 装饰器 标记自定义组件 提供build方法和状态管理 所有自定义组件 创建组件实例有固定开销
3 @State 装饰器 声明响应式状态变量 赋值时自动触发UI刷新 activeTab、showXxxModal等 差分渲染有计算成本
4 @Builder 装饰器 声明UI构建函数 可被多次调用复用UI片段 contentArea、bottomTabItem、modalOverlay 值传递参数捕获
5 interface 类型定义 定义数据接口契约 编译期类型检查 CourseItem、SongSheet等 零运行时开销
6 class 类型定义 定义可实例化的类 实现接口、构造函数 CourseItemData等 实例化有内存开销
7 enum 类型定义 定义命名常量集合 编译期合法性检查 ChordTab 零运行时开销
8 Column 布局容器 垂直排列子元素 主轴垂直,交叉轴水平 几乎所有UI结构 子元素数量影响布局计算
9 Row 布局容器 水平排列子元素 主轴水平,交叉轴垂直 导航栏、卡片行、按钮行 子元素数量影响布局计算
10 Stack 布局容器 堆叠排列子元素 Z轴堆叠,后者覆盖前者 弹窗层叠场景 层叠数量影响渲染
11 Flex 布局容器 弹性排列子元素 支持自动换行 和弦宫格 换行计算有额外开销
12 Scroll 滚动容器 提供滚动能力 仅允许单个直接子组件 课程目录、曲谱列表等 内容量影响内存占用
13 ForEach 渲染组件 列表循环渲染 需要key生成函数 所有列表和网格场景 key策略影响更新性能
14 Text 基础组件 显示文本 支持fontSize、fontColor等 所有文本展示 文本长度影响测量
15 TextInput 基础组件 文本输入 onChange回调双向绑定 纠错表单、反馈表单 输入频繁触发onChange
16 Divider 基础组件 分隔线 可设置颜色 功能列表项之间 极低开销
17 layoutWeight 布局属性 分配剩余空间权重 值为1时占据全部剩余空间 内容区域、等分列 触发二次布局
18 linearGradient 样式属性 线性渐变背景 支持角度和多色标 头部、计时卡、入口卡 渲染开销高于纯色
19 animation 动画属性 添加过渡动画 duration和curve参数 Tab切换、按钮点击 动画期间持续渲染
20 scale 变换属性 缩放变换 x和y轴独立设置 Tab图标、选项按钮 GPU加速变换
21 opacity 样式属性 设置透明度 0完全透明,1完全不透明 Tab未选中态 影响合成层
22 borderRadius 样式属性 设置圆角 支持四角独立设置 卡片、按钮、头像 触发裁剪渲染
23 shadow 样式属性 设置阴影 radius、color、offsetY 底部导航栏 增加渲染层
24 constraintSize 布局属性 约束尺寸范围 maxHeight、minHeight等 课程目录Scroll 限制布局空间
25 scrollable 滚动属性 设置滚动方向 Horizontal、Vertical 横向滚动卡片 按需渲染滚动内容
26 scrollBar 滚动属性 控制滚动条显示 On、Off、Auto 所有Scroll容器 Off减少视觉干扰
27 onClick 事件属性 点击事件处理 回调函数 所有可交互元素 事件冒泡机制
28 onChange 事件属性 输入变化处理 回调接收新值 TextInput 频繁触发需节流
29 if 条件渲染 控制组件创建销毁 条件为假时组件不存在 副标题、弹窗、热门标签 切换时创建/销毁组件
30 maxLines 文本属性 限制文本行数 配合textOverflow使用 课程标题、弹窗标题 影响文本测量
31 textOverflow 文本属性 文本溢出处理 Ellipsis省略号 配合maxLines使用 需先测量文本宽度
32 decoration 文本属性 文本装饰效果 LineThrough删除线 原价、已完成计划 不影响布局
33 fontFamily 样式属性 设置字体族 monospace等宽字体 和弦进行、简谱、计时器 字体加载可能延迟
34 textAlign 对齐属性 文本对齐方式 Center、Start、End 图标、数值居中 不影响布局计算
35 padding 间距属性 设置内边距 可四方向独立设置 几乎所有容器 扩展内容区域
36 margin 间距属性 设置外边距 可四方向独立设置 卡片间距、元素间距 影响外部布局
37 backgroundColor 样式属性 设置背景色 支持十六进制和RGBA 所有可见元素 纯色渲染开销最低
38 fontWeight 文本属性 设置字重 Bold、Medium、Normal 标题、价格、按钮文字 字体子集切换
39 alignItems 对齐属性 交叉轴对齐 Start、Center、End Column和Row子元素对齐 影响子元素位置计算
40 justifyContent 对齐属性 主轴对齐 SpaceBetween、Center、End 弹窗标题行、柱状图底部 影响子元素分布

十四、终章:工程实践与技术沉淀

纵观全文的逐行剖析,这款乐器学习应用的ArkTS源码虽然规模适中(约2600行),但其技术覆盖面和工程实践深度都具有很高的学习价值。从宏观架构到微观实现,每一个层面都体现了鸿蒙原生开发的最佳实践和设计智慧。

在架构层面,应用采用了"根组件+五个Tab子组件"的扁平化组件树结构。根组件通过@State管理的activeTab枚举值控制内容区域的条件渲染,实现了页面路由的效果。每个Tab子组件内部又采用"Stack+Scroll+弹窗"的三层架构:底层是可滚动的内容区域,顶层是条件渲染的弹窗层。这种架构模式简洁清晰,既保证了组件的独立性(每个Tab页面自包含自己的状态和弹窗),又通过NoteHeader公共组件实现了跨页面的UI复用。14个弹窗全部采用"状态变量+条件渲染+modalOverlay遮罩"的统一模式实现,虽然未使用ArkTS原生的CustomDialog控制器,但这种自管理的弹窗方案在灵活性和代码局部性方面具有独特优势。

在类型系统层面,应用定义了四个核心接口(CourseItem、SongSheet、ChordShape、PlanItem),每个接口都有对应的实现类(CourseItemData、SongSheetData、ChordShapeData、PlanItemData)和模拟数据集(mockCourses、mockSongs、mockChords、mockPlans)。这种"接口-类-数据"三层结构实现了类型安全与数据灵活性的平衡:接口提供了编译期的类型保障,类提供了运行时的实例化能力,数据集提供了开发阶段的测试数据。当后端API就绪后,只需将模拟数据替换为API返回的数据(确保符合接口类型),UI层的代码无需任何修改即可正常工作。

在状态管理层面,应用广泛使用了@State装饰器来管理组件内部状态。从Tab切换到筛选过滤,从弹窗开关到计时器数值,从答题积分到表单输入,所有交互行为都通过修改@State变量来触发UI更新。应用没有使用@Prop、@Link等跨组件状态同步装饰器,这是因为各Tab页面之间相对独立,不需要共享状态。NoteHeader通过普通成员变量(非@State)接收父组件传入的配置参数,通过函数类型属性接收回调,这种"配置型属性+回调型属性"的模式是一种轻量级的组件通信方案。

在布局系统层面,应用充分运用了Column、Row、Stack、Flex四种布局容器的组合能力。Column和Row构成了UI的基本骨架(垂直和水平排列),Stack实现了弹窗的层叠覆盖,Flex实现了和弦宫格的自动换行排列。layoutWeight属性在空间分配中发挥了关键作用——它使得内容区域能够填满导航栏以上的空间,使等分的统计栏和筛选标签能够均匀分布。constraintSize属性为Scroll容器提供了高度上限约束,防止内容过长导致布局溢出。

在视觉设计层面,应用采用了"奶油纸感"的设计风格,以米色(#FAF6EF)为背景基调,墨绿色(#1E3A2F)为主色调,赭石色(#C2410C)为强调色。这种配色方案温暖而不失专业感,适合音乐学习类应用的人文气质。linearGradient渐变背景用于头部、计时大卡和每日一题入口等关键视觉区域,营造出层次感和品质感。animation动画属性为Tab切换(180ms EaseOut)、按钮点击(200ms EaseOut)、选项选中(130ms EaseOut)等交互添加了流畅的过渡效果,这些微交互细节显著提升了应用的使用体验。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// 奶油纸感风:米色纸底 + 墨绿主色 + 赭石强调 + 手账元素
// 遵循要求.md:无 Blank、无 UI 内变量声明、constraintSize 限高、单子组件 Scroll、接口约束全部对象字面量

// ============ 类型定义 ============
interface CourseItem {
  id: number;
  title: string;
  teacher: string;
  level: string;
  lessons: number;
  price: number;
  originPrice: number;
  students: number;
  rating: string;
  tag: string;
  purchased: boolean;
}

interface SongSheet {
  id: number;
  name: string;
  artist: string;
  instrument: string;
  keySignature: string;
  difficulty: number;
  bars: number;
  bpm: number;
  chordProgression: string;
  hot: boolean;
}

interface ChordShape {
  id: number;
  name: string;
  type: string;
  frets: string;
  fingers: string;
  difficulty: string;
}

interface PlanItem {
  id: number;
  name: string;
  minutes: number;
  days: string;
  done: boolean;
}

// ============ 静态配置 ============
const DIFFICULTY_DOTS: number = 5;

// ============ 课程数据(18 条) ============
class CourseItemData implements CourseItem {
  id: number = 0;
  title: string = '';
  teacher: string = '';
  level: string = '';
  lessons: number = 0;
  price: number = 0;
  originPrice: number = 0;
  students: number = 0;
  rating: string = '';
  tag: string = '';
  purchased: boolean = false;

  constructor(id: number, title: string, teacher: string, level: string, lessons: number,
    price: number, originPrice: number, students: number, rating: string, tag: string, purchased: boolean) {
    this.id = id;
    this.title = title;
    this.teacher = teacher;
    this.level = level;
    this.lessons = lessons;
    this.price = price;
    this.originPrice = originPrice;
    this.students = students;
    this.rating = rating;
    this.tag = tag;
    this.purchased = purchased;
  }
}

const mockCourses: CourseItem[] = [
  new CourseItemData(1, '吉他入门:从零到弹唱', '老周吉他教室', '入门', 42, 199, 399, 28640, '4.9', '镇店之宝', true),
  new CourseItemData(2, '民谣指弹进阶训练营', '南窗音乐', '进阶', 36, 299, 499, 12310, '4.8', '口碑王', true),
  new CourseItemData(3, '钢琴即兴伴奏速成', '白鹭琴房', '入门', 30, 259, 459, 19870, '4.9', '热销', false),
  new CourseItemData(4, '尤克里里弹唱一本通', '海岛小乐器', '入门', 24, 99, 199, 33120, '4.7', '高性价比', false),
  new CourseItemData(5, '爵士和声实战解析', 'BlueNote研究所', '高级', 28, 459, 699, 4860, '4.9', '进阶必学', false),
  new CourseItemData(6, '贝斯律动完全指南', '低音先生', '进阶', 32, 289, 489, 7520, '4.8', '小众精品', false),
  new CourseItemData(7, '架子鼓零基础十二课', '鼓房老猫', '入门', 12, 159, 299, 15430, '4.6', '短视频同款', false),
  new CourseItemData(8, '电吉他速弹技巧精讲', '失真车间', '高级', 26, 399, 599, 6910, '4.8', '手速党', false),
  new CourseItemData(9, '口琴入门:随身乐队', '口袋蓝调', '入门', 18, 89, 169, 21070, '4.7', '轻量', false),
  new CourseItemData(10, '小提琴音色修炼课', '松香工坊', '进阶', 30, 349, 549, 4380, '4.9', '质感', false),
  new CourseItemData(11, '古典吉他名曲精讲', '六弦诗社', '进阶', 24, 329, 529, 5940, '4.9', '艺术向', false),
  new CourseItemData(12, '键盘和声编配手册', '白鹭琴房', '进阶', 34, 359, 559, 8830, '4.8', '编曲向', false),
  new CourseItemData(13, '非洲鼓节奏宝典', '部落节拍', '入门', 16, 129, 249, 17760, '4.6', '亲子', false),
  new CourseItemData(14, '布鲁斯口琴即兴之路', '口袋蓝调', '进阶', 22, 219, 399, 5240, '4.8', '蓝调魂', false),
  new CourseItemData(15, '乐理速成:看懂五线谱', '音基教研组', '入门', 20, 139, 269, 26540, '4.7', '理论补课', true),
  new CourseItemData(16, '指弹改编实战工坊', '南窗音乐', '高级', 30, 459, 699, 3910, '4.9', '创作向', false),
  new CourseItemData(17, '萨克斯风入门七课', '铜管俱乐部', '入门', 14, 189, 359, 6870, '4.6', '浪漫', false),
  new CourseItemData(18, '编曲制作入门到上架', '家庭工作室', '进阶', 38, 499, 799, 8120, '4.8', '全链路', false)
];

// ============ 曲库数据(24 条) ============
class SongSheetData implements SongSheet {
  id: number = 0;
  name: string = '';
  artist: string = '';
  instrument: string = '';
  keySignature: string = '';
  difficulty: number = 0;
  bars: number = 0;
  bpm: number = 0;
  chordProgression: string = '';
  hot: boolean = false;

  constructor(id: number, name: string, artist: string, instrument: string, keySignature: string,
    difficulty: number, bars: number, bpm: number, chordProgression: string, hot: boolean) {
    this.id = id;
    this.name = name;
    this.artist = artist;
    this.instrument = instrument;
    this.keySignature = keySignature;
    this.difficulty = difficulty;
    this.bars = bars;
    this.bpm = bpm;
    this.chordProgression = chordProgression;
    this.hot = hot;
  }
}

const mockSongs: SongSheet[] = [
  new SongSheetData(1, '晴天', '周杰伦', '吉他', 'G', 3, 98, 74, 'G-D-Em-C', true),
  new SongSheetData(2, '成都', '赵雷', '吉他', 'C', 2, 84, 68, 'C-G-Am-F', true),
  new SongSheetData(3, '海阔天空', 'Beyond', '吉他', 'C', 4, 112, 78, 'C-G-Am-F', true),
  new SongSheetData(4, '夜空中最亮的星', '逃跑计划', '吉他', 'C', 3, 96, 72, 'C-G-Am-F', true),
  new SongSheetData(5, '致爱丽丝', '贝多芬', '钢琴', 'A小调', 5, 118, 66, 'Am-E-...', false),
  new SongSheetData(6, '卡农', '帕赫贝尔', '钢琴', 'D', 5, 132, 88, 'D-A-Bm-F#m-G', true),
  new SongSheetData(7, '梦中的婚礼', '理查德·克莱德曼', '钢琴', 'G', 4, 104, 80, 'G-Em-C-D', false),
  new SongSheetData(8, '小幸运', '田馥甄', '钢琴', 'C', 3, 92, 70, 'C-G-Am-Em-F', true),
  new SongSheetData(9, '平凡之路', '朴树', '吉他', 'Em', 2, 88, 64, 'Em-C-G-D', false),
  new SongSheetData(10, '后来', '刘若英', '钢琴', 'C', 3, 90, 69, 'C-Am-F-G', false),
  new SongSheetData(11, 'Fly Me to the Moon', 'Bart Howard', '尤克里里', 'Am', 3, 76, 116, 'Am-Dm-G-C', false),
  new SongSheetData(12, 'Creep', 'Radiohead', '吉他', 'G', 4, 88, 92, 'G-B-C-Cm', true),
  new SongSheetData(13, 'Tears in Heaven', 'Eric Clapton', '吉他', 'A', 5, 102, 84, 'A-E-F#m-D', false),
  new SongSheetData(14, '天空之城', '久石让', '钢琴', 'Am', 5, 126, 72, 'Am-G-F-E', true),
  new SongSheetData(15, '月亮代表我的心', '邓丽君', '尤克里里', 'C', 2, 80, 62, 'C-Em-F-G', false),
  new SongSheetData(16, 'Rose', '坂本龙一', '钢琴', 'Bm', 5, 94, 58, 'Bm-G-D-A', false),
  new SongSheetData(17, '起风了', '买辣椒也用券', '吉他', 'C', 3, 100, 76, 'C-G-Am-F', true),
  new SongSheetData(18, 'Bossa Nova练习曲 No.3', 'CHORDX教研组', '吉他', 'F', 4, 72, 120, 'Fmaj7-Bm7b5-E7', false),
  new SongSheetData(19, '稻香', '周杰伦', '吉他', 'A', 2, 94, 80, 'A-E-F#m-D', true),
  new SongSheetData(20, 'dont know why', 'Norah Jones', '钢琴', 'Bb', 4, 88, 96, 'Bbm7-Ebm7-F7', false),
  new SongSheetData(21, '星晴', '周杰伦', '吉他', 'G', 3, 90, 82, 'G-D-Em-C', false),
  new SongSheetData(22, '菊次郎的夏天', '久石让', '钢琴', 'C', 5, 140, 84, 'C-Am-F-G', true),
  new SongSheetData(23, '说好的幸福呢', '周杰伦', '钢琴', 'Eb', 4, 96, 66, 'Eb-Bb-Cm-Ab', false),
  new SongSheetData(24, 'Wonderful Tonight', 'Eric Clapton', '吉他', 'G', 3, 86, 52, 'G-D-C-Em', false)
];

// ============ 和弦数据(16 个) ============
class ChordShapeData implements ChordShape {
  id: number = 0;
  name: string = '';
  type: string = '';
  frets: string = '';
  fingers: string = '';
  difficulty: string = '';

  constructor(id: number, name: string, type: string, frets: string, fingers: string, difficulty: string) {
    this.id = id;
    this.name = name;
    this.type = type;
    this.frets = frets;
    this.fingers = fingers;
    this.difficulty = difficulty;
  }
}

const mockChords: ChordShape[] = [
  new ChordShapeData(1, 'C', '大三和弦', 'x32010', '13241', '入门'),
  new ChordShapeData(2, 'G', '大三和弦', '320003', '32114', '入门'),
  new ChordShapeData(3, 'Am', '小三和弦', 'x02210', '13241', '入门'),
  new ChordShapeData(4, 'F', '大三和弦', '133211', '134211', '进阶'),
  new ChordShapeData(5, 'Em', '小三和弦', '022000', '12340', '入门'),
  new ChordShapeData(6, 'Dm', '小三和弦', 'xx0231', '13241', '入门'),
  new ChordShapeData(7, 'D', '大三和弦', 'xx0232', '13241', '入门'),
  new ChordShapeData(8, 'Bm', '小三和弦', 'x24432', '13421', '进阶'),
  new ChordShapeData(9, 'Fmaj7', '大七和弦', 'xx3210', '13240', '入门'),
  new ChordShapeData(10, 'Cmaj7', '大七和弦', 'x32000', '13240', '入门'),
  new ChordShapeData(11, 'G7', '属七和弦', '320001', '32141', '入门'),
  new ChordShapeData(12, 'E7', '属七和弦', '020100', '12040', '入门'),
  new ChordShapeData(13, 'B7', '属七和弦', 'x21202', '21340', '进阶'),
  new ChordShapeData(14, 'Am7', '小七和弦', 'x02010', '12040', '入门'),
  new ChordShapeData(15, 'F#m7b5', '半减七', '2x2210', '23410', '高级'),
  new ChordShapeData(16, 'Cadd9', '加九和弦', 'x32030', '13243', '入门')
];

// ============ 练习计划数据(8 条) ============
class PlanItemData implements PlanItem {
  id: number = 0;
  name: string = '';
  minutes: number = 0;
  days: string = '';
  done: boolean = false;

  constructor(id: number, name: string, minutes: number, days: string, done: boolean) {
    this.id = id;
    this.name = name;
    this.minutes = minutes;
    this.days = days;
    this.done = done;
  }
}

const mockPlans: PlanItem[] = [
  new PlanItemData(1, '爬格子热身 120bpm', 10, '每天', true),
  new PlanItemData(2, 'C-G-Am-F 和弦转换', 15, '每天', true),
  new PlanItemData(3, '《晴天》主歌段落', 20, '每天', false),
  new PlanItemData(4, '指弹基础:靠弦法', 15, '周三·周五', false),
  new PlanItemData(5, '乐理:调式速记', 10, '每周一', true),
  new PlanItemData(6, '扫弦节奏型 4/4', 15, '每天', false),
  new PlanItemData(7, '《卡农》右手旋律', 25, '周末', false),
  new PlanItemData(8, '听力训练:单音模唱', 10, '每天', false)
];

// ============ 工具函数 ============
function getDiffColor(difficulty: number): string {
  if (difficulty >= 5) {
    return '#C2410C';
  } else if (difficulty >= 4) {
    return '#B45309';
  } else if (difficulty >= 3) {
    return '#1E3A2F';
  } else {
    return '#3F6212';
  }
}

function getInstrumentIcon(instrument: string): string {
  if (instrument === '吉他') {
    return '🎸';
  } else if (instrument === '钢琴') {
    return '🎹';
  } else if (instrument === '尤克里里') {
    return '🪕';
  } else {
    return '🎼';
  }
}

// ============ Tab 枚举 ============
enum ChordTab {
  COURSE,
  SHEET,
  PRACTICE,
  THEORY,
  ME
}

// ============ 主入口 ============
@Entry
@Component
struct ChordxApp {
  @State activeTab: ChordTab = ChordTab.COURSE

  @Builder contentArea() {
    Column() {
      if (this.activeTab === ChordTab.COURSE) {
        CourseContent()
      } else if (this.activeTab === ChordTab.SHEET) {
        SheetContent()
      } else if (this.activeTab === ChordTab.PRACTICE) {
        PracticeContent()
      } else if (this.activeTab === ChordTab.THEORY) {
        TheoryContent()
      } else {
        MeContent()
      }
    }
    .layoutWeight(1)
  }

  @Builder bottomTabItem(icon: string, label: string, tab: ChordTab) {
    Column() {
      Text(icon)
        .fontSize(20)
        .opacity(this.activeTab === tab ? 1.0 : 0.4)
        .scale({ x: this.activeTab === tab ? 1.18 : 1.0, y: this.activeTab === tab ? 1.18 : 1.0 })
        .animation({ duration: 180, curve: Curve.EaseOut })
      Text(label)
        .fontSize(9)
        .fontColor(this.activeTab === tab ? '#1E3A2F' : '#A8A29E')
        .fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
        .margin({ top: 2 })
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Center)
    .padding({ top: 6, bottom: 6 })
    .onClick(() => { this.activeTab = tab })
  }

  build() {
    Column() {
      this.contentArea()
      Row() {
        this.bottomTabItem('📚', '课程', ChordTab.COURSE)
        this.bottomTabItem('🎼', '曲库', ChordTab.SHEET)
        this.bottomTabItem('⏱️', '练琴房', ChordTab.PRACTICE)
        this.bottomTabItem('和弦', '乐理', ChordTab.THEORY)
        this.bottomTabItem('👤', '我的', ChordTab.ME)
      }
      .width('100%')
      .backgroundColor('#FFFBF2')
      .padding({ top: 5, bottom: 7 })
      .shadow({ radius: 14, color: '#261E3A2F', offsetY: -4 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FAF6EF')
  }
}

// ============ 手账式公共头部(静态,无动画) ============
@Component
struct NoteHeader {
  title: string = '琴弦手册';
  subtitle: string = '';
  streakDays: number = 42;
  onStreakTap: () => void = () => {};
  onSearchTap: () => void = () => {};

  build() {
    Column() {
      Row() {
        Column() {
          Text('🔥 连续练习 ' + this.streakDays + ' 天')
            .fontSize(13)
            .fontColor('#FAF6EF')
            .fontWeight(FontWeight.Medium)
          Text('别断签哦')
            .fontSize(8)
            .fontColor('#D6D3D1')
            .margin({ top: 1 })
        }
        .alignItems(HorizontalAlign.Start)
        .onClick(() => { this.onStreakTap() })

        Row() {
          Text('🔍')
            .fontSize(13)
            .margin({ left: 10 })
          Text('搜曲谱 / 课程 / 和弦')
            .fontSize(11)
            .fontColor('#78716C')
            .margin({ left: 6 })
        }
        .layoutWeight(1)
        .height(34)
        .backgroundColor('#FAF6EFCC')
        .borderRadius(17)
        .margin({ left: 12 })
        .onClick(() => { this.onSearchTap() })

        Text('📖')
          .fontSize(17)
          .width(34)
          .height(34)
          .textAlign(TextAlign.Center)
          .backgroundColor('#FAF6EFCC')
          .borderRadius(17)
          .margin({ left: 8 })
          .onClick(() => { this.onStreakTap() })
      }
      .width('100%')
      .padding({ left: 14, right: 14, top: 10 })

      Row() {
        Column() {
          Text(this.title)
            .fontSize(22)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FAF6EF')
          if (this.subtitle !== '') {
            Text(this.subtitle)
              .fontSize(10)
              .fontColor('#D6D3D1')
              .margin({ top: 2 })
          }
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
      }
      .width('100%')
      .padding({ left: 14, right: 14, top: 12, bottom: 10 })
    }
    .width('100%')
    .linearGradient({
      angle: 135,
      colors: [['#1E3A2F', 0], ['#0F2620', 1]]
    })
  }
}

// ============ Tab1:课程页 ============
@Component
struct CourseContent {
  @State pickedLevel: string = '全部'
  @State showDetailModal: boolean = false
  @State showBuyModal: boolean = false
  @State showCouponModal: boolean = false
  @State selectedCourse: CourseItem | null = null
  @State pickedCoupon: string = ''
  levelList: string[] = ['全部', '入门', '进阶', '高级']

  @Builder modalOverlay(onClose: () => void) {
    Column()
      .width('100%')
      .height('100%')
      .backgroundColor('rgba(50,45,35,0.5)')
      .onClick(onClose)
  }

  // 弹框A:课程详情(章节列表 + 大价格卡)
  @Builder courseModal() {
    Column() {
      this.modalOverlay(() => { this.showDetailModal = false })
      Column() {
        Text('📘 ' + (this.selectedCourse === null ? '' : this.selectedCourse.title))
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1C1917')
          .margin({ top: 18 })
        Text(this.selectedCourse === null ? '' : this.selectedCourse.teacher + ' · ' +
          this.selectedCourse.level + ' · ' + this.selectedCourse.lessons + '课时')
          .fontSize(10)
          .fontColor('#78716C')
          .margin({ top: 4 })

        Row() {
          Text('⭐ ' + (this.selectedCourse === null ? '' : this.selectedCourse.rating))
            .fontSize(11)
            .fontColor('#B45309')
            .fontWeight(FontWeight.Bold)
          Text(formatWan2(this.selectedCourse === null ? 0 : this.selectedCourse.students) + ' 人已学')
            .fontSize(10)
            .fontColor('#78716C')
            .margin({ left: 12 })
        }
        .margin({ top: 8 })

        Text('课程目录')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1C1917')
          .width('100%')
          .margin({ top: 14 })

        Scroll() {
          Column() {
            ForEach(['01 认识你的乐器与坐姿', '02 第一组开放和弦', '03 节拍与拨弦练习',
              '04 和弦转换的三个诀窍', '05 第一首完整弹唱', '06 扫弦节奏入门',
              '07 常见问题与纠错', '08 阶段综合练习曲'], (l: string) => {
              Row() {
                Text('▶')
                  .fontSize(10)
                  .fontColor('#1E3A2F')
                  .width(20)
                Text(l)
                  .fontSize(11)
                  .fontColor('#44403C')
                  .layoutWeight(1)
                Text('免费试看')
                  .fontSize(9)
                  .fontColor('#C2410C')
              }
              .width('100%')
              .padding({ top: 10, bottom: 10 })
              .backgroundColor('#FFFBF2')
              .borderRadius(10)
              .margin({ top: 6 })
            }, (l: string) => l)
          }
          .width('100%')
          .padding({ left: 16, right: 16 })
        }
        .constraintSize({ maxHeight: '42%' })
        .margin({ top: 8 })

        Row() {
          Column() {
            Text('¥' + (this.selectedCourse === null ? 0 : this.selectedCourse.price))
              .fontSize(20)
              .fontWeight(FontWeight.Bold)
              .fontColor('#C2410C')
            Text('原价 ¥' + (this.selectedCourse === null ? 0 : this.selectedCourse.originPrice))
              .fontSize(9)
              .fontColor('#A8A29E')
              .decoration({ type: TextDecorationType.LineThrough })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)

          Text(this.selectedCourse !== null && this.selectedCourse.purchased ? '已购买 · 继续学习' : '立即报名')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FAF6EF')
            .padding({ left: 26, right: 26, top: 11, bottom: 11 })
            .backgroundColor(this.selectedCourse !== null && this.selectedCourse.purchased ? '#1E3A2F' : '#C2410C')
            .borderRadius(20)
            .onClick(() => {
              if (this.selectedCourse !== null && this.selectedCourse.purchased) {
                this.showDetailModal = false
              } else {
                this.showDetailModal = false
                this.showBuyModal = true
              }
            })
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 14, bottom: 20 })
      }
      .width('90%')
      .backgroundColor('#FFFFFF')
      .borderRadius(20)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
  }

  // 弹框B:购买确认(订单卡 + 优惠券入口)
  @Builder buyModal() {
    Column() {
      this.modalOverlay(() => { this.showBuyModal = false })
      Column() {
        Text('确认报名')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1C1917')
          .margin({ top: 18 })
        Row() {
          Text('📘')
            .fontSize(28)
            .width(54)
            .height(54)
            .textAlign(TextAlign.Center)
            .backgroundColor('#FFFBF2')
            .borderRadius(14)
          Column() {
            Text(this.selectedCourse === null ? '' : this.selectedCourse.title)
              .fontSize(12)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1C1917')
              .maxLines(1)
              .textOverflow({ overflow: TextOverflow.Ellipsis })
            Text(this.selectedCourse === null ? '' : this.selectedCourse.teacher + ' · ' +
              this.selectedCourse.lessons + '课时')
              .fontSize(9)
              .fontColor('#78716C')
              .margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .margin({ left: 12 })
        }
        .width('100%')
        .padding(14)
        .backgroundColor('#FFFBF2')
        .borderRadius(14)
        .margin({ top: 14 })

        Row() {
          Text('优惠券')
            .fontSize(12)
            .fontColor('#78716C')
            .layoutWeight(1)
          Text(this.pickedCoupon === '' ? '2 张可用 ›' : this.pickedCoupon)
            .fontSize(12)
            .fontColor('#C2410C')
            .onClick(() => {
              this.showBuyModal = false
              this.showCouponModal = true
            })
        }
        .width('100%')
        .padding(14)
        .backgroundColor('#FFFBF2')
        .borderRadius(14)
        .margin({ top: 10 })

        Row() {
          Text('应付')
            .fontSize(12)
            .fontColor('#78716C')
            .layoutWeight(1)
          Text('¥' + (this.selectedCourse === null ? 0 : this.selectedCourse.price))
            .fontSize(19)
            .fontWeight(FontWeight.Bold)
            .fontColor('#C2410C')
        }
        .width('100%')
        .padding({ top: 14 })

        Row() {
          Text('取消')
            .fontSize(13)
            .fontColor('#78716C')
            .padding({ left: 28, right: 28, top: 10, bottom: 10 })
            .backgroundColor('#FFFBF2')
            .borderRadius(18)
            .onClick(() => { this.showBuyModal = false })
          Text('去支付')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FAF6EF')
            .padding({ left: 30, right: 30, top: 10, bottom: 10 })
            .backgroundColor('#C2410C')
            .borderRadius(18)
            .margin({ left: 12 })
            .onClick(() => { this.showBuyModal = false })
        }
        .margin({ top: 18, bottom: 20 })
      }
      .width('88%')
      .backgroundColor('#FFFFFF')
      .borderRadius(20)
      .alignItems(HorizontalAlign.Center)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
  }

  // 弹框C:优惠券选择(票券样式)
  @Builder couponModal() {
    Column() {
      this.modalOverlay(() => { this.showCouponModal = false })
      Column() {
        Text('🎟️ 选择优惠券')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1C1917')
          .margin({ top: 18 })

        ForEach([['立减 ¥50', '满 ¥199 可用 · 全场通用', '50券'], ['立减 ¥30', '满 ¥99 可用 · 限入门课', '30券'], ['7.5 折券', '最高抵 ¥80 · 限进阶课', '75折']], (c: string[]) => {
          Row() {
            Column() {
              Text(c[0])
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#C2410C')
              Text(c[1])
                .fontSize(9)
                .fontColor('#78716C')
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Text(this.pickedCoupon === c[2] ? '已选 ✓' : '选择')
              .fontSize(11)
              .fontColor(this.pickedCoupon === c[2] ? '#1E3A2F' : '#C2410C')
              .padding({ left: 16, right: 16, top: 8, bottom: 8 })
              .backgroundColor(this.pickedCoupon === c[2] ? '#D6E5DC' : '#FFF7ED')
              .borderRadius(14)
              .onClick(() => { this.pickedCoupon = c[2] })
          }
          .width('100%')
          .padding(14)
          .backgroundColor('#FFFBF2')
          .border({ width: 1, color: '#F5E9D9' })
          .borderRadius(14)
          .margin({ top: 10 })
        }, (c: string[]) => c[2])

        Text('确定')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FAF6EF')
          .width('60%')
          .textAlign(TextAlign.Center)
          .padding({ top: 12, bottom: 12 })
          .backgroundColor('#1E3A2F')
          .borderRadius(20)
          .margin({ top: 18, bottom: 20 })
          .onClick(() => { this.showCouponModal = false })
      }
      .width('86%')
      .backgroundColor('#FFFFFF')
      .borderRadius(20)
      .alignItems(HorizontalAlign.Center)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
  }

  build() {
    Stack() {
      Scroll() {
        Column() {
          NoteHeader({ title: '课程', subtitle: '今日推荐 · 好课上新 8 折起', onStreakTap: () => {}, onSearchTap: () => {} })

          // 精选横滑卡
          Text('🏆 口碑精选')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#1C1917')
            .width('100%')
            .padding({ left: 16, top: 14 })
          Scroll() {
            Row() {
              ForEach(mockCourses, (c: CourseItem) => {
                if (c.rating === '4.9') {
                  Column() {
                    Text('📘')
                      .fontSize(34)
                      .width(110)
                      .height(80)
                      .textAlign(TextAlign.Center)
                      .backgroundColor('#E7EFE9')
                      .borderRadius(14)
                    Text(c.title)
                      .fontSize(12)
                      .fontWeight(FontWeight.Bold)
                      .fontColor('#1C1917')
                      .margin({ top: 8 })
                      .maxLines(1)
                      .width(110)
                    Text(c.teacher + ' · ⭐' + c.rating)
                      .fontSize(9)
                      .fontColor('#78716C')
                      .margin({ top: 3 })
                    Text('¥' + c.price)
                      .fontSize(13)
                      .fontWeight(FontWeight.Bold)
                      .fontColor('#C2410C')
                      .margin({ top: 4 })
                  }
                  .width(118)
                  .alignItems(HorizontalAlign.Start)
                  .padding(10)
                  .backgroundColor('#FFFFFF')
                  .borderRadius(16)
                  .margin({ left: 6, right: 6 })
                  .onClick(() => {
                    this.selectedCourse = c
                    this.showDetailModal = true
                  })
                }
              }, (c: CourseItem) => 'top' + c.id.toString())
            }
            .padding({ left: 10, right: 10 })
          }
          .scrollable(ScrollDirection.Horizontal)
          .scrollBar(BarState.Off)
          .width('100%')

          // 筛选
          Row() {
            ForEach(this.levelList, (l: string) => {
              Text(l)
                .fontSize(11)
                .fontColor(this.pickedLevel === l ? '#FAF6EF' : '#78716C')
                .fontWeight(this.pickedLevel === l ? FontWeight.Bold : FontWeight.Normal)
                .padding({ left: 14, right: 14, top: 7, bottom: 7 })
                .backgroundColor(this.pickedLevel === l ? '#1E3A2F' : '#FFFFFF')
                .borderRadius(15)
                .margin({ right: 8 })
                .onClick(() => { this.pickedLevel = l })
            }, (l: string) => l)
          }
          .width('100%')
          .padding({ left: 14, right: 14, top: 14 })

          ForEach(mockCourses, (c: CourseItem) => {
            if (this.pickedLevel === '全部' || c.level === this.pickedLevel) {
              Row() {
                Column() {
                  Text('📘')
                    .fontSize(30)
                    .width(78)
                    .height(78)
                    .textAlign(TextAlign.Center)
                    .backgroundColor('#E7EFE9')
                    .borderRadius(14)
                  Text(c.tag)
                    .fontSize(8)
                    .fontColor('#C2410C')
                    .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                    .backgroundColor('#FFF7ED')
                    .borderRadius(8)
                    .margin({ top: 6 })
                }
                .alignItems(HorizontalAlign.Center)

                Column() {
                  Text(c.title)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#1C1917')
                  Text(c.teacher + ' · ' + c.level + ' · ' + c.lessons + '课时')
                    .fontSize(9)
                    .fontColor('#78716C')
                    .margin({ top: 4 })
                  Row() {
                    Text('⭐ ' + c.rating)
                      .fontSize(10)
                      .fontColor('#B45309')
                    Text(formatWan2(c.students) + '人已学')
                      .fontSize(9)
                      .fontColor('#A8A29E')
                      .margin({ left: 10 })
                  }
                  .margin({ top: 5 })
                  Row() {
                    Text('¥' + c.price)
                      .fontSize(15)
                      .fontWeight(FontWeight.Bold)
                      .fontColor('#C2410C')
                    Text('¥' + c.originPrice)
                      .fontSize(9)
                      .fontColor('#A8A29E')
                      .decoration({ type: TextDecorationType.LineThrough })
                      .margin({ left: 6 })
                    if (c.purchased) {
                      Text('已购 ✓')
                        .fontSize(9)
                        .fontColor('#1E3A2F')
                        .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                        .backgroundColor('#D6E5DC')
                        .borderRadius(8)
                        .margin({ left: 10 })
                    }
                  }
                  .margin({ top: 6 })
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)
                .margin({ left: 12 })
              }
              .width('100%')
              .padding(12)
              .backgroundColor('#FFFFFF')
              .borderRadius(16)
              .margin({ top: 8, left: 12, right: 12 })
              .onClick(() => {
                this.selectedCourse = c
                this.showDetailModal = true
              })
            }
          }, (c: CourseItem) => c.id.toString() + this.pickedLevel)
        }
        .width('100%')
        .padding({ bottom: 16 })
      }
      .scrollBar(BarState.Off)
      .layoutWeight(1)

      if (this.showDetailModal) {
        this.courseModal()
      }
      if (this.showBuyModal) {
        this.buyModal()
      }
      if (this.showCouponModal) {
        this.couponModal()
      }
    }
    .width('100%')
    .height('100%')
  }
}

// ============ 数字格式工具 ============
function formatWan2(n: number): string {
  if (n >= 10000) {
    return (n / 10000).toFixed(1) + '万';
  }
  return n.toString();
}


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

在这里插入图片描述

在数据可视化层面,应用展示了纯ArkTS实现可视化的能力:难度圆点通过ForEach+条件颜色映射实现,周练习柱状图通过ForEach+动态高度计算实现,和弦掌握度进度条通过嵌套Column+百分比宽度实现,和弦指法网格通过嵌套ForEach+工具函数解码实现。这些可视化方案无需引入任何第三方图表库,仅凭ArkTS的声明式语法就构建出了功能完整、视觉可接受的数据展示效果。当然,对于更复杂的可视化需求,仍然推荐使用专业的图表组件或Canvas绘制。

在工具函数层面,应用定义了四个核心工具函数:getDiffColor将难度数值映射为颜色字符串,getInstrumentIcon将乐器名称映射为emoji图标,formatWan2将大数字格式化为"万"单位字符串,getChordDot将和弦编码字符串解码为网格标记。这些函数都是纯函数(无副作用,相同输入产生相同输出),易于测试和复用。它们将复杂的业务逻辑封装为简洁的函数调用,使得UI层的代码更加声明式和可读。

在事件处理层面,应用的所有交互行为都通过onClick和onChange两个事件属性实现。onClick处理点击事件,将用户操作转化为状态变量的修改;onChange处理输入变化,将用户输入同步到状态变量。这种"事件→状态→UI"的单向数据流模式,确保了应用状态的变更路径清晰可追踪,是声明式UI框架推荐的状态管理模式。

涵盖了类型系统、状态管理、布局容器、列表渲染、条件渲染、动画效果、渐变背景、事件处理、表单输入、数据可视化、组件通信、弹窗架构等几乎所有核心主题。每一个技术点都在实际业务场景中得到了合理的应用,而非为了演示而堆砌。这种将技术深度与业务广度相结合的代码实践,对于学习鸿蒙ArkTS开发、理解声明式UI编程范式、掌握组件化架构设计都具有重要的参考价值。

Logo

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

更多推荐