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

本文基于 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_TUNES 18 首 从先秦《高山流水》到当代《云水禅心》,覆盖 8 种乐器
MASTER_LIST 10 位 吴钊、闵惠芬、刘德海、王中山等民乐名家
LEARN_COURSES 12 门 从免费入门到付费进阶,覆盖 10 种乐器
GATHERING_LIST 8 场 中秋雅集、国风夜市、古琴公开课等线下活动
SCORE_LIST 10 份 减字谱/简谱/工尺谱/五线谱多制式曲谱
PRACTICE_WEEK 7 天 本周每日习琴分钟数
MASTERY_LIST 4 乐器 古琴/洞箫/琵琶/古筝掌握度
INSTRUMENT_FILTERS 7 项 全部/古琴/古筝/琵琶/二胡/笛子/唢呐筛选
MODE_NAMES 6 项 全部/宫调/商调/角调/徵调/羽调式筛选

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:00 40 ¥88
国风夜市·民乐快闪 上海 豫园·九曲桥 10-02 18:30 100 免费
古琴公开课·减字谱 北京 国家图书馆·学津堂 10-11 14:00 80 免费
琵琶社·秋日合奏会 北京 中央音乐学院 10-18 15:00 30 ¥50
江南丝竹·老茶馆听乐 上海 城隍庙·湖心亭 10-25 13:30 50 ¥68
笛箫雅集·平湖秋月 杭州 西湖·曲院风荷 11-01 18:00 60 ¥58
埙友小聚·城墙暮色 西安 西安城墙·永宁门 11-08 17:30 40 免费
新民乐交流·电子与丝竹 深圳 迷笛中心 11-15 20:00 120 ¥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、测试、元服务和应用上架分发等。

更多推荐