HarmonyOS 6效果实现:chip 标签选择器实现:Flex wrap 布局、选中态背景与文字色反转、@State 索引记录单选及多选项 toggle 切换的状态逻辑
高山流水,知音难觅。古韵阁收录民乐三千曲,愿与君共守丝竹一脉。
本文基于 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 项目中使用
- 状态集中:所有状态和数据在一个文件中,便于理解全局数据流
文件的整体组织顺序为:
- 文件头注释(项目名、风格、规范说明)
- 类型定义(interface)
- 静态数据(const 数组)
- 工具函数(function)
- 枚举(enum)
- 主入口组件(@Entry @Component GuyunPavilionApp)
- 头部组件(GuyunHeader)
- 六大 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 首经典民乐曲目中快速找到想听的曲子。

页面结构:
- 乐器筛选标签栏:顶部横向排列 7 个圆角标签(全部/古琴/古筝/琵琶/二胡/笛子/唢呐),选中态为朱砂红底白字,未选中为白底黛青字带边框。标签支持缩放动画(选中时放大 1.05 倍)
- 调式筛选行:显示当前调式(「调式:全部」)和「☰ 换调式」按钮,点击弹出底部抽屉(modeSheet),可从宫调/商调/角调/徵调/羽调中选择
- 曲目列表:每首曲目为一行卡片,包含:
- 左侧:乐器首字图标(46×46px,宣纸白底+边框,朱砂红文字)
- 中间:曲名(加粗)+ 年代·乐器 + 等级标签(经典/名曲/绝响/新声/小众,各有不同颜色)
- 右侧:播放量+时长 + 收藏心形(♥/♡,收藏时放大 1.2 倍并变朱砂红)
- 底部装饰:「— 已展示 X 曲 · 曲终 —」浅灰文字
交互逻辑:
- 点击乐器筛选标签 → 切换 filterIdx → tunesNow() 方法按乐器过滤曲目列表
- 点击「换调式」→ 弹出底部抽屉选择调式 → 切换 modeIdx
- 点击曲目卡片 → 弹出曲目详情弹窗(tuneDetailModal),展示曲名、年代、调式、时长、乐器、等级、累计聆听等信息,提供「立即聆听」和「收藏」两个按钮
- 点击收藏心形 → toggleLike() 方法切换收藏状态,心形图标缩放动画
- 收藏后自动显示 Toast「♥ 已加入我的曲库收藏」
设计亮点:
曲库 Tab 的双维度筛选是核心设计——乐器筛选解决「我想听什么乐器」的问题,调式筛选解决「我想听什么情绪」的问题(宫调明亮、商调悲凉、角调清新、徵调欢快、羽调悠远)。曲目卡片的等级标签用颜色编码(经典黛青、名曲朱砂、绝响鎏金、新声蓝灰),让用户一眼识别曲目的地位。收藏心形的缩放动画(1.0→1.2→1.0)为静态列表增添了灵动的交互反馈。
5.2 名家 Tab:印章式头像的民乐名家堂
名家 Tab(MastersContent)汇聚了 10 位民乐界的国家级非遗传承人和一代宗师,以印章式头像和名家卡片的形式展示,让用户可以关注名家、了解名家师承和代表作。

页面结构:
- 名家堂标题区:「名 家 堂」大字标题(字间距 4px,营造题跋感)+「一方水土养一方弦音」副标题
- 名家卡片列表:每位名家为一行卡片,包含:
- 左侧:圆形印章头像(50×50px),已关注为朱砂红底,未关注为黛青底,中间是名家的印章字(琴/弦/琶/筝/笛/唢等)
- 中间:名家姓名(加粗)+ 乐器标签 + 头衔(如「国家级古琴非遗传承人」)+ 作品数·师承
- 右侧:关注按钮(已关注为灰底灰字,未关注为朱砂红边框朱砂红字)
交互逻辑:
- 点击名家卡片 → 弹出名家详情弹窗(masterDetailModal),展示印章头像(带 -8° 旋转动画)、姓名、头衔、乐器、作品数、名家引言(「操缦六十载,以指底苍劲之音复现太古遗韵」)、师承、代表作数、印章信息等
- 详情弹窗中点击「+ 关注名家」→ 将名家 ID 加入 followed 列表 → 关闭弹窗 → 显示 Toast「✓ 关注成功,将收到名家新曲推送」
- 列表中点击关注按钮 → 直接切换关注状态(已关注变灰,未关注变红)
设计亮点:
名家 Tab 的印章式头像是最具辨识度的设计——每位名家的头像都是一个圆形的「朱文印」,中间一个字代表名家的乐器或名号(琴/弦/琶/筝/笛/唢/潮/埙/悦/乐),已关注的名家印章变为朱砂红,未关注的为黛青。这种设计将中国传统印章文化与移动端头像完美融合,一眼就能感受到民乐的文化底蕴。名家详情弹窗中的印章头像带有 -8° 的旋转动画,如同钤印时的微微倾斜,细节考究。
5.3 学堂 Tab:习琴打卡与系统化课程
学堂 Tab(LearnContent)是古韵阁的学习中心,提供今日习琴打卡、在学课程统计、精选课程列表三大功能,帮助用户建立系统化的民乐学习路径。

页面结构:
- 顶部双卡片:
- 「今日习琴」卡片:🧘 图标 + 今日习琴 + 连续 6 天(朱砂红),点击弹出打卡抽屉
- 「在学课程」卡片:🎓 图标 + 在学课程 + 2 门(黛青)
- 精选课程标题行:「精 选 课 程」+「免费优先」排序标签
- 课程列表:每门课程为一行卡片,包含:
- 左侧:乐器首字图标(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 场线下民乐雅集的浏览、报名、取消功能,让散居各地的琴友可以找到同好、参与聚会。

页面结构:
- 顶部双卡片:
- 「雅集预告」横幅(黛青底):🏮 雅集预告 + 近三十日八场
- 「我的报名」卡片(白底):🧧 图标 + 我的报名
- 雅集列表:每场雅集为一个卡片,包含:
- 左侧:日期卡片(52×52px,朱砂红底白字,上方月日 09-25,下方时间 19:00)
- 中间:雅集名称(加粗)+ 城市·费用 + 地点(如「拙政园·远香堂」)
- 右侧:报名状态按钮(已报名为鎏金底「✓ 已报名」,可报名为朱砂红边框「报名」)
- 底部:报名进度条(已报名/名额百分比,爆满时朱砂红,未满时黛青)
交互逻辑:
- 点击雅集卡片 → 弹出雅集详情弹窗(eventDetailModal),展示雅集名、城市·地点、时间、费用、名额、携带乐器说明、报名进度条,提供「立即报名/取消报名」和「分享雅集」两个按钮
- 未报名雅集点击报名 → 弹出报名确认弹窗(joinConfirmModal)→ 确认后加入 joinedEvents → 显示 Toast「✓ 报名成功,静候佳音」
- 已报名雅集点击取消 → 弹出取消确认弹窗(cancelConfirmModal)→ 确认后从 joinedEvents 移除 → 显示 Toast「已取消,欢迎改日再会」
- 名额已满的雅集(如琵琶社秋日合奏会 30/30)进度条变朱砂红
设计亮点:
雅集 Tab 的日期卡片是核心视觉元素——每场雅集的日期以朱砂红底白字的卡片形式展示在左侧,如同一张传统的请柬,月日在上、时间在下,醒目而雅致。报名进度条的颜色编码(爆满朱砂红/未满黛青)让用户一眼判断雅集的热门程度。雅集地点的选择充满文化气息——拙政园远香堂、豫园九曲桥、西湖曲院风荷、西安城墙永宁门,每一个地点本身就是一幅文人画,与民乐雅集的气质高度契合。
5.5 乐谱 Tab:双列书页网格与多制式曲谱
乐谱 Tab(ScoresContent)是古韵阁的曲谱研习中心,提供减字谱、简谱、工尺谱、五线谱多制式曲谱的浏览、下载、打印功能,以双列书页网格的形式展示,如同摊开在书桌上的卷册。

页面结构:
- 类型筛选 Tab 栏:顶部黛青底横向 Tab(全部/简谱/减字谱/工尺谱/五线谱),选中态为白字+底部白色短下划线,未选中为浅灰字
- 双列书页网格:每份曲谱为一个卡片,两列排列:
- 上方:书页区域(86px 高,宣纸白底+边框,中间是曲谱类型首字大字「减」「简」「工」「五」,下方是乐器名,书页带有 ±2° 的随机微旋转)
- 中间:曲谱标题(加粗,单行省略)
- 下方:难度圆点(●●●○○)+ 页数 + 下载状态(已下载显示「✓ 已下载」,未下载显示「X 次下载」)
交互逻辑:
- 点击类型筛选 Tab → 切换 typeIdx → scoresNow() 方法按类型过滤曲谱
- 点击曲谱卡片 → 弹出曲谱详情弹窗(scoreDetailModal),展示曲谱名、类型标签、乐器标签、工尺谱示意(「上尺工凡六五乙」大字+「凡工尺上·合四一」小字)、难度圆点、页数、累计下载、格式说明,提供「⤓ 下载曲谱」和「🖨 打印装订」两个按钮
- 未下载曲谱点击下载 → 弹出下载确认弹窗(downloadConfirmModal)→ 确认后加入 downloadedIds → 显示 Toast「✓ 已存入书架,可离线翻阅」
- 已下载曲谱显示「✓ 已在书架」灰底按钮
设计亮点:
乐谱 Tab 的双列书页网格是最具文人气质的设计——每份曲谱的上方区域模拟一本摊开的书册,宣纸白底+细边框+中间一个大字(减/简/工/五),±2° 的随机微旋转让书册看起来像是随意摊放在书桌上,而非机械排列。曲谱详情弹窗中的「工尺谱示意」是点睛之笔——「上尺工凡六五乙」七个大字以字间距 6px 排列,下方「凡工尺上·合四一」小字对应,让用户在数字界面中直观感受到传统工尺谱的形态。多制式曲谱(减字谱/简谱/工尺谱/五线谱)的支持满足了不同层次学习者的需求——初学者用简谱,进阶者用工尺谱,古琴专业者用减字谱。
5.6 我的 Tab:习琴数据与曲单管理
我的 Tab(MeContent)是用户的个人中心,展示个人信息、本周习琴数据、乐器掌握度、我的曲单管理四大模块,是用户了解自己学习进度和管理个人曲库的地方。

页面结构:
- 个人信息卡片:左侧圆形朱砂红印章头像(「琴」字),中间用户名「青崖居士」+ 琴龄三年·习琴 462 小时 + 徽章「初窥门径 → 渐入佳境」
- 本周习琴卡片:标题「本 周 习 琴」+ 共 4 小时 40 分(朱砂红),下方是 7 天柱状图(周一至周五黛青,周六周日朱砂红),底部「周六练琴最勤,达 75 分钟」
- 乐器掌握卡片:标题「乐 器 掌 握」,下方是 4 个乐器的进度条(古琴 72%、洞箫 45%、琵琶 28%、古筝 16%),全部朱砂红进度条
- 我的曲单区:标题「我 的 曲 单」+「+ 新建」朱砂红按钮,下方是曲单列表(抚琴静心 18曲·夜读、晨起丝竹 12曲·通勤等),每个曲单右侧有编辑(✎)和删除(🗑)按钮
- 底部功能:关于古韵阁 + 退出登录
交互逻辑:
- 点击「+ 新建」→ 弹出新建曲单弹窗(addSheetModal),输入曲单名称和场景标签 → 创建后加入 sheets 列表
- 点击曲单编辑按钮(✎)→ 弹出编辑曲单弹窗(editSheetModal),输入新名称 → 保存后更新曲单
- 点击曲单删除按钮(🗑)→ 弹出删除确认弹窗(deleteSheetModal)→ 确认后从 sheets 移除
- 点击「关于古韵阁」→ 弹出关于弹窗(aboutModal),展示版本号、slogan、版权信息
- 点击「退出登录」→ 弹出退出确认弹窗(logoutModal)
设计亮点:
我的 Tab 的「本周习琴」柱状图是数据可视化的亮点——7 天的练习时长以柱状图展示,工作日为黛青、周末为朱砂红,颜色区分工作/休息的练习模式,柱子高度对应分钟数(分钟×1.1),用户一眼就能看到本周哪一天练得最勤。「乐器掌握度」的 4 条进度条全部使用朱砂红,与民乐的主色调统一,百分比数字标注在右侧。曲单管理的增删改完整实现了个人曲库的自定义——用户可以按场景(夜读/通勤/雨天/练习)创建曲单,将 18 首经典曲目按自己的喜好组织起来。
第六章 弹窗交互体系深度解析
古韵阁共实现约 15 套弹窗组件,覆盖曲目详情、名家详情、课程详情、报名确认、打卡、雅集详情、曲谱详情、下载确认、新建/编辑/删除曲单、关于、退出登录等完整交互场景。本章选取两个最具代表性的弹窗进行深度解析。
6.1 新建曲单弹窗:极简表单的文人雅趣
新建曲单弹窗(addSheetModal)是我的 Tab 的核心交互,用户可以创建自定义曲单,按场景组织民乐曲目。

弹窗结构:
- 标题:「新 建 曲 单」(鎏金色,字间距 3px)
- 曲单名称输入框:TextInput,placeholder「曲单名称(如:雪夜听琴)」,宣纸白底+暖灰边框
- 场景标签输入框:TextInput,placeholder「场景标签(如:夜读 / 雨天)」
- 操作按钮:「取消」(灰底灰字)+「创建」(朱砂红底白字)
交互流程:
- 用户在我的 Tab 点击「+ 新建」按钮 → 弹出新建曲单弹窗
- 用户输入曲单名称(如「雪夜听琴」)和场景标签(如「夜读」)
- 点击「创建」→ push 新曲单到 sheets 数组(名称为空时默认「未命名曲单」,标签为空时默认「随心」)→ 清空输入框 → 关闭弹窗
- 点击「取消」→ 直接关闭弹窗
设计亮点:
新建曲单弹窗的 placeholder 文案是点睛之笔——「曲单名称(如:雪夜听琴)」「场景标签(如:夜读 / 雨天)」,示例本身就充满文人雅趣,引导用户创建有诗意的曲单名,而非冷冰冰的「我的收藏1」。两个输入框的宣纸白底+暖灰边框与全局水墨风格统一,弹窗整体居中显示,半透明墨色遮罩(rgba(43,43,38,0.5))营造出夜读时分的静谧氛围。
6.2 曲谱详情弹窗:工尺谱示意与多制式曲谱
曲谱详情弹窗(scoreDetailModal)是乐谱 Tab 的核心交互,展示曲谱的详细信息,最具特色的是其中的「工尺谱示意」区域。

弹窗结构:
- 标题:「曲 谱 详 情」(鎏金色,字间距 3px)
- 曲谱名:「高山流水(古琴减字谱)」(大字加粗)
- 标签行:「减字谱」(朱砂红底)+「古琴」(黛青底)
- 工尺谱示意区:宣纸白底+边框卡片,包含:
- 小字标签「工尺谱示意」
- 大字「上 尺 工 凡 六 五 乙」(字间距 6px,加粗墨色)
- 小字「凡 工 尺 上 · 合 四 一」(鎏金色,字间距 4px)
- 详细信息列表:难度(●●●●● 朱砂红圆点)、页数(8 页)、累计下载(12400 次)、格式(PDF 竖排·支持打印)
- 操作按钮:「⤓ 下载曲谱」(朱砂红底白字)+「🖨 打印装订」(白底黛青字)
交互流程:
- 用户在乐谱 Tab 点击曲谱卡片 → 弹出曲谱详情弹窗
- 用户查看工尺谱示意、难度、页数、下载量、格式等信息
- 点击「⤓ 下载曲谱」→ 未下载时弹出下载确认弹窗 → 确认后加入 downloadedIds → 显示 Toast「✓ 已存入书架,可离线翻阅」
- 点击「🖨 打印装订」→ 直接显示下载成功 Toast(演示功能)
- 已下载的曲谱按钮变为「✓ 已在书架」灰底
设计亮点:
曲谱详情弹窗的「工尺谱示意」是整个古韵阁最具文化深度的设计——工尺谱是中国传统记谱法,以「上尺工凡六五乙」七个字对应音阶,在数字界面中重现这种古老的记谱方式,本身就是一种文化传承。大字「上尺工凡六五乙」以字间距 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

从华为开发者官网下载最新版本,运行安装包,选择安装路径,勾选创建桌面快捷方式,点击安装。
13.3 配置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 叠加实现半透明遮罩弹窗,属性动画实现交互动效。
技术亮点
- 水墨留白的视觉体系:宣纸白底+朱砂红+黛青+鎏金的四级色彩体系,印章式名家头像、书页微旋转乐谱卡片、工尺谱示意等中国传统视觉元素的数字化转译,让每一屏都如同一幅可交互的文人画
- 乐器+调式双维度筛选:曲库 Tab 提供 7 种乐器+6 种调式的双维度筛选体系,调式与情绪对应(宫调明亮/商调悲凉/角调清新/徵调欢快/羽调悠远),帮助用户按心境选曲
- 印章式名家头像:每位名家的头像都是一个圆形的「朱文印」,中间一个字代表乐器或名号,已关注为朱砂红、未关注为黛青,将中国传统印章文化与移动端头像完美融合
- 多制式曲谱研习:提供减字谱、简谱、工尺谱、五线谱 4 种制式曲谱,曲谱详情中展示「上尺工凡六五乙」工尺谱示意,让传统记谱法在数字界面中重现
- 21 天习琴打卡:学堂 Tab 提供 21 天打卡日历,已打卡为朱砂红圆形印章、未打卡为浅灰日期数字,将抽象的坚持转化为可视化进度,帮助用户建立每日习琴习惯
- 线下雅集文化场景:8 场雅集的地点选择充满文化气息(拙政园远香堂、西湖曲院风荷、西安城墙永宁门等),日期与传统节日结合(中秋雅集、国庆夜市),连接线上线下
- 个人曲单增删改:我的 Tab 提供完整的曲单管理(新建/编辑/删除),按场景标签(夜读/通勤/雨天/练习)组织曲目,实现个人曲库的完全自定义
- 约 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%')
}
}
更多推荐



所有评论(0)