引言:把一千个房间的独唱,合成为一次云端的合唱

合唱是人类最古老的集体艺术形式之一——从教堂里的圣咏到音乐厅里的交响合唱,从校园里的合唱团到社区里的老年合唱队,人声交织产生的共鸣,是任何乐器都无法替代的。但传统合唱有一个无法回避的门槛:所有人必须在同一个时间、同一个空间里。地理距离、工作时间、疫情隔离,都可能让一群热爱唱歌的人无法聚在一起。

声部云 CHORUS CLOUD 正是为解决这个问题而生。这是一款基于 HarmonyOS ArkTS 声明式 UI 框架开发的云上合唱社区应用,核心定位是"百人千声 · 一屏合唱"。它打破了传统合唱的时空限制:每个团员在自己的房间里戴上耳机,跟着钢琴基准伴奏录下自己的声部,录音上传到云端后,指挥可以在录音棚里把女高音、女中音、男高音、男低音四个声部的轨道逐条合成,最终生成一首完整的云合唱作品。

这款应用的视觉设计采用"深夜教堂彩窗风"——深邃的午夜蓝底色(#10142A)如同教堂里的幽暗空间,鎏金色(#F0C75E)如同彩窗透进来的神圣光芒,雾蓝色(#7FA8F5)如同清晨的薄雾,紫罗兰色(#9B7EDE)如同教堂里的紫色祭袍。整体视觉氛围庄重而温暖,既呼应了合唱艺术的教堂渊源,又营造出深夜独自练声的沉浸感。

应用包含 6 大核心 Tab:合唱团(发现和加入各地合唱团)、声部(个人分声部录音与打分)、录音棚(多轨协作项目管理)、曲目(合唱曲库与声部分轨试听)、排练赛(线上合唱赛事与投票)、我的(团员个人中心与排练数据)。每个 Tab 都配有完整的弹窗交互体系,从入团申请、录音确认、项目新建、曲目加入曲库、赛事投票到团员资料编辑,形成了完整的云上合唱业务闭环。

声部云合唱团Tab首页

本文将从项目概述、色彩系统、架构设计、数据模型、六大 Tab 页面解析、弹窗交互体系、动画效果、分声部录音系统、云录音棚协作、合唱曲库、排练赛事、团员个人中心、开发环境搭建等维度,对声部云 CHORUS CLOUD 进行全方位深度解析,并在附录中提供完整的 ArkTS 源码。


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

1.1 项目背景

合唱艺术在中国大陆有着广泛的群众基础。据中国合唱协会统计,全国各类注册合唱团超过 5 万个,参与人数超过 500 万。但传统合唱面临三大痛点:

第一,时空限制。合唱团排练需要所有团员在固定时间到达固定地点,对于上班族、学生、异地团员来说极不友好。很多合唱团因为团员无法按时参加排练而导致作品排练进度缓慢。

第二,分声部练习困难。合唱作品通常分为女高音(Soprano)、女中音(Alto)、男高音(Tenor)、男低音(Bass)四个声部,每个声部的旋律线不同。团员在自己声部练习时,需要听到其他声部的声音来找准音准和节奏,但独自在家练习时无法获得这种参照。

第三,异地合唱无法实现。不同城市的合唱爱好者想要合作一首作品,在传统模式下几乎不可能——他们无法聚在一起排练,更无法同时演出。

声部云 CHORUS CLOUD 正是针对这三大痛点设计的:通过云录音技术实现分声部异步录制,通过多轨合成技术实现异地合唱,通过声部分轨试听解决独自练声的音准参照问题。

1.2 核心定位

声部云的核心定位可以用三句话概括:

  • 云上合唱团:打破时空限制,让不同城市的合唱爱好者可以在云端组成合唱团,共同完成一首合唱作品
  • 分声部录音协作:每个团员录制自己的声部轨道,指挥在云端合成多轨,最终生成完整合唱作品
  • 排练挑战驱动:通过线上合唱赛事、投票、排行榜等机制,激励团员持续练习,提升合唱水平

这三个定位层层递进:云上合唱团解决"人在哪里"的问题,分声部录音协作解决"怎么合"的问题,排练挑战解决"为什么坚持"的问题。

1.3 目标用户

声部云的目标用户主要包括四类人群:

  1. 合唱团团员:已经加入某个合唱团的普通团员,使用声部云进行分声部练习、提交录音、参与云合唱项目
  2. 合唱团指挥/团长:合唱团的组织者和艺术指导,使用声部云创建协作项目、收集团员录音、合成多轨作品、管理团员
  3. 合唱爱好者:尚未加入合唱团但热爱唱歌的人,使用声部云发现合唱团、参加声部测试、加入曲库练习
  4. 合唱赛事组织者:线上合唱比赛的主办方,使用声部云发布赛事、收集作品、组织投票

1.4 设计理念

声部云的设计理念围绕"教堂彩窗"这一核心视觉隐喻展开:

  • 深邃蓝底如教堂空间:应用整体采用深蓝色调(#10142A / #141A38 / #1A2042 三级明度),如同教堂内部幽暗而庄重的空间,营造出沉浸、专注的练声氛围
  • 鎏金色如彩窗光芒:主色鎏金(#F0C75E)如同教堂彩窗透进来的神圣光芒,用于标题、按钮、高亮数据、徽章等关键元素,传递出合唱艺术的庄重感和仪式感
  • 雾蓝与紫罗兰如彩窗色块:辅助色雾蓝(#7FA8F5)和紫罗兰(#9B7EDE)如同教堂彩窗上的蓝色和紫色玻璃,用于次要信息、标签、进度条等,丰富视觉层次
  • 波形图如声波在教堂中回荡:录音确认弹窗中的波形图、录音棚项目中的轨道波形,如同声波在教堂穹顶下回荡,将抽象的声音可视化

这种设计理念让声部云在视觉上区别于一般的音乐类应用(通常采用鲜艳活泼的配色),传递出合唱艺术特有的庄重感、仪式感和沉浸感。


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

2.1 主色调:鎏金 #F0C75E

鎏金色(#F0C75E)是声部云的主色调,也是整个应用中最醒目的颜色。它在应用中的使用场景包括:

  • 底部导航激活态:当前选中的 Tab 文字和图标采用鎏金色,未选中的采用灰蓝色(#6A7195),形成清晰的视觉焦点
  • 主要按钮:“申请入团”、“开始录制”、“创建项目”、"确认投票"等核心操作按钮采用鎏金底色+深蓝字(#10142A),在深蓝背景上极为醒目
  • 高亮数据:本周最佳分数、平均分、连续练习天数、曲目演唱人数等关键数据采用鎏金色
  • 徽章与等级:金牌团徽章、音准之星徽章、全勤团员徽章等采用鎏金色
  • 进度条高亮:声部掌握度≥70% 的进度条、高票数赛事的进度条采用鎏金色

鎏金色在深蓝底色上的对比度极高,既保证了可读性,又传递出合唱艺术的庄重感和仪式感。

2.2 背景色:三级深蓝体系

声部云采用三级深蓝背景色体系,通过明度差异构建视觉层次:

  • 最深蓝 #10142A:应用最底层背景色,用于整个应用的底色
  • 中深蓝 #141A38:头部区域、底部导航栏、弹窗遮罩下的背景色
  • 浅深蓝 #1A2042:卡片、列表项、弹窗内容区、输入框背景色

这种三级明度体系让应用在整体深蓝基调下仍有清晰的视觉层次——卡片浮在背景之上,弹窗浮在卡片之上,每层之间通过微妙的明度差异区分。

2.3 辅助色:雾蓝与紫罗兰

雾蓝色(#7FA8F5 / #5B8DEF) 是声部云的第一辅助色,用于:

  • 次要按钮和操作(如"试听"、“催交”、“分享项目”)
  • 声部标签(女高音 S、女中音 A 等声部标签采用雾蓝底色)
  • 进度条(声部掌握度<70% 的进度条、普通票数赛事的进度条)
  • 链接和可点击文字
  • 录音确认弹窗中的波形图

紫罗兰色(#9B7EDE) 是声部云的第二辅助色,用于:

  • 传统类曲目标签(传统曲目采用紫罗兰圆形头像)
  • 中等分数的录音打分(70-79 分采用紫罗兰色)
  • 次要装饰元素

2.4 文字色彩层级

声部云的文字色彩分为四个层级:

  • 一级文字 #E8EAF6:标题、列表项主标题、按钮文字(深色按钮上),接近白色但略带蓝调,在深蓝背景上清晰可读
  • 二级文字 #8A90B8:副标题、说明文字、列表项次要信息,灰蓝色,与一级文字形成对比
  • 三级文字 #6A7195:辅助说明、时间戳、底部导航未激活态,更深的灰蓝色,用于最不重要的信息
  • 四级文字 #4A5078:分割线、装饰性文字、页脚标语,最深的灰蓝色,几乎与背景融为一体

2.5 合唱团等级色彩

声部云为合唱团设计了三个等级,每个等级有专属色彩:

  • 金牌团:鎏金色系(#F0C75E 文字 + rgba(240,199,94,0.16) 背景),代表最高水平的合唱团
  • 银牌团:雾蓝色系(#A8C6EE 文字 + rgba(155,185,222,0.16) 背景),代表中等水平的合唱团
  • 新秀团:亮蓝色系(#7FA8F5 文字 + rgba(91,141,239,0.16) 背景),代表新成立的合唱团

每个合唱团卡片左侧有一个圆形头像区域,显示等级徽章(🥇金牌 / 🥈银牌 / 🌱新秀),头像背景色与等级色彩对应,形成直观的等级识别。

2.6 录音打分色彩

声部录音的打分采用三级色彩体系:

  • 90 分以上:鎏金色(#F0C75E),代表优秀
  • 80-89 分:雾蓝色(#7FA8F5),代表良好
  • 70-79 分:紫罗兰色(#9B7EDE),代表及格
  • 70 分以下:灰紫色,代表需要加强

打分数字显示在录音卡片左侧的圆形区域中,分数对应的进度条在卡片底部,颜色与分数等级一致,形成直观的评分反馈。


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

3.1 单文件架构

声部云 CHORUS CLOUD 采用单文件架构,所有代码集中在一个 2134.ets 文件中,约 3100 行代码。这种架构的选择基于以下考虑:

  • 演示与教学友好:单文件架构便于完整展示应用的所有代码,适合作为 HarmonyOS ArkTS 开发的教学案例和演示项目
  • 无构建依赖:单文件不需要复杂的模块导入导出配置,降低了项目的构建复杂度
  • 快速原型验证:对于功能验证阶段的应用,单文件架构可以快速迭代,不需要在多个文件之间切换

3.2 组件划分

虽然是单文件架构,但声部云在代码层面通过 @Component 装饰器划分为 8 个独立组件,每个组件负责一个明确的功能模块:

组件名功能行数占比
ChorusCloudApp应用根入口,Tab 路由与底部导航~5%
ChorusHeader顶部头部,Logo/搜索/活动横幅~6%
ChoirsContent合唱团 Tab,团列表与入团申请~18%
PartsContent声部 Tab,录音记录与打分~14%
StudioContent录音棚 Tab,协作项目管理~20%
PiecesContent曲目 Tab,曲库与年代筛选~14%
ContestContent排练赛 Tab,赛事与投票~14%
MeContent我的 Tab,团员中心与数据~9%

3.3 根入口组件 ChorusCloudApp

ChorusCloudApp 是应用的根入口组件,使用 @Entry 装饰器标记。它的核心职责是:

  1. Tab 状态管理:通过 @State activeTab: ChorusTab 管理当前激活的 Tab,ChorusTab 是一个枚举类型,包含 CHOIRS、PARTS、STUDIO、PIECES、CONTEST、ME 六个值
  2. 内容区域路由:通过 @Builder contentArea() 构建器方法,根据 activeTab 的值渲染对应的 Tab 内容组件
  3. 底部导航栏:通过 @Builder bottomTabItem() 构建器方法渲染底部导航的每个 Tab 项,包含图标和文字,点击时切换 activeTab
  4. 整体布局:Column 垂直布局,从上到下依次为头部(ChorusHeader)、内容区域(contentArea,layoutWeight(1) 占满剩余空间)、分割线、底部导航栏

底部导航的每个 Tab 项在激活时会有缩放动画(scale 1.18 倍),文字颜色变为鎏金色,未激活时为灰蓝色,动画时长 220ms,缓动曲线为 EaseOut。

3.4 头部组件 ChorusHeader

ChorusHeader 是应用的顶部头部组件,采用电商风静态头部设计,包含三个区域:

  1. 顶部导航栏:左侧为鎏金圆形 Logo(音符图标 ♪),中间为应用名称"声部云"和副标题"百人千声 · 一屏合唱",右侧为通知和消息图标
  2. 搜索栏:圆角搜索框,占位文字为"搜索合唱团 / 曲目 / 声部",点击可触发搜索(当前为静态演示)
  3. 活动横幅:两个并排的活动卡片,左侧为"开学季云合唱"(深紫底+鎏金字),右侧为"新人声部测试"(深蓝底+雾蓝字)

头部背景色为中深蓝(#141A38),与应用底色(#10142A)形成微妙的层次区分。

3.5 状态管理策略

声部云采用 @State 装饰器进行组件内局部状态管理,每个 Tab 组件独立管理自己的状态:

  • 合唱团 Tab:管理选中的合唱团、入团申请表单、申请结果 Toast
  • 声部 Tab:管理录音确认弹窗、删除确认弹窗、已归档录音 ID 列表
  • 录音棚 Tab:管理选中的项目、新建/编辑/删除项目弹窗、邀请 Toast
  • 曲目 Tab:管理年代筛选索引、选中的曲目、加入曲库确认
  • 排练赛 Tab:管理选中的赛事、投票确认、赛制规则弹窗、已投票 ID 列表
  • 我的 Tab:管理团员资料编辑、退团确认、关于弹窗、退出登录、我的合唱团列表

这种组件内局部状态管理策略避免了全局状态管理的复杂性,每个组件的状态变更只影响自身,符合 ArkTS 的响应式编程范式。

3.6 @Builder 构建器模式

声部云大量使用 @Builder 装饰器封装可复用的 UI 片段,主要用于两类场景:

  1. 弹窗内容封装:每个弹窗的内容都封装为一个 @Builder 方法,如 choirDetailModal()、applyModal()、recordConfirmModal() 等,在 build 方法中通过 if 条件渲染时调用
  2. 遮罩层封装:modalOverlay(onClose: () => void) 是一个通用的半透明遮罩层构建器,接收一个关闭回调函数,点击遮罩区域时触发关闭

这种模式让弹窗的 UI 代码与主页面代码分离,提高了代码的可读性和可维护性。


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

4.1 接口定义

声部云定义了 8 个接口(interface)来描述应用中的核心数据模型,所有接口均采用对象字面量约束:

ChoirItem(合唱团):描述一个合唱团的基本信息,包含 id(编号)、name(团名)、city(所在城市)、members(团员人数)、level(等级:金牌团/银牌团/新秀团)、piece(近期排演曲目)、joined(是否已加入)、badge(等级徽章 emoji)。

PartTrack(声部录音):描述一条个人声部录音记录,包含 id、piece(曲目名称)、part(声部:女高音S/女中音A/男高音T/男低音B)、score(打分 0-100)、duration(录音时长)、done(是否已归档)、date(录制日期)。

StudioProject(录音棚项目):描述一个云合唱协作项目,包含 id、name(项目名称)、piece(曲目)、tracks(总轨道数)、doneTracks(已提交轨道数)、owner(发起人/指挥)、deadline(截止日期)。

PieceInfo(曲目):描述合唱曲库中的一首曲目,包含 id、title(曲名)、composer(作曲者)、era(年代:当代/现代/传统)、voices(声部编制:SATB四声部/SSA女声/TTBB男声等)、difficulty(难度 1-5)、singers(演唱人次)。

ContestEntry(排练赛事):描述一个线上合唱赛事,包含 id、name(赛事名称)、theme(主题)、teams(参赛队伍数)、votes(累计票数)、joined(是否已参赛/投票)、deadline(截止日期)。

RehearseDay(排练日):描述一周中某一天的排练时长,包含 day(星期几)、minutes(排练分钟数)。

VoicePart(声部掌握度):描述某个声部的掌握程度,包含 name(声部名称)、emoji(图标)、percent(掌握百分比)。

4.2 静态数据

声部云的所有数据均为静态常量(const),在文件顶部定义,共 8 组数据:

  • CHOIR_LIST:12 个合唱团,覆盖上海、深圳、北京、杭州、重庆、成都、厦门、南京、呼和浩特、广州、苏州、长沙 12 个城市,包含金牌团 4 个、银牌团 6 个、新秀团 2 个
  • PART_TRACKS:12 条声部录音记录,涵盖《夜航星》《海平面之下》《茉莉风》等多首曲目,分数从 68 到 96 不等
  • STUDIO_PROJECTS:10 个录音棚协作项目,包含进行中、已截止等不同状态,轨道数从 4 到 10 不等
  • PIECE_LIST:16 首合唱曲目,涵盖当代、现代、传统三个年代,包含 SATB 四声部、SSA 女声、TTBB 男声、童声二部等多种编制
  • CONTEST_LIST:10 个线上排练赛事,涵盖九月云合唱大赛、中秋月圆合唱夜、新年钟声云合唱等不同主题
  • REHEARSE_WEEK:7 天排练数据,从周一到周日,排练时长从 20 分钟到 80 分钟不等
  • VOICE_PARTS:4 个声部掌握度数据,女高音 92%、女中音 74%、男高音 41%、男低音 23%
  • ERA_FILTERS:4 个年代筛选选项(全部/当代/现代/传统)

4.3 工具函数

声部云定义了 4 个工具函数,用于处理颜色和显示逻辑:

choirLevelColor(lv: string): string:根据合唱团等级返回对应的背景色(rgba 格式),金牌团返回鎏金半透明色,银牌团返回雾蓝半透明色,新秀团返回亮蓝半透明色。

choirLevelText(lv: string): string:根据合唱团等级返回对应的文字颜色,金牌团返回鎏金色,银牌团返回银蓝色,新秀团返回亮蓝色。

dotRow(n: number): string:根据难度等级(1-5)返回对应的圆点字符串,5 级返回"●●●●●",4 级返回"●●●●○",以此类推,用于曲目卡片中显示难度等级。

trackBarWidth(p: number): string:将分数转换为百分比宽度字符串,用于录音卡片底部的打分进度条。

4.4 枚举类型

声部云定义了一个枚举类型 ChorusTab,包含六个值:CHOIRS(合唱团)、PARTS(声部)、STUDIO(录音棚)、PIECES(曲目)、CONTEST(排练赛)、ME(我的)。这个枚举用于根入口组件的 Tab 状态管理和路由切换。

4.5 全局状态管理策略

声部云采用"静态数据 + 组件局部状态"的全局状态管理策略:

  • 静态数据层:所有业务数据(合唱团列表、录音记录、项目列表等)定义为文件顶部的 const 常量,在应用启动时加载,运行期间不修改(除了录音棚 Tab 中的项目列表支持增删改,通过 @State projects: StudioProject[] = STUDIO_PROJECTS 复制一份到组件状态中)
  • 组件局部状态层:每个 Tab 组件通过 @State 管理自己的 UI 状态(弹窗显示/隐藏、表单项、选中项、已操作 ID 列表等),状态变更只影响当前组件
  • 跨组件通信:当前版本中各 Tab 组件之间没有直接的跨组件通信需求,因为每个 Tab 的功能相对独立。如果未来需要跨组件通信(如在我的 Tab 中显示已加入的合唱团),可以通过提升状态到根组件或使用 AppStorage 实现

这种策略简单高效,适合演示项目和中小型应用,避免了复杂的全局状态管理框架带来的学习成本和运行时开销。


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

声部云包含 6 大 Tab 页面,每个页面负责一个核心业务模块。本章将逐页解析每个 Tab 的布局结构、交互逻辑和设计要点。

5.1 合唱团 Tab:发现与加入各地合唱团

合唱团 Tab 是应用的首页,也是用户进入应用后看到的第一个页面。它的核心功能是帮助用户发现各地的合唱团,并申请加入。

合唱团Tab页面

页面布局结构:

  1. 团员榜 TOP3 横幅:页面顶部是一个鎏金色的横幅卡片,显示"团员榜 TOP3"和前三名合唱团名称(云雀 · 京韵 · 金陵),右侧是一个"同城找团"按钮(深紫底+指南针图标)。这个横幅既展示了平台上的优质合唱团,又提供了按城市筛选的入口。

  2. 热门合唱团列表:横幅下方是"热门合唱团"标题栏(左侧标题+右侧"按城市 12 团"统计),下方是 12 个合唱团的卡片列表。

  3. 合唱团卡片设计:每个合唱团卡片采用水平布局,从左到右依次为:

    • 等级徽章圆形头像:46×46 圆角矩形,背景色根据等级变化(金牌团鎏金半透明/银牌团雾蓝半透明/新秀团亮蓝半透明),显示等级徽章 emoji(🥇/🥈/🌱)
    • 合唱团信息区:第一行为团名(加粗白字)+ 状态标签(已加入/可申请),第二行为城市·人数·正在排演曲目,第三行为等级标签+每周两练标签
    • 右箭头:深灰蓝色的 › 箭头,提示可点击查看详情
  4. 页脚标语:列表底部是"— 声入云端 · 与千人同频 —"的装饰性标语。

交互逻辑:

  • 点击任意合唱团卡片,弹出合唱团详情弹窗(choirDetailModal),显示该团的详细信息(每周排练时间、声部空缺、入团考核、云录音要求等)
  • 如果用户尚未加入该团,详情弹窗底部显示"申请入团"按钮,点击后弹出入团申请弹窗(applyModal)
  • 入团申请弹窗包含昵称输入框和四个声部选项(女高音S/女中音A/男高音T/男低音B),提交后显示"申请已递交,指挥将在 3 日内回复"的 Toast

设计要点:

合唱团卡片的等级色彩系统是这个页面的核心设计亮点——通过徽章 emoji、头像背景色、等级标签文字色三重视觉编码,用户可以一眼识别出合唱团的等级水平。金牌团的鎏金色在深蓝背景上极为醒目,自然形成视觉焦点。

5.2 声部 Tab:个人分声部录音与打分

声部 Tab 是个人录音管理页面,核心功能是展示用户的声部录音记录、打分情况,并提供录制新声部的入口。

声部Tab页面

页面布局结构:

  1. 三卡片数据概览:页面顶部是三个并排的统计卡片,分别显示:

    • 本周最佳:奖杯图标+96 分·夜航星(鎏金色)
    • 平均分:折线图图标+84.6 分(雾蓝色)
    • 连续练习:火焰图标+12 天(紫罗兰色)
  2. 录制新声部按钮:三卡片下方是一个醒目的鎏金色大按钮"● 录一段新声部",宽度占满 94%,高度 46,圆角 23。

  3. 录音记录列表:按钮下方是"我的录音棚记录"标题栏(左侧标题+右侧"共 12 条"统计),下方是 12 条录音记录卡片。

  4. 录音卡片设计:每个录音卡片采用垂直布局:

    • 顶部行:左侧为分数圆形头像(48×48,显示分数,颜色根据分数等级变化:≥90鎏金/≥80雾蓝/≥70紫罗兰),中间为曲目名+声部标签+时长+日期,右侧为状态标签(已归档/草稿)+删除按钮
    • 底部进度条:卡片底部是一条打分进度条,宽度等于分数百分比,颜色与分数等级一致

交互逻辑:

  • 点击"录一段新声部"按钮,弹出录音确认弹窗(recordConfirmModal),显示曲目名称、波形图、伴奏版本、打分标准等信息,点击"开始录制"后显示"录音已上传,等待指挥审听"的 Toast
  • 点击录音卡片的删除按钮,弹出删除确认弹窗(deleteConfirmModal),确认后从已归档列表中移除该录音

设计要点:

录音卡片的分数可视化是这个页面的核心设计——分数不仅以数字形式显示在圆形头像中,还以进度条形式显示在卡片底部,且颜色随分数等级变化。这种双重可视化让用户可以快速感知自己的录音质量,鎏金色的 96 分卡片自然成为列表中的视觉焦点。

5.3 录音棚 Tab:多轨协作项目管理

录音棚 Tab 是云合唱协作项目的管理页面,核心功能是展示进行中的协作项目、轨道完成进度,并提供新建项目入口。

录音棚Tab页面

页面布局结构:

  1. 项目标题栏:页面顶部是标题行,左侧为"🎛 进行中的项目"标题+副标题(8 个 · 2 个已收齐轨道),右侧为鎏金色"+ 新建项目"按钮。

  2. 项目卡片列表:标题栏下方是 10 个项目卡片。

  3. 项目卡片设计:每个项目卡片采用垂直布局:

    • 顶部信息行:左侧为轨道完成度圆形头像(44×44,显示"已完成/总数"如"6/8",全部完成显示✓),中间为项目名+曲目+发起人+截止日期,右侧为编辑按钮(✎)和删除按钮(🗑)
    • 轨道波形图:信息行下方是一条 12 柱的波形图,柱高随机(16-38px),高于 28px 的柱为鎏金色,其余为深灰蓝色,模拟音频波形
    • 完成进度条:波形图下方是一条轨道完成进度条,宽度等于已完成轨道百分比,雾蓝色

交互逻辑:

  • 点击项目卡片,弹出项目详情弹窗(projectDetailModal),显示各声部轨道提交状态(女高音S·已交/女中音A·已交/男高音T·缺等)、完成进度、分享和改项目名按钮
  • 点击"新建项目"按钮,弹出新建项目弹窗(newProjectModal),包含项目名输入框、曲目选择、轨道数、截止时间等表单项
  • 点击项目卡片的编辑按钮,弹出编辑项目名弹窗(editProjectModal)
  • 点击项目卡片的删除按钮,弹出解散项目确认弹窗(deleteProjectModal)

设计要点:

项目卡片的轨道波形图是这个页面的标志性设计——12 根高低错落的柱子模拟音频波形,鎏金色的高柱与深灰蓝色的低柱形成对比,既美观又传递出"这是一个音频项目"的语义信息。轨道完成度头像(6/8)和底部进度条双重展示项目进度,让用户一眼看出项目的完成状态。

5.4 曲目 Tab:合唱曲库与年代筛选

曲目 Tab 是合唱曲库浏览页面,核心功能是按年代筛选曲目、查看曲目详情、试听声部分轨,并加入个人排练曲库。

曲目Tab页面

页面布局结构:

  1. 年代筛选标签栏:页面顶部是四个年代筛选标签(全部/当代/现代/传统),采用下划线指示器设计——选中标签文字为鎏金色,下方有 2px 鎏金下划线;未选中标签文字为灰蓝色。

  2. 统计栏:筛选栏下方是统计行,左侧为"共 16 首可选",右侧为"🔥 本周上新 2 首"(鎏金色)。

  3. 曲目列表:统计栏下方是 16 首曲目的卡片列表。

  4. 曲目卡片设计:每个曲目卡片采用水平布局:

    • 年代圆形头像:44×44 圆形,显示年代首字(当/现/传),传统曲目为紫罗兰色,其余为鎏金色
    • 曲目信息区:第一行为曲名(加粗白字)+NEW 标签(仅新曲显示,鎏金底深蓝字),第二行为作曲者+声部编制,第三行为难度圆点(●●●○○)+演唱人次
    • 试听按钮:右侧为 32×32 圆形播放按钮(雾蓝底+白色▶图标)

交互逻辑:

  • 点击年代筛选标签,切换筛选条件,列表实时过滤
  • 点击曲目卡片,弹出曲目详情底部抽屉(pieceSheet),显示曲目基本信息、演唱人次、难度、声部编制、四个声部分轨试听列表,底部为"+ 加入我的排练曲库"按钮
  • 点击"加入我的排练曲库",弹出加入确认弹窗(addConfirmModal),确认后显示"已加入排练曲库,声部练习已排期"的 Toast

设计要点:

曲目卡片的年代色彩编码是这个页面的核心设计——传统曲目采用紫罗兰色圆形头像,当代和现代曲目采用鎏金色圆形头像,用户可以通过颜色快速区分曲目的年代属性。难度圆点(●●●○○)是一种直观的难度可视化方式,比数字评分更符合音乐曲目的语境。

5.5 排练赛 Tab:线上合唱赛事与投票

排练赛 Tab 是线上合唱赛事页面,核心功能是展示进行中的赛事、参赛队伍和投票情况,并提供投票入口。

排练赛Tab页面

页面布局结构:

  1. 焦点赛事横幅:页面顶部是一个鎏金色横幅卡片,显示"🏆 九月云合唱大赛 · 投票进行中",副标题为"42 支队伍 · 1.86 万票 · 09-20 截止"。这个横幅突出展示当前最重要的赛事。

  2. 赛事列表标题栏:横幅下方是"全部赛事"标题栏(左侧标题+右侧"每日 3 票"提示)。

  3. 赛事卡片列表:标题栏下方是 10 个赛事卡片。

  4. 赛事卡片设计:每个赛事卡片采用垂直布局:

    • 顶部信息行:左侧为星形图标(★已参赛/☆未参赛,鎏金色),中间为赛事名+主题+队伍数·票数·截止日期,右侧为状态标签(已投票/可投票)
    • 底部进度条:卡片底部是一条票数进度条,宽度等于票数百分比(上限 600 票/单位),高票数赛事(>20000票)为鎏金色,普通赛事为雾蓝色

交互逻辑:

  • 点击赛事卡片,弹出赛事详情弹窗(contestDetailModal),显示参赛队伍数、累计票数、截稿时间、作品时长等信息,以及"赛制规则"和"投稿参赛"按钮
  • 点击"为喜爱的队伍投票"按钮(未投票时),弹出投票确认弹窗(voteConfirmModal),确认后显示"投票成功,感谢为热爱发声"的 Toast
  • 点击"赛制规则"按钮,弹出赛制规则弹窗(rulesModal),显示 5 条赛制规则

设计要点:

赛事卡片的票数进度条颜色编码是这个页面的核心设计——高票数赛事(如新年钟声云合唱 3.24 万票、万人云合唱国庆版 5.86 万票)采用鎏金色进度条,普通赛事采用雾蓝色进度条,用户可以通过颜色快速识别赛事的热度。焦点赛事横幅的鎏金色设计在深蓝背景上极为醒目,自然引导用户关注当前最重要的赛事。

5.6 我的 Tab:团员个人中心与排练数据

我的 Tab 是团员个人中心页面,核心功能是展示团员个人资料、排练数据、声部掌握度、所属合唱团,并提供资料编辑和退团入口。

我的Tab页面

页面布局结构:

  1. 团员资料卡:页面顶部是团员资料卡片,左侧为鎏金色圆形头像(显示声部首字母"S"),中间为昵称(夜航者Sofia)+声部·合唱团+徽章(音准之星·全勤团员),右侧为编辑按钮(✎)。

  2. 本周排练时长图表:资料卡下方是排练数据卡片,标题为"本周排练时长"(右侧"共 325 分钟"),下方是 7 天柱状图(周一到周日,柱高=分钟数×0.9,周六日为鎏金色,其余为雾蓝色),底部标注"周六排练最久,达 80 分钟"。

  3. 四声部掌握度:排练图表下方是声部掌握度卡片,标题为"四声部掌握度",下方是四个声部的进度条:

    • 女高音 S:92%(鎏金进度条)
    • 女中音 A:74%(鎏金进度条)
    • 男高音 T:41%(雾蓝进度条)
    • 男低音 B:23%(雾蓝进度条,部分被遮挡)
  4. 我的合唱团列表:掌握度下方是"我的合唱团"标题栏+合唱团列表,每个合唱团卡片显示徽章+团名+城市·等级·人数,右侧为"退团"按钮(红底白字)。

  5. 关于与退出:列表下方是"关于声部云"入口和"退出登录"按钮(红色文字)。

交互逻辑:

  • 点击编辑按钮(✎),弹出团员资料编辑弹窗(profileEditModal),包含昵称输入框、主唱声部、音域记录、入团时间等信息
  • 点击"退团"按钮,弹出退团确认弹窗(quitConfirmModal),确认后从我的合唱团列表中移除
  • 点击"关于声部云",弹出关于弹窗(aboutModal),显示应用版本、标语、版权信息
  • 点击"退出登录",弹出退出确认弹窗(logoutModal)

设计要点:

我的 Tab 的数据可视化是这个页面的核心设计——本周排练时长柱状图和四声部掌握度进度条将抽象的排练数据转化为直观的视觉图表。鎏金色的周六日柱子和≥70% 的进度条自然形成视觉焦点,让用户一眼看出自己的排练高峰和优势声部。团员资料卡的鎏金头像和徽章设计传递出"这是一个有成就的团员"的语义信息。


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

声部云的弹窗交互体系非常完整,6 大 Tab 共包含约 18 套弹窗组件,覆盖了从入团申请、录音确认、项目管理、曲目加入、赛事投票到团员资料编辑的全业务流程。本章将深度解析两套最具代表性的弹窗。

6.1 合唱团详情弹窗:沉浸式入团体验

合唱团详情弹窗(choirDetailModal)是用户了解合唱团并申请入团的核心交互入口。

合唱团详情弹窗

弹窗结构:

弹窗采用居中卡片式设计,宽度 88%,背景色为浅深蓝(#1A2042),圆角 16,边框 1px 深紫蓝(#2A3160)。内容从上到下依次为:

  1. 标题区:“合 唱 团 详 情”(鎏金色,字间距 3)+ 团名(18号加粗白字)+ 城市·等级·人数(11号灰蓝字)+ 近期排演曲目(鎏金色)
  2. 分割线:1px 深紫蓝分割线
  3. 信息列表:四行键值对信息,左标签(灰蓝)右值(白/鎏金):
    • 每周排练:周三 20:00 · 周日 15:00
    • 声部空缺:女中音 2 · 男低音 5
    • 入团考核:音域测试 + 视唱八小节
    • 云录音要求:每周交一次声部录音
  4. 操作按钮:底部为"✓ 已是团员"(已加入时,灰底灰字)或"申请入团"(未加入时,鎏金底深蓝字)

交互流程:

  1. 用户在合唱团列表点击某个团 → 弹出详情弹窗
  2. 用户查看团的详细信息(排练时间、声部空缺、考核要求等)
  3. 如果未加入,点击"申请入团"→ 关闭详情弹窗 → 弹出入团申请弹窗
  4. 在申请弹窗中填写昵称、选择声部 → 点击"提交申请"→ 显示 Toast"申请已递交,指挥将在 3 日内回复"

设计亮点:

合唱团详情弹窗的信息架构非常清晰——团名和等级是第一层级(大字号加粗),近期排演曲目是第二层级(鎏金色高亮),四行详细信息是第三层级(键值对列表)。这种层级设计让用户可以快速获取关键信息,需要时再深入查看细节。声部空缺信息(女中音 2 · 男低音 5)采用鎏金色高亮,直接告诉用户"这个团需要你",有效提升入团转化率。

6.2 录音确认弹窗:波形图驱动的沉浸录音体验

录音确认弹窗(recordConfirmModal)是声部 Tab 的核心交互,用户在录制新声部前需要确认曲目信息和录音设置。

录音确认弹窗

弹窗结构:

弹窗采用居中卡片式设计,宽度 86%,背景色为浅深蓝(#1A2042),圆角 16。内容从上到下依次为:

  1. 标题区:“开 始 录 音”(鎏金色,字间距 3)+ 曲目名称《夜航星》女高音 S 声部(15号加粗白字)
  2. 波形图:12 根高低错落的柱状波形图(柱高 18-44px,雾蓝色),模拟音频波形,高度 46px
  3. 提示文字:“建议佩戴耳机 · 单次不超过 10 分钟”(10号灰蓝字)
  4. 设置列表:两行键值对信息:
    • 伴奏版本:钢琴基准 · 88bpm
    • 打分标准:音准 / 节奏 / 咬字
  5. 操作按钮:底部为"取消"(深灰底灰字)和"● 开始录制"(鎏金底深蓝字)两个并排按钮

交互流程:

  1. 用户在声部 Tab 点击"录一段新声部"按钮 → 弹出录音确认弹窗
  2. 用户查看曲目信息、波形图、伴奏版本和打分标准
  3. 点击"开始录制"→ 关闭弹窗 → 显示 Toast"录音已上传,等待指挥审听"

设计亮点:

录音确认弹窗的波形图是最具标志性的设计——12 根雾蓝色柱子高低错落,模拟真实的音频波形,在深蓝背景上营造出"声音正在流动"的沉浸感。波形图不仅是装饰,它传递出"这是一个录音操作"的语义信息,让用户在点击"开始录制"之前就进入录音的心理状态。伴奏版本(钢琴基准 · 88bpm)和打分标准(音准/节奏/咬字)的信息展示让用户明确录音要求,减少因不了解标准而产生的焦虑。

6.3 弹窗体系总览

声部云的弹窗体系可以分为以下几类:

弹窗类型所属 Tab弹窗名称功能
详情类合唱团choirDetailModal合唱团详情展示
表单类合唱团applyModal入团申请表单
确认类声部recordConfirmModal录音确认
确认类声部deleteConfirmModal删除录音确认
详情类录音棚projectDetailModal项目详情与轨道状态
表单类录音棚newProjectModal新建项目表单
表单类录音棚editProjectModal编辑项目名
确认类录音棚deleteProjectModal解散项目确认
抽屉类曲目pieceSheet曲目详情底部抽屉
确认类曲目addConfirmModal加入曲库确认
详情类排练赛contestDetailModal赛事详情
确认类排练赛voteConfirmModal投票确认
规则类排练赛rulesModal赛制规则
表单类我的profileEditModal团员资料编辑
确认类我的quitConfirmModal退团确认
关于类我的aboutModal关于应用
确认类我的logoutModal退出登录确认

所有弹窗均采用 Stack 叠加 + if 条件渲染的实现方式,弹窗背景为半透明深色遮罩(rgba(8,10,26,0.7~0.78)),弹窗内容为居中卡片或底部抽屉。点击遮罩区域可关闭弹窗,形成完整的交互闭环。


第七章 动画与过渡效果

声部云在动画设计上遵循"克制而精准"的原则——不追求花哨的动画效果,而是在关键交互节点上使用恰到好处的动画来提升用户体验。

7.1 底部导航切换动画

底部导航的每个 Tab 项在激活时会有缩放动画:

.scale(this.activeTab === tab ? { x: 1.18, y: 1.18 } : { x: 1, y: 1 })
.animation({ duration: 220, curve: Curve.EaseOut })

当用户点击某个 Tab 时,图标会从 1.0 倍放大到 1.18 倍,动画时长 220ms,缓动曲线为 EaseOut(先快后慢)。这种微缩放动画让 Tab 切换有了"被按下"的触感反馈,同时不会过于张扬。

7.2 筛选标签切换动画

合唱团 Tab 的风格筛选标签和曲目 Tab 的年代筛选标签在切换时也有缩放动画:

.scale(this.styleIdx === i ? { x: 1.05, y: 1.05 } : { x: 1, y: 1 })
.animation({ duration: 200, curve: Curve.EaseOut })

选中的标签会轻微放大到 1.05 倍,背景色变为鎏金色,文字变为深色。200ms 的动画时长让切换感觉流畅而不拖沓。

7.3 进度条动画

录音棚 Tab 的项目完成进度条、我的 Tab 的声部掌握度进度条和排练时长柱状图都有入场动画:

.animation({ duration: 300~400, curve: Curve.EaseOut })

进度条从 0 宽度增长到目标宽度,动画时长 300-400ms。这种"填充"动画让数据展示有了生命力,用户可以直观地看到进度的增长过程。

7.4 Toast 提示动画

所有操作成功后的 Toast 提示都有缩放动画:

.scale({ x: 1.05, y: 1.05 })
.animation({ duration: 240, curve: Curve.EaseOut })

Toast 出现时会从 1.0 倍放大到 1.05 倍,营造出"弹出"的效果。Toast 背景为半透明鎏金色或雾蓝色,文字为深色,在深蓝背景上清晰可见。点击 Toast 可关闭。

7.5 弹窗过渡

弹窗的显示/隐藏通过 if 条件渲染实现,ArkUI 框架会自动处理组件的插入/删除过渡。虽然代码中没有显式定义弹窗的过渡动画,但 ArkUI 的默认过渡效果(淡入淡出)已经足够流畅,配合半透明遮罩的渐变,弹窗的出现和消失都很自然。

7.6 动画设计哲学

声部云的动画设计哲学可以总结为三点:

  1. 克制:动画只在关键交互节点使用(Tab 切换、筛选切换、进度条填充、Toast 弹出),不在列表滚动、卡片悬停等非关键节点使用多余动画,保持界面的简洁和专业感
  2. 精准:每个动画的时长(200-400ms)和缓动曲线(EaseOut)都经过精心选择,既保证了流畅感,又不会让用户等待
  3. 语义化:每个动画都有明确的语义——缩放表示"选中/激活",进度条填充表示"数据增长",Toast 弹出表示"操作成功"。动画不是装饰,而是信息传递的一部分

第八章 分声部录音系统

分声部录音是声部云的核心功能之一,也是区别于一般音乐类应用的关键特性。本章将深度解析分声部录音系统的设计与实现。

8.1 四声部体系

声部云采用标准的四声部合唱体系:

  • 女高音 Soprano(S):合唱中最高的声部,通常担任主旋律,音域约 C4-G6
  • 女中音 Alto(A):女声的中低声部,担任和声填充,音域约 F3-D5
  • 男高音 Tenor(T):男声的高声部,可与女高音形成八度叠唱,音域约 C3-A4
  • 男低音 Bass(B):合唱中最低的声部,担任低音支撑,音域约 F2-C4

在声部 Tab 的录音记录中,每条录音都标注了对应的声部(女高音 S / 女中音 A),声部标签采用雾蓝色圆角标签设计。在我的 Tab 的四声部掌握度中,四个声部的掌握百分比以进度条形式展示,让用户清楚自己在各个声部上的水平。

8.2 录音打分系统

每条声部录音都有一个 0-100 的打分,打分维度包括三个方面:

  • 音准:演唱的音高是否准确,是否有跑调、偏音等问题
  • 节奏:演唱的节奏是否稳定,是否与伴奏节拍同步
  • 咬字:歌词的发音是否清晰准确,是否有含糊不清的问题

打分结果以三级色彩体系展示:

  • 90 分以上:鎏金色(优秀)
  • 80-89 分:雾蓝色(良好)
  • 70-79 分:紫罗兰色(及格)
  • 70 分以下:灰紫色(需加强)

在声部 Tab 的录音列表中,分数不仅以数字形式显示在圆形头像中,还以进度条形式显示在卡片底部,颜色随分数等级变化。这种双重可视化让用户可以快速感知自己的录音质量。

8.3 录音确认流程

用户录制新声部的流程如下:

  1. 点击录制按钮:在声部 Tab 点击"● 录一段新声部"鎏金色大按钮
  2. 确认录音信息:弹出录音确认弹窗,显示曲目名称、声部、波形图、伴奏版本(钢琴基准 · 88bpm)、打分标准(音准/节奏/咬字)
  3. 开始录制:点击"● 开始录制"按钮,进入录音状态(当前为演示,直接显示上传成功 Toast)
  4. 上传与审听:录音上传到云端,等待指挥审听打分
  5. 查看结果:指挥打分后,录音出现在声部 Tab 的录音列表中,显示分数和进度条

8.4 录音归档与管理

声部录音有两种状态:

  • 已归档:指挥已审听打分的录音,状态标签为鎏金色"已归档"
  • 草稿:尚未提交或指挥尚未审听的录音,状态标签为灰蓝色"草稿"

用户可以对已归档的录音进行删除操作——点击录音卡片右侧的删除按钮(🗑),弹出删除确认弹窗,确认后从列表中移除。删除时会提示"删除后打分记录一并清除,无法恢复",防止误操作。

8.5 声部测试与定位

对于新用户,声部云提供"新人声部测试"功能(头部活动横幅右侧卡片),通过 1 分钟的音域测试帮助用户确定自己适合的声部。测试结果会记录在团员资料中(我的 Tab → 主唱声部),并在入团申请时作为参考。


第九章 云录音棚协作系统

云录音棚是声部云的另一核心功能,它让不同城市的团员可以异步录制各自的声部,最终在云端合成一首完整的合唱作品。

9.1 协作项目模型

每个云合唱协作项目(StudioProject)包含以下信息:

  • 项目名称:如"夜航星 · 九月开学季版"、“茉莉风 · 女声三团联动”
  • 曲目:项目排演的合唱曲目
  • 总轨道数:项目需要的声部轨道总数(4-10 轨不等)
  • 已完成轨道数:已提交的轨道数
  • 发起人/指挥:项目的创建者和艺术指导
  • 截止日期:项目的轨道提交截止日期

9.2 项目卡片设计

录音棚 Tab 的项目卡片采用垂直布局,包含三个视觉层次:

  1. 轨道完成度头像:左侧 44×44 圆形头像,显示"已完成/总数"(如"6/8"),全部完成时显示✓。头像背景色根据完成状态变化——全部完成为鎏金半透明色,进行中为雾蓝半透明色。

  2. 轨道波形图:卡片中部是 12 柱波形图,柱高 16-38px 随机,高于 28px 的柱为鎏金色,其余为深灰蓝色。这个波形图既是项目的视觉标识,也传递出"音频项目"的语义。

  3. 完成进度条:卡片底部是一条雾蓝色进度条,宽度等于已完成轨道百分比。进度条有 300ms 的 EaseOut 入场动画。

9.3 项目详情与轨道管理

点击项目卡片,弹出项目详情弹窗(projectDetailModal),显示:

  • 项目基本信息:项目名、曲目、发起人、截止日期
  • 轨道状态列表:每个声部轨道的提交状态(女高音S·已交/女中音A·已交/男高音T·缺/男低音B·已交/钢琴伴奏·已交),未提交的轨道显示"催交"按钮
  • 完成进度条:轨道完成百分比进度条
  • 操作按钮:“↗ 分享项目"和"✎ 改项目名”

9.4 项目生命周期

云录音棚项目的完整生命周期如下:

  1. 创建项目:指挥/团长点击"+ 新建项目",填写项目名、选择曲目、设置轨道数和截止日期,创建项目
  2. 邀请团员:指挥将项目分享给团员,团员收到通知后开始录制各自的声部
  3. 提交轨道:团员录制完成后提交声部轨道,轨道状态变为"已交"
  4. 催交轨道:对于未按时提交的轨道,指挥可以在项目详情中点击"催交",系统发送提醒
  5. 合成作品:所有轨道提交完成后,指挥在云端进行多轨合成,生成完整的合唱作品
  6. 项目归档:截止日期过后,项目状态变为"已截止",已完成的项目可以继续查看和播放

9.5 新建项目表单

新建项目弹窗(newProjectModal)包含以下表单项:

  • 项目名称输入框:占位文字"项目名称(如:夜航星·元旦版)"
  • 选择曲目:默认显示《夜航星》(当前为静态演示)
  • 轨道数:默认 8 轨(SATB×2)
  • 截止时间:默认 09-30 22:00

提交后项目被添加到列表顶部,项目名默认为用户输入的名称(未输入时为"新建协作项目"),轨道数默认为 8,已完成轨道数为 0,发起人为"我"。


第十章 合唱曲库系统

合唱曲库是声部云的内容基础,为用户提供可排练、可试听的合唱曲目资源。

10.1 曲目分类体系

声部云的曲目库采用三维分类体系:

  1. 年代分类:当代、现代、传统三个年代,通过曲目 Tab 顶部的筛选标签切换

    • 当代:2000 年以后创作的合唱作品,如《夜航星》《海平面之下》《茉莉风》
    • 现代:20 世纪创作的合唱作品,如《紫禁城之光》《秦淮夜》《雨巷》
    • 传统:根据传统民歌或古曲改编的合唱作品,如《川江谣》《风吹草低》《渡口谣》
  2. 声部编制分类:

    • SATB 四声部:最常见的混声合唱编制
    • SSA 女声:女声三声部合唱
    • TTBB 男声:男声四声部合唱
    • SATB+童声:混声加童声
    • 童声二部:儿童合唱
    • 混声+长调:特殊编制
  3. 难度分类:1-5 级难度,通过圆点可视化(●●●○○):

    • 1 级:最简单,适合初学者(如《萤火集》童声二部)
    • 2 级:较简单,适合有基础的团员(如《茉莉风》SSA 女声)
    • 3 级:中等难度,适合一般合唱团(如《夜航星》SATB 四声部)
    • 4 级:较难,适合有经验的合唱团(如《海平面之下》SATB 四声部)
    • 5 级:高难度,适合专业合唱团(如《紫禁城之光》SATB+童声)

10.2 曲目卡片设计

曲目 Tab 的曲目卡片采用水平布局:

  • 年代圆形头像:左侧 44×44 圆形,显示年代首字(当/现/传),传统曲目为紫罗兰色,其余为鎏金色
  • 曲目信息区:
    • 第一行:曲名(加粗白字)+ NEW 标签(仅新曲显示,鎏金底深蓝字,透明度控制显示/隐藏)
    • 第二行:作曲者 + 声部编制
    • 第三行:难度圆点 + 演唱人次(如"4820 人唱过")
  • 试听按钮:右侧 32×32 圆形播放按钮(雾蓝底+白色▶图标)

10.3 曲目详情抽屉

点击曲目卡片,从底部弹出曲目详情抽屉(pieceSheet),包含:

  • 曲目基本信息:曲名首字圆形头像(鎏金底深蓝字)+ 曲名 + 作曲者·年代·声部编制
  • 三列数据:演唱人次(鎏金色大数字)、难度(圆点)、声部编制(缩写)
  • 声部分轨试听列表:四个声部的分轨试听条目(女高音S·4分12秒/女中音A·4分12秒/男高音T·4分12秒/男低音B·4分12秒),每条有播放按钮和"试听"文字
  • 加入曲库按钮:底部鎏金色大按钮"+ 加入我的排练曲库"

10.4 声部分轨试听

声部分轨试听是合唱曲库的核心功能之一。传统合唱团员在独自练习时,很难听到其他声部的声音来找准音准和节奏。声部云的每首曲目都提供四个声部的分轨试听,团员可以:

  • 单独听自己的声部,熟悉旋律线
  • 听其他声部,了解和声关系
  • 跟着钢琴基准伴奏练习,保持节奏稳定

分轨试听列表中的每个条目都有独立的播放按钮,点击即可播放对应声部的录音。这种分轨试听功能让团员在家练习时也能获得类似合唱团排练的听觉参照。

10.5 排练曲库管理

用户可以将感兴趣的曲目加入"我的排练曲库",加入后系统会自动排期对应的声部练习。加入流程:

  1. 在曲目详情抽屉点击"+ 加入我的排练曲库"
  2. 弹出加入确认弹窗,显示曲目名和声部编制
  3. 确认后显示 Toast"已加入排练曲库,声部练习已排期"

加入曲库的曲目会出现在个人的排练计划中,系统会根据用户的声部和掌握度推荐练习顺序。


第十一章 线上排练赛事系统

线上排练赛事是声部云的社区激励机制,通过赛事、投票、排行榜等形式激励团员持续练习。

11.1 赛事类型

声部云的赛事库包含 10 个不同类型的线上合唱赛事:

赛事名称主题参赛队伍累计票数截止日期
九月云合唱大赛夜与星光42 支1.86 万09-20
城市印象挑战赛唱给你的城市28 支1.42 万09-30
中秋月圆合唱夜月亮为主题55 支2.73 万09-12
校园回声季毕业与开学37 支1.19 万10-08
方言保护计划赛家乡话合唱19 支0.87 万10-15
新年钟声云合唱跨年之夜66 支3.24 万12-25
春日童声杯孩子的歌24 支0.96 万03-20
电影金曲翻唱赛银幕之声45 支2.05 万11-11
无伴奏阿卡季纯人声挑战16 支0.73 万10-30
万人云合唱·国庆版山河之歌128 支5.86 万09-28

赛事主题丰富多样,涵盖节日(中秋、新年、国庆)、主题(城市、方言、电影金曲)、形式(无伴奏、童声、万人合唱)等多个维度,满足不同兴趣和水平的团员参与。

11.2 赛事卡片设计

排练赛 Tab 的赛事卡片采用垂直布局:

  • 顶部信息行:左侧星形图标(★已参赛/☆未参赛,鎏金色),中间为赛事名(加粗白字)+ 主题 + 队伍数·票数·截止日期,右侧为状态标签(已投票/可投票)
  • 底部进度条:票数进度条,高票数赛事(>20000票)为鎏金色,普通赛事为雾蓝色

赛事卡片的票数进度条颜色编码让用户可以快速识别赛事热度——鎏金色的高票数赛事(如新年钟声云合唱、万人云合唱国庆版)自然形成视觉焦点。

11.3 赛事详情与投票

点击赛事卡片,弹出赛事详情弹窗(contestDetailModal),包含:

  • 赛事基本信息:赛事名、主题、参赛队伍数、累计票数、截稿时间、作品时长要求
  • 操作按钮:“📖 赛制规则”(雾蓝底)和"✍ 投稿参赛"(鎏金底)
  • 投票按钮:底部"为喜爱的队伍投票"(未投票时鎏金底)或"✓ 已投过票"(已投票时灰底)

投票流程:

  1. 在赛事详情点击"为喜爱的队伍投票"
  2. 弹出投票确认弹窗(voteConfirmModal),显示赛事主题
  3. 确认后显示 Toast"投票成功,感谢为热爱发声"
  4. 每人每天可为 3 支队伍投票(标题栏右侧提示"每日 3 票")

11.4 赛制规则

点击"赛制规则"按钮,弹出规则弹窗(rulesModal),显示 5 条赛制规则:

  1. 每支队伍以云录音合成作品参赛
  2. 每人每天可为 3 支队伍投票
  3. 决赛前人气前八晋级专家评审
  4. 严禁修音与代唱,违者取消资格
  5. 冠军团将获得线下公演邀请

这 5 条规则覆盖了参赛方式、投票规则、晋级机制、违规处罚和奖励机制,形成完整的赛事规则体系。

11.5 赛事激励机制

声部云的赛事激励机制包括三个层面:

  1. 即时反馈:投票后立即显示 Toast 反馈,让用户感受到自己的参与有价值
  2. 人气排名:票数进度条和参赛队伍数展示赛事热度,激发用户的竞争意识和参与欲
  3. 终极奖励:冠军团获得线下公演邀请,将线上的虚拟荣誉转化为线下的真实体验,形成最强激励

这种三层激励机制让赛事不仅是"投票游戏",而是真正驱动团员持续练习、提升合唱水平的动力来源。


第十二章 团员个人中心与数据系统

我的 Tab 是团员个人中心,集中展示团员的个人资料、排练数据、声部掌握度和所属合唱团。

12.1 团员资料卡

团员资料卡位于我的 Tab 顶部,采用水平布局:

  • 声部头像:左侧 58×58 鎏金色圆形头像,显示声部首字母(如"S"代表女高音)
  • 个人信息:中间为昵称(17号加粗白字)+ 声部·所属合唱团(11号灰蓝字)+ 徽章(10号鎏金字,如"音准之星 · 全勤团员")
  • 编辑按钮:右侧✎编辑按钮(雾蓝色),点击弹出资料编辑弹窗

团员资料卡的设计简洁而信息丰富——声部头像一眼识别声部,昵称和合唱团明确身份,徽章展示成就。鎏金色的头像和徽章在深蓝背景上形成视觉焦点。

12.2 本周排练时长图表

本周排练时长图表是个人中心的核心数据可视化组件:

  • 标题栏:“本周排练时长”(左侧)+ “共 325 分钟”(右侧鎏金色)
  • 7 天柱状图:周一到周日的排练时长柱状图,柱高=分钟数×0.9,周六日为鎏金色,其余为雾蓝色。周二休息(0 分钟)的柱子不显示
  • 数据标注:底部标注"周六排练最久,达 80 分钟"

柱状图的颜色编码(周六日鎏金/工作日雾蓝)让用户一眼看出周末是排练高峰,数据标注进一步强调了峰值信息。这种数据可视化方式比纯数字展示更直观、更有洞察力。

12.3 四声部掌握度

四声部掌握度是另一个核心数据可视化组件:

  • 标题:“四声部掌握度”
  • 四个声部进度条:
    • 女高音 S:92%(鎏金进度条,≥70%)
    • 女中音 A:74%(鎏金进度条,≥70%)
    • 男高音 T:41%(雾蓝进度条,<70%)
    • 男低音 B:23%(雾蓝进度条,<70%)

每个声部进度条包含声部名称+emoji(左侧)、百分比(右侧鎏金色)、进度条(底部)。进度条颜色根据掌握度变化——≥70% 为鎏金色(优势声部),<70% 为雾蓝色(待提升声部)。

这种掌握度展示让用户清楚自己的声部优势和短板——女高音 92% 是绝对优势,男低音 23% 是明显短板,用户可以根据这个数据有针对性地练习薄弱声部。

12.4 我的合唱团管理

我的合唱团列表展示用户已加入的合唱团,每个合唱团卡片包含:

  • 等级徽章:左侧 40×40 圆形,显示等级徽章 emoji(🥇/🥈)
  • 合唱团信息:团名(加粗白字)+ 城市·等级·人数(灰蓝字)
  • 退团按钮:右侧"退团"按钮(红色文字+浅红底)

点击"退团"按钮,弹出退团确认弹窗(quitConfirmModal),显示团名和提示"已交录音将保留在云端档案,可随时再申请归团"。确认后从列表中移除。

退团流程的设计考虑了用户的反悔心理——提示"可随时再申请归团"降低了退团的心理门槛,同时红色的退团按钮和确认弹窗的警告图标(⚠)防止误操作。

12.5 团员资料编辑

团员资料编辑弹窗(profileEditModal)包含:

  • 昵称输入框:占位文字"当前昵称:夜航者Sofia"
  • 固定信息展示:主唱声部(女高音 S)、音域记录(C4 - G6)、入团时间(2025-03)

保存后显示 Toast"团员资料已更新"。当前版本中主唱声部、音域记录等信息为静态展示,未来版本可支持声部测试更新和音域记录修改。

12.6 数据驱动的个人成长

我的 Tab 的核心设计理念是"数据驱动个人成长"——通过本周排练时长图表和四声部掌握度进度条,将团员的练习数据可视化,让用户:

  1. 看见自己的努力:本周排练 325 分钟,周六最久 80 分钟——这些数据让用户感受到自己的付出
  2. 发现自己的优势:女高音 92%、女中音 74%——优势声部一目了然
  3. 识别自己的短板:男高音 41%、男低音 23%——薄弱声部明确,有针对性地练习
  4. 追踪自己的进步:随着练习的持续,掌握度百分比会增长,形成正向反馈循环

这种数据驱动的设计让个人中心不仅是"信息展示页",而是"个人成长仪表盘",激励团员持续练习、不断提升。


十三、开发环境搭建

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等调试能力。

总结与展望

项目总结

声部云 CHORUS CLOUD 是一款以云上合唱团和分声部录音协作为核心的合唱社区应用,基于 HarmonyOS ArkTS 声明式 UI 框架开发,单文件约 3100 行代码,无外部依赖。项目实现了 6 大 Tab 页面(合唱团/声部/录音棚/曲目/排练赛/我的)和约 18 套弹窗组件,完整覆盖合唱团发现与入团、分声部录音与打分、云录音棚多轨协作、合唱曲库与声部分轨试听、线上赛事与投票、团员个人中心与排练数据等核心业务场景。

视觉设计上采用"深夜教堂彩窗风"——深邃午夜蓝底(#10142A)营造教堂般的幽暗沉浸空间,鎏金色(#F0C75E)如同彩窗透进的神圣光芒,雾蓝色(#7FA8F5)如同清晨薄雾,紫罗兰色(#9B7EDE)如同教堂紫色祭袍。整体视觉氛围庄重而温暖,既呼应了合唱艺术的教堂渊源,又营造出深夜独自练声的沉浸感。色彩语义明确——鎏金代表优秀/高亮/主操作,雾蓝代表良好/次要/辅助,紫罗兰代表传统/中等/装饰。

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

技术亮点

  1. 分声部录音协作系统:核心创新功能,每个团员在自己房间录制声部轨道,指挥在云端合成多轨,打破传统合唱的时空限制。四声部体系(S/A/T/B)+ 三维打分(音准/节奏/咬字)+ 三级色彩反馈(鎏金/雾蓝/紫罗兰)构成完整的录音评价体系
  2. 云录音棚多轨项目管理:10 个协作项目,每个项目包含轨道完成度头像、波形图可视化、进度条三重展示,支持新建/编辑/删除/分享/催交等完整项目管理操作
  3. 合唱曲库与声部分轨试听:16 首曲目覆盖当代/现代/传统三个年代,包含 SATB/SSA/TTBB/童声等多种编制,每首曲目提供四声部独立分轨试听,解决团员独自练声的音准参照难题
  4. 深夜教堂彩窗风视觉体系:深蓝底+鎏金+雾蓝+紫罗兰的四级色彩体系,合唱团等级色彩(金牌/银牌/新秀)、录音打分色彩(优秀/良好/及格)、年代色彩(当代鎏金/传统紫罗兰)等多维度色彩语义编码
  5. 线上排练赛事激励体系:10 个多样化赛事+每日 3 票投票+人气前八晋级+冠军线下公演邀请,形成即时反馈→人气排名→终极奖励的三层激励机制
  6. 数据驱动的个人成长仪表盘:本周排练时长柱状图(周末鎏金高亮)+四声部掌握度进度条(≥70%鎏金/<70%雾蓝),将抽象练习数据转化为直观可视化,帮助团员发现优势、识别短板、追踪进步
  7. 约 18 套完整弹窗交互体系:从入团申请、录音确认、项目新建、曲目加入、赛事投票到团员资料编辑,每个业务场景都有对应的弹窗交互,形成完整的云上合唱业务闭环

扩展方向

声部云 CHORUS CLOUD 作为演示项目,仍有多个可扩展方向:

  • 真实录音功能:当前录音为静态演示,可接入 HarmonyOS 音频录制 API 实现真实的分声部录音,支持耳机伴奏监听、实时音准检测、录音波形显示
  • 云端多轨合成:当前项目轨道状态为静态数据,可接入云端音频合成服务,实现多轨自动对齐、音量平衡、混响效果处理,最终生成完整合唱作品
  • 在线合唱曲库扩充:当前 16 首曲目为演示数据,可对接专业合唱曲库 API,提供数百首合唱作品的分轨试听、乐谱展示、伴奏下载
  • 社区互动功能:当前赛事和投票为基础功能,可扩展为完整社区:团员动态、作品评论、关注粉丝、合唱团团内讨论、指挥点评等社交功能
  • AI 音准辅导:可集成 AI 音频分析能力,实现实时音准检测、节奏纠正、发音建议、个性化练习计划生成等 AI 教练功能
  • 智能设备联动:可对接华为手表、耳机等智能穿戴设备,实现录音时的心率监测、练声时长统计、耳机低延迟伴奏等专业功能
  • 线下演出对接:可扩展为线上线下联动平台,支持线下合唱演出报名、票务、演出直播、演出录音回看等完整演出服务
  • 多端协同:可扩展为手机/平板/PC 多端协同,手机用于录音和社交,平板用于乐谱展示和分轨试听,PC 用于专业多轨编辑和合成

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

本文基于 HarmonyOS ArkTS 声明式 UI 框架实战项目撰写,所有代码和图片均来自声部云 CHORUS CLOUD 项目。


附录:完整 ArkTS 源码(2134.ets)

// 2134.ets 声部云 CHORUS CLOUD —— 云上合唱团 / 分声部录音协作 / 排练挑战
// 深夜教堂彩窗风:深蓝底 + 鎏金 + 雾蓝 + 紫罗兰
// 遵循要求.md:无 Blank、无 UI 内变量声明、constraintSize 限高、单子组件 Scroll、接口约束全部对象字面量

// ============ 类型定义 ============
interface ChoirItem {
  id: number;
  name: string;
  city: string;
  members: number;
  level: string;
  piece: string;
  joined: boolean;
  badge: string;
}

interface PartTrack {
  id: number;
  piece: string;
  part: string;
  score: number;
  duration: string;
  done: boolean;
  date: string;
}

interface StudioProject {
  id: number;
  name: string;
  piece: string;
  tracks: number;
  doneTracks: number;
  owner: string;
  deadline: string;
}

interface PieceInfo {
  id: number;
  title: string;
  composer: string;
  era: string;
  voices: string;
  difficulty: number;
  singers: number;
}

interface ContestEntry {
  id: number;
  name: string;
  theme: string;
  teams: number;
  votes: number;
  joined: boolean;
  deadline: string;
}

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

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

// ============ 静态数据 ============
const CHOIR_LIST: ChoirItem[] = [
  { id: 1, name: '云雀混声合唱团', city: '上海', members: 86, level: '金牌团', piece: '《夜航星》', joined: true, badge: '🥇' },
  { id: 2, name: '湾区青年合唱团', city: '深圳', members: 64, level: '银牌团', piece: '《海平面之下》', joined: false, badge: '🥈' },
  { id: 3, name: '京韵之声', city: '北京', members: 120, level: '金牌团', piece: '《紫禁城之光》', joined: false, badge: '🥇' },
  { id: 4, name: '南屏女声合唱团', city: '杭州', members: 48, level: '新秀团', piece: '《茉莉风》', joined: false, badge: '🌱' },
  { id: 5, name: '川江号子传承团', city: '重庆', members: 92, level: '银牌团', piece: '《川江谣》', joined: false, badge: '🥈' },
  { id: 6, name: '星空室内合唱团', city: '成都', members: 36, level: '新秀团', piece: '《猎户座》', joined: false, badge: '🌱' },
  { id: 7, name: '鹭岛白领合唱团', city: '厦门', members: 58, level: '银牌团', piece: '《潮汐来信》', joined: false, badge: '🥈' },
  { id: 8, name: '金陵古调合唱团', city: '南京', members: 74, level: '金牌团', piece: '《秦淮夜》', joined: false, badge: '🥇' },
  { id: 9, name: '草原长调合唱团', city: '呼和浩特', members: 103, level: '金牌团', piece: '《风吹草低》', joined: false, badge: '🥇' },
  { id: 10, name: '珠江夜航合唱团', city: '广州', members: 67, level: '银牌团', piece: '《桥下之光》', joined: false, badge: '🥈' },
  { id: 11, name: '姑苏平江合唱团', city: '苏州', members: 41, level: '新秀团', piece: '《雨巷》', joined: false, badge: '🌱' },
  { id: 12, name: '湘江律动合唱团', city: '长沙', members: 79, level: '银牌团', piece: '《岳麓秋声》', joined: false, badge: '🥈' }
];

const PART_TRACKS: PartTrack[] = [
  { id: 1, piece: '《夜航星》', part: '女高音 S', score: 96, duration: '3:42', done: true, date: '08-26' },
  { id: 2, piece: '《夜航星》', part: '女高音 S', score: 88, duration: '3:42', done: true, date: '08-22' },
  { id: 3, piece: '《海平面之下》', part: '女高音 S', score: 91, duration: '4:15', done: true, date: '08-20' },
  { id: 4, piece: '《夜航星》', part: '女中音 A', score: 79, duration: '3:42', done: true, date: '08-18' },
  { id: 5, piece: '《茉莉风》', part: '女高音 S', score: 84, duration: '3:08', done: false, date: '08-15' },
  { id: 6, piece: '《川江谣》', part: '女中音 A', score: 72, duration: '4:46', done: true, date: '08-12' },
  { id: 7, piece: '《猎户座》', part: '女高音 S', score: 68, duration: '5:02', done: false, date: '08-10' },
  { id: 8, piece: '《潮汐来信》', part: '女高音 S', score: 93, duration: '3:57', done: true, date: '08-08' },
  { id: 9, piece: '《秦淮夜》', part: '女中音 A', score: 81, duration: '4:33', done: false, date: '08-05' },
  { id: 10, piece: '《雨巷》', part: '女高音 S', score: 87, duration: '2:56', done: true, date: '08-02' },
  { id: 11, piece: '《桥下之光》', part: '女高音 S', score: 75, duration: '4:21', done: false, date: '07-30' },
  { id: 12, piece: '《岳麓秋声》', part: '女中音 A', score: 83, duration: '3:35', done: true, date: '07-28' }
];

const STUDIO_PROJECTS: StudioProject[] = [
  { id: 1, name: '夜航星 · 九月开学季版', piece: '《夜航星》', tracks: 8, doneTracks: 6, owner: '指挥·陆声', deadline: '09-01' },
  { id: 2, name: '茉莉风 · 女声三团联动', piece: '《茉莉风》', tracks: 6, doneTracks: 4, owner: '指挥·白鹭', deadline: '09-05' },
  { id: 3, name: '海平面之下 · 修正混音', piece: '《海平面之下》', tracks: 8, doneTracks: 8, owner: '指挥·陆声', deadline: '已截止' },
  { id: 4, name: '猎户座 · 新人练声版', piece: '《猎户座》', tracks: 4, doneTracks: 1, owner: '助教·小舟', deadline: '09-12' },
  { id: 5, name: '秦淮夜 · 男声补录', piece: '《秦淮夜》', tracks: 6, doneTracks: 3, owner: '指挥·韩渡', deadline: '09-08' },
  { id: 6, name: '川江谣 · 方言指导版', piece: '《川江谣》', tracks: 8, doneTracks: 5, owner: '指挥·岩哥', deadline: '09-15' },
  { id: 7, name: '潮汐来信 · 双语版', piece: '《潮汐来信》', tracks: 6, doneTracks: 6, owner: '指挥·白鹭', deadline: '已截止' },
  { id: 8, name: '雨巷 · 诗朗诵融合', piece: '《雨巷》', tracks: 5, doneTracks: 2, owner: '助教·阿杏', deadline: '09-20' },
  { id: 9, name: '紫禁城之光 · 百人云合唱', piece: '《紫禁城之光》', tracks: 10, doneTracks: 7, owner: '指挥·陆声', deadline: '10-01' },
  { id: 10, name: '岳麓秋声 · 校庆版', piece: '《岳麓秋声》', tracks: 6, doneTracks: 4, owner: '指挥·谭静', deadline: '09-28' }
];

const PIECE_LIST: PieceInfo[] = [
  { id: 1, title: '夜航星', composer: '陈亦洺', era: '当代', voices: 'SATB 四声部', difficulty: 3, singers: 4820 },
  { id: 2, title: '海平面之下', composer: '天天', era: '当代', voices: 'SATB 四声部', difficulty: 4, singers: 3360 },
  { id: 3, title: '紫禁城之光', composer: '佚名', era: '现代', voices: 'SATB+童声', difficulty: 5, singers: 2870 },
  { id: 4, title: '茉莉风', composer: '白鹭', era: '当代', voices: 'SSA 女声', difficulty: 2, singers: 5210 },
  { id: 5, title: '川江谣', composer: '岩哥', era: '传统', voices: 'TTBB 男声', difficulty: 4, singers: 1980 },
  { id: 6, title: '猎户座', composer: '小舟', era: '当代', voices: 'SATB 四声部', difficulty: 3, singers: 2450 },
  { id: 7, title: '潮汐来信', composer: '白鹭', era: '当代', voices: 'SSA 女声', difficulty: 2, singers: 4130 },
  { id: 8, title: '秦淮夜', composer: '韩渡', era: '现代', voices: 'SATB 四声部', difficulty: 4, singers: 3020 },
  { id: 9, title: '风吹草低', composer: '佚名', era: '传统', voices: '混声 + 长调', difficulty: 5, singers: 1670 },
  { id: 10, title: '桥下之光', composer: '阿灯', era: '当代', voices: 'SATB 四声部', difficulty: 3, singers: 2240 },
  { id: 11, title: '雨巷', composer: '阿杏', era: '现代', voices: 'SSA 女声', difficulty: 2, singers: 3980 },
  { id: 12, title: '岳麓秋声', composer: '谭静', era: '当代', voices: 'SATB 四声部', difficulty: 3, singers: 2650 },
  { id: 13, title: '雪落长河', composer: '陈亦洺', era: '当代', voices: 'SATB 四声部', difficulty: 4, singers: 2110 },
  { id: 14, title: '萤火集', composer: '小舟', era: '当代', voices: '童声二部', difficulty: 1, singers: 6320 },
  { id: 15, title: '山雀与晨雾', composer: '阿灯', era: '当代', voices: 'SSA 女声', difficulty: 2, singers: 3480 },
  { id: 16, title: '渡口谣', composer: '佚名', era: '传统', voices: 'TTBB 男声', difficulty: 3, singers: 1540 }
];

const CONTEST_LIST: ContestEntry[] = [
  { id: 1, name: '九月云合唱大赛', theme: '夜与星光', teams: 42, votes: 18600, joined: true, deadline: '09-20' },
  { id: 2, name: '城市印象挑战赛', theme: '唱给你的城市', teams: 28, votes: 14200, joined: false, deadline: '09-30' },
  { id: 3, name: '中秋月圆合唱夜', theme: '月亮为主题', teams: 55, votes: 27300, joined: false, deadline: '09-12' },
  { id: 4, name: '校园回声季', theme: '毕业与开学', teams: 37, votes: 11900, joined: false, deadline: '10-08' },
  { id: 5, name: '方言保护计划赛', theme: '家乡话合唱', teams: 19, votes: 8700, joined: false, deadline: '10-15' },
  { id: 6, name: '新年钟声云合唱', theme: '跨年之夜', teams: 66, votes: 32400, joined: false, deadline: '12-25' },
  { id: 7, name: '春日童声杯', theme: '孩子的歌', teams: 24, votes: 9600, joined: false, deadline: '03-20' },
  { id: 8, name: '电影金曲翻唱赛', theme: '银幕之声', teams: 45, votes: 20500, joined: false, deadline: '11-11' },
  { id: 9, name: '无伴奏阿卡季', theme: '纯人声挑战', teams: 16, votes: 7300, joined: false, deadline: '10-30' },
  { id: 10, name: '万人云合唱·国庆版', theme: '山河之歌', teams: 128, votes: 58600, joined: false, deadline: '09-28' }
];

const REHEARSE_WEEK: RehearseDay[] = [
  { day: '一', minutes: 20 },
  { day: '二', minutes: 45 },
  { day: '三', minutes: 30 },
  { day: '四', minutes: 60 },
  { day: '五', minutes: 35 },
  { day: '六', minutes: 80 },
  { day: '日', minutes: 55 }
];

const VOICE_PARTS: VoicePart[] = [
  { name: '女高音 S', emoji: '🎤', percent: 92 },
  { name: '女中音 A', emoji: '🎵', percent: 74 },
  { name: '男高音 T', emoji: '🎶', percent: 41 },
  { name: '男低音 B', emoji: '🎼', percent: 23 }
];

const ERA_FILTERS: string[] = ['全部', '当代', '现代', '传统'];

// ============ 工具函数 ============
function choirLevelColor(lv: string): string {
  if (lv === '金牌团') {
    return 'rgba(240,199,94,0.16)';
  }
  if (lv === '银牌团') {
    return 'rgba(155,185,222,0.16)';
  }
  return 'rgba(91,141,239,0.16)';
}

function choirLevelText(lv: string): string {
  if (lv === '金牌团') {
    return '#F0C75E';
  }
  if (lv === '银牌团') {
    return '#A8C6EE';
  }
  return '#7FA8F5';
}

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

function trackBarWidth(p: number): string {
  return p.toString() + '%';
}

enum ChorusTab {
  CHOIRS,
  PARTS,
  STUDIO,
  PIECES,
  CONTEST,
  ME
}
// ============ 主入口 ============
@Entry
@Component
struct ChorusCloudApp {
  @State activeTab: ChorusTab = ChorusTab.CHOIRS

  @Builder contentArea() {
    if (this.activeTab === ChorusTab.CHOIRS) {
      ChoirsContent()
    } else if (this.activeTab === ChorusTab.PARTS) {
      PartsContent()
    } else if (this.activeTab === ChorusTab.STUDIO) {
      StudioContent()
    } else if (this.activeTab === ChorusTab.PIECES) {
      PiecesContent()
    } else if (this.activeTab === ChorusTab.CONTEST) {
      ContestContent()
    } else {
      MeContent()
    }
  }

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

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

      Divider()
        .color('#232A4F')
        .strokeWidth(1)

      Row() {
        this.bottomTabItem('⛪', '合唱团', ChorusTab.CHOIRS)
        this.bottomTabItem('🎙', '声部', ChorusTab.PARTS)
        this.bottomTabItem('🎛', '录音棚', ChorusTab.STUDIO)
        this.bottomTabItem('🎼', '曲目', ChorusTab.PIECES)
        this.bottomTabItem('🏆', '排练赛', ChorusTab.CONTEST)
        this.bottomTabItem('👤', '我的', ChorusTab.ME)
      }
      .width('100%')
      .backgroundColor('#141A38')
      .padding({ bottom: 6 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#10142A')
  }
}

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

  build() {
    Column() {
      Row({ space: 10 }) {
        Column() {
          Text('♪')
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor('#10142A')
        }
        .width(36)
        .height(36)
        .borderRadius(18)
        .backgroundColor('#F0C75E')
        .justifyContent(FlexAlign.Center)

        Column({ space: 3 }) {
          Text('声部云')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#E8EAF6')
          Text('百人千声 · 一屏合唱')
            .fontSize(9)
            .fontColor('#6A7195')
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)

        Text('🔔')
          .fontSize(19)
          .padding(5)

        Text('💬')
          .fontSize(19)
          .padding(5)
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 10 })

      Row({ space: 8 }) {
        Text('🔍')
          .fontSize(14)
        Text('搜索合唱团 / 曲目 / 声部')
          .fontSize(12)
          .fontColor('#6A7195')
          .layoutWeight(1)
      }
      .width('100%')
      .padding({ left: 14, right: 14, top: 10, bottom: 10 })
      .borderRadius(20)
      .backgroundColor('#1A2042')
      .margin({ left: 16, right: 16, top: 10 })
      .onClick(() => {
        this.onSearchTap();
      })

      Row({ space: 10 }) {
        Column({ space: 4 }) {
          Text('🎤')
            .fontSize(22)
          Text('开学季云合唱')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor('#E8EAF6')
          Text('百团招新进行中')
            .fontSize(9)
            .fontColor('#F0C75E')
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1.4)
        .padding(14)
        .borderRadius(14)
        .backgroundColor('#2A2450')

        Column({ space: 4 }) {
          Text('🎧')
            .fontSize(22)
          Text('新人声部测试')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor('#E8EAF6')
          Text('1 分钟定声部')
            .fontSize(9)
            .fontColor('#7FA8F5')
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        .padding(14)
        .borderRadius(14)
        .backgroundColor('#1B2C52')
      }
      .width('94%')
      .margin({ top: 10, bottom: 12 })
    }
    .width('100%')
    .backgroundColor('#141A38')
  }
}
// ============ 合唱团 Tab(精选横幅 + 团列表 + 入团弹窗) ============
@Component
struct ChoirsContent {
  @State showChoirDetail: boolean = false
  @State selectedChoir: ChoirItem | null = null
  @State showApplyModal: boolean = false
  @State applyName: string = ''
  @State applyPart: number = 0
  @State showApplyToast: boolean = false

  @Builder modalOverlay(onClose: () => void) {
    Column() {
      Column()
        .width('100%')
        .layoutWeight(1)
        .onClick(() => {
          onClose();
        })
      Column() {
        Text('⛪')
          .fontSize(30)
          .margin({ bottom: 8 })
        Text('声部云')
          .fontSize(12)
          .fontColor('#6A7195')
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
  }

  @Builder choirDetailModal() {
    Column({ space: 12 }) {
      Text('合 唱 团 详 情')
        .fontSize(12)
        .fontColor('#F0C75E')
        .letterSpacing(3)
        .margin({ top: 20 })

      Text(this.selectedChoir!.name)
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor('#E8EAF6')
      Text(this.selectedChoir!.city + ' · ' + this.selectedChoir!.level + ' · ' + this.selectedChoir!.members.toString() + ' 人')
        .fontSize(11)
        .fontColor('#8A90B8')
      Row({ space: 8 }) {
        Text('近期排演')
          .fontSize(10)
          .fontColor('#8A90B8')
        Text(this.selectedChoir!.piece)
          .fontSize(10)
          .fontColor('#F0C75E')
      }

      Divider()
        .color('#2A3160')
        .strokeWidth(1)
        .width('84%')
      Column({ space: 10 }) {
        Row() {
          Text('每周排练')
            .fontSize(11)
            .fontColor('#8A90B8')
          Text('周三 20:00 · 周日 15:00')
            .fontSize(11)
            .fontColor('#E8EAF6')
        }
        .width('84%')
        .justifyContent(FlexAlign.SpaceBetween)

        Row() {
          Text('声部空缺')
            .fontSize(11)
            .fontColor('#8A90B8')
          Text('女中音 2 · 男低音 5')
            .fontSize(11)
            .fontColor('#F0C75E')
        }
        .width('84%')
        .justifyContent(FlexAlign.SpaceBetween)

        Row() {
          Text('入团考核')
            .fontSize(11)
            .fontColor('#8A90B8')
          Text('音域测试 + 视唱八小节')
            .fontSize(11)
            .fontColor('#E8EAF6')
        }
        .width('84%')
        .justifyContent(FlexAlign.SpaceBetween)

        Row() {
          Text('云录音要求')
            .fontSize(11)
            .fontColor('#8A90B8')
          Text('每周交一次声部录音')
            .fontSize(11)
            .fontColor('#E8EAF6')
        }
        .width('84%')
        .justifyContent(FlexAlign.SpaceBetween)
      }
      .width('100%')

      Button() {
        Text(this.selectedChoir!.joined ? '✓ 已是团员' : '申请入团')
          .fontSize(14)
          .fontColor('#10142A')
      }
      .width('60%')
      .height(42)
      .backgroundColor(this.selectedChoir!.joined ? '#3A4370' : '#F0C75E')
      .borderRadius(21)
      .margin({ top: 6, bottom: 22 })
      .onClick(() => {
        this.showChoirDetail = false;
        if (!this.selectedChoir!.joined) {
          this.showApplyModal = true;
        }
      })
    }
    .width('88%')
    .backgroundColor('#1A2042')
    .borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#2A3160' })
  }

  @Builder applyModal() {
    Column({ space: 12 }) {
      Text('申 请 入 团')
        .fontSize(12)
        .fontColor('#F0C75E')
        .letterSpacing(3)
        .margin({ top: 22 })

      Text(this.selectedChoir!.name)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#E8EAF6')

      TextInput({ placeholder: '你的昵称' })
        .fontSize(13)
        .fontColor('#E8EAF6')
        .width('84%')
        .height(44)
        .backgroundColor('#141A38')
        .borderRadius(10)
        .border({ width: 1, color: '#2A3160' })
        .onChange((v: string) => {
          this.applyName = v;
        })

      Text('选择申报声部')
        .fontSize(11)
        .fontColor('#8A90B8')
        .width('84%')
      Flex({ wrap: FlexWrap.Wrap }) {
        ForEach(['女高音 S', '女中音 A', '男高音 T', '男低音 B'], (p: string, i: number) => {
          Text(p)
            .fontSize(12)
            .fontColor(this.applyPart === i ? '#10142A' : '#A8B0D8')
            .padding({ left: 16, right: 16, top: 8, bottom: 8 })
            .borderRadius(18)
            .backgroundColor(this.applyPart === i ? '#F0C75E' : '#141A38')
            .border({ width: 1, color: this.applyPart === i ? '#F0C75E' : '#2A3160' })
            .margin({ right: 8, bottom: 8 })
            .scale(this.applyPart === i ? { x: 1.06, y: 1.06 } : { x: 1, y: 1 })
            .animation({ duration: 200, curve: Curve.EaseOut })
            .onClick(() => {
              this.applyPart = i;
            })
        }, (p: string) => p)
      }
      .width('88%')

      Row({ space: 12 }) {
        Button() {
          Text('取消')
            .fontSize(13)
            .fontColor('#8A90B8')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('#141A38')
        .borderRadius(20)
        .onClick(() => {
          this.showApplyModal = false;
        })

        Button() {
          Text('提交申请')
            .fontSize(13)
            .fontColor('#10142A')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('#F0C75E')
        .borderRadius(20)
        .onClick(() => {
          this.applyName = '';
          this.applyPart = 0;
          this.showApplyModal = false;
          this.showApplyToast = true;
        })
      }
      .width('84%')
      .margin({ top: 6, bottom: 22 })
    }
    .width('88%')
    .backgroundColor('#1A2042')
    .borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#2A3160' })
  }

  build() {
    Stack() {
      Scroll() {
        Column() {
          Row({ space: 10 }) {
            Column({ space: 6 }) {
              Text('🏅 团员榜 TOP3')
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor('#10142A')
              Text('云雀 · 京韵 · 金陵')
                .fontSize(10)
                .fontColor('#4A3A12')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            .padding(16)
            .backgroundColor('#F0C75E')
            .borderRadius(14)

            Column({ space: 6 }) {
              Text('🧭')
                .fontSize(24)
              Text('同城找团')
                .fontSize(10)
                .fontColor('#F0C75E')
            }
            .padding({ left: 16, right: 16, top: 12, bottom: 12 })
            .backgroundColor('#1A2042')
            .borderRadius(14)
            .border({ width: 1, color: '#2A3160' })
          }
          .width('94%')
          .margin({ top: 12 })

          Row() {
            Text('热 门 合 唱 团')
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor('#E8EAF6')
              .letterSpacing(2)
              .layoutWeight(1)
            Text('按城市 12 团')
              .fontSize(10)
              .fontColor('#6A7195')
          }
          .width('94%')
          .margin({ top: 14, bottom: 4 })

          ForEach(CHOIR_LIST, (c: ChoirItem) => {
            Row({ space: 12 }) {
              Column() {
                Text(c.badge)
                  .fontSize(20)
              }
              .width(46)
              .height(46)
              .borderRadius(12)
              .backgroundColor(choirLevelColor(c.level))
              .justifyContent(FlexAlign.Center)

              Column({ space: 5 }) {
                Row({ space: 6 }) {
                  Text(c.name)
                    .fontSize(14)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#E8EAF6')
                    .layoutWeight(1)
                    .maxLines(1)
                    .textOverflow({ overflow: TextOverflow.Ellipsis })
                  Text(c.joined ? '已加入' : '可申请')
                    .fontSize(9)
                    .fontColor(c.joined ? '#F0C75E' : '#7FA8F5')
                    .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                    .borderRadius(3)
                    .backgroundColor(c.joined ? 'rgba(240,199,94,0.14)' : 'rgba(91,141,239,0.14)')
                }
                .width('100%')

                Text(c.city + ' · ' + c.members.toString() + ' 人 · 正在排 ' + c.piece)
                  .fontSize(10)
                  .fontColor('#8A90B8')
                  .maxLines(1)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })

                Row({ space: 6 }) {
                  Text(c.level)
                    .fontSize(9)
                    .fontColor(choirLevelText(c.level))
                    .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                    .borderRadius(3)
                    .backgroundColor(choirLevelColor(c.level))
                  Text('每周两练')
                    .fontSize(9)
                    .fontColor('#6A7195')
                    .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                    .borderRadius(3)
                    .backgroundColor('rgba(106,113,149,0.14)')
                }
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)

              Text('›')
                .fontSize(18)
                .fontColor('#4A5078')
            }
            .width('100%')
            .padding(14)
            .backgroundColor('#1A2042')
            .borderRadius(12)
            .border({ width: 1, color: '#2A3160' })
            .margin({ left: 16, right: 16, top: 8 })
            .onClick(() => {
              this.selectedChoir = c;
              this.showChoirDetail = true;
            })
          }, (c: ChoirItem) => c.id.toString())

          Text('— 声入云端 · 与千人同频 —')
            .fontSize(10)
            .fontColor('#4A5078')
            .margin({ top: 18, bottom: 24 })
        }
        .width('100%')
      }
      .scrollBar(BarState.Off)
      .layoutWeight(1)
      .width('100%')

      if (this.showChoirDetail) {
        Column() {
          Column()
            .width('100%')
            .layoutWeight(1)
            .onClick(() => {
              this.showChoirDetail = false;
            })
          this.choirDetailModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(8,10,26,0.7)')
      }

      if (this.showApplyModal) {
        Column() {
          Column()
            .width('100%')
            .layoutWeight(1)
            .onClick(() => {
              this.showApplyModal = false;
            })
          this.applyModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(8,10,26,0.75)')
      }

      if (this.showApplyToast) {
        Column() {
          Text('✓ 申请已递交,指挥将在 3 日内回复')
            .fontSize(13)
            .fontColor('#10142A')
            .padding({ left: 22, right: 22, top: 11, bottom: 11 })
            .borderRadius(22)
            .backgroundColor('rgba(240,199,94,0.95)')
            .scale({ x: 1.05, y: 1.05 })
            .animation({ duration: 240, curve: Curve.EaseOut })
        }
        .width('100%')
        .height('100%')
        .justifyContent(FlexAlign.Center)
        .onClick(() => {
          this.showApplyToast = false;
        })
      }
    }
    .width('100%')
    .height('100%')
  }
}
// ============ 声部 Tab(我的录音 + 打分进度 + 删除确认) ============
@Component
struct PartsContent {
  @State showRecordConfirm: boolean = false
  @State showDeleteConfirm: boolean = false
  @State selectedTrack: PartTrack | null = null
  @State showUploadToast: boolean = false
  @State doneIds: number[] = [1, 2, 3, 4, 6, 8, 10, 12]

  isDone(t: PartTrack): boolean {
    return this.doneIds.indexOf(t.id) >= 0 || t.done;
  }

  @Builder modalOverlay(onClose: () => void) {
    Column() {
      Column()
        .width('100%')
        .layoutWeight(1)
        .onClick(() => {
          onClose();
        })
      Column() {
        Text('🎙')
          .fontSize(30)
          .margin({ bottom: 8 })
        Text('我的声部')
          .fontSize(12)
          .fontColor('#6A7195')
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
  }

  @Builder recordConfirmModal() {
    Column({ space: 12 }) {
      Text('开 始 录 音')
        .fontSize(12)
        .fontColor('#F0C75E')
        .letterSpacing(3)
        .margin({ top: 22 })

      Text('《夜航星》女高音 S 声部')
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor('#E8EAF6')
      Row({ space: 4 }) {
        ForEach([18, 34, 26, 40, 22, 36, 30, 44, 20, 38, 28, 32], (h: number) => {
          Column()
            .width(7)
            .height(h)
            .borderRadius(3)
            .backgroundColor('#7FA8F5')
            .margin({ left: 2, right: 2 })
        }, (h: number) => h.toString())
      }
      .height(46)
      .alignItems(VerticalAlign.Bottom)
      .margin({ top: 6 })

      Text('建议佩戴耳机 · 单次不超过 10 分钟')
        .fontSize(10)
        .fontColor('#8A90B8')
      Column({ space: 8 }) {
        Row() {
          Text('伴奏版本')
            .fontSize(11)
            .fontColor('#8A90B8')
          Text('钢琴基准 · 88bpm')
            .fontSize(11)
            .fontColor('#E8EAF6')
        }
        .width('84%')
        .justifyContent(FlexAlign.SpaceBetween)

        Row() {
          Text('打分标准')
            .fontSize(11)
            .fontColor('#8A90B8')
          Text('音准 / 节奏 / 咬字')
            .fontSize(11)
            .fontColor('#E8EAF6')
        }
        .width('84%')
        .justifyContent(FlexAlign.SpaceBetween)
      }
      .width('100%')

      Row({ space: 12 }) {
        Button() {
          Text('取消')
            .fontSize(13)
            .fontColor('#8A90B8')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('#141A38')
        .borderRadius(20)
        .onClick(() => {
          this.showRecordConfirm = false;
        })

        Button() {
          Text('● 开始录制')
            .fontSize(13)
            .fontColor('#10142A')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('#F0C75E')
        .borderRadius(20)
        .onClick(() => {
          this.showRecordConfirm = false;
          this.showUploadToast = true;
        })
      }
      .width('84%')
      .margin({ top: 6, bottom: 22 })
    }
    .width('86%')
    .backgroundColor('#1A2042')
    .borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#2A3160' })
  }

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

      Text('删除这条录音?')
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor('#E8EAF6')
      Text(this.selectedTrack!.piece + ' · ' + this.selectedTrack!.part)
        .fontSize(11)
        .fontColor('#8A90B8')
      Text('删除后打分记录一并清除,无法恢复')
        .fontSize(10)
        .fontColor('#6A7195')
      Row({ space: 12 }) {
        Button() {
          Text('保留')
            .fontSize(13)
            .fontColor('#7FA8F5')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('#141A38')
        .borderRadius(20)
        .onClick(() => {
          this.showDeleteConfirm = false;
        })

        Button() {
          Text('删除录音')
            .fontSize(13)
            .fontColor('#E8EAF6')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('#C2453F')
        .borderRadius(20)
        .onClick(() => {
          this.doneIds.splice(this.doneIds.indexOf(this.selectedTrack!.id), 1);
          this.showDeleteConfirm = false;
        })
      }
      .width('84%')
      .margin({ top: 4, bottom: 22 })
    }
    .width('78%')
    .backgroundColor('#1A2042')
    .borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#2A3160' })
  }

  build() {
    Stack() {
      Scroll() {
        Column() {
          Row({ space: 12 }) {
            Column({ space: 5 }) {
              Text('🏆')
                .fontSize(24)
              Text('本周最佳')
                .fontSize(11)
                .fontColor('#E8EAF6')
              Text('96 分 · 夜航星')
                .fontSize(10)
                .fontColor('#F0C75E')
            }
            .layoutWeight(1)
            .padding({ top: 14, bottom: 14 })
            .backgroundColor('#1A2042')
            .borderRadius(12)
            .border({ width: 1, color: '#2A3160' })

            Column({ space: 5 }) {
              Text('📈')
                .fontSize(24)
              Text('平均分')
                .fontSize(11)
                .fontColor('#E8EAF6')
              Text('84.6 分')
                .fontSize(10)
                .fontColor('#7FA8F5')
            }
            .layoutWeight(1)
            .padding({ top: 14, bottom: 14 })
            .backgroundColor('#1A2042')
            .borderRadius(12)
            .border({ width: 1, color: '#2A3160' })

            Column({ space: 5 }) {
              Text('🔥')
                .fontSize(24)
              Text('连续练习')
                .fontSize(11)
                .fontColor('#E8EAF6')
              Text('12 天')
                .fontSize(10)
                .fontColor('#9B7EDE')
            }
            .layoutWeight(1)
            .padding({ top: 14, bottom: 14 })
            .backgroundColor('#1A2042')
            .borderRadius(12)
            .border({ width: 1, color: '#2A3160' })
          }
          .width('94%')
          .margin({ top: 12 })

          Button() {
            Text('● 录一段新声部')
              .fontSize(14)
              .fontColor('#10142A')
          }
          .width('94%')
          .height(46)
          .backgroundColor('#F0C75E')
          .borderRadius(23)
          .margin({ top: 12 })
          .onClick(() => {
            this.showRecordConfirm = true;
          })

          Row() {
            Text('我 的 录 音 棚 记 录')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor('#E8EAF6')
              .letterSpacing(2)
              .layoutWeight(1)
            Text('共 12 条')
              .fontSize(10)
              .fontColor('#6A7195')
          }
          .width('94%')
          .margin({ top: 14, bottom: 4 })

          ForEach(PART_TRACKS, (t: PartTrack) => {
            Column({ space: 10 }) {
              Row({ space: 12 }) {
                Column() {
                  Text(t.score.toString())
                    .fontSize(17)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(t.score >= 90 ? '#F0C75E' : (t.score >= 80 ? '#7FA8F5' : '#9B7EDE'))
                }
                .width(48)
                .height(48)
                .borderRadius(12)
                .backgroundColor('rgba(240,199,94,0.08)')
                .justifyContent(FlexAlign.Center)

                Column({ space: 5 }) {
                  Text(t.piece)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#E8EAF6')
                  Row({ space: 8 }) {
                    Text(t.part)
                      .fontSize(9)
                      .fontColor('#7FA8F5')
                      .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                      .borderRadius(3)
                      .backgroundColor('rgba(91,141,239,0.14)')
                    Text(t.duration + ' · ' + t.date)
                      .fontSize(9)
                      .fontColor('#6A7195')
                  }
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)

                Text(this.isDone(t) ? '已归档' : '草稿')
                  .fontSize(9)
                  .fontColor(this.isDone(t) ? '#F0C75E' : '#6A7195')
                  .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                  .borderRadius(3)
                  .backgroundColor(this.isDone(t) ? 'rgba(240,199,94,0.14)' : 'rgba(106,113,149,0.14)')

                Text('🗑')
                  .fontSize(14)
                  .padding(6)
                  .onClick(() => {
                    this.selectedTrack = t;
                    this.showDeleteConfirm = true;
                  })
              }
              .width('100%')

              Column() {
                Column()
                  .width(trackBarWidth(t.score))
                  .height(4)
                  .borderRadius(2)
                  .backgroundColor(t.score >= 90 ? '#F0C75E' : (t.score >= 80 ? '#5B8DEF' : '#9B7EDE'))
                  .animation({ duration: 300, curve: Curve.EaseOut })
              }
              .width('100%')
              .height(4)
              .borderRadius(2)
              .backgroundColor('#141A38')
            }
            .width('100%')
            .padding(14)
            .backgroundColor('#1A2042')
            .borderRadius(12)
            .border({ width: 1, color: '#2A3160' })
            .margin({ left: 16, right: 16, top: 8 })
          }, (t: PartTrack) => t.id.toString())

          Text('— 每周一录,声部渐稳 —')
            .fontSize(10)
            .fontColor('#4A5078')
            .margin({ top: 18, bottom: 24 })
        }
        .width('100%')
      }
      .scrollBar(BarState.Off)
      .layoutWeight(1)
      .width('100%')

      if (this.showRecordConfirm) {
        Column() {
          Column()
            .width('100%')
            .layoutWeight(1)
            .onClick(() => {
              this.showRecordConfirm = false;
            })
          this.recordConfirmModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(8,10,26,0.7)')
      }

      if (this.showDeleteConfirm) {
        Column() {
          this.deleteConfirmModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(8,10,26,0.75)')
        .justifyContent(FlexAlign.Center)
      }

      if (this.showUploadToast) {
        Column() {
          Text('✓ 录音已上传,等待指挥审听')
            .fontSize(13)
            .fontColor('#10142A')
            .padding({ left: 22, right: 22, top: 11, bottom: 11 })
            .borderRadius(22)
            .backgroundColor('rgba(127,168,245,0.95)')
        }
        .width('100%')
        .height('100%')
        .justifyContent(FlexAlign.Center)
        .onClick(() => {
          this.showUploadToast = false;
        })
      }
    }
    .width('100%')
    .height('100%')
  }
}
// ============ 录音棚 Tab(协作项目 + 轨道波形 + 新建/编辑/删除弹窗) ============
@Component
struct StudioContent {
  @State showProjectDetail: boolean = false
  @State selectedProject: StudioProject | null = null
  @State showNewProject: boolean = false
  @State showEditProject: boolean = false
  @State showDeleteProject: boolean = false
  @State showInviteToast: boolean = false
  @State projectName: string = ''
  @State projects: StudioProject[] = STUDIO_PROJECTS

  @Builder modalOverlay(onClose: () => void) {
    Column() {
      Column()
        .width('100%')
        .layoutWeight(1)
        .onClick(() => {
          onClose();
        })
      Column() {
        Text('🎛')
          .fontSize(30)
          .margin({ bottom: 8 })
        Text('云录音棚')
          .fontSize(12)
          .fontColor('#6A7195')
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
  }

  @Builder projectDetailModal() {
    Column({ space: 12 }) {
      Text('项 目 详 情')
        .fontSize(12)
        .fontColor('#F0C75E')
        .letterSpacing(3)
        .margin({ top: 20 })

      Text(this.selectedProject!.name)
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#E8EAF6')
      Text(this.selectedProject!.piece + ' · 指挥 ' + this.selectedProject!.owner + ' · 截止 ' + this.selectedProject!.deadline)
        .fontSize(10)
        .fontColor('#8A90B8')
      Divider()
        .color('#2A3160')
        .strokeWidth(1)
        .width('84%')
      Column({ space: 8 }) {
        ForEach(['女高音 S · 已交', '女中音 A · 已交', '男高音 T · 缺', '男低音 B · 已交', '钢琴伴奏 · 已交'], (s: string) => {
          Row({ space: 8 }) {
            Text(s.indexOf('缺') >= 0 ? '○' : '✓')
              .fontSize(13)
              .fontColor(s.indexOf('缺') >= 0 ? '#C2453F' : '#F0C75E')
            Text(s)
              .fontSize(11)
              .fontColor('#E8EAF6')
              .layoutWeight(1)
            Text(s.indexOf('缺') >= 0 ? '催交' : '试听')
              .fontSize(10)
              .fontColor('#7FA8F5')
              .padding(6)
              .onClick(() => {
                this.showProjectDetail = false;
                this.showInviteToast = true;
              })
          }
          .width('84%')
        }, (s: string) => s)
      }
      .width('100%')

      Column() {
        Column()
          .width((this.selectedProject!.doneTracks / this.selectedProject!.tracks * 100).toFixed(0) + '%')
          .height(8)
          .borderRadius(4)
          .backgroundColor('#F0C75E')
          .animation({ duration: 300, curve: Curve.EaseOut })
      }
      .width('84%')
      .height(8)
      .borderRadius(4)
      .backgroundColor('#141A38')

      Text('轨道完成 ' + this.selectedProject!.doneTracks.toString() + ' / ' + this.selectedProject!.tracks.toString())
        .fontSize(10)
        .fontColor('#8A90B8')

      Row({ space: 12 }) {
        Button() {
          Text('↗ 分享项目')
            .fontSize(13)
            .fontColor('#7FA8F5')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('rgba(91,141,239,0.12)')
        .borderRadius(20)
        .onClick(() => {
          this.showProjectDetail = false;
          this.showInviteToast = true;
        })

        Button() {
          Text('✎ 改项目名')
            .fontSize(13)
            .fontColor('#F0C75E')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('rgba(240,199,94,0.12)')
        .borderRadius(20)
        .onClick(() => {
          this.showProjectDetail = false;
          this.showEditProject = true;
        })
      }
      .width('84%')
      .margin({ top: 4, bottom: 22 })
    }
    .width('88%')
    .backgroundColor('#1A2042')
    .borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#2A3160' })
  }

  @Builder newProjectModal() {
    Column({ space: 12 }) {
      Text('新 建 协 作 项 目')
        .fontSize(12)
        .fontColor('#F0C75E')
        .letterSpacing(3)
        .margin({ top: 22 })

      TextInput({ placeholder: '项目名称(如:夜航星·元旦版)' })
        .fontSize(13)
        .fontColor('#E8EAF6')
        .width('84%')
        .height(44)
        .backgroundColor('#141A38')
        .borderRadius(10)
        .border({ width: 1, color: '#2A3160' })
        .onChange((v: string) => {
          this.projectName = v;
        })
      Column({ space: 8 }) {
        Row() {
          Text('选择曲目')
            .fontSize(11)
            .fontColor('#8A90B8')
            .layoutWeight(1)
          Text('《夜航星》')
            .fontSize(11)
            .fontColor('#E8EAF6')
        }
        .width('84%')

        Row() {
          Text('轨道数')
            .fontSize(11)
            .fontColor('#8A90B8')
            .layoutWeight(1)
          Text('8 轨(SATB×2)')
            .fontSize(11)
            .fontColor('#E8EAF6')
        }
        .width('84%')

        Row() {
          Text('截止时间')
            .fontSize(11)
            .fontColor('#8A90B8')
            .layoutWeight(1)
          Text('09-30 22:00')
            .fontSize(11)
            .fontColor('#E8EAF6')
        }
        .width('84%')
      }
      .width('100%')

      Row({ space: 12 }) {
        Button() {
          Text('取消')
            .fontSize(13)
            .fontColor('#8A90B8')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('#141A38')
        .borderRadius(20)
        .onClick(() => {
          this.showNewProject = false;
        })

        Button() {
          Text('创建项目')
            .fontSize(13)
            .fontColor('#10142A')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('#F0C75E')
        .borderRadius(20)
        .onClick(() => {
          this.projects.push({
            id: this.projects.length + 1,
            name: this.projectName.length > 0 ? this.projectName : '新建协作项目',
            piece: '《夜航星》',
            tracks: 8,
            doneTracks: 0,
            owner: '我',
            deadline: '09-30'
          });
          this.projectName = '';
          this.showNewProject = false;
        })
      }
      .width('84%')
      .margin({ top: 6, bottom: 22 })
    }
    .width('88%')
    .backgroundColor('#1A2042')
    .borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#2A3160' })
  }

  @Builder editProjectModal() {
    Column({ space: 12 }) {
      Text('编 辑 项 目 名')
        .fontSize(12)
        .fontColor('#F0C75E')
        .letterSpacing(3)
        .margin({ top: 22 })

      Text('当前:' + this.selectedProject!.name)
        .fontSize(11)
        .fontColor('#8A90B8')
      TextInput({ placeholder: '输入新的项目名称' })
        .fontSize(13)
        .fontColor('#E8EAF6')
        .width('84%')
        .height(44)
        .backgroundColor('#141A38')
        .borderRadius(10)
        .border({ width: 1, color: '#2A3160' })
        .onChange((v: string) => {
          this.projectName = v;
        })
      Row({ space: 12 }) {
        Button() {
          Text('取消')
            .fontSize(13)
            .fontColor('#8A90B8')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('#141A38')
        .borderRadius(20)
        .onClick(() => {
          this.showEditProject = false;
        })

        Button() {
          Text('保存')
            .fontSize(13)
            .fontColor('#10142A')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('#F0C75E')
        .borderRadius(20)
        .onClick(() => {
          this.selectedProject!.name = this.projectName.length > 0 ? this.projectName : this.selectedProject!.name;
          this.projectName = '';
          this.showEditProject = false;
        })
      }
      .width('84%')
      .margin({ top: 6, bottom: 22 })
    }
    .width('88%')
    .backgroundColor('#1A2042')
    .borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#2A3160' })
  }

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

      Text('解散该协作项目?')
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor('#E8EAF6')
      Text(this.selectedProject!.name)
        .fontSize(11)
        .fontColor('#8A90B8')
      Text('已交轨道将退回各位团员的录音棚')
        .fontSize(10)
        .fontColor('#6A7195')
      Row({ space: 12 }) {
        Button() {
          Text('保留项目')
            .fontSize(13)
            .fontColor('#7FA8F5')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('#141A38')
        .borderRadius(20)
        .onClick(() => {
          this.showDeleteProject = false;
        })

        Button() {
          Text('解散')
            .fontSize(13)
            .fontColor('#E8EAF6')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('#C2453F')
        .borderRadius(20)
        .onClick(() => {
          this.projects.splice(this.projects.map((p: StudioProject) => p.id).indexOf(this.selectedProject!.id), 1);
          this.showDeleteProject = false;
        })
      }
      .width('84%')
      .margin({ top: 4, bottom: 22 })
    }
    .width('80%')
    .backgroundColor('#1A2042')
    .borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#2A3160' })
  }

  build() {
    Stack() {
      Scroll() {
        Column() {
          Row() {
            Column({ space: 5 }) {
              Text('🎛 进行中的项目')
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor('#E8EAF6')
              Text('8 个 · 2 个已收齐轨道')
                .fontSize(10)
                .fontColor('#8A90B8')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Text('+ 新建项目')
              .fontSize(11)
              .fontColor('#10142A')
              .padding({ left: 14, right: 14, top: 8, bottom: 8 })
              .borderRadius(16)
              .backgroundColor('#F0C75E')
              .onClick(() => {
                this.showNewProject = true;
              })
          }
          .width('94%')
          .margin({ top: 12 })

          ForEach(this.projects, (p: StudioProject) => {
            Column({ space: 10 }) {
              Row({ space: 10 }) {
                Column() {
                  Text(p.doneTracks === p.tracks ? '✓' : p.doneTracks.toString() + '/' + p.tracks.toString())
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(p.doneTracks === p.tracks ? '#F0C75E' : '#7FA8F5')
                }
                .width(44)
                .height(44)
                .borderRadius(10)
                .backgroundColor(p.doneTracks === p.tracks ? 'rgba(240,199,94,0.12)' : 'rgba(91,141,239,0.12)')
                .justifyContent(FlexAlign.Center)

                Column({ space: 5 }) {
                  Text(p.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#E8EAF6')
                    .maxLines(1)
                    .textOverflow({ overflow: TextOverflow.Ellipsis })
                  Text(p.piece + ' · 发起人 ' + p.owner)
                    .fontSize(10)
                    .fontColor('#8A90B8')
                  Text('截止 ' + p.deadline)
                    .fontSize(9)
                    .fontColor(p.deadline === '已截止' ? '#6A7195' : '#F0C75E')
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)

                Column({ space: 6 }) {
                  Text('✎')
                    .fontSize(14)
                    .fontColor('#7FA8F5')
                    .padding(4)
                    .onClick(() => {
                      this.selectedProject = p;
                      this.showEditProject = true;
                    })
                  Text('🗑')
                    .fontSize(13)
                    .fontColor('#C2453F')
                    .padding(4)
                    .onClick(() => {
                      this.selectedProject = p;
                      this.showDeleteProject = true;
                    })
                }
              }
              .width('100%')

              Row({ space: 2 }) {
                ForEach([16, 28, 20, 34, 24, 38, 18, 30, 22, 26, 32, 20], (h: number) => {
                  Column()
                    .width('8%')
                    .height(h)
                    .borderRadius(2)
                    .backgroundColor(h > 28 ? '#F0C75E' : '#3A4370')
                }, (h: number) => h.toString())
              }
              .width('100%')
              .height(40)
              .alignItems(VerticalAlign.Bottom)

              Column() {
                Column()
                  .width((p.doneTracks / p.tracks * 100).toFixed(0) + '%')
                  .height(5)
                  .borderRadius(3)
                  .backgroundColor('#5B8DEF')
                  .animation({ duration: 300, curve: Curve.EaseOut })
              }
              .width('100%')
              .height(5)
              .borderRadius(3)
              .backgroundColor('#141A38')
            }
            .width('100%')
            .padding(14)
            .backgroundColor('#1A2042')
            .borderRadius(12)
            .border({ width: 1, color: '#2A3160' })
            .margin({ left: 16, right: 16, top: 8 })
            .onClick(() => {
              this.selectedProject = p;
              this.showProjectDetail = true;
            })
          }, (p: StudioProject) => p.id.toString())

          Text('— 你的声部,正在云端汇合 —')
            .fontSize(10)
            .fontColor('#4A5078')
            .margin({ top: 18, bottom: 24 })
        }
        .width('100%')
      }
      .scrollBar(BarState.Off)
      .layoutWeight(1)
      .width('100%')

      if (this.showProjectDetail) {
        Column() {
          Column()
            .width('100%')
            .layoutWeight(1)
            .onClick(() => {
              this.showProjectDetail = false;
            })
          this.projectDetailModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(8,10,26,0.7)')
      }

      if (this.showNewProject) {
        Column() {
          Column()
            .width('100%')
            .layoutWeight(1)
            .onClick(() => {
              this.showNewProject = false;
            })
          this.newProjectModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(8,10,26,0.75)')
      }

      if (this.showEditProject) {
        Column() {
          Column()
            .width('100%')
            .layoutWeight(1)
            .onClick(() => {
              this.showEditProject = false;
            })
          this.editProjectModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(8,10,26,0.75)')
      }

      if (this.showDeleteProject) {
        Column() {
          this.deleteProjectModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(8,10,26,0.78)')
        .justifyContent(FlexAlign.Center)
      }

      if (this.showInviteToast) {
        Column() {
          Text('✓ 已通知团员,请留意站内信')
            .fontSize(13)
            .fontColor('#10142A')
            .padding({ left: 22, right: 22, top: 11, bottom: 11 })
            .borderRadius(22)
            .backgroundColor('rgba(240,199,94,0.95)')
        }
        .width('100%')
        .height('100%')
        .justifyContent(FlexAlign.Center)
        .onClick(() => {
          this.showInviteToast = false;
        })
      }
    }
    .width('100%')
    .height('100%')
  }
}
// ============ 曲目 Tab(年代筛选 + 曲目底部抽屉 + 加入库确认) ============
@Component
struct PiecesContent {
  @State eraIdx: number = 0
  @State showPieceSheet: boolean = false
  @State selectedPiece: PieceInfo | null = null
  @State showAddConfirm: boolean = false
  @State showAddToast: boolean = false

  piecesNow(): PieceInfo[] {
    return PIECE_LIST.filter((p: PieceInfo) => {
      if (this.eraIdx === 0) {
        return true;
      }
      return p.era === ERA_FILTERS[this.eraIdx];
    });
  }

  @Builder modalOverlay(onClose: () => void) {
    Column() {
      Column()
        .width('100%')
        .layoutWeight(1)
        .onClick(() => {
          onClose();
        })
      Column() {
        Text('🎼')
          .fontSize(30)
          .margin({ bottom: 8 })
        Text('合唱曲库')
          .fontSize(12)
          .fontColor('#6A7195')
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
  }

  @Builder pieceSheet() {
    Column({ space: 12 }) {
      Row({ space: 10 }) {
        Column() {
          Text(this.selectedPiece!.title.slice(0, 1))
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor('#10142A')
        }
        .width(48)
        .height(48)
        .borderRadius(12)
        .backgroundColor('#F0C75E')
        .justifyContent(FlexAlign.Center)

        Column({ space: 4 }) {
          Text(this.selectedPiece!.title)
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#E8EAF6')
          Text(this.selectedPiece!.composer + ' · ' + this.selectedPiece!.era + ' · ' + this.selectedPiece!.voices)
            .fontSize(10)
            .fontColor('#8A90B8')
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)

        Text('✕')
          .fontSize(16)
          .fontColor('#6A7195')
          .padding(8)
          .onClick(() => {
            this.showPieceSheet = false;
          })
      }
      .width('100%')
      .padding({ left: 18, right: 18, top: 16 })

      Row({ space: 8 }) {
        Column() {
          Text(this.selectedPiece!.singers.toString())
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#F0C75E')
          Text('人次演唱')
            .fontSize(9)
            .fontColor('#8A90B8')
        }
        .layoutWeight(1)

        Column() {
          Text(dotRow(this.selectedPiece!.difficulty))
            .fontSize(14)
            .fontColor('#F0C75E')
          Text('难度')
            .fontSize(9)
            .fontColor('#8A90B8')
        }
        .layoutWeight(1)

        Column() {
          Text('SATB')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#F0C75E')
          Text('声部编制')
            .fontSize(9)
            .fontColor('#8A90B8')
        }
        .layoutWeight(1)
      }
      .width('100%')
      .margin({ top: 6 })

      Column({ space: 6 }) {
        Text('声部分轨')
          .fontSize(11)
          .fontColor('#8A90B8')
          .width('88%')
        ForEach(['女高音 S · 4 分 12 秒', '女中音 A · 4 分 12 秒', '男高音 T · 4 分 12 秒', '男低音 B · 4 分 12 秒'], (s: string) => {
          Row({ space: 8 }) {
            Text('▶')
              .fontSize(11)
              .fontColor('#7FA8F5')
            Text(s)
              .fontSize(12)
              .fontColor('#E8EAF6')
              .layoutWeight(1)
            Text('试听')
              .fontSize(10)
              .fontColor('#7FA8F5')
          }
          .width('88%')
          .padding({ top: 10, bottom: 10 })
          .backgroundColor('#141A38')
          .borderRadius(10)
          .margin({ top: 4 })
        }, (s: string) => s)
      }
      .width('100%')
      .margin({ top: 6 })

      Button() {
        Text('+ 加入我的排练曲库')
          .fontSize(14)
          .fontColor('#10142A')
      }
      .width('84%')
      .height(44)
      .backgroundColor('#F0C75E')
      .borderRadius(22)
      .margin({ top: 10, bottom: 20 })
      .onClick(() => {
        this.showPieceSheet = false;
        this.showAddConfirm = true;
      })
    }
    .width('100%')
    .backgroundColor('#1A2042')
    .borderRadius({ topLeft: 18, topRight: 18 })
    .alignItems(HorizontalAlign.Center)
  }

  @Builder addConfirmModal() {
    Column({ space: 12 }) {
      Text('+')
        .fontSize(34)
        .margin({ top: 22 })

      Text('加入排练曲库?')
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor('#E8EAF6')
      Text(this.selectedPiece!.title + ' · ' + this.selectedPiece!.voices)
        .fontSize(11)
        .fontColor('#8A90B8')
      Text('加入后每周将推送对应声部练习')
        .fontSize(10)
        .fontColor('#6A7195')
      Row({ space: 12 }) {
        Button() {
          Text('取消')
            .fontSize(13)
            .fontColor('#8A90B8')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('#141A38')
        .borderRadius(20)
        .onClick(() => {
          this.showAddConfirm = false;
        })

        Button() {
          Text('确认加入')
            .fontSize(13)
            .fontColor('#10142A')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('#F0C75E')
        .borderRadius(20)
        .onClick(() => {
          this.showAddConfirm = false;
          this.showAddToast = true;
        })
      }
      .width('80%')
      .margin({ top: 4, bottom: 22 })
    }
    .width('78%')
    .backgroundColor('#1A2042')
    .borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#2A3160' })
  }

  build() {
    Stack() {
      Scroll() {
        Column() {
          Row() {
            ForEach(ERA_FILTERS, (e: string, i: number) => {
              Column({ space: 4 }) {
                Text(e)
                  .fontSize(12)
                  .fontColor(this.eraIdx === i ? '#F0C75E' : '#8A90B8')
                Divider()
                  .color(this.eraIdx === i ? '#F0C75E' : 'rgba(0,0,0,0)')
                  .strokeWidth(2)
                  .width('50%')
              }
              .padding({ left: 14, right: 14, top: 10, bottom: 6 })
              .onClick(() => {
                this.eraIdx = i;
              })
            }, (e: string) => e)
          }
          .width('100%')
          .backgroundColor('#1A2042')
          .justifyContent(FlexAlign.SpaceEvenly)

          Row() {
            Text('共 ' + this.piecesNow().length.toString() + ' 首可选')
              .fontSize(10)
              .fontColor('#6A7195')
            Text('🔥 本周上新 2 首')
              .fontSize(10)
              .fontColor('#F0C75E')
          }
          .width('94%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ top: 10, bottom: 4 })

          ForEach(this.piecesNow(), (p: PieceInfo) => {
            Row({ space: 12 }) {
              Column() {
                Text(p.era.slice(0, 1))
                  .fontSize(15)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(p.era === '传统' ? '#9B7EDE' : '#F0C75E')
              }
              .width(44)
              .height(44)
              .borderRadius(22)
              .backgroundColor(p.era === '传统' ? 'rgba(155,126,222,0.14)' : 'rgba(240,199,94,0.12)')
              .justifyContent(FlexAlign.Center)

              Column({ space: 5 }) {
                Row({ space: 8 }) {
                  Text(p.title)
                    .fontSize(14)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#E8EAF6')
                    .layoutWeight(1)
                    .maxLines(1)
                    .textOverflow({ overflow: TextOverflow.Ellipsis })
                  Text('NEW')
                    .fontSize(8)
                    .fontColor('#10142A')
                    .padding({ left: 5, right: 5, top: 1, bottom: 1 })
                    .borderRadius(3)
                    .backgroundColor('#F0C75E')
                    .opacity(p.id > 14 ? 1 : 0)
                }
                .width('100%')

                Text(p.composer + ' · ' + p.voices)
                  .fontSize(10)
                  .fontColor('#8A90B8')
                  .maxLines(1)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })

                Row({ space: 6 }) {
                  Text(dotRow(p.difficulty))
                    .fontSize(10)
                    .fontColor('#F0C75E')
                  Text(p.singers.toString() + ' 人唱过')
                    .fontSize(9)
                    .fontColor('#6A7195')
                }
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)

              Text('▶')
                .fontSize(14)
                .fontColor('#7FA8F5')
                .width(32)
                .height(32)
                .borderRadius(16)
                .backgroundColor('rgba(91,141,239,0.12)')
                .textAlign(TextAlign.Center)
            }
            .width('100%')
            .padding(14)
            .backgroundColor('#1A2042')
            .borderRadius(12)
            .border({ width: 1, color: '#2A3160' })
            .margin({ left: 16, right: 16, top: 8 })
            .onClick(() => {
              this.selectedPiece = p;
              this.showPieceSheet = true;
            })
          }, (p: PieceInfo) => p.id.toString())

          Text('— 曲高和不寡 —')
            .fontSize(10)
            .fontColor('#4A5078')
            .margin({ top: 18, bottom: 24 })
        }
        .width('100%')
      }
      .scrollBar(BarState.Off)
      .layoutWeight(1)
      .width('100%')

      if (this.showPieceSheet) {
        Column() {
          Column()
            .width('100%')
            .layoutWeight(1)
            .onClick(() => {
              this.showPieceSheet = false;
            })
          this.pieceSheet()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(8,10,26,0.7)')
      }

      if (this.showAddConfirm) {
        Column() {
          this.addConfirmModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(8,10,26,0.75)')
        .justifyContent(FlexAlign.Center)
      }

      if (this.showAddToast) {
        Column() {
          Text('✓ 已加入排练曲库,声部练习已排期')
            .fontSize(13)
            .fontColor('#10142A')
            .padding({ left: 22, right: 22, top: 11, bottom: 11 })
            .borderRadius(22)
            .backgroundColor('rgba(127,168,245,0.95)')
        }
        .width('100%')
        .height('100%')
        .justifyContent(FlexAlign.Center)
        .onClick(() => {
          this.showAddToast = false;
        })
      }
    }
    .width('100%')
    .height('100%')
  }
}
// ============ 排练赛 Tab(赛事卡片 + 投票进度 + 规则弹窗) ============
@Component
struct ContestContent {
  @State showContestDetail: boolean = false
  @State selectedContest: ContestEntry | null = null
  @State showVoteConfirm: boolean = false
  @State showRules: boolean = false
  @State showVoteToast: boolean = false
  @State votedIds: number[] = [1]

  isVoted(c: ContestEntry): boolean {
    return this.votedIds.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('#6A7195')
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
  }

  @Builder contestDetailModal() {
    Column({ space: 12 }) {
      Text('赛 事 详 情')
        .fontSize(12)
        .fontColor('#F0C75E')
        .letterSpacing(3)
        .margin({ top: 20 })

      Text(this.selectedContest!.name)
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#E8EAF6')
      Text('主题 · ' + this.selectedContest!.theme)
        .fontSize(11)
        .fontColor('#7FA8F5')
      Divider()
        .color('#2A3160')
        .strokeWidth(1)
        .width('84%')
      Column({ space: 10 }) {
        Row() {
          Text('参赛队伍')
            .fontSize(11)
            .fontColor('#8A90B8')
          Text(this.selectedContest!.teams.toString() + ' 支')
            .fontSize(11)
            .fontColor('#E8EAF6')
        }
        .width('84%')
        .justifyContent(FlexAlign.SpaceBetween)

        Row() {
          Text('累计票数')
            .fontSize(11)
            .fontColor('#8A90B8')
          Text((this.selectedContest!.votes / 10000).toFixed(1) + ' 万')
            .fontSize(11)
            .fontColor('#F0C75E')
        }
        .width('84%')
        .justifyContent(FlexAlign.SpaceBetween)

        Row() {
          Text('截稿时间')
            .fontSize(11)
            .fontColor('#8A90B8')
          Text(this.selectedContest!.deadline)
            .fontSize(11)
            .fontColor('#E8EAF6')
        }
        .width('84%')
        .justifyContent(FlexAlign.SpaceBetween)

        Row() {
          Text('作品时长')
            .fontSize(11)
            .fontColor('#8A90B8')
          Text('3 - 6 分钟')
            .fontSize(11)
            .fontColor('#E8EAF6')
        }
        .width('84%')
        .justifyContent(FlexAlign.SpaceBetween)
      }
      .width('100%')

      Row({ space: 10 }) {
        Text('📖 赛制规则')
          .fontSize(11)
          .fontColor('#7FA8F5')
          .padding({ left: 14, right: 14, top: 8, bottom: 8 })
          .borderRadius(16)
          .backgroundColor('rgba(91,141,239,0.12)')
          .onClick(() => {
            this.showRules = true;
          })
        Text('✍ 投稿参赛')
          .fontSize(11)
          .fontColor('#F0C75E')
          .padding({ left: 14, right: 14, top: 8, bottom: 8 })
          .borderRadius(16)
          .backgroundColor('rgba(240,199,94,0.12)')
      }

      Button() {
        Text(this.isVoted(this.selectedContest!) ? '✓ 已投过票' : '为喜爱的队伍投票')
          .fontSize(14)
          .fontColor('#10142A')
      }
      .width('70%')
      .height(42)
      .backgroundColor(this.isVoted(this.selectedContest!) ? '#3A4370' : '#F0C75E')
      .borderRadius(21)
      .margin({ top: 4, bottom: 22 })
      .onClick(() => {
        this.showContestDetail = false;
        if (!this.isVoted(this.selectedContest!)) {
          this.showVoteConfirm = true;
        }
      })
    }
    .width('88%')
    .backgroundColor('#1A2042')
    .borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#2A3160' })
  }

  @Builder rulesModal() {
    Column({ space: 12 }) {
      Text('赛 制 规 则')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#F0C75E')
        .letterSpacing(3)
        .margin({ top: 22 })

      Text('① 每支队伍以云录音合成作品参赛')
        .fontSize(12)
        .fontColor('#E8EAF6')
      Text('② 每人每天可为 3 支队伍投票')
        .fontSize(12)
        .fontColor('#E8EAF6')
      Text('③ 决赛前人气前八晋级专家评审')
        .fontSize(12)
        .fontColor('#E8EAF6')
      Text('④ 严禁修音与代唱,违者取消资格')
        .fontSize(12)
        .fontColor('#E8EAF6')
      Text('⑤ 冠军团将获得线下公演邀请')
        .fontSize(12)
        .fontColor('#E8EAF6')

      Button() {
        Text('知道了')
          .fontSize(13)
          .fontColor('#10142A')
      }
      .width('60%')
      .height(40)
      .backgroundColor('#F0C75E')
      .borderRadius(20)
      .margin({ top: 8, bottom: 22 })
      .onClick(() => {
        this.showRules = false;
      })
    }
    .width('82%')
    .backgroundColor('#1A2042')
    .borderRadius(16)
    .alignItems(HorizontalAlign.Start)
    .border({ width: 1, color: '#2A3160' })
  }

  @Builder voteConfirmModal() {
    Column({ space: 12 }) {
      Text('🗳')
        .fontSize(34)
        .margin({ top: 22 })

      Text('确认投出一票?')
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor('#E8EAF6')
      Text('投给「' + this.selectedContest!.theme + '」主题下你支持的队伍')
        .fontSize(10)
        .fontColor('#8A90B8')
        .textAlign(TextAlign.Center)
      Row({ space: 12 }) {
        Button() {
          Text('取消')
            .fontSize(13)
            .fontColor('#8A90B8')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('#141A38')
        .borderRadius(20)
        .onClick(() => {
          this.showVoteConfirm = false;
        })

        Button() {
          Text('确认投票')
            .fontSize(13)
            .fontColor('#10142A')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('#F0C75E')
        .borderRadius(20)
        .onClick(() => {
          this.votedIds.push(this.selectedContest!.id);
          this.showVoteConfirm = false;
          this.showVoteToast = true;
        })
      }
      .width('80%')
      .margin({ top: 4, bottom: 22 })
    }
    .width('78%')
    .backgroundColor('#1A2042')
    .borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#2A3160' })
  }

  build() {
    Stack() {
      Scroll() {
        Column() {
          Column({ space: 6 }) {
            Text('🏆 九月云合唱大赛 · 投票进行中')
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor('#10142A')
            Text('42 支队伍 · 1.86 万票 · 09-20 截止')
              .fontSize(10)
              .fontColor('#4A3A12')
          }
          .width('94%')
          .padding({ top: 16, bottom: 16 })
          .backgroundColor('#F0C75E')
          .borderRadius(14)
          .margin({ top: 12 })

          Row() {
            Text('全 部 赛 事')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor('#E8EAF6')
              .letterSpacing(2)
              .layoutWeight(1)
            Text('每日 3 票')
              .fontSize(10)
              .fontColor('#F0C75E')
          }
          .width('94%')
          .margin({ top: 12, bottom: 4 })

          ForEach(CONTEST_LIST, (c: ContestEntry) => {
            Column({ space: 10 }) {
              Row({ space: 12 }) {
                Column() {
                  Text(c.joined ? '★' : '☆')
                    .fontSize(18)
                    .fontColor('#F0C75E')
                }
                .width(42)
                .height(42)
                .borderRadius(10)
                .backgroundColor('rgba(240,199,94,0.1)')
                .justifyContent(FlexAlign.Center)

                Column({ space: 5 }) {
                  Row({ space: 6 }) {
                    Text(c.name)
                      .fontSize(13)
                      .fontWeight(FontWeight.Bold)
                      .fontColor('#E8EAF6')
                      .layoutWeight(1)
                      .maxLines(1)
                      .textOverflow({ overflow: TextOverflow.Ellipsis })
                    Text(this.isVoted(c) ? '已投票' : '可投票')
                      .fontSize(9)
                      .fontColor(this.isVoted(c) ? '#6A7195' : '#F0C75E')
                      .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                      .borderRadius(3)
                      .backgroundColor(this.isVoted(c) ? 'rgba(106,113,149,0.14)' : 'rgba(240,199,94,0.14)')
                  }
                  .width('100%')

                  Text('主题:' + c.theme)
                    .fontSize(10)
                    .fontColor('#7FA8F5')
                  Text(c.teams.toString() + ' 队 · ' + (c.votes / 10000).toFixed(1) + ' 万票 · 截止 ' + c.deadline)
                    .fontSize(9)
                    .fontColor('#6A7195')
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)
              }
              .width('100%')

              Column() {
                Column()
                  .width(Math.min(Math.round(c.votes / 600), 100).toString() + '%')
                  .height(5)
                  .borderRadius(3)
                  .backgroundColor(c.votes > 20000 ? '#F0C75E' : '#5B8DEF')
                  .animation({ duration: 300, curve: Curve.EaseOut })
              }
              .width('100%')
              .height(5)
              .borderRadius(3)
              .backgroundColor('#141A38')
            }
            .width('100%')
            .padding(14)
            .backgroundColor('#1A2042')
            .borderRadius(12)
            .border({ width: 1, color: '#2A3160' })
            .margin({ left: 16, right: 16, top: 8 })
            .onClick(() => {
              this.selectedContest = c;
              this.showContestDetail = true;
            })
          }, (c: ContestEntry) => c.id.toString())

          Text('— 以歌会友 · 以赛促练 —')
            .fontSize(10)
            .fontColor('#4A5078')
            .margin({ top: 18, bottom: 24 })
        }
        .width('100%')
      }
      .scrollBar(BarState.Off)
      .layoutWeight(1)
      .width('100%')

      if (this.showContestDetail) {
        Column() {
          Column()
            .width('100%')
            .layoutWeight(1)
            .onClick(() => {
              this.showContestDetail = false;
            })
          this.contestDetailModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(8,10,26,0.7)')
      }

      if (this.showVoteConfirm) {
        Column() {
          this.voteConfirmModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(8,10,26,0.75)')
        .justifyContent(FlexAlign.Center)
      }

      if (this.showRules) {
        Column() {
          this.rulesModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(8,10,26,0.75)')
        .justifyContent(FlexAlign.Center)
      }

      if (this.showVoteToast) {
        Column() {
          Text('✓ 投票成功,感谢为热爱发声')
            .fontSize(13)
            .fontColor('#10142A')
            .padding({ left: 22, right: 22, top: 11, bottom: 11 })
            .borderRadius(22)
            .backgroundColor('rgba(240,199,94,0.95)')
            .scale({ x: 1.05, y: 1.05 })
            .animation({ duration: 240, curve: Curve.EaseOut })
        }
        .width('100%')
        .height('100%')
        .justifyContent(FlexAlign.Center)
        .onClick(() => {
          this.showVoteToast = false;
        })
      }
    }
    .width('100%')
    .height('100%')
  }
}
// ============ 我的 Tab(排练图表 + 声部掌握 + 团员资料弹窗) ============
@Component
struct MeContent {
  @State showProfileEdit: boolean = false
  @State showQuitConfirm: boolean = false
  @State showAbout: boolean = false
  @State showLogout: boolean = false
  @State showSaveToast: boolean = false
  @State nickName: string = ''
  @State myChoirs: ChoirItem[] = [
    { id: 1, name: '云雀混声合唱团', city: '上海', members: 86, level: '金牌团', piece: '《夜航星》', joined: true, badge: '🥇' },
    { id: 7, name: '鹭岛白领合唱团', city: '厦门', members: 58, level: '银牌团', piece: '《潮汐来信》', joined: true, badge: '🥈' }
  ]
  @State editingChoir: ChoirItem | null = null

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

  @Builder profileEditModal() {
    Column({ space: 12 }) {
      Text('编 辑 团 员 资 料')
        .fontSize(12)
        .fontColor('#F0C75E')
        .letterSpacing(3)
        .margin({ top: 22 })

      TextInput({ placeholder: '当前昵称:夜航者Sofia' })
        .fontSize(13)
        .fontColor('#E8EAF6')
        .width('84%')
        .height(44)
        .backgroundColor('#141A38')
        .borderRadius(10)
        .border({ width: 1, color: '#2A3160' })
        .onChange((v: string) => {
          this.nickName = v;
        })
      Column({ space: 8 }) {
        Row() {
          Text('主唱声部')
            .fontSize(11)
            .fontColor('#8A90B8')
            .layoutWeight(1)
          Text('女高音 S')
            .fontSize(11)
            .fontColor('#E8EAF6')
        }
        .width('84%')

        Row() {
          Text('音域记录')
            .fontSize(11)
            .fontColor('#8A90B8')
            .layoutWeight(1)
          Text('C4 - G6')
            .fontSize(11)
            .fontColor('#E8EAF6')
        }
        .width('84%')

        Row() {
          Text('入团时间')
            .fontSize(11)
            .fontColor('#8A90B8')
            .layoutWeight(1)
          Text('2025-03')
            .fontSize(11)
            .fontColor('#E8EAF6')
        }
        .width('84%')
      }
      .width('100%')

      Row({ space: 12 }) {
        Button() {
          Text('取消')
            .fontSize(13)
            .fontColor('#8A90B8')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('#141A38')
        .borderRadius(20)
        .onClick(() => {
          this.showProfileEdit = false;
        })

        Button() {
          Text('保存资料')
            .fontSize(13)
            .fontColor('#10142A')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('#F0C75E')
        .borderRadius(20)
        .onClick(() => {
          this.nickName = '';
          this.showProfileEdit = false;
          this.showSaveToast = true;
        })
      }
      .width('84%')
      .margin({ top: 6, bottom: 22 })
    }
    .width('88%')
    .backgroundColor('#1A2042')
    .borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#2A3160' })
  }

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

      Text('退出该合唱团?')
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor('#E8EAF6')
      Text(this.editingChoir!.name)
        .fontSize(11)
        .fontColor('#8A90B8')
      Text('已交录音将保留在云端档案,可随时再申请归团')
        .fontSize(10)
        .fontColor('#6A7195')
        .textAlign(TextAlign.Center)
      Row({ space: 12 }) {
        Button() {
          Text('留下来')
            .fontSize(13)
            .fontColor('#7FA8F5')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('#141A38')
        .borderRadius(20)
        .onClick(() => {
          this.showQuitConfirm = false;
        })

        Button() {
          Text('确认退团')
            .fontSize(13)
            .fontColor('#E8EAF6')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('#C2453F')
        .borderRadius(20)
        .onClick(() => {
          this.myChoirs.splice(this.myChoirs.map((c: ChoirItem) => c.id).indexOf(this.editingChoir!.id), 1);
          this.showQuitConfirm = false;
        })
      }
      .width('84%')
      .margin({ top: 4, bottom: 22 })
    }
    .width('80%')
    .backgroundColor('#1A2042')
    .borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#2A3160' })
  }

  @Builder aboutModal() {
    Column({ space: 10 }) {
      Text('声 部 云')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor('#F0C75E')
        .letterSpacing(4)
        .margin({ top: 24 })

      Text('v2.6 · 谷雨版')
        .fontSize(11)
        .fontColor('#8A90B8')
      Divider()
        .color('#2A3160')
        .strokeWidth(1)
        .width('70%')

      Text('把一千个房间的独唱,')
        .fontSize(12)
        .fontColor('#A8B0D8')
      Text('合成为一次云端的合唱。')
        .fontSize(12)
        .fontColor('#A8B0D8')

      Text('© 2026 声部云 CHORUS CLOUD')
        .fontSize(10)
        .fontColor('#4A5078')
        .margin({ top: 8, bottom: 24 })
    }
    .width('78%')
    .backgroundColor('#1A2042')
    .borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#2A3160' })
  }

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

      Text('确定退出登录?')
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor('#E8EAF6')
      Text('录音档案与团籍将保留在云端')
        .fontSize(10)
        .fontColor('#6A7195')
      Row({ space: 12 }) {
        Button() {
          Text('再唱一会')
            .fontSize(13)
            .fontColor('#7FA8F5')
        }
        .layoutWeight(1)
        .height(40)
        .backgroundColor('#141A38')
        .borderRadius(20)
        .onClick(() => {
          this.showLogout = false;
        })

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

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

            Column({ space: 5 }) {
              Text('夜航者Sofia')
                .fontSize(17)
                .fontWeight(FontWeight.Bold)
                .fontColor('#E8EAF6')
              Text('女高音 · 云雀混声合唱团')
                .fontSize(11)
                .fontColor('#8A90B8')
              Text('徽章:音准之星 · 全勤团员')
                .fontSize(10)
                .fontColor('#F0C75E')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Text('✎')
              .fontSize(16)
              .fontColor('#7FA8F5')
              .padding(8)
              .onClick(() => {
                this.showProfileEdit = true;
              })
          }
          .width('100%')
          .padding(16)
          .backgroundColor('#1A2042')
          .borderRadius(14)
          .border({ width: 1, color: '#2A3160' })
          .margin({ left: 16, right: 16, top: 12 })

          Column({ space: 10 }) {
            Row() {
              Text('本 周 排 练 时 长')
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor('#E8EAF6')
                .letterSpacing(2)
                .layoutWeight(1)
              Text('共 325 分钟')
                .fontSize(10)
                .fontColor('#F0C75E')
            }
            .width('100%')

            Row() {
              ForEach(REHEARSE_WEEK, (d: RehearseDay, i: number) => {
                Column({ space: 5 }) {
                  Column()
                    .width(16)
                    .height(d.minutes * 0.9)
                    .borderRadius(4)
                    .backgroundColor(i >= 5 ? '#F0C75E' : '#5B8DEF')
                  Text(d.day)
                    .fontSize(9)
                    .fontColor('#8A90B8')
                }
                .alignItems(HorizontalAlign.Center)
              }, (d: RehearseDay) => d.day)
            }
            .width('100%')
            .height(90)
            .alignItems(VerticalAlign.Bottom)
            .justifyContent(FlexAlign.SpaceBetween)

            Text('周六排练最久,达 80 分钟')
              .fontSize(10)
              .fontColor('#6A7195')
          }
          .width('100%')
          .padding(16)
          .backgroundColor('#1A2042')
          .borderRadius(14)
          .border({ width: 1, color: '#2A3160' })
          .margin({ left: 16, right: 16, top: 10 })

          Column({ space: 12 }) {
            Text('四 声 部 掌 握 度')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor('#E8EAF6')
              .letterSpacing(2)
              .width('100%')

            ForEach(VOICE_PARTS, (v: VoicePart) => {
              Column({ space: 5 }) {
                Row() {
                  Text(v.emoji + ' ' + v.name)
                    .fontSize(11)
                    .fontColor('#E8EAF6')
                    .layoutWeight(1)
                  Text(v.percent.toString() + '%')
                    .fontSize(11)
                    .fontColor('#F0C75E')
                }
                .width('100%')

                Column() {
                  Column()
                    .width(v.percent + '%')
                    .height(6)
                    .borderRadius(3)
                    .backgroundColor(v.percent >= 70 ? '#F0C75E' : '#5B8DEF')
                    .animation({ duration: 400, curve: Curve.EaseOut })
                }
                .width('100%')
                .height(6)
                .borderRadius(3)
                .backgroundColor('#141A38')
              }
              .width('100%')
            }, (v: VoicePart) => v.name)
          }
          .width('100%')
          .padding(16)
          .backgroundColor('#1A2042')
          .borderRadius(14)
          .border({ width: 1, color: '#2A3160' })
          .margin({ left: 16, right: 16, top: 10 })

          Row() {
            Text('我 的 合 唱 团')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor('#E8EAF6')
              .letterSpacing(2)
              .layoutWeight(1)
            Text(this.myChoirs.length.toString() + ' 个')
              .fontSize(10)
              .fontColor('#6A7195')
          }
          .width('94%')
          .margin({ top: 14, bottom: 4 })

          ForEach(this.myChoirs, (c: ChoirItem) => {
            Row({ space: 12 }) {
              Text(c.badge)
                .fontSize(20)
                .width(40)
                .height(40)
                .borderRadius(10)
                .backgroundColor('rgba(240,199,94,0.1)')
                .textAlign(TextAlign.Center)

              Column({ space: 4 }) {
                Text(c.name)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#E8EAF6')
                Text(c.city + ' · ' + c.level + ' · ' + c.members.toString() + ' 人')
                  .fontSize(10)
                  .fontColor('#8A90B8')
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)

              Text('退团')
                .fontSize(10)
                .fontColor('#C2453F')
                .padding({ left: 10, right: 10, top: 5, bottom: 5 })
                .borderRadius(13)
                .backgroundColor('rgba(194,69,63,0.12)')
                .onClick(() => {
                  this.editingChoir = c;
                  this.showQuitConfirm = true;
                })
            }
            .width('100%')
            .padding(14)
            .backgroundColor('#1A2042')
            .borderRadius(12)
            .border({ width: 1, color: '#2A3160' })
            .margin({ left: 16, right: 16, top: 8 })
          }, (c: ChoirItem) => c.id.toString())

          Row({ space: 12 }) {
            Text('ℹ 关于声部云')
              .fontSize(13)
              .fontColor('#7FA8F5')
              .layoutWeight(1)
            Text('›')
              .fontSize(15)
              .fontColor('#4A5078')
          }
          .width('100%')
          .padding(16)
          .backgroundColor('#1A2042')
          .borderRadius(12)
          .border({ width: 1, color: '#2A3160' })
          .margin({ left: 16, right: 16, top: 12 })
          .onClick(() => {
            this.showAbout = true;
          })

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

      if (this.showProfileEdit) {
        Column() {
          Column()
            .width('100%')
            .layoutWeight(1)
            .onClick(() => {
              this.showProfileEdit = false;
            })
          this.profileEditModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(8,10,26,0.7)')
      }

      if (this.showQuitConfirm) {
        Column() {
          this.quitConfirmModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(8,10,26,0.75)')
        .justifyContent(FlexAlign.Center)
      }

      if (this.showAbout) {
        Column() {
          this.aboutModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(8,10,26,0.7)')
        .justifyContent(FlexAlign.Center)
      }

      if (this.showLogout) {
        Column() {
          this.logoutModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(8,10,26,0.7)')
        .justifyContent(FlexAlign.Center)
      }

      if (this.showSaveToast) {
        Column() {
          Text('✓ 团员资料已更新')
            .fontSize(13)
            .fontColor('#10142A')
            .padding({ left: 22, right: 22, top: 11, bottom: 11 })
            .borderRadius(22)
            .backgroundColor('rgba(240,199,94,0.95)')
        }
        .width('100%')
        .height('100%')
        .justifyContent(FlexAlign.Center)
        .onClick(() => {
          this.showSaveToast = false;
        })
      }
    }
    .width('100%')
    .height('100%')
  }
}
Logo

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

更多推荐