高山流水,知音难觅。古韵阁收录民乐三千曲,愿与君共守丝竹一脉。

本文基于 HarmonyOS ArkTS 声明式 UI 框架,深度解析「古韵阁 GU YUN GE」国风民乐欣赏 App 的完整实现——从水墨留白的视觉设计、六大 Tab 的页面架构,到名家讲堂、线下雅集、曲谱研习等核心业务系统,并在附录中提供完整的 ArkTS 源码。


引言:把千年丝竹,装进一方宣纸

中国民乐有着数千年的传承——古琴的《高山流水》可追溯至先秦,琵琶的《十面埋伏》描绘楚汉相争,二胡的《二泉映月》诉说近代人间悲欢。然而在数字时代,这些珍贵的音乐遗产面临着传播断层的困境:年轻人接触民乐的渠道有限,传统曲谱(减字谱、工尺谱)门槛高企,线下雅集难寻同好,名家课程价格不菲。

古韵阁 GU YUN GE 正是为解决这些痛点而设计的国风民乐 App。它以「水墨留白」为视觉灵魂——宣纸白底上朱砂红的印章、黛青色的山峦、鎏金色的题跋,每一屏都如同一幅可交互的文人画。功能上覆盖民乐欣赏(曲库)、名家讲堂(名家+学堂)、线下雅集(雅集)、曲谱研习(乐谱)四大核心场景,让千年丝竹在数字时代焕发新生。

本文将从设计理念、色彩系统、项目架构、数据模型、六大 Tab 页面、弹窗交互、动画效果、核心业务系统、开发环境等维度,对古韵阁进行全方位深度解析,并在附录中提供完整的 ArkTS 源码(2133.ets),供开发者参考学习。

古韵阁曲库首页


第一章 项目概述与设计理念

1.1 项目背景

中国民乐是中华传统文化的重要组成部分。据中国音乐家协会数据,全国各类民乐社团超过 3 万个,习琴人数逾千万。但民乐的数字化传播仍面临四大痛点:

第一,曲库分散且门槛高。民乐曲目散落在各类专辑、视频平台和个人收藏中,缺乏统一的高质量曲库。传统曲谱如古琴减字谱、琵琶工尺谱,对初学者极不友好,识谱本身就是一道高墙。

第二,名家资源稀缺。民乐界的国家级非遗传承人、一代宗师数量有限,且多集中在一线城市。普通爱好者很难有机会跟随名家学习,线下课程价格高昂且一座难求。

第三,线下雅集难组织。民乐雅集(琴友聚会、丝竹合奏)是民乐传承的重要形式,但组织一场雅集需要场地、乐器、同好,门槛较高。散居各地的琴友难以找到同好。

第四,学习路径不清晰。民乐学习缺乏系统化的在线课程体系,初学者往往不知道从何学起、如何进阶、如何检验学习成果。

古韵阁 GU YUN GE 正是针对这四大痛点设计的:通过统一曲库+多制式曲谱解决曲库问题,通过名家堂+学堂课程解决名师资源问题,通过雅集板块解决线下聚会问题,通过系统化课程+习琴打卡解决学习路径问题。

1.2 核心定位

古韵阁的核心定位可以用四句话概括:

  • 国风民乐欣赏:收录从先秦到当代的经典民乐曲目,按乐器、调式、年代、难度多维分类,提供高质量聆听体验
  • 名家讲堂:汇聚国家级非遗传承人、一代宗师,提供从入门到精通的系统化课程,含减字谱识读、持弓技法、轮指训练等专项
  • 线下雅集:连接各地琴友,提供雅集发布、报名、分享功能,从中秋抚琴到国风夜市,让线下民乐聚会触手可及
  • 曲谱研习:提供减字谱、简谱、工尺谱、五线谱多制式曲谱下载,含工尺谱示意、难度标注、页数信息,支持打印装订

1.3 设计理念:水墨留白

古韵阁的视觉设计以「水墨留白」为核心理念,将中国传统文人画的美学融入移动端 UI:

  • 宣纸白底如展开的卷轴:主背景采用宣纸质感的米白色(#F4F2ED / #FBF9F4),如同展开的宣纸卷轴,营造温润、雅致的基底氛围
  • 朱砂红如钤印:主色朱砂红(#B03A2E)如同文人画上的朱砂印章,用于标题、按钮、高亮标签、收藏心形等关键元素,传递出传统艺术的庄重感
  • 黛青如山峦:辅助色黛青(#4A6B7A)如同水墨画中的远山,用于次要信息、筛选Tab、进度条、名家头像等,丰富视觉层次
  • 鎏金如题跋:点缀色鎏金(#8D6E4C)如同书画上的金箔题跋,用于难度点、曲谱类型、雅集标签等装饰性元素,增添精致感
  • 书页微倾如摊开的卷册:乐谱卡片采用 ±2° 的微旋转角度,如同摊开在书桌上的卷册,增添文人雅趣
  • 工尺谱示意如古谱重现:曲谱详情弹窗中展示「上尺工凡六五乙」的工尺谱示意,让传统记谱法在数字界面中重现

这种设计理念让古韵阁在视觉上区别于一般的音乐类 App(通常采用鲜艳活泼的配色),传递出中国民乐特有的雅致、含蓄、悠远的文化气质。

1.4 技术选型

古韵阁基于 HarmonyOS ArkTS 声明式 UI 框架开发:

  • ArkTS 声明式 UI:采用 @Component / @State / @Builder 等装饰器构建声明式 UI,代码简洁直观
  • 单文件架构:所有代码集中在 2133.ets 一个文件中,约 2900 行,无外部依赖,便于学习和移植
  • Stack 叠加弹窗:通过 Stack 容器叠加半透明遮罩+弹窗组件,实现约 15 套弹窗交互
  • 属性动画:使用 .animation() 实现 Tab 切换缩放、收藏心形弹跳、进度条增长等流畅动效
  • 严格遵循规范:无 Blank 组件、无 UI 内变量声明、constraintSize 限高、单子组件 Scroll、接口约束全部对象字面量

第二章 色彩系统与视觉风格

2.1 主色:朱砂红 #B03A2E

朱砂红是古韵阁的主色,灵感来源于中国传统书画中的朱砂印章。朱砂红在古代被视为正色,常用于印章、批校、书画题跋,具有庄重、权威、点睛的视觉语义。

在古韵阁中,朱砂红用于:

  • 头部 Logo 背景(「阁」字印章)
  • 底部 Tab 选中态文字和图标
  • 乐器筛选标签选中态(全部/古琴/古筝等)
  • 曲目列表中的乐器首字图标
  • 收藏心形(♥ 已收藏)
  • 立即聆听按钮、报名按钮、创建按钮等主操作按钮
  • 雅集日期卡片(09-25 / 10-02 等)
  • 本周习琴图表中周末的柱子(周六/日)
  • 乐器掌握度进度条
  • 新建曲单按钮

朱砂红在宣纸白底上的对比恰到好处——既足够醒目起到点睛作用,又不会过于刺眼破坏雅致氛围。

2.2 辅助色:黛青 #4A6B7A

黛青是古韵阁的第一辅助色,灵感来源于水墨画中的远山。黛青是一种偏蓝的深灰色,如同水墨晕染后的远山,含蓄、沉静、深远。

在古韵阁中,黛青用于:

  • 名家头像背景(未关注的名家)
  • 学堂 Tab 的课程乐器图标(付费课程)
  • 雅集预告横幅背景
  • 乐谱 Tab 的类型筛选 Tab 栏(全部/简谱/减字谱等)
  • 乐谱书页网格中的类型首字
  • 课程详情弹窗中的乐器标签
  • 保存修改按钮(编辑曲单)
  • 关注成功 Toast 背景
  • 习琴图表中工作日的柱子(周一至周五)

黛青与朱砂红形成冷暖对比——朱砂红的热烈与黛青的沉静相互映衬,如同画中的印章与远山。

2.3 点缀色:鎏金 #8D6E4C

鎏金是古韵阁的第二辅助色,灵感来源于书画上的金箔题跋和古铜器的包浆。鎏金是一种偏棕的暖金色,温润、古朴、精致。

在古韵阁中,鎏金用于:

  • 二级标题文字(「曲目详情」「课程详情」「雅集详情」等弹窗标题)
  • 难度圆点(●●●●●)
  • 曲谱类型标签
  • 名家详情中的印章信息
  • 习琴时长统计文字
  • 曲单场景标签首字
  • 下载曲谱按钮
  • 功课已记 Toast 背景
  • 雅集报名进度条(未爆满时)

鎏金在宣纸白底上呈现出一种古旧的质感,如同老书页上的题跋,与民乐的古典气质高度契合。

2.4 中性色:宣纸白与墨色

古韵阁的中性色系统以宣纸白和墨色为基础:

  • 宣纸白底 #F4F2ED:全局背景色,略带暖调的米白,如同宣纸
  • 卡片白 #FFFFFF / #FBF9F4:内容卡片背景,比全局背景略亮
  • 分割线 #E8E2D5:卡片边框和分割线,暖灰色
  • 正文墨色 #2B2B26:主要文字颜色,偏暖的深灰黑,如同墨色
  • 次要文字 #9B9186:次要信息文字,中灰色
  • 辅助文字 #C9C2B4:底部装饰文字(「— 曲终 —」),浅灰色

这套中性色系统整体偏暖,与朱砂红、黛青、鎏金共同构成完整的水墨留白色彩体系。

2.5 视觉风格特征

除了色彩系统,古韵阁的视觉风格还有以下特征:

  • 圆角偏方圆角(8-16px):卡片和按钮采用 8-16px 的圆角,既不过于尖锐也不过于圆润,符合传统器物的造型感
  • 细边框(1px):卡片普遍带有 1px 的暖灰色边框,如同画框
  • 印章式头像:名家头像采用圆形+单字印章的形式(琴/弦/琶/筝/笛/唢),如同名家的朱文印
  • 书页微旋转:乐谱卡片采用 ±2° 的随机旋转,模拟摊开的卷册
  • 工尺谱装饰:曲谱详情中展示「上尺工凡六五乙」工尺谱,作为传统记谱法的视觉符号
  • 竖排标题感:部分标题采用字间距(letterSpacing)营造竖排题跋的感觉

第三章 项目架构与文件组织

3.1 单文件架构

古韵阁采用单文件架构,所有代码集中在 2133.ets 一个文件中。这种架构的优势是:

  • 学习成本低:一个文件即可了解整个应用的实现,适合教学和参考
  • 无外部依赖:不依赖任何第三方库,纯 ArkTS 原生实现,可直接运行
  • 移植方便:复制单个文件即可在其他 HarmonyOS 项目中使用
  • 状态集中:所有状态和数据在一个文件中,便于理解全局数据流

文件的整体组织顺序为:

  1. 文件头注释(项目名、风格、规范说明)
  2. 类型定义(interface)
  3. 静态数据(const 数组)
  4. 工具函数(function)
  5. 枚举(enum)
  6. 主入口组件(@Entry @Component GuyunPavilionApp)
  7. 头部组件(GuyunHeader)
  8. 六大 Tab 内容组件(TunesContent / MastersContent / LearnContent / GatheringContent / ScoresContent / MeContent)

3.2 组件架构

古韵阁共包含 8 个 @Component 组件:

组件名角色说明
GuyunPavilionApp主入口(@Entry)管理底部 Tab 切换,路由六大内容区
GuyunHeader全局头部Logo、搜索栏、秋分特辑通知条
TunesContent曲库 Tab乐器筛选、调式筛选、曲目列表、详情弹窗
MastersContent名家 Tab名家堂列表、名家详情弹窗、关注功能
LearnContent学堂 Tab今日习琴、精选课程、课程详情、报名、打卡
GatheringContent雅集 Tab雅集预告、雅集列表、详情、报名/取消
ScoresContent乐谱 Tab类型筛选、双列书页网格、详情、下载
MeContent我的 Tab个人信息、习琴图表、乐器掌握、曲单管理

组件之间的关系是:GuyunPavilionApp 作为根组件,包含 GuyunHeader 和当前激活的 Tab 内容组件。各 Tab 内容组件独立管理自己的状态和弹窗,组件之间通过静态数据共享(const 数组在文件顶层定义,所有组件可直接访问)。

3.3 状态管理

古韵阁使用 @State 装饰器管理组件局部状态。每个 Tab 组件管理自己的状态:

  • 曲库 Tab:筛选索引、选中曲目、收藏状态、调式筛选
  • 名家 Tab:选中名家、已关注名家 ID 列表
  • 学堂 Tab:选中课程、已报名课程 ID 列表、打卡状态
  • 雅集 Tab:选中雅集、已报名雅集 ID 列表、结果 Toast 文字
  • 乐谱 Tab:类型筛选索引、选中曲谱、已下载 ID 列表
  • 我的 Tab:曲单列表(可增删改)、弹窗状态

全局共享的静态数据(FOLK_TUNES、MASTER_LIST、LEARN_COURSES 等)在文件顶层以 const 定义,所有组件均可直接读取,无需通过 props 传递。这种设计在单文件架构中简洁高效。

3.4 弹窗架构

古韵阁的弹窗采用 Stack 叠加方案实现:每个 Tab 组件的 build() 方法返回一个 Stack,第一层是正常的 Scroll 内容区,后续层是通过 if 条件渲染的弹窗。

弹窗的标准结构为:

Column() {
  Column()  // 半透明遮罩层(点击关闭)
    .layoutWeight(1)
    .onClick(() => { this.showXxx = false; })
  this.xxxModal()  // 弹窗内容(@Builder 方法)
}
.width('100%')
.height('100%')
.backgroundColor('rgba(43,43,38,0.5)')  // 半透明墨色遮罩

古韵阁共实现约 15 套弹窗,覆盖曲目详情、名家详情、课程详情、报名确认、打卡、雅集详情、曲谱详情、下载确认、新建/编辑/删除曲单、关于、退出登录等完整交互场景。


第四章 数据模型与全局状态管理

4.1 核心数据模型

古韵阁定义了 8 个 interface 数据模型:

FolkTune(民乐曲目):曲库的核心数据结构

  • id: 曲目唯一标识
  • title: 曲名(如「高山流水」「十面埋伏」)
  • instrument: 演奏乐器(古琴/琵琶/二胡/古筝/笛子/唢呐等)
  • mode: 传统调式(宫调/商调/角调/徵调/羽调/变宫)
  • dynasty: 流传年代(先秦/晋/唐/宋/明/清/近代/当代)
  • duration: 曲目时长
  • plays: 累计播放量
  • liked: 是否已收藏
  • level: 曲目等级(经典/名曲/绝响/新声/小众)

MasterInfo(名家信息):名家堂的数据结构

  • id / name / title(头衔)/ instrument / works(作品数)/ students(师承描述)/ seal(印章字)/ intro(简介)

LearnCourse(课程):学堂课程的数据结构

  • id / name / instrument / chapters(讲数)/ hours(总时长)/ price / free(是否免费)/ students(在学人数)

GatheringEvent(雅集活动):线下雅集的数据结构

  • id / name / place / city / date / quota(名额)/ joined(已报名)/ fee(费用)

ScoreEntry(曲谱):乐谱坊的数据结构

  • id / title / instrument / type(简谱/减字谱/工尺谱/五线谱)/ pages / downloads / difficulty

PracticeDay(习琴日):本周习琴数据

  • day: 星期几 / minutes: 练习分钟数

MasteryItem(乐器掌握):乐器掌握度数据

  • name / emoji / percent

PlaySheet(曲单):我的曲单数据

  • id / name / count(曲目数)/ mood(场景标签)

4.2 静态数据概览

古韵阁包含丰富的静态数据:

数据数组数量说明
FOLK_TUNES18 首从先秦《高山流水》到当代《云水禅心》,覆盖 8 种乐器
MASTER_LIST10 位吴钊、闵惠芬、刘德海、王中山等民乐名家
LEARN_COURSES12 门从免费入门到付费进阶,覆盖 10 种乐器
GATHERING_LIST8 场中秋雅集、国风夜市、古琴公开课等线下活动
SCORE_LIST10 份减字谱/简谱/工尺谱/五线谱多制式曲谱
PRACTICE_WEEK7 天本周每日习琴分钟数
MASTERY_LIST4 乐器古琴/洞箫/琵琶/古筝掌握度
INSTRUMENT_FILTERS7 项全部/古琴/古筝/琵琶/二胡/笛子/唢呐筛选
MODE_NAMES6 项全部/宫调/商调/角调/徵调/羽调式筛选

4.3 工具函数

古韵阁定义了 4 个工具函数:

  • tuneLevelColor(lv):根据曲目等级返回背景色(经典/名曲/绝响/新声/小众各有不同)
  • tuneLevelTextColor(lv):根据曲目等级返回文字颜色
  • practiceBarHeight(m):根据习琴分钟数计算柱状图高度(分钟×1.1)
  • difficultyDots(n):根据难度等级(1-5)返回圆点字符串(●●●○○)

4.4 枚举与 Tab 路由

古韵阁定义了 GuyunTab 枚举,包含 6 个值:TUNES(曲库)、MASTERS(名家)、LEARN(学堂)、GATHERING(雅集)、SCORES(乐谱)、ME(我的)。

主入口组件 GuyunPavilionApp 通过 @State activeTab 管理当前激活的 Tab,在 contentArea() @Builder 方法中通过 if-else 路由到对应的内容组件。底部 Tab 栏通过 bottomTabItem() @Builder 方法渲染 6 个 Tab 项,点击时切换 activeTab。


第五章 六大 Tab 页面深度解析

古韵阁的主界面由底部 6 个 Tab 组成:曲库、名家、学堂、雅集、乐谱、我的。每个 Tab 都是一个独立的 @Component 组件,管理自己的状态和弹窗。本章逐一解析六大 Tab 的页面结构、交互逻辑和设计亮点。

5.1 曲库 Tab:乐器与调式双维度筛选的民乐聆听

曲库 Tab(TunesContent)是古韵阁的首页,也是用户进入 App 后看到的第一个页面。它以乐器筛选+调式筛选的双维度分类体系,帮助用户从 18 首经典民乐曲目中快速找到想听的曲子。

曲库Tab页面

页面结构:

  1. 乐器筛选标签栏:顶部横向排列 7 个圆角标签(全部/古琴/古筝/琵琶/二胡/笛子/唢呐),选中态为朱砂红底白字,未选中为白底黛青字带边框。标签支持缩放动画(选中时放大 1.05 倍)
  2. 调式筛选行:显示当前调式(「调式:全部」)和「☰ 换调式」按钮,点击弹出底部抽屉(modeSheet),可从宫调/商调/角调/徵调/羽调中选择
  3. 曲目列表:每首曲目为一行卡片,包含:
    • 左侧:乐器首字图标(46×46px,宣纸白底+边框,朱砂红文字)
    • 中间:曲名(加粗)+ 年代·乐器 + 等级标签(经典/名曲/绝响/新声/小众,各有不同颜色)
    • 右侧:播放量+时长 + 收藏心形(♥/♡,收藏时放大 1.2 倍并变朱砂红)
  4. 底部装饰:「— 已展示 X 曲 · 曲终 —」浅灰文字

交互逻辑:

  • 点击乐器筛选标签 → 切换 filterIdx → tunesNow() 方法按乐器过滤曲目列表
  • 点击「换调式」→ 弹出底部抽屉选择调式 → 切换 modeIdx
  • 点击曲目卡片 → 弹出曲目详情弹窗(tuneDetailModal),展示曲名、年代、调式、时长、乐器、等级、累计聆听等信息,提供「立即聆听」和「收藏」两个按钮
  • 点击收藏心形 → toggleLike() 方法切换收藏状态,心形图标缩放动画
  • 收藏后自动显示 Toast「♥ 已加入我的曲库收藏」

设计亮点:

曲库 Tab 的双维度筛选是核心设计——乐器筛选解决「我想听什么乐器」的问题,调式筛选解决「我想听什么情绪」的问题(宫调明亮、商调悲凉、角调清新、徵调欢快、羽调悠远)。曲目卡片的等级标签用颜色编码(经典黛青、名曲朱砂、绝响鎏金、新声蓝灰),让用户一眼识别曲目的地位。收藏心形的缩放动画(1.0→1.2→1.0)为静态列表增添了灵动的交互反馈。

5.2 名家 Tab:印章式头像的民乐名家堂

名家 Tab(MastersContent)汇聚了 10 位民乐界的国家级非遗传承人和一代宗师,以印章式头像和名家卡片的形式展示,让用户可以关注名家、了解名家师承和代表作。

名家Tab页面

页面结构:

  1. 名家堂标题区:「名 家 堂」大字标题(字间距 4px,营造题跋感)+「一方水土养一方弦音」副标题
  2. 名家卡片列表:每位名家为一行卡片,包含:
    • 左侧:圆形印章头像(50×50px),已关注为朱砂红底,未关注为黛青底,中间是名家的印章字(琴/弦/琶/筝/笛/唢等)
    • 中间:名家姓名(加粗)+ 乐器标签 + 头衔(如「国家级古琴非遗传承人」)+ 作品数·师承
    • 右侧:关注按钮(已关注为灰底灰字,未关注为朱砂红边框朱砂红字)

交互逻辑:

  • 点击名家卡片 → 弹出名家详情弹窗(masterDetailModal),展示印章头像(带 -8° 旋转动画)、姓名、头衔、乐器、作品数、名家引言(「操缦六十载,以指底苍劲之音复现太古遗韵」)、师承、代表作数、印章信息等
  • 详情弹窗中点击「+ 关注名家」→ 将名家 ID 加入 followed 列表 → 关闭弹窗 → 显示 Toast「✓ 关注成功,将收到名家新曲推送」
  • 列表中点击关注按钮 → 直接切换关注状态(已关注变灰,未关注变红)

设计亮点:

名家 Tab 的印章式头像是最具辨识度的设计——每位名家的头像都是一个圆形的「朱文印」,中间一个字代表名家的乐器或名号(琴/弦/琶/筝/笛/唢/潮/埙/悦/乐),已关注的名家印章变为朱砂红,未关注的为黛青。这种设计将中国传统印章文化与移动端头像完美融合,一眼就能感受到民乐的文化底蕴。名家详情弹窗中的印章头像带有 -8° 的旋转动画,如同钤印时的微微倾斜,细节考究。

5.3 学堂 Tab:习琴打卡与系统化课程

学堂 Tab(LearnContent)是古韵阁的学习中心,提供今日习琴打卡、在学课程统计、精选课程列表三大功能,帮助用户建立系统化的民乐学习路径。

学堂Tab页面

页面结构:

  1. 顶部双卡片:
    • 「今日习琴」卡片:🧘 图标 + 今日习琴 + 连续 6 天(朱砂红),点击弹出打卡抽屉
    • 「在学课程」卡片:🎓 图标 + 在学课程 + 2 门(黛青)
  2. 精选课程标题行:「精 选 课 程」+「免费优先」排序标签
  3. 课程列表:每门课程为一行卡片,包含:
    • 左侧:乐器首字图标(48×56px,免费课程黛青底,付费课程朱砂红底)
    • 中间:课程名(加粗)+ 讲数·时长·在学人数 + 学习进度条(已报名课程 40%,未报名 12%)
    • 右侧:价格标签(免费为黛青底,付费为朱砂红底显示 ¥199 等)

交互逻辑:

  • 点击「今日习琴」→ 弹出打卡抽屉(checkinSheet),展示 21 天打卡日历(已打卡为朱砂红底白✓,未打卡为浅灰底显示日期),当前进度 16/21,点击「📜 记下今日功课」→ 显示 Toast「✓ 功课已记,笔下见功夫」
  • 点击课程卡片 → 弹出课程详情弹窗(courseDetailModal),展示课程名、乐器、价格标签、章节数、总时长、在学人数、配套曲谱等,提供「立即报名学习」按钮
  • 未报名课程点击报名 → 弹出报名确认弹窗(enrollConfirmModal)→ 确认后加入 enrolledIds → 显示 Toast
  • 已报名课程显示「✓ 已在学 · 继续上课」

设计亮点:

学堂 Tab 的「习琴打卡」功能是核心激励机制——21 天打卡日历将抽象的坚持转化为可视化的进度,已打卡的日子是朱砂红的圆形印章,未打卡的是浅灰的日期数字,用户一眼就能看到自己的坚持轨迹。课程卡片的学习进度条(已报名 40% vs 未报名 12%)用颜色和长度双重编码,让用户直观看到学习进度。免费课程的黛青底色和付费课程的朱砂红底色形成清晰的价格区分。

5.4 雅集 Tab:线下民乐聚会的报名与管理

雅集 Tab(GatheringContent)连接线上线下,提供近 30 日 8 场线下民乐雅集的浏览、报名、取消功能,让散居各地的琴友可以找到同好、参与聚会。

雅集Tab页面

页面结构:

  1. 顶部双卡片:
    • 「雅集预告」横幅(黛青底):🏮 雅集预告 + 近三十日八场
    • 「我的报名」卡片(白底):🧧 图标 + 我的报名
  2. 雅集列表:每场雅集为一个卡片,包含:
    • 左侧:日期卡片(52×52px,朱砂红底白字,上方月日 09-25,下方时间 19:00)
    • 中间:雅集名称(加粗)+ 城市·费用 + 地点(如「拙政园·远香堂」)
    • 右侧:报名状态按钮(已报名为鎏金底「✓ 已报名」,可报名为朱砂红边框「报名」)
    • 底部:报名进度条(已报名/名额百分比,爆满时朱砂红,未满时黛青)

交互逻辑:

  • 点击雅集卡片 → 弹出雅集详情弹窗(eventDetailModal),展示雅集名、城市·地点、时间、费用、名额、携带乐器说明、报名进度条,提供「立即报名/取消报名」和「分享雅集」两个按钮
  • 未报名雅集点击报名 → 弹出报名确认弹窗(joinConfirmModal)→ 确认后加入 joinedEvents → 显示 Toast「✓ 报名成功,静候佳音」
  • 已报名雅集点击取消 → 弹出取消确认弹窗(cancelConfirmModal)→ 确认后从 joinedEvents 移除 → 显示 Toast「已取消,欢迎改日再会」
  • 名额已满的雅集(如琵琶社秋日合奏会 30/30)进度条变朱砂红

设计亮点:

雅集 Tab 的日期卡片是核心视觉元素——每场雅集的日期以朱砂红底白字的卡片形式展示在左侧,如同一张传统的请柬,月日在上、时间在下,醒目而雅致。报名进度条的颜色编码(爆满朱砂红/未满黛青)让用户一眼判断雅集的热门程度。雅集地点的选择充满文化气息——拙政园远香堂、豫园九曲桥、西湖曲院风荷、西安城墙永宁门,每一个地点本身就是一幅文人画,与民乐雅集的气质高度契合。

5.5 乐谱 Tab:双列书页网格与多制式曲谱

乐谱 Tab(ScoresContent)是古韵阁的曲谱研习中心,提供减字谱、简谱、工尺谱、五线谱多制式曲谱的浏览、下载、打印功能,以双列书页网格的形式展示,如同摊开在书桌上的卷册。

乐谱Tab页面

页面结构:

  1. 类型筛选 Tab 栏:顶部黛青底横向 Tab(全部/简谱/减字谱/工尺谱/五线谱),选中态为白字+底部白色短下划线,未选中为浅灰字
  2. 双列书页网格:每份曲谱为一个卡片,两列排列:
    • 上方:书页区域(86px 高,宣纸白底+边框,中间是曲谱类型首字大字「减」「简」「工」「五」,下方是乐器名,书页带有 ±2° 的随机微旋转)
    • 中间:曲谱标题(加粗,单行省略)
    • 下方:难度圆点(●●●○○)+ 页数 + 下载状态(已下载显示「✓ 已下载」,未下载显示「X 次下载」)

交互逻辑:

  • 点击类型筛选 Tab → 切换 typeIdx → scoresNow() 方法按类型过滤曲谱
  • 点击曲谱卡片 → 弹出曲谱详情弹窗(scoreDetailModal),展示曲谱名、类型标签、乐器标签、工尺谱示意(「上尺工凡六五乙」大字+「凡工尺上·合四一」小字)、难度圆点、页数、累计下载、格式说明,提供「⤓ 下载曲谱」和「🖨 打印装订」两个按钮
  • 未下载曲谱点击下载 → 弹出下载确认弹窗(downloadConfirmModal)→ 确认后加入 downloadedIds → 显示 Toast「✓ 已存入书架,可离线翻阅」
  • 已下载曲谱显示「✓ 已在书架」灰底按钮

设计亮点:

乐谱 Tab 的双列书页网格是最具文人气质的设计——每份曲谱的上方区域模拟一本摊开的书册,宣纸白底+细边框+中间一个大字(减/简/工/五),±2° 的随机微旋转让书册看起来像是随意摊放在书桌上,而非机械排列。曲谱详情弹窗中的「工尺谱示意」是点睛之笔——「上尺工凡六五乙」七个大字以字间距 6px 排列,下方「凡工尺上·合四一」小字对应,让用户在数字界面中直观感受到传统工尺谱的形态。多制式曲谱(减字谱/简谱/工尺谱/五线谱)的支持满足了不同层次学习者的需求——初学者用简谱,进阶者用工尺谱,古琴专业者用减字谱。

5.6 我的 Tab:习琴数据与曲单管理

我的 Tab(MeContent)是用户的个人中心,展示个人信息、本周习琴数据、乐器掌握度、我的曲单管理四大模块,是用户了解自己学习进度和管理个人曲库的地方。

我的Tab页面

页面结构:

  1. 个人信息卡片:左侧圆形朱砂红印章头像(「琴」字),中间用户名「青崖居士」+ 琴龄三年·习琴 462 小时 + 徽章「初窥门径 → 渐入佳境」
  2. 本周习琴卡片:标题「本 周 习 琴」+ 共 4 小时 40 分(朱砂红),下方是 7 天柱状图(周一至周五黛青,周六周日朱砂红),底部「周六练琴最勤,达 75 分钟」
  3. 乐器掌握卡片:标题「乐 器 掌 握」,下方是 4 个乐器的进度条(古琴 72%、洞箫 45%、琵琶 28%、古筝 16%),全部朱砂红进度条
  4. 我的曲单区:标题「我 的 曲 单」+「+ 新建」朱砂红按钮,下方是曲单列表(抚琴静心 18曲·夜读、晨起丝竹 12曲·通勤等),每个曲单右侧有编辑(✎)和删除(🗑)按钮
  5. 底部功能:关于古韵阁 + 退出登录

交互逻辑:

  • 点击「+ 新建」→ 弹出新建曲单弹窗(addSheetModal),输入曲单名称和场景标签 → 创建后加入 sheets 列表
  • 点击曲单编辑按钮(✎)→ 弹出编辑曲单弹窗(editSheetModal),输入新名称 → 保存后更新曲单
  • 点击曲单删除按钮(🗑)→ 弹出删除确认弹窗(deleteSheetModal)→ 确认后从 sheets 移除
  • 点击「关于古韵阁」→ 弹出关于弹窗(aboutModal),展示版本号、slogan、版权信息
  • 点击「退出登录」→ 弹出退出确认弹窗(logoutModal)

设计亮点:

我的 Tab 的「本周习琴」柱状图是数据可视化的亮点——7 天的练习时长以柱状图展示,工作日为黛青、周末为朱砂红,颜色区分工作/休息的练习模式,柱子高度对应分钟数(分钟×1.1),用户一眼就能看到本周哪一天练得最勤。「乐器掌握度」的 4 条进度条全部使用朱砂红,与民乐的主色调统一,百分比数字标注在右侧。曲单管理的增删改完整实现了个人曲库的自定义——用户可以按场景(夜读/通勤/雨天/练习)创建曲单,将 18 首经典曲目按自己的喜好组织起来。


第六章 弹窗交互体系深度解析

古韵阁共实现约 15 套弹窗组件,覆盖曲目详情、名家详情、课程详情、报名确认、打卡、雅集详情、曲谱详情、下载确认、新建/编辑/删除曲单、关于、退出登录等完整交互场景。本章选取两个最具代表性的弹窗进行深度解析。

6.1 新建曲单弹窗:极简表单的文人雅趣

新建曲单弹窗(addSheetModal)是我的 Tab 的核心交互,用户可以创建自定义曲单,按场景组织民乐曲目。

新建曲单弹窗

弹窗结构:

  1. 标题:「新 建 曲 单」(鎏金色,字间距 3px)
  2. 曲单名称输入框:TextInput,placeholder「曲单名称(如:雪夜听琴)」,宣纸白底+暖灰边框
  3. 场景标签输入框:TextInput,placeholder「场景标签(如:夜读 / 雨天)」
  4. 操作按钮:「取消」(灰底灰字)+「创建」(朱砂红底白字)

交互流程:

  1. 用户在我的 Tab 点击「+ 新建」按钮 → 弹出新建曲单弹窗
  2. 用户输入曲单名称(如「雪夜听琴」)和场景标签(如「夜读」)
  3. 点击「创建」→ push 新曲单到 sheets 数组(名称为空时默认「未命名曲单」,标签为空时默认「随心」)→ 清空输入框 → 关闭弹窗
  4. 点击「取消」→ 直接关闭弹窗

设计亮点:

新建曲单弹窗的 placeholder 文案是点睛之笔——「曲单名称(如:雪夜听琴)」「场景标签(如:夜读 / 雨天)」,示例本身就充满文人雅趣,引导用户创建有诗意的曲单名,而非冷冰冰的「我的收藏1」。两个输入框的宣纸白底+暖灰边框与全局水墨风格统一,弹窗整体居中显示,半透明墨色遮罩(rgba(43,43,38,0.5))营造出夜读时分的静谧氛围。

6.2 曲谱详情弹窗:工尺谱示意与多制式曲谱

曲谱详情弹窗(scoreDetailModal)是乐谱 Tab 的核心交互,展示曲谱的详细信息,最具特色的是其中的「工尺谱示意」区域。

曲谱详情弹窗

弹窗结构:

  1. 标题:「曲 谱 详 情」(鎏金色,字间距 3px)
  2. 曲谱名:「高山流水(古琴减字谱)」(大字加粗)
  3. 标签行:「减字谱」(朱砂红底)+「古琴」(黛青底)
  4. 工尺谱示意区:宣纸白底+边框卡片,包含:
    • 小字标签「工尺谱示意」
    • 大字「上 尺 工 凡 六 五 乙」(字间距 6px,加粗墨色)
    • 小字「凡 工 尺 上 · 合 四 一」(鎏金色,字间距 4px)
  5. 详细信息列表:难度(●●●●● 朱砂红圆点)、页数(8 页)、累计下载(12400 次)、格式(PDF 竖排·支持打印)
  6. 操作按钮:「⤓ 下载曲谱」(朱砂红底白字)+「🖨 打印装订」(白底黛青字)

交互流程:

  1. 用户在乐谱 Tab 点击曲谱卡片 → 弹出曲谱详情弹窗
  2. 用户查看工尺谱示意、难度、页数、下载量、格式等信息
  3. 点击「⤓ 下载曲谱」→ 未下载时弹出下载确认弹窗 → 确认后加入 downloadedIds → 显示 Toast「✓ 已存入书架,可离线翻阅」
  4. 点击「🖨 打印装订」→ 直接显示下载成功 Toast(演示功能)
  5. 已下载的曲谱按钮变为「✓ 已在书架」灰底

设计亮点:

曲谱详情弹窗的「工尺谱示意」是整个古韵阁最具文化深度的设计——工尺谱是中国传统记谱法,以「上尺工凡六五乙」七个字对应音阶,在数字界面中重现这种古老的记谱方式,本身就是一种文化传承。大字「上尺工凡六五乙」以字间距 6px 排列,如同古谱中的竖排文字;小字「凡工尺上·合四一」以鎏金色对应,模拟工尺谱的板式结构。这种设计让用户在下载曲谱之前,就能直观感受到传统记谱法的形态和美感,激发对民乐文化的兴趣。

6.3 弹窗体系总览

古韵阁的约 15 套弹窗可分为四类:

类型弹窗名称所在 Tab说明
详情类tuneDetailModal曲库曲目详情,含聆听/收藏
详情类masterDetailModal名家名家详情,含印章头像旋转动画
详情类courseDetailModal学堂课程详情,含报名
详情类eventDetailModal雅集雅集详情,含报名/取消/分享
详情类scoreDetailModal乐谱曲谱详情,含工尺谱示意/下载/打印
确认类enrollConfirmModal学堂课程报名确认
确认类joinConfirmModal雅集雅集报名确认
确认类cancelConfirmModal雅集雅集取消确认
确认类downloadConfirmModal乐谱曲谱下载确认
确认类deleteSheetModal我的曲单删除确认
表单类modeSheet曲库调式筛选底部抽屉
表单类checkinSheet学堂习琴打卡底部抽屉
表单类addSheetModal我的新建曲单弹窗
表单类editSheetModal我的编辑曲单弹窗
信息类aboutModal / logoutModal我的关于/退出登录

所有弹窗均采用 Stack 叠加+if 条件渲染的统一方案,半透明墨色遮罩(rgba(43,43,38,0.5)),弹窗内容为宣纸白底+暖灰边框+圆角,与全局水墨风格统一。


第七章 动画与过渡效果

古韵阁虽然是一个以静态展示为主的民乐 App,但通过精心设计的属性动画,为界面增添了灵动的交互反馈。本章解析古韵阁中的主要动画效果。

7.1 Tab 切换缩放动画

底部 Tab 栏的图标在选中时会放大 1.16 倍(scale { x: 1.16, y: 1.16 }),未选中时为原始大小 1.0。动画时长 220ms,缓动曲线 Curve.EaseOut。这种微妙的缩放动画让 Tab 切换有了「按下-弹起」的触感反馈,而非生硬的颜色切换。

7.2 筛选标签缩放动画

曲库 Tab 的乐器筛选标签在选中时放大 1.05 倍(scale { x: 1.05, y: 1.05 }),动画时长 200ms。标签的背景色从白底变为朱砂红底,文字从黛青变为白色,配合缩放动画,形成「选中即高亮」的视觉反馈。

7.3 收藏心形弹跳动画

曲目列表中的收藏心形(♥/♡)在收藏时放大 1.2 倍(scale { x: 1.2, y: 1.2 }),颜色从浅灰(#C9C2B4)变为朱砂红(#B03A2E),动画时长 260ms。这种「心跳」般的弹跳动画为收藏操作增添了情感反馈,让用户感受到「心动收藏」的愉悦。

7.4 名家印章旋转动画

名家详情弹窗中的印章头像在显示时带有 -8° 的旋转(rotate { angle: -8 }),动画时长 600ms。这种微旋转模拟了真实钤印时印章微微倾斜的效果,让数字头像有了「手工钤印」的质感,细节考究。

7.5 进度条增长动画

多个页面中的进度条(课程学习进度、雅集报名进度、乐器掌握度、曲目详情中的聆听进度等)在显示时带有增长动画(.animation({ duration: 300-400, curve: Curve.EaseOut }))。进度条从 0 增长到目标百分比,形成「数据加载」的视觉效果,让静态数据有了动态的生命力。

7.6 Toast 缩放动画

所有 Toast 提示(收藏成功、关注成功、报名成功、下载成功等)在显示时带有 1.05 倍缩放动画(scale { x: 1.05, y: 1.05 }),动画时长 220-240ms。Toast 背景为半透明纯色(朱砂红/黛青/鎏金),圆角 22px,居中显示,点击可关闭。这种微缩放让 Toast 有了「弹出」的动效,而非突兀出现。

7.7 动画设计原则

古韵阁的动画设计遵循以下原则:

  • 克制有度:动画时长控制在 200-600ms,缩放比例控制在 1.05-1.2 倍,不过度炫技
  • 反馈明确:每个用户操作(点击/收藏/切换)都有对应的动画反馈
  • 风格统一:所有动画使用 Curve.EaseOut 缓动曲线,与水墨风格的含蓄气质匹配
  • 性能友好:仅使用 transform(scale/rotate)和 opacity 动画,不触发重排重绘

第八章 民乐欣赏系统

民乐欣赏系统是古韵阁的核心功能,由曲库 Tab 承载,包含 18 首经典民乐曲目的多维分类、筛选、详情展示和收藏功能。

8.1 曲目数据体系

古韵阁收录 18 首经典民乐曲目,覆盖从先秦到当代的漫长历史时期:

  • 先秦:高山流水(古琴)、阳春白雪(琵琶)
  • 汉:广陵散(古琴,绝响)
  • 晋:梅花三弄(古琴)
  • 唐:春江花月夜(琵琶)
  • 宋:汉宫秋月(二胡)
  • 明:十面埋伏(琵琶)、平沙落雁(古琴)
  • 清:百鸟朝凤(唢呐)、寒鸦戏水(古筝)、夜深沉(京胡)
  • 近代:二泉映月(二胡)、渔舟唱晚(古筝)、赛马(二胡)、彩云追月(古筝)、姑苏行(笛子)、鹧鸪飞(笛子)
  • 当代:云水禅心(古筝,新声)

曲目按等级分为:经典(6首)、名曲(8首)、绝响(1首·广陵散)、新声(1首·云水禅心)、小众(2首·寒鸦戏水/夜深沉)。

8.2 双维度筛选

民乐欣赏系统提供乐器+调式双维度筛选:

  • 乐器筛选:7 个选项(全部/古琴/古筝/琵琶/二胡/笛子/唢呐),对应 8 种乐器(京胡归入胡琴类)
  • 调式筛选:6 个选项(全部/宫调/商调/角调/徵调/羽调),中国传统五声调式+变宫

调式与情绪的对应关系:

  • 宫调:明亮庄重(高山流水、渔舟唱晚、赛马、百鸟朝凤)
  • 商调:悲凉慷慨(十面埋伏、汉宫秋月、寒鸦戏水)
  • 角调:清新生机(平沙落雁、夜深沉)
  • 徵调:欢快热烈(梅花三弄、彩云追月、云水禅心)
  • 羽调:悠远深沉(二泉映月、春江花月夜、鹧鸪飞)

8.3 曲目详情

曲目详情弹窗展示曲目的完整信息:曲名、年代、调式、时长、乐器、等级、累计聆听量,以及「立即聆听」和「收藏」两个操作。累计聆听量以「万」为单位显示(如 32.8万),让用户了解曲目的热门程度。

8.4 收藏系统

用户可以收藏喜欢的曲目,收藏状态通过 likedExtra 数组管理(原始数据中的 liked 字段为初始收藏状态,likedExtra 为用户新增的收藏)。收藏的心形图标在收藏时变朱砂红并放大 1.2 倍,收藏后显示 Toast 提示。收藏的曲目可以在「我的曲单」中组织和管理。


第九章 名家讲堂系统

名家讲堂系统由名家 Tab 和学堂 Tab 共同承载,汇聚 10 位民乐名家,提供 12 门系统化课程,帮助用户跟随名师学习民乐。

9.1 名家堂

名家堂收录 10 位民乐界的代表性人物:

名家乐器头衔作品数师承
吴钊古琴国家级古琴非遗传承人46嫡传12人
闵惠芬二胡二胡艺术一代宗师38桃李满门
刘德海琵琶琵琶泰斗52传人遍五洲
王中山古筝古筝领军人物33弟子逾千
陆春龄笛子笛子南派宗师41南笛传四海
任同祥唢呐唢呐大家27齐鲁一脉
林玲古筝潮州筝派代表人22潮韵承一脉
张维良笛埙竹笛与埙演奏家35跨界传艺
赵聪琵琶琵琶演奏家19青年翘楚
方锦龙五弦琵琶国乐四大才子之一44国乐推广者

每位名家都有独特的印章字(琴/弦/琶/筝/笛/唢/潮/埙/悦/乐),名家详情中展示引言式的简介(如「操缦六十载,以指底苍劲之音复现太古遗韵」),让用户感受到名家的艺术风格和人格魅力。

9.2 课程体系

学堂提供 12 门系统化课程,覆盖 10 种乐器,分为免费公开课和付费进阶课:

免费课程(3门):

  • 二胡零基础·持弓三十课(30讲·15小时·15320人在学)
  • 笛子气息基础与长音(16讲·8小时·9864人在学)
  • 民乐乐理·宫商角徵羽(20讲·10小时·13480人在学)

付费课程(9门):

  • 古琴入门·减字谱识读(24讲·12.5小时·¥199·8642人)
  • 琵琶轮指专项训练(18讲·9小时·¥149·5218人)
  • 古筝考级一路带学(36讲·22小时·¥299·11046人)
  • 唢呐初阶·循环换气(12讲·6.5小时·¥129·3412人)
  • 埙的雅音·零基础吹埙(14讲·7小时·¥99·2876人)
  • 扬琴调音与音阶(15讲·7.5小时·¥139·2158人)
  • 柳琴戏韵速成(13讲·6小时·¥89·1534人)
  • 中阮伴奏与合奏(17讲·8.5小时·¥119·2640人)
  • 洞箫雅集曲目十讲(10讲·5小时·¥79·3128人)

9.3 习琴打卡

学堂提供 21 天习琴打卡功能,打卡日历以圆形印章形式展示(已打卡为朱砂红底白✓,未打卡为浅灰底日期),当前进度 16/21,连续打卡 6 天。打卡功能帮助用户建立每日习琴的习惯,将抽象的坚持转化为可视化的进度。


第十章 曲谱研习系统

曲谱研习系统由乐谱 Tab 承载,提供 10 份多制式曲谱的浏览、下载、打印功能,是古韵阁最具文化特色的功能模块。

10.1 多制式曲谱

古韵阁提供 4 种制式的曲谱:

  • 减字谱:古琴专用记谱法,以汉字减笔组合表示指法和音位(高山流水、梅花三弄)
  • 简谱:数字记谱法,1234567 对应音阶,最广泛使用(十面埋伏、二泉映月、渔舟唱晚、姑苏行、鹧鸪飞)
  • 工尺谱:中国传统记谱法,上尺工凡六五乙对应音阶(春江花月夜)
  • 五线谱:国际通用记谱法(彩云追月古筝合奏总谱)

10.2 曲谱数据

曲谱乐器制式页数难度下载量
高山流水古琴减字谱8●●●●○12400
十面埋伏琵琶简谱12●●●●●9800
二泉映月二胡简谱6●●●○○15200
渔舟唱晚古筝简谱5●●●○○18600
梅花三弄古琴减字谱9●●●●○8600
姑苏行笛子简谱4●●○○○11400
百鸟朝凤唢呐简谱11●●●●●7800
彩云追月古筝五线谱7●●●○○9200
鹧鸪飞笛子简谱4●●●●○6400
春江花月夜琵琶工尺谱10●●●●●5100

10.3 工尺谱示意

曲谱详情弹窗中的「工尺谱示意」是曲谱研习系统的文化亮点,以「上尺工凡六五乙」七个大字和「凡工尺上·合四一」小字展示传统工尺谱的形态。工尺谱是中国传统记谱法,起源于唐代,成熟于宋代,以「上、尺、工、凡、六、五、乙」七个字对应 do re mi fa sol la si,是中国音乐文化的重要遗产。

10.4 下载与打印

用户可以下载曲谱到「我的书架」,支持 PDF 竖排格式,可离线翻阅。曲谱还支持「打印装订」功能,方便用户打印成实体曲谱使用。已下载的曲谱显示「✓ 已在书架」状态,下载量数据帮助用户了解曲谱的热门程度。


第十一章 线下雅集系统

线下雅集系统由雅集 Tab 承载,提供 8 场线下民乐雅集的浏览、报名、取消功能,连接线上线下,让琴友找到同好。

11.1 雅集数据

雅集名称城市地点日期名额费用
中秋雅集·抚琴对月苏州拙政园·远香堂09-25 19:0040¥88
国风夜市·民乐快闪上海豫园·九曲桥10-02 18:30100免费
古琴公开课·减字谱北京国家图书馆·学津堂10-11 14:0080免费
琵琶社·秋日合奏会北京中央音乐学院10-18 15:0030¥50
江南丝竹·老茶馆听乐上海城隍庙·湖心亭10-25 13:3050¥68
笛箫雅集·平湖秋月杭州西湖·曲院风荷11-01 18:0060¥58
埙友小聚·城墙暮色西安西安城墙·永宁门11-08 17:3040免费
新民乐交流·电子与丝竹深圳迷笛中心11-15 20:00120¥120

11.2 雅集类型

8 场雅集涵盖多种类型:

  • 传统雅集:中秋雅集、笛箫雅集、埙友小聚(文人雅集形式,抚琴对月、品茗听乐)
  • 公开课程:古琴公开课(免费公益讲座,普及减字谱知识)
  • 合奏排练:琵琶社秋日合奏会(乐手合奏排练,名额有限)
  • 公共演出:国风夜市民乐快闪、江南丝竹老茶馆听乐(面向公众的演出活动)
  • 跨界交流:新民乐交流·电子与丝竹(传统民乐与电子音乐的跨界碰撞)

11.3 报名与管理

用户可以浏览雅集详情(时间、地点、费用、名额、携带乐器说明),报名或取消报名。报名进度条实时显示已报名/名额比例,爆满的雅集进度条变朱砂红。报名成功后显示 Toast「✓ 报名成功,静候佳音」,取消后显示「已取消,欢迎改日再会」。雅集还支持分享功能,用户可以将雅集信息分享给同好。

11.4 文化场景设计

雅集的地点选择充满文化气息——拙政园远香堂、豫园九曲桥、西湖曲院风荷、西安城墙永宁门、城隍庙湖心亭,每一个地点都是中国传统文化的标志性场景,与民乐雅集的气质高度契合。日期选择也讲究——中秋雅集安排在 09-25(临近中秋),国风夜市在 10-02(国庆假期),让雅集与传统节日和假期结合,提升参与感。


第十二章 个人中心与习琴数据系统

个人中心由我的 Tab 承载,包含个人信息、本周习琴数据、乐器掌握度、我的曲单管理四大模块,是用户了解自己学习进度和管理个人曲库的中心。

12.1 个人信息

个人信息卡片展示用户的基本信息:

  • 用户名:青崖居士(取自李白「且放白鹿青崖间」的诗意)
  • 琴龄:三年
  • 累计习琴:462 小时
  • 徽章进度:初窥门径 → 渐入佳境(等级晋升路径)
  • 头像:朱砂红圆形印章,中间「琴」字

12.2 本周习琴数据

本周习琴卡片以柱状图展示 7 天的练习时长:

  • 周一:25 分钟
  • 周二:40 分钟
  • 周三:15 分钟
  • 周四:50 分钟
  • 周五:35 分钟
  • 周六:75 分钟(本周最高,朱砂红高亮)
  • 周日:60 分钟(朱砂红高亮)

本周总计 4 小时 40 分钟,工作日为黛青柱子、周末为朱砂红柱子,颜色区分工作/休息的练习模式。底部标注「周六练琴最勤,达 75 分钟」,帮助用户了解自己的练习规律。

12.3 乐器掌握度

乐器掌握度展示 4 种乐器的学习进度:

  • 古琴:72%(主修乐器,进度最高)
  • 洞箫:45%(副修乐器)
  • 琵琶:28%(涉猎)
  • 古筝:16%(入门)

全部使用朱砂红进度条,与民乐主色调统一。掌握度数据帮助用户了解自己在不同乐器上的学习进度,合理分配练习时间。

12.4 我的曲单管理

我的曲单是用户的个人曲库管理功能,支持增删改操作:

初始曲单(4个):

  • 抚琴静心:18 曲·夜读
  • 晨起丝竹:12 曲·通勤
  • 雨窗听埙:9 曲·雨天
  • 考级备选:22 曲·练习

操作功能:

  • 新建曲单:输入曲单名称和场景标签,创建自定义曲单
  • 编辑曲单:修改曲单名称
  • 删除曲单:删除不需要的曲单(曲单内曲目不会从曲库移除)

曲单的场景标签(夜读/通勤/雨天/练习)让用户按使用场景组织曲目,提升聆听体验。曲单管理的完整增删改实现了个人曲库的自定义,让每个用户都能拥有属于自己的民乐曲库。


十三、开发环境搭建

13.1 环境要求

DevEco Studio 5.0+(支持HarmonyOS 6.1.1)、HarmonyOS SDK API 24、Node.js 18+、至少8GB内存。

13.2 安装DevEco Studio

DevEco Studio 安装界面

从华为开发者官网下载最新版本,运行安装包,选择安装路径,勾选创建桌面快捷方式,点击安装。

13.3 配置SDK

SDK 配置界面

首次启动后进入File > Settings > HarmonyOS SDK,勾选API 24及以上版本,点击Apply等待下载完成。

13.4 创建项目

创建项目界面

选择Create Project > Empty Ability,配置项目名称、包名、保存路径,选择Compile SDK为API 24,点击Finish。

13.5 运行应用

运行应用界面

将1316.ets代码替换到entry/src/main/ets/pages/Index.ets,连接HarmonyOS设备或启动模拟器,点击运行按钮预览。

13.6 预览调试

预览调试界面

DevEco Studio提供实时预览功能,编辑器右侧打开Previewer面板,支持多设备尺寸预览、深色模式切换、组件树inspect等调试能力。

总结与展望

项目总结

古韵阁 GU YUN GE 是一款以国风民乐欣赏为核心的传统文化类 App,基于 HarmonyOS ArkTS 声明式 UI 框架开发,单文件约 2900 行代码,无外部依赖。项目实现了 6 大 Tab 页面(曲库/名家/学堂/雅集/乐谱/我的)和约 15 套弹窗组件,完整覆盖民乐欣赏、名家讲堂、线下雅集、曲谱研习、习琴打卡、个人曲库管理等核心业务场景。

视觉设计上采用"水墨留白风"——宣纸白底(#F4F2ED)营造温润雅致的基底,朱砂红(#B03A2E)如同文人画上的钤印,黛青(#4A6B7A)如同水墨晕染的远山,鎏金(#8D6E4C)如同书画上的金箔题跋。整体视觉氛围雅致、含蓄、悠远,传递出中国民乐特有的文化气质。色彩语义明确——朱砂红代表主操作/高亮/收藏,黛青代表次要/辅助/未关注,鎏金代表装饰/难度/传统元素。

技术实现上严格遵循 ArkTS 规范:无 Blank 组件、无 UI 内变量声明、constraintSize 限高、单子组件 Scroll、接口约束全部对象字面量。采用单文件组件化架构,通过 @Component 划分为 8 个独立组件,@State 管理局部状态,@Builder 封装可复用 UI 和弹窗,Stack 叠加实现半透明遮罩弹窗,属性动画实现交互动效。

技术亮点

  1. 水墨留白的视觉体系:宣纸白底+朱砂红+黛青+鎏金的四级色彩体系,印章式名家头像、书页微旋转乐谱卡片、工尺谱示意等中国传统视觉元素的数字化转译,让每一屏都如同一幅可交互的文人画
  2. 乐器+调式双维度筛选:曲库 Tab 提供 7 种乐器+6 种调式的双维度筛选体系,调式与情绪对应(宫调明亮/商调悲凉/角调清新/徵调欢快/羽调悠远),帮助用户按心境选曲
  3. 印章式名家头像:每位名家的头像都是一个圆形的「朱文印」,中间一个字代表乐器或名号,已关注为朱砂红、未关注为黛青,将中国传统印章文化与移动端头像完美融合
  4. 多制式曲谱研习:提供减字谱、简谱、工尺谱、五线谱 4 种制式曲谱,曲谱详情中展示「上尺工凡六五乙」工尺谱示意,让传统记谱法在数字界面中重现
  5. 21 天习琴打卡:学堂 Tab 提供 21 天打卡日历,已打卡为朱砂红圆形印章、未打卡为浅灰日期数字,将抽象的坚持转化为可视化进度,帮助用户建立每日习琴习惯
  6. 线下雅集文化场景:8 场雅集的地点选择充满文化气息(拙政园远香堂、西湖曲院风荷、西安城墙永宁门等),日期与传统节日结合(中秋雅集、国庆夜市),连接线上线下
  7. 个人曲单增删改:我的 Tab 提供完整的曲单管理(新建/编辑/删除),按场景标签(夜读/通勤/雨天/练习)组织曲目,实现个人曲库的完全自定义
  8. 约 15 套完整弹窗交互体系:从曲目详情、名家详情、课程报名、雅集报名、曲谱下载到曲单增删改,每个业务场景都有对应的弹窗交互,形成完整的民乐欣赏与学习业务闭环

扩展方向

古韵阁 GU YUN GE 作为演示项目,仍有多个可扩展方向:

  • 真实音频播放:当前曲目聆听为静态演示,可接入 HarmonyOS 音频播放 API 实现真实的民乐音频播放,支持播放列表、循环模式、定时关闭等
  • 曲谱翻页与跟练:当前曲谱为静态图片,可扩展为交互式曲谱,支持翻页、放大、跟练模式(曲谱高亮当前演奏位置)、节拍器等专业功能
  • 名家直播与互动:当前名家为静态信息,可扩展为名家直播课、在线答疑、一对一视频教学等实时互动功能
  • 雅集地图与导航:当前雅集为列表展示,可接入地图服务,在地图上展示附近的雅集活动,提供导航功能
  • AI 琴音评测:可集成 AI 音频分析能力,实现演奏录音的音准检测、节奏评估、指法建议等 AI 教练功能
  • 民乐社区:可扩展为完整社区,用户可发布演奏视频、琴友动态、曲谱分享、评论互动等社交功能
  • 智能设备联动:可对接华为手表、耳机等智能穿戴设备,实现习琴时长统计、耳机低延迟伴奏、演奏录音等专业功能
  • 多端协同:可扩展为手机/平板/PC 多端协同,手机用于聆听和社交,平板用于曲谱展示和跟练,PC 用于曲谱编辑和音频处理

古韵阁 GU YUN GE 展示了 HarmonyOS ArkTS 在传统文化类 App 开发中的强大能力——声明式 UI 的简洁语法、@State 的高效状态管理、Stack 叠加弹窗的灵活实现、属性动画的流畅动效、Previewer 的实时预览,让开发者可以用较少的代码实现丰富的民乐数据展示与交互体验。希望本文能为 HarmonyOS 开发者和传统文化产品从业者提供有价值的参考。

高山流水,知音难觅。愿古韵阁与君共守丝竹一脉。

本文基于 HarmonyOS ArkTS 声明式 UI 框架实战项目撰写,所有代码和图片均来自古韵阁 GU YUN GE 项目。


附录:完整源码(2133.ets)

以下为古韵阁 GU YUN GE 应用的完整 ArkTS 源码,单文件约 2900 行,包含类型定义、静态数据、工具函数、枚举、8 个 @Component 组件的完整实现。可直接复制到 HarmonyOS 项目的 entry/src/main/ets/pages/Index.ets 中运行。

// 2133.ets 古韵阁 —— 国风民乐欣赏 / 名家讲堂 / 线下雅集 / 曲谱研习
// 水墨留白风:宣纸白底 + 朱砂红 + 黛青 + 鎏金
// 遵循要求.md:无 Blank、无 UI 内变量声明、constraintSize 限高、单子组件 Scroll、接口约束全部对象字面量

// ============ 类型定义 ============
interface FolkTune {
  id: number;
  title: string;
  instrument: string;
  mode: string;
  dynasty: string;
  duration: string;
  plays: number;
  liked: boolean;
  level: string;
}

interface MasterInfo {
  id: number;
  name: string;
  title: string;
  instrument: string;
  works: number;
  students: string;
  seal: string;
  intro: string;
}

interface LearnCourse {
  id: number;
  name: string;
  instrument: string;
  chapters: number;
  hours: string;
  price: number;
  free: boolean;
  students: number;
}

interface GatheringEvent {
  id: number;
  name: string;
  place: string;
  city: string;
  date: string;
  quota: number;
  joined: number;
  fee: string;
}

interface ScoreEntry {
  id: number;
  title: string;
  instrument: string;
  type: string;
  pages: number;
  downloads: number;
  difficulty: number;
}

interface PracticeDay {
  day: string;
  minutes: number;
}

interface MasteryItem {
  name: string;
  emoji: string;
  percent: number;
}

interface PlaySheet {
  id: number;
  name: string;
  count: number;
  mood: string;
}

// ============ 静态数据 ============
const FOLK_TUNES: FolkTune[] = [
  { id: 1, title: '高山流水', instrument: '古琴', mode: '宫调', dynasty: '先秦', duration: '7:32', plays: 328000, liked: true, level: '经典' },
  { id: 2, title: '梅花三弄', instrument: '古琴', mode: '徵调', dynasty: '晋', duration: '6:48', plays: 286000, liked: false, level: '经典' },
  { id: 3, title: '十面埋伏', instrument: '琵琶', mode: '商调', dynasty: '明', duration: '8:21', plays: 412000, liked: true, level: '名曲' },
  { id: 4, title: '二泉映月', instrument: '二胡', mode: '羽调', dynasty: '近代', duration: '6:54', plays: 498000, liked: true, level: '名曲' },
  { id: 5, title: '渔舟唱晚', instrument: '古筝', mode: '宫调', dynasty: '近代', duration: '5:12', plays: 388000, liked: false, level: '名曲' },
  { id: 6, title: '春江花月夜', instrument: '琵琶', mode: '羽调', dynasty: '唐', duration: '9:03', plays: 356000, liked: false, level: '经典' },
  { id: 7, title: '汉宫秋月', instrument: '二胡', mode: '商调', dynasty: '宋', duration: '6:15', plays: 245000, liked: false, level: '经典' },
  { id: 8, title: '平沙落雁', instrument: '古琴', mode: '角调', dynasty: '明', duration: '7:05', plays: 198000, liked: false, level: '经典' },
  { id: 9, title: '广陵散', instrument: '古琴', mode: '变宫', dynasty: '汉', duration: '10:26', plays: 276000, liked: true, level: '绝响' },
  { id: 10, title: '赛马', instrument: '二胡', mode: '宫调', dynasty: '近代', duration: '4:38', plays: 462000, liked: false, level: '名曲' },
  { id: 11, title: '彩云追月', instrument: '古筝', mode: '徵调', dynasty: '近代', duration: '5:24', plays: 372000, liked: true, level: '名曲' },
  { id: 12, title: '姑苏行', instrument: '笛子', mode: '宫调', dynasty: '近代', duration: '5:48', plays: 268000, liked: false, level: '名曲' },
  { id: 13, title: '百鸟朝凤', instrument: '唢呐', mode: '宫调', dynasty: '清', duration: '8:52', plays: 334000, liked: false, level: '名曲' },
  { id: 14, title: '鹧鸪飞', instrument: '笛子', mode: '羽调', dynasty: '近代', duration: '6:37', plays: 186000, liked: false, level: '名曲' },
  { id: 15, title: '寒鸦戏水', instrument: '古筝', mode: '商调', dynasty: '清', duration: '7:18', plays: 154000, liked: false, level: '小众' },
  { id: 16, title: '阳春白雪', instrument: '琵琶', mode: '宫调', dynasty: '春秋', duration: '6:02', plays: 232000, liked: false, level: '经典' },
  { id: 17, title: '夜深沉', instrument: '京胡', mode: '角调', dynasty: '清', duration: '5:55', plays: 176000, liked: false, level: '小众' },
  { id: 18, title: '云水禅心', instrument: '古筝', mode: '徵调', dynasty: '当代', duration: '8:40', plays: 418000, liked: true, level: '新声' }
];

const MASTER_LIST: MasterInfo[] = [
  { id: 1, name: '吴钊', title: '国家级古琴非遗传承人', instrument: '古琴', works: 46, students: '嫡传12人', seal: '琴', intro: '操缦六十载,以指底苍劲之音复现太古遗韵' },
  { id: 2, name: '闵惠芬', title: '二胡艺术一代宗师', instrument: '二胡', works: 38, students: '桃李满门', seal: '弦', intro: '其《二泉映月》被誉为一吟一叹皆入骨' },
  { id: 3, name: '刘德海', title: '琵琶泰斗', instrument: '琵琶', works: 52, students: '传人遍五洲', seal: '琶', intro: '一生改指法、创新声,重塑琵琶语汇' },
  { id: 4, name: '王中山', title: '古筝领军人物', instrument: '古筝', works: 33, students: '弟子逾千', seal: '筝', intro: '快指如飞而不失清雅,人送外号「筝圣」' },
  { id: 5, name: '陆春龄', title: '笛子南派宗师', instrument: '笛子', works: 41, students: '南笛传四海', seal: '笛', intro: '一曲《鹧鸪飞》,江南烟雨尽在唇齿之间' },
  { id: 6, name: '任同祥', title: '唢呐大家', instrument: '唢呐', works: 27, students: '齐鲁一脉', seal: '唢', intro: '《百鸟朝凤》出其手,百鸟争鸣震屋瓦' },
  { id: 7, name: '林玲', title: '潮州筝派代表人', instrument: '古筝', works: 22, students: '潮韵承一脉', seal: '潮', intro: '以细腻催奏法存潮乐活态之美' },
  { id: 8, name: '张维良', title: '竹笛与埙演奏家', instrument: '笛埙', works: 35, students: '跨界传艺', seal: '埙', intro: '让千年埙声走进现代音乐厅' },
  { id: 9, name: '赵聪', title: '琵琶演奏家', instrument: '琵琶', works: 19, students: '青年翘楚', seal: '悦', intro: '以琵琶对话世界,弦上生风华' },
  { id: 10, name: '方锦龙', title: '国乐四大才子之一', instrument: '五弦琵琶', works: 44, students: '国乐推广者', seal: '乐', intro: '一把五弦琵琶,弹尽古今中外' }
];

const LEARN_COURSES: LearnCourse[] = [
  { id: 1, name: '古琴入门 · 减字谱识读', instrument: '古琴', chapters: 24, hours: '12.5小时', price: 199, free: false, students: 8642 },
  { id: 2, name: '二胡零基础 · 持弓三十课', instrument: '二胡', chapters: 30, hours: '15小时', price: 0, free: true, students: 15320 },
  { id: 3, name: '琵琶轮指专项训练', instrument: '琵琶', chapters: 18, hours: '9小时', price: 149, free: false, students: 5218 },
  { id: 4, name: '古筝考级一路带学', instrument: '古筝', chapters: 36, hours: '22小时', price: 299, free: false, students: 11046 },
  { id: 5, name: '笛子气息基础与长音', instrument: '笛子', chapters: 16, hours: '8小时', price: 0, free: true, students: 9864 },
  { id: 6, name: '唢呐初阶 · 循环换气', instrument: '唢呐', chapters: 12, hours: '6.5小时', price: 129, free: false, students: 3412 },
  { id: 7, name: '埙的雅音 · 零基础吹埙', instrument: '埙', chapters: 14, hours: '7小时', price: 99, free: false, students: 2876 },
  { id: 8, name: '民乐乐理 · 宫商角徵羽', instrument: '乐理', chapters: 20, hours: '10小时', price: 0, free: true, students: 13480 },
  { id: 9, name: '扬琴调音与音阶', instrument: '扬琴', chapters: 15, hours: '7.5小时', price: 139, free: false, students: 2158 },
  { id: 10, name: '柳琴戏韵速成', instrument: '柳琴', chapters: 13, hours: '6小时', price: 89, free: false, students: 1534 },
  { id: 11, name: '中阮伴奏与合奏', instrument: '中阮', chapters: 17, hours: '8.5小时', price: 119, free: false, students: 2640 },
  { id: 12, name: '洞箫雅集曲目十讲', instrument: '洞箫', chapters: 10, hours: '5小时', price: 79, free: false, students: 3128 }
];

const GATHERING_LIST: GatheringEvent[] = [
  { id: 1, name: '中秋雅集 · 抚琴对月', place: '拙政园·远香堂', city: '苏州', date: '09-25 19:00', quota: 40, joined: 33, fee: '¥88' },
  { id: 2, name: '国风夜市 · 民乐快闪', place: '豫园·九曲桥', city: '上海', date: '10-02 18:30', quota: 100, joined: 87, fee: '免费' },
  { id: 3, name: '古琴公开课 · 减字谱', place: '国家图书馆·学津堂', city: '北京', date: '10-11 14:00', quota: 80, joined: 66, fee: '免费' },
  { id: 4, name: '琵琶社 · 秋日合奏会', place: '中央音乐学院', city: '北京', date: '10-18 15:00', quota: 30, joined: 30, fee: '¥50' },
  { id: 5, name: '江南丝竹 · 老茶馆听乐', place: '城隍庙·湖心亭', city: '上海', date: '10-25 13:30', quota: 50, joined: 21, fee: '¥68' },
  { id: 6, name: '笛箫雅集 · 平湖秋月', place: '西湖·曲院风荷', city: '杭州', date: '11-01 18:00', quota: 60, joined: 44, fee: '¥58' },
  { id: 7, name: '埙友小聚 · 城墙暮色', place: '西安城墙·永宁门', city: '西安', date: '11-08 17:30', quota: 40, joined: 12, fee: '免费' },
  { id: 8, name: '新民乐交流 · 电子与丝竹', place: '迷笛中心', city: '深圳', date: '11-15 20:00', quota: 120, joined: 96, fee: '¥120' }
];

const SCORE_LIST: ScoreEntry[] = [
  { id: 1, title: '高山流水(古琴减字谱)', instrument: '古琴', type: '减字谱', pages: 8, downloads: 12400, difficulty: 4 },
  { id: 2, title: '十面埋伏(琵琶简谱)', instrument: '琵琶', type: '简谱', pages: 12, downloads: 9800, difficulty: 5 },
  { id: 3, title: '二泉映月(二胡简谱)', instrument: '二胡', type: '简谱', pages: 6, downloads: 15200, difficulty: 3 },
  { id: 4, title: '渔舟唱晚(古筝D调)', instrument: '古筝', type: '简谱', pages: 5, downloads: 18600, difficulty: 3 },
  { id: 5, title: '梅花三弄(古琴减字谱)', instrument: '古琴', type: '减字谱', pages: 9, downloads: 8600, difficulty: 4 },
  { id: 6, title: '姑苏行(笛子简谱)', instrument: '笛子', type: '简谱', pages: 4, downloads: 11400, difficulty: 2 },
  { id: 7, title: '百鸟朝凤(唢呐简谱)', instrument: '唢呐', type: '简谱', pages: 11, downloads: 7800, difficulty: 5 },
  { id: 8, title: '彩云追月(古筝合奏总谱)', instrument: '古筝', type: '五线谱', pages: 7, downloads: 9200, difficulty: 3 },
  { id: 9, title: '鹧鸪飞(笛子独奏谱)', instrument: '笛子', type: '简谱', pages: 4, downloads: 6400, difficulty: 4 },
  { id: 10, title: '春江花月夜(琵琶工尺谱)', instrument: '琵琶', type: '工尺谱', pages: 10, downloads: 5100, difficulty: 5 }
];

const PRACTICE_WEEK: PracticeDay[] = [
  { day: '一', minutes: 25 },
  { day: '二', minutes: 40 },
  { day: '三', minutes: 15 },
  { day: '四', minutes: 50 },
  { day: '五', minutes: 35 },
  { day: '六', minutes: 75 },
  { day: '日', minutes: 60 }
];

const MASTERY_LIST: MasteryItem[] = [
  { name: '古琴', emoji: '🎼', percent: 72 },
  { name: '洞箫', emoji: '🎵', percent: 45 },
  { name: '琵琶', emoji: '🪕', percent: 28 },
  { name: '古筝', emoji: '🎶', percent: 16 }
];

const INSTRUMENT_FILTERS: string[] = ['全部', '古琴', '古筝', '琵琶', '二胡', '笛子', '唢呐'];

const MODE_NAMES: string[] = ['全部', '宫调', '商调', '角调', '徵调', '羽调'];

// ============ 工具函数 ============
function tuneLevelColor(lv: string): string {
  if (lv === '经典') {
    return 'rgba(52,73,94,0.1)';
  }
  if (lv === '名曲') {
    return 'rgba(176,58,46,0.1)';
  }
  if (lv === '绝响') {
    return 'rgba(141,110,76,0.14)';
  }
  if (lv === '新声') {
    return 'rgba(74,107,122,0.12)';
  }
  return 'rgba(74,107,122,0.08)';
}

function tuneLevelTextColor(lv: string): string {
  if (lv === '经典') {
    return '#34495E';
  }
  if (lv === '名曲') {
    return '#B03A2E';
  }
  if (lv === '绝响') {
    return '#8D6E4C';
  }
  return '#4A6B7A';
}

function practiceBarHeight(m: number): string {
  return (m * 1.2).toFixed(0) + 'vp';
}

function difficultyDots(n: number): string {
  if (n >= 5) {
    return '●●●●●';
  }
  if (n === 4) {
    return '●●●●○';
  }
  if (n === 3) {
    return '●●●○○';
  }
  return '●●○○○';
}

enum GuyunTab {
  TUNES,
  MASTERS,
  LEARN,
  GATHERING,
  SCORES,
  ME
}
// ============ 主入口 ============
@Entry
@Component
struct GuyunPavilionApp {
  @State activeTab: GuyunTab = GuyunTab.TUNES

  @Builder contentArea() {
    if (this.activeTab === GuyunTab.TUNES) {
      TunesContent()
    } else if (this.activeTab === GuyunTab.MASTERS) {
      MastersContent()
    } else if (this.activeTab === GuyunTab.LEARN) {
      LearnContent()
    } else if (this.activeTab === GuyunTab.GATHERING) {
      GatheringContent()
    } else if (this.activeTab === GuyunTab.SCORES) {
      ScoresContent()
    } else {
      MeContent()
    }
  }

  @Builder bottomTabItem(icon: string, label: string, tab: GuyunTab) {
    Column({ space: 3 }) {
      Text(icon)
        .fontSize(21)
        .scale(this.activeTab === tab ? { x: 1.16, y: 1.16 } : { x: 1, y: 1 })
        .animation({ duration: 220, curve: Curve.EaseOut })
      Text(label)
        .fontSize(10)
        .fontColor(this.activeTab === tab ? '#B03A2E' : '#9B9186')
    }
    .alignItems(HorizontalAlign.Center)
    .padding({ top: 8, bottom: 6 })
    .width('16.6%')
    .onClick(() => {
      this.activeTab = tab;
    })
  }

  build() {
    Column() {
      GuyunHeader()
      Column() {
        this.contentArea()
      }
      .layoutWeight(1)
      .width('100%')

      Divider()
        .color('#E8E2D5')
        .strokeWidth(1)

      Row() {
        this.bottomTabItem('🎼', '曲库', GuyunTab.TUNES)
        this.bottomTabItem('🍵', '名家', GuyunTab.MASTERS)
        this.bottomTabItem('📖', '学堂', GuyunTab.LEARN)
        this.bottomTabItem('🏮', '雅集', GuyunTab.GATHERING)
        this.bottomTabItem('📜', '乐谱', GuyunTab.SCORES)
        this.bottomTabItem('👤', '我的', GuyunTab.ME)
      }
      .width('100%')
      .backgroundColor('#FBF9F4')
      .padding({ bottom: 6 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F4F2ED')
  }
}

// ============ 头部(电商风·静态) ============
@Component
struct GuyunHeader {
  onSearchTap: () => void = () => {};

  build() {
    Column() {
      Row({ space: 10 }) {
        Column() {
          Text('阁')
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FBF9F4')
        }
        .width(42)
        .height(42)
        .borderRadius(8)
        .backgroundColor('#B03A2E')
        .justifyContent(FlexAlign.Center)

        Column({ space: 2 }) {
          Text('古韵阁')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor('#2B2B2B')
          Text('GU YUN GE · 民乐雅赏')
            .fontSize(8)
            .fontColor('#9B9186')
            .letterSpacing(2)
        }
        .alignItems(HorizontalAlign.Start)

        Row({ space: 6 }) {
          Text('🔎')
            .fontSize(13)
          Text('搜曲目 / 名家 / 雅集')
            .fontSize(11)
            .fontColor('#9B9186')
        }
        .padding({ left: 12, right: 12, top: 8, bottom: 8 })
        .backgroundColor('#FFFFFF')
        .borderRadius(22)
        .border({ width: 1, color: '#E8E2D5' })
        .layoutWeight(1)
        .onClick(() => {
          this.onSearchTap();
        })

        Text('🔔')
          .fontSize(18)
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12, bottom: 12 })

      Row({ space: 8 }) {
        Text('秋分特辑 · 十首适合秋夜的民乐')
          .fontSize(10)
          .fontColor('#B03A2E')
          .padding({ left: 10, right: 10, top: 4, bottom: 4 })
          .backgroundColor('rgba(176,58,46,0.07)')
          .borderRadius(4)
        Text('新上:埙的雅音课程 · 中秋雅集报名中')
          .fontSize(10)
          .fontColor('#9B9186')
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .layoutWeight(1)
      }
      .width('100%')
      .padding({ left: 16, right: 16, bottom: 12 })
    }
    .width('100%')
    .backgroundColor('#FBF9F4')
  }
}
// ============ 曲库 Tab(乐器筛选 + 曲目列表 + 详情弹窗) ============
@Component
struct TunesContent {
  @State filterIdx: number = 0
  @State showTuneDetail: boolean = false
  @State selectedTune: FolkTune | null = null
  @State showLikeToast: boolean = false
  @State showModeSheet: boolean = false
  @State modeIdx: number = 0
  @State likedExtra: number[] = []

  tunesNow(): FolkTune[] {
    return FOLK_TUNES.filter((t: FolkTune) => {
      if (this.filterIdx === 0) {
        return true;
      }
      return t.instrument === INSTRUMENT_FILTERS[this.filterIdx];
    });
  }

  isLiked(t: FolkTune): boolean {
    return t.liked || this.likedExtra.indexOf(t.id) >= 0;
  }

  toggleLike(id: number) {
    if (this.likedExtra.indexOf(id) >= 0) {
      this.likedExtra.splice(this.likedExtra.indexOf(id), 1);
    } else {
      this.likedExtra.push(id);
    }
  }

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

  @Builder modalOverlay(onClose: () => void) {
    Column() {
      Column()
        .width('100%')
        .layoutWeight(1)
        .onClick(() => {
          onClose();
        })
      Column() {
        Text('🎼')
          .fontSize(30)
          .margin({ bottom: 8 })
        Text('古韵阁')
          .fontSize(12)
          .fontColor('#9B9186')
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
  }

  @Builder tuneDetailModal() {
    Column({ space: 12 }) {
      Text('曲 目 详 情')
        .fontSize(12)
        .fontColor('#8D6E4C')
        .letterSpacing(3)
        .margin({ top: 20 })

      Text(this.selectedTune!.title)
        .fontSize(22)
        .fontWeight(FontWeight.Bold)
        .fontColor('#2B2B26')
      Text(this.selectedTune!.dynasty + ' · ' + this.selectedTune!.mode + ' · ' + this.selectedTune!.duration)
        .fontSize(12)
        .fontColor('#9B9186')
      Row({ space: 8 }) {
        Text(this.selectedTune!.instrument)
          .fontSize(11)
          .fontColor('#4A6B7A')
          .padding({ left: 12, right: 12, top: 4, bottom: 4 })
          .borderRadius(4)
          .backgroundColor('rgba(74,107,122,0.1)')
        Text(this.selectedTune!.level)
          .fontSize(11)
          .fontColor(tuneLevelTextColor(this.selectedTune!.level))
          .padding({ left: 12, right: 12, top: 4, bottom: 4 })
          .borderRadius(4)
          .backgroundColor(tuneLevelColor(this.selectedTune!.level))
        Text('减字谱可查')
          .fontSize(11)
          .fontColor('#8D6E4C')
          .padding({ left: 12, right: 12, top: 4, bottom: 4 })
          .borderRadius(4)
          .backgroundColor('rgba(141,110,76,0.12)')
      }

      Divider()
        .color('#E8E2D5')
        .strokeWidth(1)
        .width('84%')
      Column({ space: 10 }) {
        Row() {
          Text('演奏乐器')
            .fontSize(11)
            .fontColor('#9B9186')
          Text(this.selectedTune!.instrument)
            .fontSize(11)
            .fontColor('#2B2B26')
        }
        .width('84%')
        .justifyContent(FlexAlign.SpaceBetween)

        Row() {
          Text('传统调式')
            .fontSize(11)
            .fontColor('#9B9186')
          Text(this.selectedTune!.mode)
            .fontSize(11)
            .fontColor('#2B2B26')
        }
        .width('84%')
        .justifyContent(FlexAlign.SpaceBetween)

        Row() {
          Text('流传年代')
            .fontSize(11)
            .fontColor('#9B9186')
          Text(this.selectedTune!.dynasty)
            .fontSize(11)
            .fontColor('#2B2B26')
        }
        .width('84%')
        .justifyContent(FlexAlign.SpaceBetween)

        Row() {
          Text('累计聆听')
            .fontSize(11)
            .fontColor('#9B9186')
          Text(this.playsText(this.selectedTune!.plays) + ' 人')
            .fontSize(11)
            .fontColor('#B03A2E')
        }
        .width('84%')
        .justifyContent(FlexAlign.SpaceBetween)
      }
      .width('100%')

      Row({ space: 10 }) {
        Button() {
          Text('▷ 立即聆听')
            .fontSize(14)
            .fontColor('#FFFFFF')
        }
        .layoutWeight(1)
        .height(42)
        .backgroundColor('#B03A2E')
        .borderRadius(21)

        Button() {
          Text(this.isLiked(this.selectedTune!) ? '♥ 已收藏' : '♡ 收藏')
            .fontSize(14)
            .fontColor('#B03A2E')
        }
        .layoutWeight(1)
        .height(42)
        .backgroundColor('rgba(176,58,46,0.08)')
        .borderRadius(21)
        .onClick(() => {
          this.toggleLike(this.selectedTune!.id);
          this.showLikeToast = true;
        })
      }
      .width('84%')
      .margin({ top: 6, bottom: 22 })
    }
    .width('88%')
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#E8E2D5' })
  }

  @Builder modeSheet() {
    Column({ space: 4 }) {
      Text('按传统调式筛选')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#2B2B26')
        .margin({ top: 18, bottom: 8 })

      ForEach(MODE_NAMES, (m: string, i: number) => {
        Row() {
          Text(m)
            .fontSize(13)
            .fontColor(this.modeIdx === i ? '#B03A2E' : '#2B2B26')
          Text(this.modeIdx === i ? '✓' : '')
            .fontSize(14)
            .fontColor('#B03A2E')
        }
        .width('100%')
        .padding({ left: 24, right: 24, top: 13, bottom: 13 })
        .justifyContent(FlexAlign.SpaceBetween)
        .onClick(() => {
          this.modeIdx = i;
          this.showModeSheet = false;
        })
      }, (m: string) => m)

      Divider()
        .color('#E8E2D5')
        .strokeWidth(1)
      Text('取消')
        .fontSize(14)
        .fontColor('#9B9186')
        .padding({ top: 14, bottom: 18 })
        .width('100%')
        .textAlign(TextAlign.Center)
        .onClick(() => {
          this.showModeSheet = false;
        })
    }
    .width('100%')
    .backgroundColor('#FBF9F4')
    .borderRadius({ topLeft: 18, topRight: 18 })
  }

  build() {
    Stack() {
      Scroll() {
        Column() {
          Flex({ wrap: FlexWrap.Wrap }) {
            ForEach(INSTRUMENT_FILTERS, (f: string, i: number) => {
              Text(f)
                .fontSize(12)
                .fontColor(this.filterIdx === i ? '#FFFFFF' : '#4A6B7A')
                .padding({ left: 16, right: 16, top: 7, bottom: 7 })
                .borderRadius(18)
                .backgroundColor(this.filterIdx === i ? '#B03A2E' : '#FFFFFF')
                .border({ width: 1, color: this.filterIdx === i ? '#B03A2E' : '#E8E2D5' })
                .margin({ right: 8, bottom: 8 })
                .scale(this.filterIdx === i ? { x: 1.05, y: 1.05 } : { x: 1, y: 1 })
                .animation({ duration: 200, curve: Curve.EaseOut })
                .onClick(() => {
                  this.filterIdx = i;
                })
            }, (f: string) => f)
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 14 })

          Row() {
            Text('调式:' + MODE_NAMES[this.modeIdx])
              .fontSize(12)
              .fontColor('#8D6E4C')
            Text('☰ 换调式')
              .fontSize(12)
              .fontColor('#B03A2E')
              .padding({ left: 12, right: 12, top: 5, bottom: 5 })
              .borderRadius(14)
              .backgroundColor('rgba(176,58,46,0.08)')
          }
          .width('94%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 4 })

          ForEach(this.tunesNow(), (t: FolkTune) => {
            Row({ space: 12 }) {
              Column() {
                Text(t.instrument.slice(0, 1))
                  .fontSize(17)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#B03A2E')
              }
              .width(46)
              .height(46)
              .borderRadius(8)
              .backgroundColor('#FBF9F4')
              .border({ width: 1, color: '#E8E2D5' })
              .justifyContent(FlexAlign.Center)

              Column({ space: 5 }) {
                Text(t.title)
                  .fontSize(15)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#2B2B26')
                Row({ space: 6 }) {
                  Text(t.dynasty + ' ' + t.instrument)
                    .fontSize(10)
                    .fontColor('#9B9186')
                  Text(t.level)
                    .fontSize(9)
                    .fontColor(tuneLevelTextColor(t.level))
                    .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                    .borderRadius(3)
                    .backgroundColor(tuneLevelColor(t.level))
                }
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)

              Column({ space: 4 }) {
                Row({ space: 4 }) {
                  Text('▶')
                    .fontSize(10)
                    .fontColor('#9B9186')
                  Text(this.playsText(t.plays))
                    .fontSize(10)
                    .fontColor('#9B9186')
                }
                Text(t.duration)
                  .fontSize(10)
                  .fontColor('#9B9186')
              }
              .alignItems(HorizontalAlign.End)

              Text(this.isLiked(t) ? '♥' : '♡')
                .fontSize(18)
                .fontColor(this.isLiked(t) ? '#B03A2E' : '#C9C2B4')
                .scale(this.isLiked(t) ? { x: 1.2, y: 1.2 } : { x: 1, y: 1 })
                .animation({ duration: 260, curve: Curve.EaseOut })
                .onClick(() => {
                  this.toggleLike(t.id);
                })
            }
            .width('100%')
            .padding(14)
            .backgroundColor('#FFFFFF')
            .borderRadius(12)
            .border({ width: 1, color: '#E8E2D5' })
            .margin({ left: 16, right: 16, top: 8 })
            .onClick(() => {
              this.selectedTune = t;
              this.showTuneDetail = true;
            })
          }, (t: FolkTune) => t.id.toString())

          Text('— 已展示 ' + this.tunesNow().length.toString() + ' 曲 · 曲终 —')
            .fontSize(10)
            .fontColor('#C9C2B4')
            .margin({ top: 18, bottom: 24 })
        }
        .width('100%')
      }
      .scrollBar(BarState.Off)
      .layoutWeight(1)
      .width('100%')

      if (this.showTuneDetail) {
        Column() {
          Column()
            .width('100%')
            .layoutWeight(1)
            .onClick(() => {
              this.showTuneDetail = false;
            })
          this.tuneDetailModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(43,43,38,0.45)')
      }

      if (this.showModeSheet) {
        Column() {
          Column()
            .width('100%')
            .layoutWeight(1)
            .onClick(() => {
              this.showModeSheet = false;
            })
          this.modeSheet()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(43,43,38,0.45)')
      }

      if (this.showLikeToast) {
        Column() {
          Text('♥ 已加入我的曲库收藏')
            .fontSize(13)
            .fontColor('#FFFFFF')
            .padding({ left: 22, right: 22, top: 11, bottom: 11 })
            .borderRadius(22)
            .backgroundColor('rgba(176,58,46,0.92)')
            .scale({ x: 1.06, y: 1.06 })
            .animation({ duration: 240, curve: Curve.EaseOut })
        }
        .width('100%')
        .height('100%')
        .justifyContent(FlexAlign.Center)
        .onClick(() => {
          this.showLikeToast = false;
        })
      }
    }
    .width('100%')
    .height('100%')
  }
}
// ============ 名家 Tab(名家卡片 + 印章详情弹窗) ============
@Component
struct MastersContent {
  @State showMasterDetail: boolean = false
  @State selectedMaster: MasterInfo | null = null
  @State followed: number[] = [1, 4]
  @State showFollowToast: boolean = false

  isFollowed(m: MasterInfo): boolean {
    return this.followed.indexOf(m.id) >= 0;
  }

  @Builder modalOverlay(onClose: () => void) {
    Column() {
      Column()
        .width('100%')
        .layoutWeight(1)
        .onClick(() => {
          onClose();
        })
      Column() {
        Text('🍵')
          .fontSize(30)
          .margin({ bottom: 8 })
        Text('名家堂')
          .fontSize(12)
          .fontColor('#9B9186')
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
  }

  @Builder masterDetailModal() {
    Column({ space: 10 }) {
      Row({ space: 14 }) {
        Column() {
          Text(this.selectedMaster!.seal)
            .fontSize(24)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
        }
        .width(56)
        .height(56)
        .borderRadius(28)
        .backgroundColor('#B03A2E')
        .justifyContent(FlexAlign.Center)
        .rotate({ angle: -8 })
        .animation({ duration: 600, curve: Curve.EaseOut })

        Column({ space: 5 }) {
          Text(this.selectedMaster!.name)
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor('#2B2B26')
          Text(this.selectedMaster!.title)
            .fontSize(11)
            .fontColor('#8D6E4C')
          Text(this.selectedMaster!.instrument + ' · 作品 ' + this.selectedMaster!.works.toString() + ' 首')
            .fontSize(10)
            .fontColor('#9B9186')
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
      }
      .width('88%')
      .margin({ top: 20 })

      Divider()
        .color('#E8E2D5')
        .strokeWidth(1)
        .width('88%')
      Text('「' + this.selectedMaster!.intro + '」')
        .fontSize(12)
        .fontColor('#4A6B7A')
        .lineHeight(20)
        .padding({ left: 24, right: 24 })
      Column({ space: 8 }) {
        Row() {
          Text('师承')
            .fontSize(11)
            .fontColor('#9B9186')
          Text(this.selectedMaster!.students)
            .fontSize(11)
            .fontColor('#2B2B26')
        }
        .width('88%')
        .justifyContent(FlexAlign.SpaceBetween)

        Row() {
          Text('代表作数')
            .fontSize(11)
            .fontColor('#9B9186')
          Text(this.selectedMaster!.works.toString() + ' 首')
            .fontSize(11)
            .fontColor('#B03A2E')
        }
        .width('88%')
        .justifyContent(FlexAlign.SpaceBetween)

        Row() {
          Text('名家印章')
            .fontSize(11)
            .fontColor('#9B9186')
          Text('印文「' + this.selectedMaster!.seal + '」朱文方印')
            .fontSize(11)
            .fontColor('#8D6E4C')
        }
        .width('88%')
        .justifyContent(FlexAlign.SpaceBetween)
      }
      .width('100%')

      Button() {
        Text(this.isFollowed(this.selectedMaster!) ? '已关注 · 去听曲' : '+ 关注名家')
          .fontSize(14)
          .fontColor('#FFFFFF')
      }
      .width('60%')
      .height(42)
      .backgroundColor(this.isFollowed(this.selectedMaster!) ? '#4A6B7A' : '#B03A2E')
      .borderRadius(21)
      .margin({ top: 8, bottom: 22 })
      .onClick(() => {
        if (this.followed.indexOf(this.selectedMaster!.id) < 0) {
          this.followed.push(this.selectedMaster!.id);
        }
        this.showMasterDetail = false;
        this.showFollowToast = true;
      })
    }
    .width('90%')
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#E8E2D5' })
  }

  build() {
    Stack() {
      Scroll() {
        Column() {
          Text('名 家 堂')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#2B2B26')
            .letterSpacing(4)
            .margin({ top: 16 })

          Text('一方水土养一方弦音')
            .fontSize(11)
            .fontColor('#9B9186')
            .margin({ bottom: 6 })

          ForEach(MASTER_LIST, (m: MasterInfo) => {
            Row({ space: 14 }) {
              Column() {
                Text(m.seal)
                  .fontSize(20)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#FFFFFF')
              }
              .width(50)
              .height(50)
              .borderRadius(25)
              .backgroundColor(this.isFollowed(m) ? '#B03A2E' : '#4A6B7A')
              .justifyContent(FlexAlign.Center)

              Column({ space: 5 }) {
                Row({ space: 8 }) {
                  Text(m.name)
                    .fontSize(15)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#2B2B26')
                  Text(m.instrument)
                    .fontSize(9)
                    .fontColor('#8D6E4C')
                    .padding({ left: 7, right: 7, top: 2, bottom: 2 })
                    .borderRadius(3)
                    .backgroundColor('rgba(141,110,76,0.12)')
                }
                Text(m.title)
                  .fontSize(11)
                  .fontColor('#4A6B7A')
                Text('作品 ' + m.works.toString() + ' · ' + m.students)
                  .fontSize(10)
                  .fontColor('#9B9186')
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)

              Text(this.isFollowed(m) ? '已关注' : '关注')
                .fontSize(11)
                .fontColor(this.isFollowed(m) ? '#9B9186' : '#B03A2E')
                .padding({ left: 13, right: 13, top: 6, bottom: 6 })
                .borderRadius(15)
                .backgroundColor(this.isFollowed(m) ? '#F4F2ED' : 'rgba(176,58,46,0.08)')
                .border({ width: 1, color: this.isFollowed(m) ? '#E8E2D5' : '#B03A2E' })
            }
            .width('100%')
            .padding(14)
            .backgroundColor('#FFFFFF')
            .borderRadius(12)
            .border({ width: 1, color: '#E8E2D5' })
            .margin({ left: 16, right: 16, top: 10 })
            .onClick(() => {
              this.selectedMaster = m;
              this.showMasterDetail = true;
            })
          }, (m: MasterInfo) => m.id.toString())

          Text('— 名家录 · 藏之名山 —')
            .fontSize(10)
            .fontColor('#C9C2B4')
            .margin({ top: 18, bottom: 24 })
        }
        .width('100%')
      }
      .scrollBar(BarState.Off)
      .layoutWeight(1)
      .width('100%')

      if (this.showMasterDetail) {
        Column() {
          Column()
            .width('100%')
            .layoutWeight(1)
            .onClick(() => {
              this.showMasterDetail = false;
            })
          this.masterDetailModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(43,43,38,0.5)')
      }

      if (this.showFollowToast) {
        Column() {
          Text('✓ 关注成功,将收到名家新曲推送')
            .fontSize(13)
            .fontColor('#FFFFFF')
            .padding({ left: 22, right: 22, top: 11, bottom: 11 })
            .borderRadius(22)
            .backgroundColor('rgba(74,107,122,0.95)')
        }
        .width('100%')
        .height('100%')
        .justifyContent(FlexAlign.Center)
        .onClick(() => {
          this.showFollowToast = false;
        })
      }
    }
    .width('100%')
    .height('100%')
  }
}
// ============ 学堂 Tab(课程列表 + 详情/报名/打卡弹窗) ============
@Component
struct LearnContent {
  @State showCourseDetail: boolean = false
  @State selectedCourse: LearnCourse | null = null
  @State showEnrollConfirm: boolean = false
  @State showCheckinSheet: boolean = false
  @State showCheckinToast: boolean = false
  @State enrolledIds: number[] = [2, 8]

  isEnrolled(c: LearnCourse): boolean {
    return this.enrolledIds.indexOf(c.id) >= 0;
  }

  @Builder modalOverlay(onClose: () => void) {
    Column() {
      Column()
        .width('100%')
        .layoutWeight(1)
        .onClick(() => {
          onClose();
        })
      Column() {
        Text('📖')
          .fontSize(30)
          .margin({ bottom: 8 })
        Text('民乐学堂')
          .fontSize(12)
          .fontColor('#9B9186')
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
  }

  @Builder courseDetailModal() {
    Column({ space: 12 }) {
      Text('课 程 详 情')
        .fontSize(12)
        .fontColor('#8D6E4C')
        .letterSpacing(3)
        .margin({ top: 20 })

      Text(this.selectedCourse!.name)
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor('#2B2B26')
      Row({ space: 8 }) {
        Text(this.selectedCourse!.instrument)
          .fontSize(11)
          .fontColor('#4A6B7A')
          .padding({ left: 10, right: 10, top: 3, bottom: 3 })
          .borderRadius(4)
          .backgroundColor('rgba(74,107,122,0.1)')
        Text(this.selectedCourse!.free ? '限免公开课' : '¥' + this.selectedCourse!.price.toString())
          .fontSize(11)
          .fontColor(this.selectedCourse!.free ? '#4A6B7A' : '#B03A2E')
          .padding({ left: 10, right: 10, top: 3, bottom: 3 })
          .borderRadius(4)
          .backgroundColor(this.selectedCourse!.free ? 'rgba(74,107,122,0.1)' : 'rgba(176,58,46,0.08)')
      }

      Divider()
        .color('#E8E2D5')
        .strokeWidth(1)
        .width('84%')
      Column({ space: 10 }) {
        Row() {
          Text('章节数量')
            .fontSize(11)
            .fontColor('#9B9186')
          Text(this.selectedCourse!.chapters.toString() + ' 讲')
            .fontSize(11)
            .fontColor('#2B2B26')
        }
        .width('84%')
        .justifyContent(FlexAlign.SpaceBetween)

        Row() {
          Text('课程总时长')
            .fontSize(11)
            .fontColor('#9B9186')
          Text(this.selectedCourse!.hours)
            .fontSize(11)
            .fontColor('#2B2B26')
        }
        .width('84%')
        .justifyContent(FlexAlign.SpaceBetween)

        Row() {
          Text('在学琴友')
            .fontSize(11)
            .fontColor('#9B9186')
          Text(this.selectedCourse!.students.toString() + ' 人')
            .fontSize(11)
            .fontColor('#B03A2E')
        }
        .width('84%')
        .justifyContent(FlexAlign.SpaceBetween)

        Row() {
          Text('配套曲谱')
            .fontSize(11)
            .fontColor('#9B9186')
          Text('购课即赠 · 含减字谱')
            .fontSize(11)
            .fontColor('#8D6E4C')
        }
        .width('84%')
        .justifyContent(FlexAlign.SpaceBetween)
      }
      .width('100%')

      Button() {
        Text(this.isEnrolled(this.selectedCourse!) ? '✓ 已在学 · 继续上课' : '立即报名学习')
          .fontSize(14)
          .fontColor('#FFFFFF')
      }
      .width('70%')
      .height(42)
      .backgroundColor(this.isEnrolled(this.selectedCourse!) ? '#4A6B7A' : '#B03A2E')
      .borderRadius(21)
      .margin({ top: 6, bottom: 22 })
      .onClick(() => {
        this.showCourseDetail = false;
        if (!this.isEnrolled(this.selectedCourse!)) {
          this.showEnrollConfirm = true;
        }
      })
    }
    .width('88%')
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#E8E2D5' })
  }

  @Builder enrollConfirmModal() {
    Column({ space: 14 }) {
      Text('确 认 报 名')
        .fontSize(12)
        .fontColor('#8D6E4C')
        .letterSpacing(3)
        .margin({ top: 22 })

      Text(this.selectedCourse!.name)
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor('#2B2B26')
      Text(this.selectedCourse!.free ? '本课程限期内免费' : '实付 ¥' + this.selectedCourse!.price.toString())
        .fontSize(13)
        .fontColor('#B03A2E')
      Row({ space: 12 }) {
        Button() {
          Text('再想想')
            .fontSize(13)
            .fontColor('#9B9186')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('#F4F2ED')
        .borderRadius(20)

        Button() {
          Text('确认报名')
            .fontSize(13)
            .fontColor('#FFFFFF')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('#B03A2E')
        .borderRadius(20)
        .onClick(() => {
          this.enrolledIds.push(this.selectedCourse!.id);
          this.showEnrollConfirm = false;
          this.showCheckinToast = true;
        })
      }
      .width('80%')
      .margin({ top: 4, bottom: 22 })
    }
    .width('76%')
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#E8E2D5' })
  }

  @Builder checkinSheet() {
    Column({ space: 12 }) {
      Text('习琴廿一日 · 打卡志')
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor('#2B2B26')
        .margin({ top: 20 })

      Text('今日已练 35 分钟,距目标还差 25 分钟')
        .fontSize(11)
        .fontColor('#9B9186')
      Flex({ wrap: FlexWrap.Wrap }) {
        ForEach([1, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 0, 1], (d: number, i: number) => {
          Text(d > 0 ? '✓' : (i + 1).toString())
            .fontSize(12)
            .fontColor(d > 0 ? '#FFFFFF' : '#C9C2B4')
            .width(36)
            .height(36)
            .textAlign(TextAlign.Center)
            .borderRadius(18)
            .backgroundColor(d > 0 ? '#B03A2E' : '#F4F2ED')
            .margin(5)
        }, (d: number, i: number) => i.toString() + d.toString())
      }
      .width('84%')

      Text('已连续打卡 6 天 · 本期完成 16/21')
        .fontSize(11)
        .fontColor('#8D6E4C')
      Button() {
        Text('📜 记下今日功课')
          .fontSize(13)
          .fontColor('#FFFFFF')
      }
      .width('60%')
      .height(40)
      .backgroundColor('#4A6B7A')
      .borderRadius(20)
      .margin({ top: 6, bottom: 22 })
      .onClick(() => {
        this.showCheckinSheet = false;
        this.showCheckinToast = true;
      })
    }
    .width('100%')
    .backgroundColor('#FBF9F4')
    .borderRadius({ topLeft: 18, topRight: 18 })
    .alignItems(HorizontalAlign.Center)
  }

  build() {
    Stack() {
      Scroll() {
        Column() {
          Row({ space: 12 }) {
            Column({ space: 6 }) {
              Text('🧘')
                .fontSize(26)
              Text('今日习琴')
                .fontSize(12)
                .fontColor('#2B2B26')
              Text('连续 6 天')
                .fontSize(10)
                .fontColor('#B03A2E')
            }
            .layoutWeight(1)
            .padding({ top: 16, bottom: 16 })
            .backgroundColor('#FFFFFF')
            .borderRadius(12)
            .border({ width: 1, color: '#E8E2D5' })
            .onClick(() => {
              this.showCheckinSheet = true;
            })

            Column({ space: 6 }) {
              Text('🎓')
                .fontSize(26)
              Text('在学课程')
                .fontSize(12)
                .fontColor('#2B2B26')
              Text(this.enrolledIds.length.toString() + ' 门')
                .fontSize(10)
                .fontColor('#4A6B7A')
            }
            .layoutWeight(1)
            .padding({ top: 16, bottom: 16 })
            .backgroundColor('#FFFFFF')
            .borderRadius(12)
            .border({ width: 1, color: '#E8E2D5' })
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 14 })

          Row() {
            Text('精 选 课 程')
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor('#2B2B26')
              .letterSpacing(2)
              .layoutWeight(1)
            Text('免费优先')
              .fontSize(10)
              .fontColor('#8D6E4C')
          }
          .width('94%')
          .margin({ top: 16, bottom: 4 })

          ForEach(LEARN_COURSES, (c: LearnCourse) => {
            Row({ space: 12 }) {
              Column() {
                Text(c.instrument.slice(0, 1))
                  .fontSize(18)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(c.free ? '#4A6B7A' : '#B03A2E')
              }
              .width(48)
              .height(56)
              .borderRadius(10)
              .backgroundColor(c.free ? 'rgba(74,107,122,0.08)' : 'rgba(176,58,46,0.07)')
              .justifyContent(FlexAlign.Center)

              Column({ space: 6 }) {
                Row({ space: 6 }) {
                  Text(c.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#2B2B26')
                    .layoutWeight(1)
                  Text(c.free ? '免费' : '¥' + c.price.toString())
                    .fontSize(10)
                    .fontColor(c.free ? '#4A6B7A' : '#B03A2E')
                    .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                    .borderRadius(3)
                    .backgroundColor(c.free ? 'rgba(74,107,122,0.1)' : 'rgba(176,58,46,0.08)')
                }
                .width('100%')

                Text(c.chapters.toString() + ' 讲 · ' + c.hours + ' · ' + c.students.toString() + ' 人在学')
                  .fontSize(10)
                  .fontColor('#9B9186')
                Column() {
                  Column()
                    .width(this.isEnrolled(c) ? '40%' : '12%')
                    .height(4)
                    .borderRadius(2)
                    .backgroundColor(this.isEnrolled(c) ? '#8D6E4C' : '#C9C2B4')
                    .animation({ duration: 300, curve: Curve.EaseOut })
                }
                .width('100%')
                .height(4)
                .borderRadius(2)
                .backgroundColor('#F4F2ED')
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
            }
            .width('100%')
            .padding(14)
            .backgroundColor('#FFFFFF')
            .borderRadius(12)
            .border({ width: 1, color: '#E8E2D5' })
            .margin({ left: 16, right: 16, top: 8 })
            .onClick(() => {
              this.selectedCourse = c;
              this.showCourseDetail = true;
            })
          }, (c: LearnCourse) => c.id.toString())

          Text('— 学而时习之 —')
            .fontSize(10)
            .fontColor('#C9C2B4')
            .margin({ top: 18, bottom: 24 })
        }
        .width('100%')
      }
      .scrollBar(BarState.Off)
      .layoutWeight(1)
      .width('100%')

      if (this.showCourseDetail) {
        Column() {
          Column()
            .width('100%')
            .layoutWeight(1)
            .onClick(() => {
              this.showCourseDetail = false;
            })
          this.courseDetailModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(43,43,38,0.5)')
      }

      if (this.showEnrollConfirm) {
        Column() {
          this.enrollConfirmModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(43,43,38,0.55)')
        .justifyContent(FlexAlign.Center)
      }

      if (this.showCheckinSheet) {
        Column() {
          Column()
            .width('100%')
            .layoutWeight(1)
            .onClick(() => {
              this.showCheckinSheet = false;
            })
          this.checkinSheet()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(43,43,38,0.45)')
      }

      if (this.showCheckinToast) {
        Column() {
          Text('✓ 功课已记,笔下见功夫')
            .fontSize(13)
            .fontColor('#FFFFFF')
            .padding({ left: 22, right: 22, top: 11, bottom: 11 })
            .borderRadius(22)
            .backgroundColor('rgba(74,107,122,0.95)')
        }
        .width('100%')
        .height('100%')
        .justifyContent(FlexAlign.Center)
        .onClick(() => {
          this.showCheckinToast = false;
        })
      }
    }
    .width('100%')
    .height('100%')
  }
}
// ============ 雅集 Tab(线下雅集 + 报名/取消弹窗) ============
@Component
struct GatheringContent {
  @State showEventDetail: boolean = false
  @State selectedEvent: GatheringEvent | null = null
  @State showJoinConfirm: boolean = false
  @State showCancelConfirm: boolean = false
  @State showResultToast: boolean = false
  @State resultText: string = ''
  @State joinedEvents: number[] = [1, 6]

  isJoined(e: GatheringEvent): boolean {
    return this.joinedEvents.indexOf(e.id) >= 0;
  }

  quotaPercent(e: GatheringEvent): number {
    return Math.round(e.joined / e.quota * 100);
  }

  @Builder modalOverlay(onClose: () => void) {
    Column() {
      Column()
        .width('100%')
        .layoutWeight(1)
        .onClick(() => {
          onClose();
        })
      Column() {
        Text('🏮')
          .fontSize(30)
          .margin({ bottom: 8 })
        Text('雅集')
          .fontSize(12)
          .fontColor('#9B9186')
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
  }

  @Builder eventDetailModal() {
    Column({ space: 12 }) {
      Text('雅 集 详 情')
        .fontSize(12)
        .fontColor('#8D6E4C')
        .letterSpacing(3)
        .margin({ top: 20 })

      Text(this.selectedEvent!.name)
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor('#2B2B26')
      Text(this.selectedEvent!.city + ' · ' + this.selectedEvent!.place)
        .fontSize(12)
        .fontColor('#4A6B7A')
      Divider()
        .color('#E8E2D5')
        .strokeWidth(1)
        .width('84%')
      Column({ space: 10 }) {
        Row() {
          Text('雅集时间')
            .fontSize(11)
            .fontColor('#9B9186')
          Text(this.selectedEvent!.date)
            .fontSize(11)
            .fontColor('#B03A2E')
        }
        .width('84%')
        .justifyContent(FlexAlign.SpaceBetween)

        Row() {
          Text('茶位费用')
            .fontSize(11)
            .fontColor('#9B9186')
          Text(this.selectedEvent!.fee)
            .fontSize(11)
            .fontColor('#2B2B26')
        }
        .width('84%')
        .justifyContent(FlexAlign.SpaceBetween)

        Row() {
          Text('名额')
            .fontSize(11)
            .fontColor('#9B9186')
          Text(this.selectedEvent!.joined.toString() + ' / ' + this.selectedEvent!.quota.toString() + ' 位')
            .fontSize(11)
            .fontColor('#2B2B26')
        }
        .width('84%')
        .justifyContent(FlexAlign.SpaceBetween)

        Row() {
          Text('携带乐器')
            .fontSize(11)
            .fontColor('#9B9186')
          Text('可空手,馆内备琴')
            .fontSize(11)
            .fontColor('#8D6E4C')
        }
        .width('84%')
        .justifyContent(FlexAlign.SpaceBetween)
      }
      .width('100%')

      Column() {
        Column()
          .width(this.quotaPercent(this.selectedEvent!) + '%')
          .height(8)
          .borderRadius(4)
          .backgroundColor(this.quotaPercent(this.selectedEvent!) >= 90 ? '#B03A2E' : '#8D6E4C')
          .animation({ duration: 300, curve: Curve.EaseOut })
      }
      .width('84%')
      .height(8)
      .borderRadius(4)
      .backgroundColor('#F4F2ED')

      Text('报名进度 ' + this.quotaPercent(this.selectedEvent!).toString() + '%')
        .fontSize(10)
        .fontColor('#9B9186')

      Row({ space: 12 }) {
        Button() {
          Text(this.isJoined(this.selectedEvent!) ? '取消报名' : '立即报名')
            .fontSize(14)
            .fontColor('#FFFFFF')
        }
        .layoutWeight(1)
        .height(42)
        .backgroundColor(this.isJoined(this.selectedEvent!) ? '#9B9186' : '#B03A2E')
        .borderRadius(21)
        .onClick(() => {
          this.showEventDetail = false;
          if (this.isJoined(this.selectedEvent!)) {
            this.showCancelConfirm = true;
          } else {
            this.showJoinConfirm = true;
          }
        })

        Button() {
          Text('↗ 分享雅集')
            .fontSize(14)
            .fontColor('#4A6B7A')
        }
        .layoutWeight(1)
        .height(42)
        .backgroundColor('rgba(74,107,122,0.08)')
        .borderRadius(21)
        .onClick(() => {
          this.showEventDetail = false;
          this.resultText = '✓ 雅集已分享给同好';
          this.showResultToast = true;
        })
      }
      .width('84%')
      .margin({ top: 6, bottom: 22 })
    }
    .width('88%')
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#E8E2D5' })
  }

  @Builder joinConfirmModal() {
    Column({ space: 14 }) {
      Text('报 名 雅 集')
        .fontSize(12)
        .fontColor('#8D6E4C')
        .letterSpacing(3)
        .margin({ top: 22 })

      Text(this.selectedEvent!.name)
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor('#2B2B26')
      Text(this.selectedEvent!.city + ' · ' + this.selectedEvent!.date)
        .fontSize(12)
        .fontColor('#4A6B7A')
      Text('报名后请准时赴约,缺席两次将限制参与')
        .fontSize(10)
        .fontColor('#9B9186')
      Row({ space: 12 }) {
        Button() {
          Text('再想想')
            .fontSize(13)
            .fontColor('#9B9186')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('#F4F2ED')
        .borderRadius(20)
        .onClick(() => {
          this.showJoinConfirm = false;
        })

        Button() {
          Text('确认报名')
            .fontSize(13)
            .fontColor('#FFFFFF')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('#B03A2E')
        .borderRadius(20)
        .onClick(() => {
          this.joinedEvents.push(this.selectedEvent!.id);
          this.showJoinConfirm = false;
          this.resultText = '✓ 报名成功,静候佳音';
          this.showResultToast = true;
        })
      }
      .width('80%')
      .margin({ top: 4, bottom: 22 })
    }
    .width('78%')
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#E8E2D5' })
  }

  @Builder cancelConfirmModal() {
    Column({ space: 14 }) {
      Text('⚠')
        .fontSize(34)
        .margin({ top: 22 })

      Text('确定取消报名?')
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor('#2B2B26')
      Text('「' + this.selectedEvent!.name + '」名额将让与其他琴友')
        .fontSize(11)
        .fontColor('#9B9186')
      Row({ space: 12 }) {
        Button() {
          Text('保留名额')
            .fontSize(13)
            .fontColor('#4A6B7A')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('#F4F2ED')
        .borderRadius(20)
        .onClick(() => {
          this.showCancelConfirm = false;
        })

        Button() {
          Text('取消报名')
            .fontSize(13)
            .fontColor('#FFFFFF')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('#B03A2E')
        .borderRadius(20)
        .onClick(() => {
          this.joinedEvents.splice(this.joinedEvents.indexOf(this.selectedEvent!.id), 1);
          this.showCancelConfirm = false;
          this.resultText = '已取消,欢迎改日再会';
          this.showResultToast = true;
        })
      }
      .width('80%')
      .margin({ top: 4, bottom: 22 })
    }
    .width('80%')
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#E8E2D5' })
  }

  build() {
    Stack() {
      Scroll() {
        Column() {
          Row({ space: 10 }) {
            Column({ space: 4 }) {
              Text('🏮 雅集预告')
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('近三十日八场')
                .fontSize(10)
                .fontColor('rgba(255,255,255,0.8)')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            .padding(16)
            .backgroundColor('#4A6B7A')
            .borderRadius(14)

            Column({ space: 4 }) {
              Text('🧧')
                .fontSize(24)
              Text('我的报名')
                .fontSize(10)
                .fontColor('#8D6E4C')
            }
            .padding({ left: 14, right: 14, top: 10, bottom: 10 })
            .backgroundColor('#FFFFFF')
            .borderRadius(14)
            .border({ width: 1, color: '#E8E2D5' })
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 14 })

          ForEach(GATHERING_LIST, (e: GatheringEvent) => {
            Column({ space: 10 }) {
              Row({ space: 10 }) {
                Column() {
                  Text(e.date.slice(0, 5))
                    .fontSize(15)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#FFFFFF')
                  Text(e.date.slice(6))
                    .fontSize(10)
                    .fontColor('rgba(255,255,255,0.8)')
                }
                .width(52)
                .height(52)
                .borderRadius(10)
                .backgroundColor('#B03A2E')
                .justifyContent(FlexAlign.Center)

                Column({ space: 5 }) {
                  Text(e.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#2B2B26')
                  Row({ space: 8 }) {
                    Text(e.city)
                      .fontSize(10)
                      .fontColor('#4A6B7A')
                    Text(e.fee)
                      .fontSize(10)
                      .fontColor('#8D6E4C')
                  }
                  Text(e.place)
                    .fontSize(10)
                    .fontColor('#9B9186')
                    .maxLines(1)
                    .textOverflow({ overflow: TextOverflow.Ellipsis })
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)

                Text(this.isJoined(e) ? '✓ 已报名' : '报名')
                  .fontSize(11)
                  .fontColor(this.isJoined(e) ? '#FFFFFF' : '#B03A2E')
                  .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                  .borderRadius(15)
                  .backgroundColor(this.isJoined(e) ? '#8D6E4C' : 'rgba(176,58,46,0.08)')
              }
              .width('100%')

              Column() {
                Column()
                  .width(this.quotaPercent(e) + '%')
                  .height(5)
                  .borderRadius(3)
                  .backgroundColor(this.quotaPercent(e) >= 90 ? '#B03A2E' : '#4A6B7A')
                  .animation({ duration: 300, curve: Curve.EaseOut })
              }
              .width('100%')
              .height(5)
              .borderRadius(3)
              .backgroundColor('#F4F2ED')
            }
            .width('100%')
            .padding(14)
            .backgroundColor('#FFFFFF')
            .borderRadius(12)
            .border({ width: 1, color: '#E8E2D5' })
            .margin({ left: 16, right: 16, top: 10 })
            .onClick(() => {
              this.selectedEvent = e;
              this.showEventDetail = true;
            })
          }, (e: GatheringEvent) => e.id.toString())

          Text('— 有朋自远方来 —')
            .fontSize(10)
            .fontColor('#C9C2B4')
            .margin({ top: 18, bottom: 24 })
        }
        .width('100%')
      }
      .scrollBar(BarState.Off)
      .layoutWeight(1)
      .width('100%')

      if (this.showEventDetail) {
        Column() {
          Column()
            .width('100%')
            .layoutWeight(1)
            .onClick(() => {
              this.showEventDetail = false;
            })
          this.eventDetailModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(43,43,38,0.5)')
      }

      if (this.showJoinConfirm) {
        Column() {
          this.joinConfirmModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(43,43,38,0.55)')
        .justifyContent(FlexAlign.Center)
      }

      if (this.showCancelConfirm) {
        Column() {
          this.cancelConfirmModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(43,43,38,0.55)')
        .justifyContent(FlexAlign.Center)
      }

      if (this.showResultToast) {
        Column() {
          Text(this.resultText)
            .fontSize(13)
            .fontColor('#FFFFFF')
            .padding({ left: 22, right: 22, top: 11, bottom: 11 })
            .borderRadius(22)
            .backgroundColor('rgba(141,110,76,0.95)')
            .scale({ x: 1.05, y: 1.05 })
            .animation({ duration: 220, curve: Curve.EaseOut })
        }
        .width('100%')
        .height('100%')
        .justifyContent(FlexAlign.Center)
        .onClick(() => {
          this.showResultToast = false;
        })
      }
    }
    .width('100%')
    .height('100%')
  }
}
// ============ 乐谱 Tab(双列书页网格 + 详情/下载弹窗) ============
@Component
struct ScoresContent {
  @State typeIdx: number = 0
  @State showScoreDetail: boolean = false
  @State selectedScore: ScoreEntry | null = null
  @State showDownloadConfirm: boolean = false
  @State showDownloadToast: boolean = false
  @State downloadedIds: number[] = [3, 4]

  scoreTypes(): string[] {
    return ['全部', '简谱', '减字谱', '工尺谱', '五线谱'];
  }

  scoresNow(): ScoreEntry[] {
    return SCORE_LIST.filter((s: ScoreEntry) => {
      if (this.typeIdx === 0) {
        return true;
      }
      return s.type === this.scoreTypes()[this.typeIdx];
    });
  }

  isDownloaded(s: ScoreEntry): boolean {
    return this.downloadedIds.indexOf(s.id) >= 0;
  }

  @Builder modalOverlay(onClose: () => void) {
    Column() {
      Column()
        .width('100%')
        .layoutWeight(1)
        .onClick(() => {
          onClose();
        })
      Column() {
        Text('📜')
          .fontSize(30)
          .margin({ bottom: 8 })
        Text('乐谱坊')
          .fontSize(12)
          .fontColor('#9B9186')
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
  }

  @Builder scoreDetailModal() {
    Column({ space: 12 }) {
      Text('曲 谱 详 情')
        .fontSize(12)
        .fontColor('#8D6E4C')
        .letterSpacing(3)
        .margin({ top: 20 })

      Text(this.selectedScore!.title)
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#2B2B26')
      Row({ space: 8 }) {
        Text(this.selectedScore!.type)
          .fontSize(11)
          .fontColor('#B03A2E')
          .padding({ left: 10, right: 10, top: 3, bottom: 3 })
          .borderRadius(4)
          .backgroundColor('rgba(176,58,46,0.08)')
        Text(this.selectedScore!.instrument)
          .fontSize(11)
          .fontColor('#4A6B7A')
          .padding({ left: 10, right: 10, top: 3, bottom: 3 })
          .borderRadius(4)
          .backgroundColor('rgba(74,107,122,0.1)')
      }

      Column({ space: 6 }) {
        Text('工尺谱示意')
          .fontSize(10)
          .fontColor('#9B9186')
        Text('上 尺 工 凡 六 五 乙')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor('#2B2B26')
          .letterSpacing(6)
        Text('凡 工 尺 上 · 合 四 一')
          .fontSize(12)
          .fontColor('#8D6E4C')
          .letterSpacing(4)
      }
      .width('84%')
      .padding({ top: 12, bottom: 12 })
      .backgroundColor('#FBF9F4')
      .borderRadius(10)
      .border({ width: 1, color: '#E8E2D5' })

      Column({ space: 10 }) {
        Row() {
          Text('难度')
            .fontSize(11)
            .fontColor('#9B9186')
          Text(difficultyDots(this.selectedScore!.difficulty))
            .fontSize(11)
            .fontColor('#B03A2E')
        }
        .width('84%')
        .justifyContent(FlexAlign.SpaceBetween)

        Row() {
          Text('页数')
            .fontSize(11)
            .fontColor('#9B9186')
          Text(this.selectedScore!.pages.toString() + ' 页')
            .fontSize(11)
            .fontColor('#2B2B26')
        }
        .width('84%')
        .justifyContent(FlexAlign.SpaceBetween)

        Row() {
          Text('累计下载')
            .fontSize(11)
            .fontColor('#9B9186')
          Text(this.selectedScore!.downloads.toString() + ' 次')
            .fontSize(11)
            .fontColor('#8D6E4C')
        }
        .width('84%')
        .justifyContent(FlexAlign.SpaceBetween)

        Row() {
          Text('格式')
            .fontSize(11)
            .fontColor('#9B9186')
          Text('PDF 竖排 · 支持打印')
            .fontSize(11)
            .fontColor('#2B2B26')
        }
        .width('84%')
        .justifyContent(FlexAlign.SpaceBetween)
      }
      .width('100%')

      Row({ space: 12 }) {
        Button() {
          Text(this.isDownloaded(this.selectedScore!) ? '✓ 已在书架' : '⤓ 下载曲谱')
            .fontSize(14)
            .fontColor('#FFFFFF')
        }
        .layoutWeight(1)
        .height(42)
        .backgroundColor(this.isDownloaded(this.selectedScore!) ? '#9B9186' : '#B03A2E')
        .borderRadius(21)
        .onClick(() => {
          this.showScoreDetail = false;
          if (!this.isDownloaded(this.selectedScore!)) {
            this.showDownloadConfirm = true;
          }
        })

        Button() {
          Text('🖨 打印装订')
            .fontSize(14)
            .fontColor('#4A6B7A')
        }
        .layoutWeight(1)
        .height(42)
        .backgroundColor('rgba(74,107,122,0.08)')
        .borderRadius(21)
        .onClick(() => {
          this.showScoreDetail = false;
          this.showDownloadToast = true;
        })
      }
      .width('84%')
      .margin({ top: 6, bottom: 22 })
    }
    .width('88%')
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#E8E2D5' })
  }

  @Builder downloadConfirmModal() {
    Column({ space: 14 }) {
      Text('⤓')
        .fontSize(34)
        .margin({ top: 22 })

      Text('下载到我的书架?')
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor('#2B2B26')
      Text(this.selectedScore!.title)
        .fontSize(11)
        .fontColor('#4A6B7A')
        .maxLines(1)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
      Text('共 ' + this.selectedScore!.pages.toString() + ' 页 · 约 3.2MB')
        .fontSize(10)
        .fontColor('#9B9186')
      Row({ space: 12 }) {
        Button() {
          Text('取消')
            .fontSize(13)
            .fontColor('#9B9186')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('#F4F2ED')
        .borderRadius(20)
        .onClick(() => {
          this.showDownloadConfirm = false;
        })

        Button() {
          Text('确认下载')
            .fontSize(13)
            .fontColor('#FFFFFF')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('#8D6E4C')
        .borderRadius(20)
        .onClick(() => {
          this.downloadedIds.push(this.selectedScore!.id);
          this.showDownloadConfirm = false;
          this.showDownloadToast = true;
        })
      }
      .width('80%')
      .margin({ top: 4, bottom: 22 })
    }
    .width('78%')
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#E8E2D5' })
  }

  build() {
    Stack() {
      Scroll() {
        Column() {
          Row() {
            ForEach(this.scoreTypes(), (t: string, i: number) => {
              Column({ space: 4 }) {
                Text(t)
                  .fontSize(12)
                  .fontColor(this.typeIdx === i ? '#FFFFFF' : '#4A6B7A')
                Divider()
                  .color(this.typeIdx === i ? '#FFFFFF' : 'rgba(255,255,255,0)')
                  .strokeWidth(2)
                  .width('60%')
              }
              .padding({ left: 14, right: 14, top: 12, bottom: 8 })
              .onClick(() => {
                this.typeIdx = i;
              })
            }, (t: string) => t)
          }
          .width('100%')
          .backgroundColor('#4A6B7A')
          .justifyContent(FlexAlign.SpaceEvenly)

          Flex({ wrap: FlexWrap.Wrap }) {
            ForEach(this.scoresNow(), (s: ScoreEntry) => {
              Column({ space: 8 }) {
                Column({ space: 6 }) {
                  Text(s.type.slice(0, 1))
                    .fontSize(30)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#B03A2E')
                  Text(s.instrument)
                    .fontSize(9)
                    .fontColor('#9B9186')
                }
                .width('100%')
                .height(86)
                .borderRadius(8)
                .backgroundColor('#FBF9F4')
                .border({ width: 1, color: '#E8E2D5' })
                .justifyContent(FlexAlign.Center)
                .rotate({ angle: s.id % 2 === 0 ? -2 : 2 })

                Text(s.title)
                  .fontSize(11)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#2B2B26')
                  .maxLines(1)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })
                  .width('100%')

                Row() {
                  Text(difficultyDots(s.difficulty))
                    .fontSize(9)
                    .fontColor('#B03A2E')
                    .layoutWeight(1)
                  Text(s.pages.toString() + '页')
                    .fontSize(9)
                    .fontColor('#9B9186')
                }
                .width('100%')

                Text(this.isDownloaded(s) ? '✓ 已下载' : s.downloads.toString() + ' 次下载')
                  .fontSize(9)
                  .fontColor(this.isDownloaded(s) ? '#8D6E4C' : '#9B9186')
              }
              .width('46%')
              .margin(6)
              .padding(10)
              .backgroundColor('#FFFFFF')
              .borderRadius(12)
              .border({ width: 1, color: '#E8E2D5' })
              .onClick(() => {
                this.selectedScore = s;
                this.showScoreDetail = true;
              })
            }, (s: ScoreEntry) => s.id.toString())
          }
          .width('100%')
          .padding({ left: 10, right: 10, top: 14 })

          Text('— 按谱习曲 · 弦歌不辍 —')
            .fontSize(10)
            .fontColor('#C9C2B4')
            .margin({ top: 16, bottom: 24 })
        }
        .width('100%')
      }
      .scrollBar(BarState.Off)
      .layoutWeight(1)
      .width('100%')

      if (this.showScoreDetail) {
        Column() {
          Column()
            .width('100%')
            .layoutWeight(1)
            .onClick(() => {
              this.showScoreDetail = false;
            })
          this.scoreDetailModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(43,43,38,0.5)')
      }

      if (this.showDownloadConfirm) {
        Column() {
          this.downloadConfirmModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(43,43,38,0.55)')
        .justifyContent(FlexAlign.Center)
      }

      if (this.showDownloadToast) {
        Column() {
          Text('✓ 已存入书架,可离线翻阅')
            .fontSize(13)
            .fontColor('#FFFFFF')
            .padding({ left: 22, right: 22, top: 11, bottom: 11 })
            .borderRadius(22)
            .backgroundColor('rgba(141,110,76,0.95)')
        }
        .width('100%')
        .height('100%')
        .justifyContent(FlexAlign.Center)
        .onClick(() => {
          this.showDownloadToast = false;
        })
      }
    }
    .width('100%')
    .height('100%')
  }
}
// ============ 我的 Tab(练习图表 + 曲单管理增删改弹窗) ============
@Component
struct MeContent {
  @State sheets: PlaySheet[] = [
    { id: 1, name: '抚琴静心', count: 18, mood: '夜读' },
    { id: 2, name: '晨起丝竹', count: 12, mood: '通勤' },
    { id: 3, name: '雨窗听埙', count: 9, mood: '雨天' },
    { id: 4, name: '考级备选', count: 22, mood: '练习' }
  ]
  @State showAddSheet: boolean = false
  @State showEditSheet: boolean = false
  @State showDeleteSheet: boolean = false
  @State showAbout: boolean = false
  @State showLogout: boolean = false
  @State sheetName: string = ''
  @State sheetMood: string = ''
  @State editingId: number = 0
  @State deletingId: number = 0

  findSheet(id: number): PlaySheet {
    return this.sheets.filter((s: PlaySheet) => s.id === id)[0];
  }

  @Builder modalOverlay(onClose: () => void) {
    Column() {
      Column()
        .width('100%')
        .layoutWeight(1)
        .onClick(() => {
          onClose();
        })
      Column() {
        Text('👤')
          .fontSize(30)
          .margin({ bottom: 8 })
        Text('我的琴房')
          .fontSize(12)
          .fontColor('#9B9186')
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
  }

  @Builder addSheetModal() {
    Column({ space: 12 }) {
      Text('新 建 曲 单')
        .fontSize(12)
        .fontColor('#8D6E4C')
        .letterSpacing(3)
        .margin({ top: 22 })

      TextInput({ placeholder: '曲单名称(如:雪夜听琴)' })
        .fontSize(13)
        .fontColor('#2B2B26')
        .width('84%')
        .height(44)
        .backgroundColor('#FBF9F4')
        .borderRadius(10)
        .border({ width: 1, color: '#E8E2D5' })
        .onChange((v: string) => {
          this.sheetName = v;
        })

      TextInput({ placeholder: '场景标签(如:夜读 / 雨天)' })
        .fontSize(13)
        .fontColor('#2B2B26')
        .width('84%')
        .height(44)
        .backgroundColor('#FBF9F4')
        .borderRadius(10)
        .border({ width: 1, color: '#E8E2D5' })
        .onChange((v: string) => {
          this.sheetMood = v;
        })

      Row({ space: 12 }) {
        Button() {
          Text('取消')
            .fontSize(13)
            .fontColor('#9B9186')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('#F4F2ED')
        .borderRadius(20)
        .onClick(() => {
          this.showAddSheet = false;
        })

        Button() {
          Text('创建')
            .fontSize(13)
            .fontColor('#FFFFFF')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('#B03A2E')
        .borderRadius(20)
        .onClick(() => {
          this.sheets.push({
            id: this.sheets.length + 1,
            name: this.sheetName.length > 0 ? this.sheetName : '未命名曲单',
            count: 0,
            mood: this.sheetMood.length > 0 ? this.sheetMood : '随心'
          });
          this.sheetName = '';
          this.sheetMood = '';
          this.showAddSheet = false;
        })
      }
      .width('84%')
      .margin({ top: 6, bottom: 22 })
    }
    .width('88%')
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#E8E2D5' })
  }

  @Builder editSheetModal() {
    Column({ space: 12 }) {
      Text('编 辑 曲 单')
        .fontSize(12)
        .fontColor('#8D6E4C')
        .letterSpacing(3)
        .margin({ top: 22 })

      Text('当前名称:' + this.findSheet(this.editingId).name)
        .fontSize(11)
        .fontColor('#9B9186')
      TextInput({ placeholder: '输入新的曲单名称' })
        .fontSize(13)
        .fontColor('#2B2B26')
        .width('84%')
        .height(44)
        .backgroundColor('#FBF9F4')
        .borderRadius(10)
        .border({ width: 1, color: '#E8E2D5' })
        .onChange((v: string) => {
          this.sheetName = v;
        })

      Row({ space: 12 }) {
        Button() {
          Text('取消')
            .fontSize(13)
            .fontColor('#9B9186')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('#F4F2ED')
        .borderRadius(20)
        .onClick(() => {
          this.showEditSheet = false;
        })

        Button() {
          Text('保存修改')
            .fontSize(13)
            .fontColor('#FFFFFF')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('#4A6B7A')
        .borderRadius(20)
        .onClick(() => {
          this.findSheet(this.editingId).name = this.sheetName.length > 0 ? this.sheetName : this.findSheet(this.editingId).name;
          this.sheetName = '';
          this.showEditSheet = false;
        })
      }
      .width('84%')
      .margin({ top: 6, bottom: 22 })
    }
    .width('88%')
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#E8E2D5' })
  }

  @Builder deleteSheetModal() {
    Column({ space: 14 }) {
      Text('⚠')
        .fontSize(34)
        .margin({ top: 22 })

      Text('删除「' + this.findSheet(this.deletingId).name + '」?')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#2B2B26')
      Text('曲单内曲目不会从曲库移除')
        .fontSize(10)
        .fontColor('#9B9186')
      Row({ space: 12 }) {
        Button() {
          Text('保留曲单')
            .fontSize(13)
            .fontColor('#4A6B7A')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('#F4F2ED')
        .borderRadius(20)
        .onClick(() => {
          this.showDeleteSheet = false;
        })

        Button() {
          Text('删除')
            .fontSize(13)
            .fontColor('#FFFFFF')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('#B03A2E')
        .borderRadius(20)
        .onClick(() => {
          this.sheets.splice(this.sheets.map((s: PlaySheet) => s.id).indexOf(this.deletingId), 1);
          this.showDeleteSheet = false;
        })
      }
      .width('84%')
      .margin({ top: 4, bottom: 22 })
    }
    .width('82%')
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#E8E2D5' })
  }

  @Builder aboutModal() {
    Column({ space: 10 }) {
      Text('古 韵 阁')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor('#2B2B26')
        .letterSpacing(4)
        .margin({ top: 24 })

      Text('v3.2 · 谷雨版')
        .fontSize(11)
        .fontColor('#8D6E4C')
      Divider()
        .color('#E8E2D5')
        .strokeWidth(1)
        .width('70%')
      Text('高山流水,知音难觅。')
        .fontSize(12)
        .fontColor('#4A6B7A')
      Text('古韵阁收录民乐三千曲,')
        .fontSize(12)
        .fontColor('#4A6B7A')
      Text('愿与君共守丝竹一脉。')
        .fontSize(12)
        .fontColor('#4A6B7A')

      Text('© 2026 古韵阁 · 弦歌不辍')
        .fontSize(10)
        .fontColor('#C9C2B4')
        .margin({ top: 8, bottom: 24 })
    }
    .width('78%')
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#E8E2D5' })
  }

  @Builder logoutModal() {
    Column({ space: 14 }) {
      Text('👋')
        .fontSize(34)
        .margin({ top: 22 })

      Text('确定退出登录?')
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor('#2B2B26')
      Text('练习记录与曲单将保留在云端')
        .fontSize(10)
        .fontColor('#9B9186')
      Row({ space: 12 }) {
        Button() {
          Text('再坐坐')
            .fontSize(13)
            .fontColor('#4A6B7A')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('#F4F2ED')
        .borderRadius(20)
        .onClick(() => {
          this.showLogout = false;
        })

        Button() {
          Text('退出')
            .fontSize(13)
            .fontColor('#FFFFFF')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('#9B9186')
        .borderRadius(20)
        .onClick(() => {
          this.showLogout = false;
        })
      }
      .width('80%')
      .margin({ top: 4, bottom: 22 })
    }
    .width('78%')
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#E8E2D5' })
  }

  build() {
    Stack() {
      Scroll() {
        Column() {
          Row({ space: 14 }) {
            Column() {
              Text('琴')
                .fontSize(22)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
            }
            .width(58)
            .height(58)
            .borderRadius(29)
            .backgroundColor('#B03A2E')
            .justifyContent(FlexAlign.Center)

            Column({ space: 5 }) {
              Text('青崖居士')
                .fontSize(17)
                .fontWeight(FontWeight.Bold)
                .fontColor('#2B2B26')
              Text('琴龄三年 · 习琴 462 小时')
                .fontSize(11)
                .fontColor('#9B9186')
              Text('徽章:初窥门径 → 渐入佳境')
                .fontSize(10)
                .fontColor('#8D6E4C')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
          }
          .width('100%')
          .padding(16)
          .backgroundColor('#FFFFFF')
          .borderRadius(14)
          .border({ width: 1, color: '#E8E2D5' })
          .margin({ left: 16, right: 16, top: 14 })

          Column({ space: 10 }) {
            Row() {
              Text('本 周 习 琴')
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor('#2B2B26')
                .letterSpacing(2)
                .layoutWeight(1)
              Text('共 4 小时 40 分')
                .fontSize(10)
                .fontColor('#B03A2E')
            }
            .width('100%')

            Row() {
              ForEach(PRACTICE_WEEK, (d: PracticeDay, i: number) => {
                Column({ space: 5 }) {
                  Column()
                    .width(16)
                    .height(d.minutes * 1.1)
                    .borderRadius(4)
                    .backgroundColor(i >= 5 ? '#B03A2E' : '#4A6B7A')
                  Text(d.day)
                    .fontSize(9)
                    .fontColor('#9B9186')
                }
                .alignItems(HorizontalAlign.Center)
              }, (d: PracticeDay) => d.day)
            }
            .width('100%')
            .height(96)
            .alignItems(VerticalAlign.Bottom)
            .justifyContent(FlexAlign.SpaceBetween)

            Text('周六练琴最勤,达 75 分钟')
              .fontSize(10)
              .fontColor('#8D6E4C')
          }
          .width('100%')
          .padding(16)
          .backgroundColor('#FFFFFF')
          .borderRadius(14)
          .border({ width: 1, color: '#E8E2D5' })
          .margin({ left: 16, right: 16, top: 10 })

          Column({ space: 12 }) {
            Text('乐 器 掌 握')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor('#2B2B26')
              .letterSpacing(2)
              .width('100%')

            ForEach(MASTERY_LIST, (m: MasteryItem) => {
              Column({ space: 5 }) {
                Row() {
                  Text(m.emoji + ' ' + m.name)
                    .fontSize(11)
                    .fontColor('#2B2B26')
                    .layoutWeight(1)
                  Text(m.percent.toString() + '%')
                    .fontSize(11)
                    .fontColor('#B03A2E')
                }
                .width('100%')

                Column() {
                  Column()
                    .width(m.percent + '%')
                    .height(6)
                    .borderRadius(3)
                    .backgroundColor('#B03A2E')
                    .animation({ duration: 400, curve: Curve.EaseOut })
                }
                .width('100%')
                .height(6)
                .borderRadius(3)
                .backgroundColor('#F4F2ED')
              }
              .width('100%')
            }, (m: MasteryItem) => m.name)
          }
          .width('100%')
          .padding(16)
          .backgroundColor('#FFFFFF')
          .borderRadius(14)
          .border({ width: 1, color: '#E8E2D5' })
          .margin({ left: 16, right: 16, top: 10 })

          Row() {
            Text('我 的 曲 单')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor('#2B2B26')
              .letterSpacing(2)
              .layoutWeight(1)
            Text('+ 新建')
              .fontSize(11)
              .fontColor('#FFFFFF')
              .padding({ left: 12, right: 12, top: 5, bottom: 5 })
              .borderRadius(14)
              .backgroundColor('#B03A2E')
              .onClick(() => {
                this.showAddSheet = true;
              })
          }
          .width('94%')
          .margin({ top: 14, bottom: 4 })

          ForEach(this.sheets, (s: PlaySheet) => {
            Row({ space: 12 }) {
              Column() {
                Text(s.mood.slice(0, 1))
                  .fontSize(15)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#8D6E4C')
              }
              .width(40)
              .height(40)
              .borderRadius(8)
              .backgroundColor('#FBF9F4')
              .border({ width: 1, color: '#E8E2D5' })
              .justifyContent(FlexAlign.Center)

              Column({ space: 4 }) {
                Text(s.name)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#2B2B26')
                Text(s.count.toString() + ' 曲 · ' + s.mood)
                  .fontSize(10)
                  .fontColor('#9B9186')
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)

              Text('✎')
                .fontSize(15)
                .fontColor('#4A6B7A')
                .padding(8)
                .onClick(() => {
                  this.editingId = s.id;
                  this.sheetName = '';
                  this.showEditSheet = true;
                })

              Text('🗑')
                .fontSize(14)
                .fontColor('#B03A2E')
                .padding(8)
                .onClick(() => {
                  this.deletingId = s.id;
                  this.showDeleteSheet = true;
                })
            }
            .width('100%')
            .padding(14)
            .backgroundColor('#FFFFFF')
            .borderRadius(12)
            .border({ width: 1, color: '#E8E2D5' })
            .margin({ left: 16, right: 16, top: 8 })
          }, (s: PlaySheet) => s.id.toString())

          Row({ space: 12 }) {
            Text('ℹ 关于古韵阁')
              .fontSize(13)
              .fontColor('#4A6B7A')
              .layoutWeight(1)
            Text('›')
              .fontSize(15)
              .fontColor('#C9C2B4')
          }
          .width('100%')
          .padding(16)
          .backgroundColor('#FFFFFF')
          .borderRadius(12)
          .border({ width: 1, color: '#E8E2D5' })
          .margin({ left: 16, right: 16, top: 12 })
          .onClick(() => {
            this.showAbout = true;
          })

          Text('退出登录')
            .fontSize(13)
            .fontColor('#B03A2E')
            .padding({ left: 32, right: 32, top: 12, bottom: 12 })
            .borderRadius(22)
            .backgroundColor('#FFFFFF')
            .border({ width: 1, color: '#E8E2D5' })
            .margin({ top: 14, bottom: 28 })
            .onClick(() => {
              this.showLogout = true;
            })
        }
        .width('100%')
      }
      .scrollBar(BarState.Off)
      .layoutWeight(1)
      .width('100%')

      if (this.showAddSheet) {
        Column() {
          Column()
            .width('100%')
            .layoutWeight(1)
            .onClick(() => {
              this.showAddSheet = false;
            })
          this.addSheetModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(43,43,38,0.5)')
      }

      if (this.showEditSheet) {
        Column() {
          Column()
            .width('100%')
            .layoutWeight(1)
            .onClick(() => {
              this.showEditSheet = false;
            })
          this.editSheetModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(43,43,38,0.5)')
      }

      if (this.showDeleteSheet) {
        Column() {
          this.deleteSheetModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(43,43,38,0.55)')
        .justifyContent(FlexAlign.Center)
      }

      if (this.showAbout) {
        Column() {
          this.aboutModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(43,43,38,0.5)')
        .justifyContent(FlexAlign.Center)
      }

      if (this.showLogout) {
        Column() {
          this.logoutModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(43,43,38,0.5)')
        .justifyContent(FlexAlign.Center)
      }
    }
    .width('100%')
    .height('100%')
  }
}

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐