本文深度解析 童谣星球 KIDTUNE——一款面向0-6岁亲子家庭的儿歌磨耳朵应用。项目采用 HarmonyOS ArkTS 声明式 UI 框架,单文件架构,无外部依赖,通过 6 大 Tab 页面、12 套弹窗组件、属性动画与过渡效果,完整实现儿歌分类播放、磨耳朵定时计划、收藏夹家庭共享、成长打卡能力追踪、儿歌榜投票互动、个人中心等核心业务闭环。全文约 8 万字,含 14 张图片。

童谣星球KIDTUNE儿歌Tab

引言:当儿歌遇上鸿蒙生态

儿歌是每个孩子成长路上最早的音乐启蒙。从《小星星》到《两只老虎》,从《拔萝卜》到《数鸭子》,这些经典旋律陪伴了一代又一代人的童年。在移动互联网时代,儿歌不再只是磁带和CD里的声音,而是变成了可以随时点播、定时播放、记录成长的数字应用。

童谣星球 KIDTUNE 正是在这一背景下诞生的亲子儿歌应用。它以"马卡龙奶油亲子风"为视觉基调,用温暖的奶油色(#FFF7EF)铺陈底色,用甜美的草莓粉(#FF8FAB)、清新的薄荷绿(#55C1A5)、明亮的奶油黄(#FFC94D)、柔和的天蓝(#6FB8FF)点亮界面,构建出一个让家长安心、让孩子喜欢的儿歌小星球。

技术层面,项目选择 HarmonyOS ArkTS 作为开发框架,通过声明式 UI 语法、@State 状态管理、@Builder 自定义构建函数、TransitionEffect 过渡动画和属性动画,用单文件约 2100 行代码实现了 6 个 Tab 页面和 12 套弹窗组件的完整交互。应用严格遵循 ArkTS 规范:无 Blank 组件、无 UI 内变量声明、constraintSize 限高、单子组件 Scroll、接口约束全部对象字面量。

本文将从设计理念、色彩系统、架构设计、数据模型、页面实现、弹窗交互、动画效果、业务逻辑等十三个维度,对童谣星球 KIDTUNE 进行全方位深度解析。


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

1.1 项目定位

童谣星球 KIDTUNE 定位为"0-6岁亲子儿歌磨耳朵"一体化数字平台,核心用户为有0-6岁孩子的年轻家庭。应用融合儿歌分类播放、磨耳朵定时计划、收藏夹家庭共享、成长打卡能力追踪、儿歌榜投票互动、个人中心六大业务模块,形成从"发现儿歌→定时磨耳→收藏整理→成长记录→投票互动→家庭共享"的完整用户旅程闭环。

与传统儿歌App不同,童谣星球 KIDTUNE 更强调"分龄推荐"和"场景化播放"——每首儿歌标注适合年龄段(0-3岁/2-5岁/3-6岁),家长可以根据孩子年龄精准选择;磨耳朵计划提供10种场景(晚安哄睡/起床唤醒/乘车安抚/吃饭伴听/午睡小憩/游戏时间/刷牙洗脸/出门散步/亲子共读/情绪安抚),每种场景预设播放时长和歌单,一键启动自动播放。

1.2 设计理念:马卡龙奶油亲子风

项目的视觉设计灵感来源于马卡龙色系和奶油质感的碰撞。走进一个有孩子的家庭,首先感受到的是温暖、柔和、安全的氛围——奶油色的墙面、马卡龙色的玩具、柔和的灯光。童谣星球 KIDTUNE 将这些亲子家庭元素抽象为数字化的视觉语言:

  • 奶油底(#FFF7EF):作为应用主背景色,模拟温暖的奶油色调,让整个界面柔和不刺眼,适合家长和孩子长时间使用。
  • 草莓粉(#FF8FAB):作为主强调色,用于按钮、选中态、Tab高亮、收藏爱心,如同孩子最喜欢的草莓味糖果。
  • 薄荷绿(#55C1A5):作为辅助强调色,用于进行中标签、播放按钮、成功状态,如同清新的薄荷糖带来舒适感。
  • 奶油黄(#FFC94D):用于打卡星星、奖励、今日儿歌盲盒、热门标识,如同温暖的阳光和奶油蛋糕。
  • 天蓝(#6FB8FF):用于信息提示、年龄段标签、定时说明,如同晴朗的天空带来安心感。

这种"亲子感"设计不仅仅停留在色彩层面,更贯穿到交互细节中——收藏爱心的缩放动画、打卡星星的点亮动画、柱状图的生长动画、弹窗的缩放淡入过渡,每一处动画都柔和缓慢,符合亲子应用的温柔调性。

1.3 网易云音乐/QQ音乐风格借鉴

项目在音乐播放体验方面借鉴了主流音乐App的设计风格:歌曲列表式布局(封面emoji+标题+分类标签+时长+播放量)、播放抽屉底部滑出(现在播放+上一首/暂停/下一首+播放设置+收藏按钮)、分类胶囊筛选(全部/睡前/律动/经典/认知/自然)、热度榜单(儿歌榜排名+趋势箭头+投票)、限时推荐(今日儿歌盲盒)。

1.4 技术选型

维度 选型 说明
开发框架 HarmonyOS ArkTS 声明式 UI,原生性能
架构模式 单文件组件化 所有代码在一个 .ets 文件中
状态管理 @State + 组件回调 轻量级,无需额外库
动画方案 属性动画 + TransitionEffect 缩放/透明度/位移动画,弹窗过渡
外部依赖 纯 ArkTS 内置组件
Tab 数量 6 个 儿歌/磨耳朵/收藏/成长/儿歌榜/我的
弹窗数量 12 套 播放抽屉/收藏成功/定时设置/场景编辑/新建收藏夹/重命名/删除确认/打卡成功/分享成绩/投票规则/重置投票/宝贝资料编辑/关于/退出登录

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

2.1 色彩体系总览

童谣星球 KIDTUNE 的色彩体系由多个核心色值构成,通过在组件中直接引用色值的方式全局使用:

主背景与卡片色:

  • 奶油底 #FFF7EF:应用主背景色,温暖柔和
  • 纯白 #FFFFFF:卡片背景、弹窗背景
  • 浅奶油 #FFF1E6:歌曲封面背景、图标容器背景
  • 极浅灰 #F6F1EC:分割线、进度条背景、未选中状态

主强调色:

  • 草莓粉 #FF8FAB:主按钮、Tab选中、收藏爱心、主操作色
  • 深草莓粉 #E85D75:删除按钮、危险操作、退出登录
  • 薄荷绿 #55C1A5:播放按钮、进行中标签、成功状态、辅助操作色
  • 奶油黄 #FFC94D:打卡星星、奖励、今日儿歌盲盒、热门标识
  • 天蓝 #6FB8FF:信息提示、年龄段标签、定时说明、投票按钮

文字色:

  • 深棕 #4A3B32:主标题、核心文字
  • 中棕 #8A7A6E:副标题、说明文字、输入框文字
  • 浅棕 #B9A99C:辅助文字、占位符、时间戳
  • 极浅棕 #D8CCBF:分割线、箭头指示

2.2 色彩层级与对比度

浅色主题通过三级背景深度和三级文字明度构建清晰的视觉层级:

背景深度三级:

  1. 奶油底 #FFF7EF——页面主背景,最浅最暖
  2. 纯白 #FFFFFF——卡片、弹窗、列表项,比背景略亮形成层次
  3. 浅奶油 #FFF1E6——封面容器、图标背景,比纯白略暖增加趣味

文字明度三级:

  1. 深棕 #4A3B32——标题、歌曲名、核心数据
  2. 中棕 #8A7A6E——副标题、说明、输入文字
  3. 浅棕 #B9A99C——辅助说明、时间戳、占位符

2.3 强调色的使用策略

草莓粉 #FF8FAB 是整个应用的视觉锚点,用于:主按钮(立即开始/保存定时/创建收藏夹)、选中态标识(Tab选中/分类选中/收藏爱心)、数据高亮(本周陪伴分钟/收藏儿歌数)、装饰元素(儿歌榜头卡/打卡星星/进度条)。

薄荷绿 #55C1A5 作为互补色,用于:播放按钮(三角形播放图标)、进行中标签(场景计划进行中)、成功状态(连续打卡/本周增长)、辅助操作按钮(立即开始磨耳朵)。

奶油黄 #FFC94D 用于:打卡星星(已打卡日期显示⭐)、今日儿歌盲盒(首页推荐卡片)、奖励标识(成长勋章/会唱总数)、热门标签。

天蓝 #6FB8FF 用于:年龄段标签(0-3岁/2-5岁/3-6岁)、定时说明文字、信息提示、投票规则按钮。

四种强调色的搭配形成了"粉为主、绿为辅、黄为奖、蓝为讯"的色彩节奏,既保证了视觉统一性,又提供了足够的状态区分度。

2.4 渐变与光影效果

项目少量使用纯色块而非复杂渐变,保持亲子应用的简洁柔和:

  • 儿歌榜头卡使用纯草莓粉 #FF8FAB 背景,白色文字,简洁醒目
  • 今日儿歌盲盒使用纯奶油黄 #FFC94D 背景,白色文字,温暖吸引
  • 柱状图使用纯色填充(草莓粉/奶油黄/天蓝),不使用渐变,保持儿童应用的简洁感

这些纯色块配合柔和的圆角(16-24px)和轻微的阴影,营造出温暖、安全、适合亲子使用的视觉体验。


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

3.1 单文件组件化架构

童谣星球 KIDTUNE 采用"单文件组件化"架构——所有代码在一个 2137.ets 文件中,通过 @Component 装饰器划分为 8 个独立组件(6 Tab + 1 头部 + 1 根入口)。优势:零配置运行、低理解成本、快速原型。代码量约 2100 行,处于单文件架构合理范围。

3.2 组件层次结构

KidtuneApp (@Entry 根组件)
├── KidtuneHeader(头部:LOGO + 搜索 + 今日盲盒 + 磨耳朵计划)
├── 内容区 Column
│   └── 6 个 Tab 内容组件(6 选 1,通过 contentArea() Builder 分发)
│       ├── SongsContent(儿歌:分类筛选 + 歌曲列表 + 播放抽屉 + 收藏弹窗)
│       ├── ListenContent(磨耳朵:场景计划 + 本周柱状图 + 定时弹窗 + 编辑弹窗)
│       ├── FavoriteContent(收藏:统计概览 + 双列收藏夹 + 新建/重命名/删除弹窗)
│       ├── GrowContent(成长:宝贝卡 + 打卡日历 + 能力曲线 + 跟唱记录 + 打卡/分享弹窗)
│       ├── ChartContent(儿歌榜:榜单头卡 + 前三甲 + 投票入口 + 完整榜单 + 投票/重置弹窗)
│       └── MeKidContent(我的:家庭卡 + 数据三连 + 陪伴柱状图 + 功能列表 + 编辑/关于/退出弹窗)
└── 底部 Tab 导航栏(6 项,通过 bottomTabItem() Builder 生成)

根组件管理当前 Tab(activeTab),通过 contentArea() Builder 分发 Tab 内容。每个 Tab 组件负责自身 UI 和局部状态(包括弹窗显隐),通过组件内部 @State 管理,无需跨组件通信。头部组件 KidtuneHeader 为静态展示组件,不包含交互状态。

3.3 @Builder 自定义构建函数

项目使用 2 个 @Builder 方法封装可复用 UI 片段:

  • contentArea():内容区分发,根据 activeTab 渲染对应 Tab 组件
  • bottomTabItem(icon, label, tab):底部 Tab 项,包含图标缩放动画和文字颜色切换

@Builder 减少重复代码,提高可读性,支持参数传递。

3.4 弹框架构设计

弹窗采用"Stack 叠加 + if 条件渲染"模式,每个 Tab 组件内部管理自身的弹窗显隐状态。12 套弹窗各具特色:

  • 播放抽屉(SongsContent):底部滑出式,包含现在播放信息+上一首/暂停/下一首+播放设置(仅童声/慢速0.8x/定时30分钟)+收进收藏夹+关闭按钮
  • 收藏成功弹窗(SongsContent):居中缩放式,爱心图标+已放进收藏夹+歌曲名+好的按钮
  • 定时设置弹窗(ListenContent):居中缩放式,15/30/45分钟分段选择+温柔结束说明+取消/保存定时
  • 场景编辑弹窗(ListenContent):居中缩放式,场景名称输入+播放时长选择(10/20/30/40分钟)+放弃修改/保存计划
  • 新建收藏夹弹窗(FavoriteContent):居中缩放式,收藏夹名字输入+6个表情选择+取消/创建
  • 重命名弹窗(FavoriteContent):居中缩放式,新名字输入+说明+取消/确认改名
  • 删除确认弹窗(FavoriteContent):居中缩放式,垃圾桶图标+删除收藏夹?+说明+我再想想/确认删除
  • 打卡成功弹窗(GrowContent):居中缩放式,星星图标+今日磨耳朵打卡+连续天数/本月天数/会唱总数+点亮小星星按钮
  • 分享成绩弹窗(GrowContent):居中缩放式,勋章图标+生成成长周报+本周数据+3种分享方式+关闭
  • 投票规则弹窗(ChartContent):居中缩放式,3条投票规则+已投数量+知道啦按钮
  • 重置投票弹窗(ChartContent):居中缩放式,刷新图标+清空本周投票?+说明+先不清空/清空重来
  • 宝贝资料编辑弹窗(MeKidContent):居中缩放式,4个头像选择+昵称输入+年龄段选择+取消/保存资料
  • 关于弹窗(MeKidContent):居中缩放式,音符图标+应用名+版本+功能说明+关闭
  • 退出登录弹窗(MeKidContent):居中缩放式,挥手图标+确定要退出登录吗?+说明+再看看/退出

所有弹窗统一使用 TransitionEffect.OPACITY.animation().combine(TransitionEffect.scale()/translate()) 实现淡入缩放/位移动画,柔和不突兀,符合亲子应用调性。


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

4.1 接口定义体系

项目定义 8 个 TypeScript 接口描述数据模型:

  • SongItem:歌曲项,包含 id、title、cat(分类:睡前/律动/经典/认知/自然)、emoji、duration、plays(播放量)、liked(是否收藏)、age(适合年龄段)
  • ScenePlan:场景计划,包含 id、name、emoji、desc、min(播放时长分钟)、songs(歌曲数量说明)
  • FavFolder:收藏夹,包含 id、name、emoji、count(歌曲数)、open(全家人可见/私密)
  • GrowLog:成长记录,包含 id、song(歌曲名)、date、score(跟唱评分)、medal(奖牌emoji)
  • ChartSong:榜单歌曲,包含 id、rank(排名)、title、singer(歌手)、weeks(在榜周数)、hots(热度)、trend(趋势:up/down/flat)
  • WeekMinute:周分钟数,包含 day(星期几)、min(分钟数)
  • SkillStat:能力统计,包含 name、emoji、percent(百分比)
  • KidTab:枚举,SONGS/LISTEN/FAVORITE/GROW/CHART/ME

4.2 全局数据常量

约 12 组全局 const 常量:

  • 核心业务数据:KID_SONGS(14首儿歌)、SCENE_PLANS(10个场景计划)、FAV_FOLDERS(6个收藏夹)、GROW_LOGS(10条成长记录)、CHART_SONGS(12首榜单歌曲)、WEEK_MINUTES(7天分钟数)、SKILL_STATS(4项能力统计)
  • 配置数据:CAT_CHIPS(6个分类标签)

使用 const 确保数据不可变,所有动态状态通过组件 @State 管理。

4.3 三级状态管理策略

  • 第一级 全局常量(const):业务数据和配置,任何组件可直接读取,运行时不可变
  • 第二级 组件局部状态(@State):组件内交互状态,如 curCat(当前分类)、playingSong(当前播放歌曲)、likeIds(收藏ID列表)、showPlaySheet(播放抽屉显隐)、planIds(进行中场景计划ID)、timerMin(定时分钟数)、folders(收藏夹列表)、dayChecked(已打卡日期)、voteIds(已投票ID)、kidName(宝贝昵称)等
  • 第三级 根组件协调状态(@State):activeTab(当前Tab),仅此一个跨组件状态

4.4 全局纯函数

项目定义 6 个全局纯函数处理数据转换和业务逻辑:

  • rankColor®:根据排名返回颜色,前3名草莓粉、4-8名薄荷绿、其他浅棕色
  • trendIcon(t):根据趋势返回箭头图标,up返回↑、down返回↓、flat返回—
  • trendColor(t):根据趋势返回颜色,up草莓粉、down天蓝、flat浅棕
  • scoreColor(s):根据跟唱评分返回颜色,90+草莓粉、80+奶油黄、其他薄荷绿
  • minuteBar(m):根据分钟数计算柱状图高度,分钟数×0.9

纯函数相同输入永远产生相同输出,无副作用,易于测试和复用。


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

5.1 儿歌 Tab:分类筛选 + 歌曲列表 + 播放抽屉

儿歌 Tab 是应用的首页和核心入口,承担"发现儿歌→播放儿歌→收藏儿歌"的核心流程。页面从上到下依次为:分类胶囊筛选栏、今日推荐大卡、儿歌乐园列表。

分类胶囊筛选栏:横向滚动的 6 个分类标签(全部/睡前/律动/经典/认知/自然),选中态为草莓粉背景白色文字,未选中为白色背景中棕色文字,点击切换时带有 180ms 缓出动画。筛选逻辑通过 filterSongs(cat) 方法实现,"全部"返回完整 KID_SONGS 数组,其他分类按 cat 字段过滤。

今日推荐大卡:白色圆角卡片,左侧为月亮emoji封面(天蓝背景),中间为"睡前摇篮曲·今晚的8首"标题+歌曲预览+已按入睡习惯排序说明,右侧为草莓粉圆形播放按钮。点击播放按钮自动播放第一首歌并弹出播放抽屉。

儿歌乐园列表:每首歌为一行白色圆角卡片,左侧为emoji封面(浅奶油背景),中间为歌曲名+分类标签(草莓粉小字标签)+年龄段·时长·播放量,右侧为收藏爱心(已收藏💖带缩放动画/未收藏🤍)和薄荷绿圆形播放按钮。点击爱心切换收藏状态,新收藏时弹出"已放进哄睡必备"成功小弹窗;点击播放按钮弹出播放抽屉。

播放抽屉:底部滑出式半屏弹窗,包含"现在播放"歌曲信息、上一首/暂停/下一首控制按钮(暂停为草莓粉大圆按钮)、播放设置标签(仅童声/慢速0.8x/定时30分钟)、"收进收藏夹"按钮、关闭按钮。抽屉带有从底部上滑+淡入的过渡动画。

5.2 磨耳朵 Tab:场景计划 + 定时播放 + 本周统计

磨耳朵 Tab 是应用的差异化核心功能,将儿歌播放场景化、定时化。页面从上到下依次为:今晚磨耳朵计划总览卡、本周磨耳朵时长柱状图、场景计划列表。

今晚磨耳朵计划总览卡:白色圆角卡片,左侧为耳机emoji封面(薄荷绿浅背景),中间为"今晚磨耳朵计划"标题+21:30自动开始·月亮船轻轻摇等12首+定时30分钟后音量渐弱至关闭说明,下方为"调整定时"(奶油黄按钮)和"立即开始"(薄荷绿按钮)两个操作按钮。

本周磨耳朵时长柱状图:白色圆角卡片,标题为"本周磨耳朵时长(分钟)“+共364分钟,下方为7根柱状图(周一到周日),柱子高度通过 minuteBar(d.min) 计算(分钟数×0.9),60分钟以上为草莓粉、以下为奶油黄,带有400ms缓出生长动画。底部说明"周六陪听最多,共78分钟🎉”。

场景计划列表:10个场景计划,每个为一行白色圆角卡片,左侧为emoji封面,中间为场景名+进行中标签(薄荷绿)+描述+歌曲数+时长进度条(奶油黄),右侧为设置齿轮按钮和播放/停止按钮(进行中为草莓粉停止按钮/未进行为薄荷绿播放按钮)。点击设置按钮弹出场景编辑弹窗,点击播放/停止按钮切换进行中状态。

童谣星球KIDTUNE磨耳朵Tab

5.3 收藏 Tab:双列收藏夹 + 新建/重命名/删除

收藏 Tab 提供儿歌收藏的文件夹化管理,支持家庭共享和私密两种可见性。页面从上到下依次为:统计概览三连、新建收藏夹入口条、全家收藏夹双列网格。

统计概览三连:三个等宽白色圆角卡片,分别显示82收藏儿歌(草莓粉数字)、6收藏夹(薄荷绿数字)、68连续打卡(奶油黄数字),下方为对应说明文字。

新建收藏夹入口条:白色圆角卡片,左侧为➕图标+"新建一个收藏夹,装满豆豆的爱听"文字,右侧为草莓粉"创建"按钮。点击弹出新建收藏夹弹窗。

全家收藏夹双列网格:使用 Flex({ wrap: FlexWrap.Wrap }) 实现双列自适应布局,每个收藏夹为白色圆角卡片,上方为emoji封面+歌曲数(浅奶油背景),中间为收藏夹名称,下方为可见性标签(家庭共享薄荷绿/私密浅棕)+编辑铅笔+删除垃圾桶图标。点击编辑弹出重命名弹窗,点击删除弹出删除确认弹窗。

童谣星球KIDTUNE收藏Tab

5.4 成长 Tab:打卡日历 + 能力曲线 + 跟唱记录

成长 Tab 记录孩子的磨耳朵成长轨迹,通过打卡日历、能力成长曲线、跟唱记录三个维度可视化成长。页面从上到下依次为:宝贝成长卡、本月打卡日历、能力成长曲线、最近跟唱记录。

宝贝成长卡:白色圆角卡片,左侧为小鸡emoji头像(天蓝浅背景),中间为"豆豆的磨耳朵成长记"标题+2岁8个月标签(天蓝)+已会唱23首标签(草莓粉)+距离小小歌唱家徽章还差2首说明。

本月打卡日历:白色圆角卡片,标题为"本月磨耳朵打卡"+5/27天,下方为27天的日历网格(使用 Flex wrap),已打卡日期显示⭐星星(奶油黄背景+缩放动画),未打卡显示日期数字(浅棕文字+浅灰背景)。点击未打卡日期弹出今日打卡弹窗,打卡后该日期变为星星。底部说明"连续打卡5天,再坚持2天就能点亮坚持一周勋章✨"。

能力成长曲线:白色圆角卡片,标题为"能力成长曲线",下方为4项能力进度条(节奏感🥁82%/音准🎵68%/词汇量💬74%/记忆力🧠56%),每项为emoji+能力名+百分比+进度条(75%以上草莓粉/以下奶油黄),带有400ms缓出生长动画。

最近跟唱记录:10条记录,每条为一行白色圆角卡片,左侧为奖牌emoji(金/银/铜/奖牌),中间为歌曲名+日期·跟唱评分+评分进度条(通过 scoreColor 函数着色),右侧为评分数字。右上角有"分享"按钮(奶油黄),点击弹出分享成绩弹窗。

童谣星球KIDTUNE成长Tab

5.5 儿歌榜 Tab:排行榜 + 投票互动 + 前三甲

儿歌榜 Tab 提供热门儿歌排行榜和用户投票互动功能。页面从上到下依次为:榜单头卡、前三甲展示台、投票入口条、完整榜单列表。

榜单头卡:草莓粉背景圆角卡片,居中显示"星球儿歌榜"标题(白色大字)+"每小时更新·由32万个家庭的播放生成"说明(白色半透明小字)。

前三甲展示台:三个等宽白色圆角卡片,第1名为奶油黄背景(皇冠👑),第2、3名为白色背景(银牌🥈/铜牌🥉),每个卡片显示排名emoji+歌曲名+歌手+热度🔥。

投票入口条:白色圆角卡片,左侧为🗳图标+"本周我最爱:可投3首,已投2首"文字,右侧为天蓝"去投票"按钮。点击弹出投票规则弹窗。

完整榜单列表:12首歌曲,每条为一行白色圆角卡片,左侧为排名数字(通过 rankColor 函数着色:前3草莓粉/4-8薄荷绿/其他浅棕)+音符emoji封面,中间为歌曲名+趋势箭头(通过 trendIcon/trendColor 函数)+歌手+在榜周数标签,右侧为投票爱心(已投💗带缩放动画/未投🗳)+热度小字。点击投票爱心切换投票状态,已投3首后再点击弹出投票规则弹窗提示。右上角有"重置我的投票"文字按钮,点击弹出重置投票确认弹窗。

童谣星球KIDTUNE儿歌榜Tab

5.6 我的 Tab:家庭卡 + 陪伴统计 + 功能列表

我的 Tab 是个人中心,展示家庭信息、陪伴数据和系统设置。页面从上到下依次为:家庭卡、数据三连、每日陪伴柱状图、功能列表、退出登录按钮。

家庭卡:白色圆角卡片,左侧为小鸡emoji头像(天蓝浅背景),中间为"豆豆的音乐小屋"标题+2岁8个月·磨耳朵第68天·家庭会员(草莓粉)+家庭成员标签(妈妈/爸爸/外婆),右侧为编辑铅笔按钮。点击编辑弹出宝贝资料编辑弹窗。

数据三连:三个等宽白色圆角卡片,分别显示364本周陪伴分钟(草莓粉)、23累计会唱(薄荷绿)、4成长勋章(奶油黄),下方为对应说明文字。

每日陪伴柱状图:白色圆角卡片,标题为"每日陪伴时长(分钟)“+本周+18%(薄荷绿),下方为7根柱状图,60分钟以上草莓粉、40-60奶油黄、40以下天蓝,带有400ms缓出生长动画。底部说明"周六户外陪听78分钟,是本周最专注的一天🌈”。

功能列表:白色圆角卡片,包含4个功能项(播放提醒设置/离线下载儿歌/儿童保护模式/关于童谣星球),每项为图标+功能名+右侧状态/版本+箭头,项之间用浅灰分割线分隔。点击"关于童谣星球"弹出关于弹窗。

退出登录按钮:白色圆角按钮,草莓红文字"退出登录",点击弹出退出登录确认弹窗。底部为"童谣星球·让每一段旋律都陪着长大"标语。

童谣星球KIDTUNE我的Tab


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

6.1 弹窗设计总览

童谣星球 KIDTUNE 共设计 15 套弹窗组件,分布在 6 个 Tab 中,涵盖播放控制、设置编辑、确认警告、成功反馈、信息展示五大类。所有弹窗统一采用"Stack 叠加 + if 条件渲染 + TransitionEffect 过渡动画"的实现模式,居中弹窗使用淡入+缩放动画,底部抽屉使用淡入+上移动画。

6.2 播放抽屉:底部滑出式音乐播放器

播放抽屉是儿歌 Tab 的核心交互组件,采用底部滑出式半屏设计。点击歌曲播放按钮后,抽屉从底部滑出,包含:

  • 现在播放信息区:歌曲名(深棕粗体)+分类·年龄段·适合时长(草莓粉小字)
  • 播放控制区:上一首⏮(浅棕)+暂停⏸(草莓粉大圆按钮56px)+下一首⏭(浅棕),三按钮水平居中排列
  • 播放设置标签:仅童声(薄荷绿)|慢速0.8x(奶油黄)|定时30分钟(天蓝),用竖线分隔
  • 操作按钮:收进收藏夹(草莓粉文字+浅粉背景)+关闭(浅棕文字+浅奶油背景)

抽屉过渡动画为 TransitionEffect.OPACITY.animation({ duration: 200 }).combine(TransitionEffect.translate({ y: 120 })),即200ms淡入+从下方120px上滑,模拟音乐播放器从底部升起的自然感。

童谣星球KIDTUNE播放抽屉

6.3 定时设置弹窗:分段选择式时间配置

定时设置弹窗是磨耳朵 Tab 的核心设置组件,采用居中缩放式设计。点击"调整定时"按钮后弹出,包含:

  • 标题:⏰定时关闭设置(深棕粗体)
  • 分段选择器:15/30/45分钟三个等宽选项,选中态为浅粉背景+草莓粉边框+草莓粉粗体数字,未选中为浅奶油背景+中棕数字,点击切换带有160ms动画
  • 说明文字:温柔结束:最后3分钟音量渐弱,不打断睡意(薄荷绿)+结束后自动锁定屏幕,防止宝宝乱点(天蓝)
  • 操作按钮:取消(浅棕文字+浅奶油背景)+保存定时(白色文字+草莓粉背景)

弹窗过渡动画为淡入+从0.9倍缩放至1倍,柔和不突兀。

童谣星球KIDTUNE定时设置弹窗

6.4 收藏成功弹窗:轻量反馈式提示

收藏成功弹窗在儿歌 Tab 点击未收藏歌曲的爱心时弹出,采用居中缩放式轻量设计:💖爱心图标(36px)+"已放进哄睡必备"标题+"歌曲名会在磨耳朵时优先出现"说明+"好的"按钮(草莓粉)。弹窗宽度72%,过渡动画为淡入+从0.85倍缩放,2秒内可自动消失(通过点击好的关闭)。

6.5 场景编辑弹窗:表单式配置

场景编辑弹窗在磨耳朵 Tab 点击场景计划的设置齿轮时弹出,包含:场景名称输入框(TextInput)+播放时长分段选择(10/20/30/40分钟,奶油黄选中态)+放弃修改/保存计划按钮。编辑后点击保存计划关闭弹窗(演示版本不实际修改数据,实际项目可扩展为修改 SCENE_PLANS 对应项)。

6.6 新建/重命名/删除收藏夹弹窗三组合

收藏 Tab 提供完整的收藏夹 CRUD 弹窗组合:

  • 新建收藏夹弹窗:收藏夹名字输入框+6个表情选择器(🌟🌙🐣🍓🌈🎵,选中态浅粉背景+草莓粉边框)+取消/创建按钮(奶油黄)。创建后向 folders 数组 push 新收藏夹对象。
  • 重命名弹窗:新名字输入框+"重命名后全家人都会看到新名字"说明+取消/确认改名按钮(天蓝)。确认后通过索引替换 folders 数组对应项。
  • 删除确认弹窗:🗑垃圾桶图标(36px)+"删除收藏夹?"标题(草莓红)+"收藏夹名将被删除,夹内歌曲不会从曲库移除"说明+我再想想/确认删除按钮(草莓红)。确认后通过 splice 删除对应项。

三个弹窗统一使用居中缩放式过渡动画,删除弹窗使用草莓红强调危险操作。

6.7 打卡成功/分享成绩弹窗

成长 Tab 提供两个反馈类弹窗:

  • 打卡成功弹窗:🌟星星图标(40px)+"今日磨耳朵打卡"标题+"完成歌曲名跟唱,为豆豆点亮一颗小星星"说明+三列数据(连续天数/本月天数/会唱总数)+"点亮小星星⭐"按钮(奶油黄)。点击后向 dayChecked 数组 push 新日期。
  • 分享成绩弹窗:🏅勋章图标(38px)+"生成豆豆的成长周报"标题+"本周磨耳朵364分钟·会唱5首新歌·打卡5天"说明(薄荷绿)+3种分享方式(带上海报封面/复制成绩链接/发到家庭群)+关闭按钮。

6.8 投票规则/重置投票弹窗

儿歌榜 Tab 提供两个投票相关弹窗:

  • 投票规则弹窗:🗳投票规则标题+3条规则(每周最多投3首/投满3首领星币兑换绘本/跟唱过的歌曲投票权重翻倍)+“当前已投X/3首”(天蓝粗体)+"知道啦"按钮(天蓝)。
  • 重置投票弹窗:🔄刷新图标+"清空本周投票?"标题+"已投的X首将全部撤回,星币进度也会回退"说明+先不清空/清空重来按钮(天蓝)。确认后将 voteIds 重置为空数组。

6.9 宝贝资料编辑/关于/退出登录弹窗

我的 Tab 提供三个个人中心弹窗:

  • 宝贝资料编辑弹窗:4个头像选择(🐣🐰🐨🐯,选中态浅粉背景+草莓粉边框)+昵称输入框+年龄段分段选择(0-1岁/1-2岁/2-3岁/3-6岁,草莓粉选中态)+取消/保存资料按钮(草莓粉)。
  • 关于弹窗:🎶音符图标+"童谣星球KIDTUNE"标题+“v3.2.1·制作团队:星球童谣工作室”+"2000+首经典与新创儿歌\n分龄推荐·磨耳朵计划·成长记录"说明+关闭按钮。
  • 退出登录弹窗:👋挥手图标+"确定要退出登录吗?"标题+"磨耳朵计划与打卡记录会保留在账号里,随时回来继续"说明+再看看/退出按钮(草莓红)。

第七章 动画与过渡效果

7.1 属性动画体系

项目使用 ArkTS 的 .animation() 属性动画实现 8 组交互动画:

  • Tab 图标缩放:底部 Tab 选中时图标从1倍缩放至1.18倍,220ms缓出,切换Tab时图标有轻微弹跳感
  • 分类胶囊切换:分类标签选中/未选中切换时背景色和文字色过渡,180ms缓出
  • 收藏爱心缩放:已收藏爱心💖保持1.15倍缩放,切换收藏状态时200ms缓出
  • 打卡星星缩放:已打卡日期⭐保持1.08倍缩放,180ms缓出
  • 投票爱心缩放:已投票爱心💗保持1.2倍缩放,200ms缓出
  • 柱状图生长:本周磨耳朵/每日陪伴柱状图从0生长到目标高度,400ms缓出,60分钟以上草莓粉、以下奶油黄/天蓝
  • 进度条生长:场景计划时长进度条/能力成长进度条/跟唱评分进度条,300-400ms缓出
  • 定时分段选择:15/30/45分钟选项切换时背景色和边框过渡,160ms

7.2 过渡动画体系

所有弹窗统一使用 TransitionEffect.OPACITY.animation().combine(TransitionEffect.scale()/translate()) 实现过渡:

  • 居中弹窗:淡入(200ms)+从0.85-0.9倍缩放至1倍,柔和弹出
  • 底部抽屉:淡入(200ms)+从下方120px上滑至原位,模拟升起感

7.3 动画设计原则

亲子应用的动画设计遵循三个原则:

  1. 柔和缓慢:所有动画时长在160-400ms之间,缓出曲线(Curve.EaseOut),不使用快速弹跳或剧烈动画
  2. 缩放为主:主要使用缩放和透明度变化,避免位移动画造成视觉干扰
  3. 状态反馈:每个可交互元素(爱心/星星/投票/按钮)都有选中态动画,让家长明确感知操作结果

第八章 儿歌曲库与分类体系

8.1 曲库数据结构

童谣星球 KIDTUNE 的曲库由 KID_SONGS 常量数组定义,包含 14 首儿歌,每首歌通过 SongItem 接口描述 8 个字段:

  • id:唯一标识,用于 ForEach keyGenerator 和收藏/播放状态匹配
  • title:歌曲名称,如"小星星洗澡澡"“拔萝卜的大合力”
  • cat:分类标签,6 种分类(睡前/律动/经典/认知/自然),用于分类筛选
  • emoji:封面表情符号,替代传统专辑封面,符合亲子应用的可爱风格
  • duration:歌曲时长,如"02:36"
  • plays:播放量,如"38.2万",用于展示热度
  • liked:是否收藏(布尔值),初始数据中 1/3/5/8/9/13 已收藏
  • age:适合年龄段,3 种(0-3岁/2-5岁/3-6岁),用于分龄推荐

8.2 六分类体系

曲库按 cat 字段分为 6 个分类,每个分类有明确的场景定位:

  • 睡前:摇篮曲、安静旋律,如"小星星洗澡澡"“月亮船轻轻摇”“小雨点跳舞了”,适合睡前哄睡
  • 律动:节奏明快、适合跟跳,如"两只老虎跳体操"“巴士轮子转呀转”“火车呜呜开远啦”“拍手点点头”,适合游戏时间
  • 经典:传统经典儿歌,如"拔萝卜的大合力"“小兔子乖乖开门”,代代相传的旋律
  • 认知:启蒙认知类,如"颜色精灵变变变"“数鸭子过小桥”“蔬菜宝宝开大会”,在听歌中学习
  • 自然:自然主题,如"萤火虫提灯笼"“四季的风筝飞”,培养对自然的感知

分类筛选通过 filterSongs(cat) 方法实现,"全部"返回完整数组,其他分类按 cat 字段 filter。分类胶囊栏使用 CAT_CHIPS 常量定义 6 个标签,横向滚动展示。

8.3 分龄推荐策略

每首歌标注 age 字段(0-3岁/2-5岁/3-6岁),家长可以根据孩子年龄选择适合的儿歌。0-3岁侧重简单旋律和重复歌词(如小星星、小兔子乖乖),2-5岁增加认知内容(如颜色精灵、数鸭子),3-6岁增加律动和故事性(如两只老虎体操、拔萝卜)。

8.4 今日推荐算法

今日推荐大卡固定展示"睡前摇篮曲·今晚的8首",包含 KID_SONGS 中 cat 为"睡前"的歌曲(小星星洗澡澡、月亮船轻轻摇、小雨点跳舞了),并说明"已按豆豆的入睡习惯排序"。实际项目中可扩展为基于孩子年龄、收听历史、时间段的个性化推荐算法。


第九章 磨耳朵计划与场景化播放

9.1 场景计划体系

磨耳朵计划是童谣星球 KIDTUNE 的核心差异化功能,通过 SCENE_PLANS 常量定义 10 个场景计划,每个场景通过 ScenePlan 接口描述 5 个字段:id、name、emoji、desc(描述)、min(播放时长分钟)、songs(歌曲数量说明)。

10 个场景覆盖亲子日常全场景:

  • 晚安哄睡(🌙,30分钟,12首循环):摇篮曲轻播,音量渐弱
  • 起床唤醒(☀️,15分钟,6首连播):欢快儿歌元气叫早
  • 乘车安抚(🚗,20分钟,8首循环):车内轻音乐防哭闹
  • 吃饭伴听(🍚,25分钟,10首连播):节奏轻快专治挑食
  • 午睡小憩(😴,40分钟,15首循环):白噪音+轻童谣
  • 游戏时间(🎈,20分钟,8首连播):律动儿歌跟跳专列
  • 刷牙洗脸(🦷,5分钟,2首连播):两分钟刷牙歌计时
  • 出门散步(🌳,30分钟,12首循环):自然声音认知特辑
  • 亲子共读(📖,35分钟,14首连播):故事童谣混合播
  • 情绪安抚(🫂,15分钟,6首循环):温柔旋律拥抱时刻

9.2 定时播放机制

磨耳朵计划支持定时关闭功能,通过 timerMin 状态管理定时分钟数(默认30分钟),提供 15/30/45 分钟三档选择。定时设置弹窗说明"温柔结束:最后3分钟音量渐弱,不打断睡意"+“结束后自动锁定屏幕,防止宝宝乱点”。

实际项目中可扩展为:到时间后自动降低音量(最后3分钟从100%渐降至0%)、自动暂停播放、自动锁定屏幕(防止宝宝误触)。

9.3 进行中状态管理

场景计划的进行中状态通过 planIds 数组管理(默认 [1,3,5],即晚安哄睡、乘车安抚、午睡小憩三个进行中)。isOn(id) 方法判断场景是否在进行中列表中。点击播放/停止按钮切换进行中状态:进行中时从 planIds 中移除(停止),未进行时 push 到 planIds(开始)。

进行中的场景显示薄荷绿"进行中"标签,播放按钮变为草莓粉停止按钮(⏹),封面背景变为浅奶油色(#FFF1E6)。

9.4 本周磨耳朵统计

本周磨耳朵时长通过 WEEK_MINUTES 常量定义 7 天数据(周一35/周二52/周三28/周四61/周五44/周六78/周日66,共364分钟)。柱状图展示每天时长,60分钟以上为草莓粉(表示达标),以下为奶油黄。底部说明"周六陪听最多,共78分钟🎉"。


第十章 收藏夹与家庭共享

10.1 收藏夹数据结构

收藏夹通过 FAV_FOLDERS 常量定义 6 个收藏夹,每个通过 FavFolder 接口描述 5 个字段:id、name、emoji、count(歌曲数)、open(可见性:全家人可见/私密)。

6 个收藏夹展示了家庭共享和私密两种模式:

  • 哄睡必备(🌙,18首,全家人可见)
  • 跳跳能量站(💃,12首,全家人可见)
  • 外婆点的歌(👵,9首,全家人可见)
  • 认知启蒙(🔤,15首,私密)
  • 车里循环榜(🚗,7首,全家人可见)
  • 爸爸乱收藏(🙈,21首,私密)

10.2 双列网格布局

收藏夹使用 Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) 实现双列自适应网格,每个收藏夹宽度 48.5%,底部 margin 10px。Flex 布局相比 Grid 更灵活,能自动换行适配不同屏幕宽度。

每个收藏夹卡片包含:emoji封面+歌曲数(浅奶油背景)、收藏夹名称、可见性标签(家庭共享薄荷绿/私密浅棕)、编辑铅笔、删除垃圾桶。

10.3 CRUD 操作实现

收藏夹支持完整的增删改查操作:

  • 新建:点击"创建"按钮弹出新建弹窗,输入名字+选择表情,确认后 folders.push({ id: 100+length, name, emoji, count: 0, open: '私密' })
  • 重命名:点击铅笔图标弹出重命名弹窗,输入新名字,确认后通过索引替换 folders[renameIdx] = { ...原对象, name: 新名字 }
  • 删除:点击垃圾桶图标弹出删除确认弹窗,确认后 folders.splice(deleteIdx, 1)

所有操作通过组件 @State 管理 folders 数组,修改后 UI 自动刷新。

10.4 家庭共享机制

收藏夹的 open 字段控制可见性:“全家人可见"表示家庭所有成员都能看到该收藏夹,“私密"表示仅创建者可见。家庭共享标签显示为"👨‍👩‍👧家庭共享”(薄荷绿),私密标签显示为"🔒私密”(浅棕)。实际项目中可扩展为基于家庭成员账号的权限控制。


第十一章 成长打卡与能力追踪

11.1 打卡日历机制

成长打卡通过 dayChecked 数组管理已打卡日期(默认 [1,2,3,4,5],即本月前5天已打卡)。isChecked(d) 方法判断日期是否在打卡列表中。

日历展示本月 1-27 日(演示数据),已打卡日期显示⭐星星(奶油黄背景 #FFF3D6 + 1.08倍缩放动画),未打卡显示日期数字(浅棕文字 + 浅灰背景 #F6F1EC)。点击未打卡日期弹出今日打卡弹窗,确认后 dayChecked.push(新日期)

打卡统计显示"5/27天"(已打卡/总天数),底部说明"连续打卡5天,再坚持2天就能点亮坚持一周勋章✨"。

11.2 能力成长曲线

能力成长通过 SKILL_STATS 常量定义 4 项能力:节奏感🥁(82%)、音准🎵(68%)、词汇量💬(74%)、记忆力🧠(56%)。每项能力通过 SkillStat 接口描述 name、emoji、percent 三个字段。

能力进度条使用嵌套 Column 实现:外层 Column 为背景(#F6F1EC,高度6px,圆角3px),内层 Column 为进度(宽度 percent%,75%以上草莓粉/以下奶油黄,高度6px,圆角3px),带有400ms缓出生长动画。

实际项目中可扩展为基于跟唱评分、收听历史、互动行为的能力评估算法。

11.3 跟唱记录与评分

跟唱记录通过 GROW_LOGS 常量定义 10 条记录,每条通过 GrowLog 接口描述 id、song、date、score(跟唱评分0-100)、medal(奖牌emoji:🥇金/🥈银/🥉铜/🏅奖牌)。

评分颜色通过 scoreColor(s) 函数实现:90分以上草莓粉(优秀)、80-89奶油黄(良好)、80以下薄荷绿(继续努力)。每条记录显示奖牌emoji+歌曲名+日期·跟唱评分+评分进度条(通过 scoreColor 着色)+评分数字。

11.4 成长周报与分享

成长 Tab 提供分享成绩功能,点击"分享"按钮弹出分享成绩弹窗,生成本周成长周报:“本周磨耳朵364分钟·会唱5首新歌·打卡5天”,提供3种分享方式(带上海报封面/复制成绩链接/发到家庭群)。实际项目中可扩展为生成带二维码的海报图片、一键分享到微信/QQ等社交平台。


第十二章 儿歌榜与投票互动

12.1 榜单数据结构

儿歌榜通过 CHART_SONGS 常量定义 12 首榜单歌曲,每首通过 ChartSong 接口描述 7 个字段:id、rank(排名1-12)、title、singer(歌手:糖糖姐姐/蘑菇哥哥/云朵阿姨/跳跳蛙剧团)、weeks(在榜周数)、hots(热度播放量)、trend(趋势:up上升/down下降/flat持平)。

榜单排名通过 rank 字段排序,排名颜色通过 rankColor(r) 函数实现:前3名草莓粉(冠军区)、4-8名薄荷绿(热门区)、其他浅棕(普通区)。趋势箭头通过 trendIcon(t)trendColor(t) 函数实现:up↑草莓粉(上升)、down↓天蓝(下降)、flat—浅棕(持平)。

12.2 前三甲展示台

榜单顶部展示前三名,使用 CHART_SONGS.slice(0, 3) 获取前三首,三个等宽卡片横向排列。第1名为奶油黄背景(#FFF3D6,冠军高亮)+👑皇冠,第2名为白色背景+🥈银牌,第3名为白色背景+🥉铜牌。每个卡片显示排名emoji+歌曲名+歌手+热度🔥。

12.3 投票机制

儿歌榜支持用户投票互动,通过 voteIds 数组管理已投票歌曲ID(默认 [1,4],即已投2首)。isVoted(id) 方法判断歌曲是否已投票。

投票规则:每位家长每周最多投3首,投票满3首可领"星币"兑换绘本,宝宝跟唱过的歌曲投票权重翻倍。点击未投票歌曲的投票图标(🗳)将歌曲ID push 到 voteIds,图标变为💗爱心(1.2倍缩放动画)。已投3首后再点击弹出投票规则弹窗提示。

投票入口条显示"本周我最爱:可投3首,已投X首",点击"去投票"弹出投票规则弹窗。右上角"重置我的投票"文字按钮,点击弹出重置投票确认弹窗,确认后 voteIds = []

12.4 榜单更新机制

榜单头卡说明"每小时更新·由32万个家庭的播放生成",表示榜单基于全平台用户播放数据实时更新。实际项目中可扩展为:每小时统计全平台播放量、投票数、收藏数,加权计算热度排名,更新 CHART_SONGS 数据。


第十三章 开发环境搭建与运行指南

本章提供完整的开发环境搭建步骤,包括 DevEco Studio 安装、HarmonyOS SDK 配置、项目创建、代码运行和预览调试。按照本章步骤操作,你可以在 30 分钟内将本应用在模拟器或真机上运行起来。

13.1 环境要求

开发童谣星球 KIDTUNE 需要以下环境配置:

  • DevEco Studio 5.0+(支持 HarmonyOS 6.1.1):华为官方 IDE,基于 IntelliJ IDEA 定制,提供 ArkTS 语法高亮、代码补全、实时预览、调试等完整开发能力
  • HarmonyOS SDK API 24:HarmonyOS 6.1.1 对应的 SDK 版本,包含 ArkUI 声明式 UI 框架、ArkTS 运行时、系统组件库等
  • Node.js 18+:DevEco Studio 构建工具链依赖,用于 hvigor 构建系统和 ohpm 包管理器
  • 至少 8GB 内存:推荐 16GB,DevEco Studio + 模拟器 + Previewer 同时运行时内存占用较高
  • Windows 10/11 或 macOS 12+:支持 64 位操作系统

13.2 安装 DevEco Studio

DevEco Studio 安装界面

从华为开发者官网(developer.huawei.com)下载最新版本 DevEco Studio 安装包。运行安装程序后:

  1. 选择安装路径(建议非系统盘,预留至少 5GB 空间)
  2. 勾选"创建桌面快捷方式"和"关联 .ets 文件"
  3. 选择安装组件(默认全选,包含 HarmonyOS SDK、模拟器、Previewer)
  4. 点击"安装"等待完成(约 10-15 分钟,取决于网络速度)
  5. 安装完成后启动 DevEco Studio,首次启动会自动检测并配置 Node.js、ohpm、hvigor 等工具链

13.3 配置 HarmonyOS SDK

SDK 配置界面

首次启动 DevEco Studio 后,需要配置 HarmonyOS SDK:

  1. 进入 File > Settings > HarmonyOS SDK(macOS 为 DevEco Studio > Settings > HarmonyOS SDK
  2. 在 SDK Platforms 标签页中,勾选 API 24 及以上版本(HarmonyOS 6.1.1 对应 API 24)
  3. 在 SDK Tools 标签页中,确认以下工具已勾选:ArkTS、Previewer、Emulator、System Image(模拟器镜像)
  4. 点击 Apply 按钮,等待 SDK 下载完成(约 2-5GB,取决于勾选的版本数量)
  5. 下载完成后点击 OK,SDK 配置生效

如果需要使用真机调试,还需在手机上开启"开发者模式"和"USB 调试",并通过 DevEco Studio 的设备管理器连接设备。

13.4 创建项目

创建项目界面

配置好 SDK 后,创建一个新的 HarmonyOS 项目:

  1. 在 DevEco Studio 欢迎页点击 Create Project(或 File > New > Create Project)
  2. 选择 Empty Ability 模板(空能力模板,最适合单文件应用开发)
  3. 配置项目参数:
    • Project name:项目名称,如 KidtuneApp
    • Bundle name:包名,如 com.example.kidtune(需全局唯一)
    • Save location:项目保存路径
    • Compile SDK:选择 API 24(HarmonyOS 6.1.1)
    • Model:选择 Stage 模型(HarmonyOS 推荐的新模型)
  4. 点击 Finish,等待项目初始化完成(约 1-2 分钟,自动生成项目结构和配置文件)

项目创建完成后,目录结构如下:

KidtuneApp/
├── entry/                    # 主模块
│   ├── src/main/
│   │   ├── ets/pages/
│   │   │   └── Index.ets    # 主页面(替换为童谣星球源码)
│   │   ├── resources/        # 资源文件
│   │   └── module.json5      # 模块配置
│   └── build-profile.json5   # 构建配置
├── AppScope/                 # 应用全局配置
├── hvigor/                   # 构建工具
└── build-profile.json5       # 全局构建配置

13.5 运行应用

运行应用界面

项目创建完成后,将童谣星球 KIDTUNE 的源码替换到项目中并运行:

  1. 打开 entry/src/main/ets/pages/Index.ets 文件
  2. 用本文附录中的完整源码(2137.ets)完全替换 Index.ets 的内容
  3. 确认 module.json5 中已注册该页面(默认创建的 Empty Ability 已自动注册 Index.ets)
  4. 连接 HarmonyOS 设备(USB 数据线连接真机并开启 USB 调试),或启动模拟器(Tools > Device Manager > Create Emulator,选择 API 24 的设备镜像)
  5. 点击工具栏的运行按钮(▶️),或按快捷键 Shift+F10
  6. 在弹出的设备选择对话框中选择目标设备(真机或模拟器)
  7. 等待编译构建完成(首次构建约 1-3 分钟,后续增量构建约 30 秒)
  8. 应用自动安装并启动到设备上,即可看到童谣星球 KIDTUNE 的儿歌 Tab 首页

如果运行报错,常见原因和解决方法:

  • SDK 版本不匹配:确认 module.json5 中的 compileSdkVersion 为 24
  • 页面未注册:确认 module.json5 的 abilities 中包含 Index.ets 页面对应的 ability
  • 内存不足:关闭其他占用内存的程序,或增加 DevEco Studio 的 VM 内存(Help > Edit Custom VM Options,增加 -Xmx4096m)

13.6 预览调试

预览调试界面

DevEco Studio 提供强大的实时预览和调试能力,是 UI 开发的高效工具:

Previewer 实时预览:

  • 打开 .ets 文件后,点击编辑器右侧的 Previewer 标签(或 View > Tool Windows > Previewer)
  • Previewer 面板实时显示当前页面的 UI 效果,修改代码后自动热重载(约 1-2 秒刷新)
  • 支持多设备尺寸预览(手机/平板/折叠屏),在 Previewer 顶部的设备下拉框中切换
  • 支持深色模式/浅色模式切换,点击 Previewer 工具栏的主题切换按钮
  • 支持组件树 inspect,点击 Previewer 中的 UI 元素可在代码中定位到对应组件

调试功能:

  • 断点调试:在代码行号左侧点击设置断点,点击 Debug 按钮(🐛)以调试模式运行,命中断点后可查看变量值、调用栈、单步执行
  • 日志查看:底部 Log 面板显示应用运行日志,支持按级别(Verbose/Debug/Info/Warn/Error)过滤,支持关键词搜索
  • 性能分析Profiler 面板提供 CPU、内存、网络、帧率等性能指标实时监控,可录制性能会话进行分析
  • 布局检查Layout Inspector(ArkUI Inspector)面板显示当前页面的组件树结构、每个组件的属性值、布局边界,可实时修改属性预览效果

对于 UI 为主的童谣星球 KIDTUNE 应用,Previewer 是最高效的开发工具——修改颜色、间距、布局后无需重新运行应用,1-2 秒即可在 Previewer 中看到效果,大幅提升 UI 开发效率。


总结与展望

项目总结

童谣星球 KIDTUNE 是一款面向 0-6 岁亲子家庭的儿歌磨耳朵应用,基于 HarmonyOS ArkTS 声明式 UI 框架开发,单文件约 2100 行代码,无外部依赖。项目实现了 6 大 Tab 页面(儿歌/磨耳朵/收藏/成长/儿歌榜/我的)和 15 套弹窗组件,完整覆盖儿歌分类播放、磨耳朵定时计划、收藏夹家庭共享、成长打卡能力追踪、儿歌榜投票互动、个人中心等核心业务场景。

视觉设计上采用"马卡龙奶油亲子风"——奶油底(#FFF7EF)铺陈温暖底色,草莓粉(#FF8FAB)作为主强调色,薄荷绿(#55C1A5)、奶油黄(#FFC94D)、天蓝(#6FB8FF)作为辅助色,构建出柔和、安全、适合亲子使用的视觉体验。动画设计遵循"柔和缓慢、缩放为主、状态反馈"三原则,所有动画时长在 160-400ms 之间,缓出曲线,不使用剧烈动画。

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

技术亮点

  1. 单文件零依赖架构:所有代码在一个 2137.ets 文件中,无需配置路由、状态管理库、UI 组件库,开箱即用,降低理解成本和部署门槛
  2. 6 Tab + 15 弹窗完整交互闭环:从儿歌发现到定时磨耳、从收藏整理到成长记录、从榜单投票到个人中心,形成完整的用户旅程闭环,每个业务场景都有对应的弹窗交互
  3. 马卡龙奶油亲子风视觉体系:5 色强调色(草莓粉/薄荷绿/奶油黄/天蓝/深草莓红)+ 3 级背景深度 + 3 级文字明度,构建出统一、柔和、适合亲子使用的视觉语言
  4. 场景化磨耳朵计划:10 种亲子日常场景(晚安哄睡/起床唤醒/乘车安抚/吃饭伴听/午睡小憩/游戏时间/刷牙洗脸/出门散步/亲子共读/情绪安抚),每种场景预设播放时长和歌单,一键启动定时播放
  5. 成长打卡与能力追踪:打卡日历(星星点亮动画)+ 4 项能力成长曲线(节奏感/音准/词汇量/记忆力)+ 跟唱评分记录(奖牌体系),可视化孩子的音乐成长轨迹
  6. 纯函数数据转换层:6 个全局纯函数(rankColor/trendIcon/trendColor/scoreColor/minuteBar)处理排名颜色、趋势图标、评分颜色、柱状图高度等数据转换,相同输入永远产生相同输出,易于测试和复用

扩展方向

童谣星球 KIDTUNE 作为演示项目,仍有多个可扩展方向:

  • 真实音频播放:当前播放抽屉为 UI 演示,可集成 HarmonyOS 音频播放 API(AVPlayer)实现真实的儿歌音频播放、暂停、上一首/下一首、进度条拖动
  • 后端数据对接:当前所有数据为本地 const 常量,可对接后端 API 实现曲库动态加载、用户收藏同步、成长记录云端存储、榜单实时更新
  • 个性化推荐算法:基于孩子年龄、收听历史、收藏行为、时间段,实现个性化儿歌推荐和磨耳朵歌单生成
  • 家庭成员账号体系:实现多家庭成员账号(爸爸/妈妈/外婆等),每个成员有独立的收藏夹、播放历史、投票记录,家庭共享收藏夹支持权限管理
  • 生成成长海报:成长周报分享功能可扩展为生成带二维码的精美海报图片,支持一键分享到微信/QQ等社交平台
  • 离线下载与缓存:我的 Tab 的"离线下载儿歌"功能可扩展为真实的音频下载和本地缓存,支持无网络环境下播放

童谣星球 KIDTUNE 展示了 HarmonyOS ArkTS 在亲子教育类应用开发中的强大能力——声明式 UI 的简洁语法、@State 的高效状态管理、属性动画和过渡动画的流畅动效、Previewer 的实时预览,让开发者可以用较少的代码实现丰富的交互体验。希望本文能为 HarmonyOS 开发者和亲子教育产品从业者提供有价值的参考。
童谣星球KIDTUNE儿歌Tab

本文基于 HarmonyOS ArkTS 声明式 UI 框架实战项目撰写,所有代码和图片均来自童谣星球 KIDTUNE 项目。


附录:完整源码

// 2137.ets 童谣星球 KIDTUNE —— 亲子儿歌磨耳朵 / 定时播放 / 成长打卡 / 儿歌榜
// 马卡龙彩虹亲子风:奶油底 + 草莓粉 + 薄荷绿 + 奶油黄 + 天蓝(浅色主题,与前批深色形成对比)
// 遵循要求.md:无 Blank、无 UI 内变量声明、constraintSize 限高、单子组件 Scroll、接口约束全部对象字面量

// ============ 类型定义 ============
interface SongItem {
  id: number;
  title: string;
  cat: string;
  emoji: string;
  duration: string;
  plays: string;
  liked: boolean;
  age: string;
}

interface ScenePlan {
  id: number;
  name: string;
  emoji: string;
  desc: string;
  min: number;
  songs: string;
}

interface FavFolder {
  id: number;
  name: string;
  emoji: string;
  count: number;
  open: string;
}

interface GrowLog {
  id: number;
  song: string;
  date: string;
  score: number;
  medal: string;
}

interface ChartSong {
  id: number;
  rank: number;
  title: string;
  singer: string;
  weeks: string;
  hots: string;
  trend: string;
}

interface WeekMinute {
  day: string;
  min: number;
}

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

// ============ 静态数据 ============
const KID_SONGS: SongItem[] = [
  { id: 1, title: '小星星洗澡澡', cat: '睡前', emoji: '⭐', duration: '02:36', plays: '38.2万', liked: true, age: '0-3岁' },
  { id: 2, title: '两只老虎跳体操', cat: '律动', emoji: '🐯', duration: '03:12', plays: '52.7万', liked: false, age: '3-6岁' },
  { id: 3, title: '拔萝卜的大合力', cat: '经典', emoji: '🥕', duration: '02:48', plays: '61.9万', liked: true, age: '3-6岁' },
  { id: 4, title: '颜色精灵变变变', cat: '认知', emoji: '🎨', duration: '03:05', plays: '27.4万', liked: false, age: '2-5岁' },
  { id: 5, title: '小兔子乖乖开门', cat: '经典', emoji: '🐰', duration: '02:20', plays: '44.8万', liked: true, age: '0-3岁' },
  { id: 6, title: '巴士轮子转呀转', cat: '律动', emoji: '🚌', duration: '03:30', plays: '33.6万', liked: false, age: '2-5岁' },
  { id: 7, title: '小雨点跳舞了', cat: '自然', emoji: '💧', duration: '02:15', plays: '18.9万', liked: false, age: '0-3岁' },
  { id: 8, title: '数鸭子过小桥', cat: '认知', emoji: '🦆', duration: '02:52', plays: '57.3万', liked: true, age: '3-6岁' },
  { id: 9, title: '月亮船轻轻摇', cat: '睡前', emoji: '🌙', duration: '04:08', plays: '41.5万', liked: true, age: '0-3岁' },
  { id: 10, title: '蔬菜宝宝开大会', cat: '认知', emoji: '🥦', duration: '03:20', plays: '22.1万', liked: false, age: '2-5岁' },
  { id: 11, title: '火车呜呜开远啦', cat: '律动', emoji: '🚂', duration: '02:44', plays: '36.8万', liked: false, age: '3-6岁' },
  { id: 12, title: '萤火虫提灯笼', cat: '自然', emoji: '✨', duration: '03:16', plays: '19.7万', liked: false, age: '2-5岁' },
  { id: 13, title: '拍手点点头', cat: '律动', emoji: '👏', duration: '02:10', plays: '48.4万', liked: true, age: '0-3岁' },
  { id: 14, title: '四季的风筝飞', cat: '自然', emoji: '🪁', duration: '03:42', plays: '15.3万', liked: false, age: '3-6岁' }
];

const SCENE_PLANS: ScenePlan[] = [
  { id: 1, name: '晚安哄睡', emoji: '🌙', desc: '摇篮曲轻播,音量渐弱', min: 30, songs: '12 首循环' },
  { id: 2, name: '起床唤醒', emoji: '☀️', desc: '欢快儿歌元气叫早', min: 15, songs: '6 首连播' },
  { id: 3, name: '乘车安抚', emoji: '🚗', desc: '车内轻音乐防哭闹', min: 20, songs: '8 首循环' },
  { id: 4, name: '吃饭伴听', emoji: '🍚', desc: '节奏轻快专治挑食', min: 25, songs: '10 首连播' },
  { id: 5, name: '午睡小憩', emoji: '😴', desc: '白噪音 + 轻童谣', min: 40, songs: '15 首循环' },
  { id: 6, name: '游戏时间', emoji: '🎈', desc: '律动儿歌跟跳专列', min: 20, songs: '8 首连播' },
  { id: 7, name: '刷牙洗脸', emoji: '🦷', desc: '两分钟刷牙歌计时', min: 5, songs: '2 首连播' },
  { id: 8, name: '出门散步', emoji: '🌳', desc: '自然声音认知特辑', min: 30, songs: '12 首循环' },
  { id: 9, name: '亲子共读', emoji: '📖', desc: '故事童谣混合播', min: 35, songs: '14 首连播' },
  { id: 10, name: '情绪安抚', emoji: '🫂', desc: '温柔旋律拥抱时刻', min: 15, songs: '6 首循环' }
];

const FAV_FOLDERS: FavFolder[] = [
  { id: 1, name: '哄睡必备', emoji: '🌙', count: 18, open: '全家人可见' },
  { id: 2, name: '跳跳能量站', emoji: '💃', count: 12, open: '全家人可见' },
  { id: 3, name: '外婆点的歌', emoji: '👵', count: 9, open: '全家人可见' },
  { id: 4, name: '认知启蒙', emoji: '🔤', count: 15, open: '私密' },
  { id: 5, name: '车里循环榜', emoji: '🚗', count: 7, open: '全家人可见' },
  { id: 6, name: '爸爸乱收藏', emoji: '🙈', count: 21, open: '私密' }
];

const GROW_LOGS: GrowLog[] = [
  { id: 1, song: '小星星洗澡澡', date: '今天 20:15', score: 96, medal: '🏅' },
  { id: 2, song: '拔萝卜的大合力', date: '今天 18:40', score: 88, medal: '🥈' },
  { id: 3, song: '数鸭子过小桥', date: '昨天 19:52', score: 92, medal: '🥇' },
  { id: 4, song: '拍手点点头', date: '昨天 08:30', score: 85, medal: '🥉' },
  { id: 5, song: '月亮船轻轻摇', date: '08-25', score: 90, medal: '🥇' },
  { id: 6, song: '两只老虎跳体操', date: '08-24', score: 79, medal: '🥉' },
  { id: 7, song: '颜色精灵变变变', date: '08-23', score: 83, medal: '🥈' },
  { id: 8, song: '巴士轮子转呀转', date: '08-22', score: 87, medal: '🥈' },
  { id: 9, song: '小兔子乖乖开门', date: '08-21', score: 94, medal: '🏅' },
  { id: 10, song: '火车呜呜开远啦', date: '08-20', score: 76, medal: '🥉' }
];

const CHART_SONGS: ChartSong[] = [
  { id: 1, rank: 1, title: '小星星洗澡澡', singer: '糖糖姐姐', weeks: '在榜 26 周', hots: '38.2万', trend: 'up' },
  { id: 2, rank: 2, title: '数鸭子过小桥', singer: '蘑菇哥哥', weeks: '在榜 18 周', hots: '57.3万', trend: 'up' },
  { id: 3, rank: 3, title: '月亮船轻轻摇', singer: '云朵阿姨', weeks: '在榜 31 周', hots: '41.5万', trend: 'flat' },
  { id: 4, rank: 4, title: '拔萝卜的大合力', singer: '糖糖姐姐', weeks: '在榜 22 周', hots: '61.9万', trend: 'up' },
  { id: 5, rank: 5, title: '两只老虎跳体操', singer: '跳跳蛙剧团', weeks: '在榜 15 周', hots: '52.7万', trend: 'down' },
  { id: 6, rank: 6, title: '小兔子乖乖开门', singer: '蘑菇哥哥', weeks: '在榜 28 周', hots: '44.8万', trend: 'flat' },
  { id: 7, rank: 7, title: '拍手点点头', singer: '云朵阿姨', weeks: '在榜 12 周', hots: '48.4万', trend: 'up' },
  { id: 8, rank: 8, title: '巴士轮子转呀转', singer: '跳跳蛙剧团', weeks: '在榜 9 周', hots: '33.6万', trend: 'down' },
  { id: 9, rank: 9, title: '蔬菜宝宝开大会', singer: '糖糖姐姐', weeks: '在榜 7 周', hots: '22.1万', trend: 'up' },
  { id: 10, rank: 10, title: '萤火虫提灯笼', singer: '云朵阿姨', weeks: '在榜 5 周', hots: '19.7万', trend: 'up' },
  { id: 11, rank: 11, title: '火车呜呜开远啦', singer: '蘑菇哥哥', weeks: '在榜 11 周', hots: '36.8万', trend: 'flat' },
  { id: 12, rank: 12, title: '四季的风筝飞', singer: '跳跳蛙剧团', weeks: '在榜 4 周', hots: '15.3万', trend: 'down' }
];

const WEEK_MINUTES: WeekMinute[] = [
  { day: '一', min: 35 },
  { day: '二', min: 52 },
  { day: '三', min: 28 },
  { day: '四', min: 61 },
  { day: '五', min: 44 },
  { day: '六', min: 78 },
  { day: '日', min: 66 }
];

const SKILL_STATS: SkillStat[] = [
  { name: '节奏感', emoji: '🥁', percent: 82 },
  { name: '音准', emoji: '🎵', percent: 68 },
  { name: '词汇量', emoji: '💬', percent: 74 },
  { name: '记忆力', emoji: '🧠', percent: 56 }
];

const CAT_CHIPS: string[] = ['全部', '睡前', '律动', '经典', '认知', '自然'];

// ============ 工具函数 ============
function rankColor(r: number): string {
  if (r <= 3) {
    return '#FF8FAB';
  }
  if (r <= 8) {
    return '#55C1A5';
  }
  return '#B9A99C';
}

function trendIcon(t: string): string {
  if (t === 'up') {
    return '↑';
  }
  if (t === 'down') {
    return '↓';
  }
  return '—';
}

function trendColor(t: string): string {
  if (t === 'up') {
    return '#FF6B81';
  }
  if (t === 'down') {
    return '#6FB8FF';
  }
  return '#B9A99C';
}

function scoreColor(s: number): string {
  if (s >= 90) {
    return '#FF8FAB';
  }
  if (s >= 80) {
    return '#FFC94D';
  }
  return '#55C1A5';
}

function minuteBar(m: number): number {
  return m * 0.9;
}

enum KidTab {
  SONGS,
  LISTEN,
  FAVORITE,
  GROW,
  CHART,
  ME
}
// ============ 主入口 ============
@Entry
@Component
struct KidtuneApp {
  @State activeTab: KidTab = KidTab.SONGS

  @Builder contentArea() {
    if (this.activeTab === KidTab.SONGS) {
      SongsContent()
    } else if (this.activeTab === KidTab.LISTEN) {
      ListenContent()
    } else if (this.activeTab === KidTab.FAVORITE) {
      FavoriteContent()
    } else if (this.activeTab === KidTab.GROW) {
      GrowContent()
    } else if (this.activeTab === KidTab.CHART) {
      ChartContent()
    } else {
      MeKidContent()
    }
  }

  @Builder bottomTabItem(icon: string, label: string, tab: KidTab) {
    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 ? '#FF8FAB' : '#B9A99C')
    }
    .alignItems(HorizontalAlign.Center)
    .padding({ top: 8, bottom: 6 })
    .width('16.6%')
    .onClick(() => {
      this.activeTab = tab;
    })
  }

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

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

      Row() {
        this.bottomTabItem('🎵', '儿歌', KidTab.SONGS)
        this.bottomTabItem('⏰', '磨耳朵', KidTab.LISTEN)
        this.bottomTabItem('💖', '收藏', KidTab.FAVORITE)
        this.bottomTabItem('🌱', '成长', KidTab.GROW)
        this.bottomTabItem('🏆', '儿歌榜', KidTab.CHART)
        this.bottomTabItem('👨‍👩‍👧', '我的', KidTab.ME)
      }
      .width('100%')
      .backgroundColor('#FFFFFF')
      .padding({ bottom: 6 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FFF7EF')
  }
}

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

  build() {
    Column() {
      Row({ space: 10 }) {
        Column() {
          Text('童')
            .fontSize(19)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
        }
        .width(36)
        .height(36)
        .borderRadius(18)
        .backgroundColor('#FF8FAB')
        .justifyContent(FlexAlign.Center)

        Column({ space: 3 }) {
          Text('童谣星球 KIDTUNE')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#4A3B32')
          Text('豆豆的磨耳朵小星球')
            .fontSize(9)
            .fontColor('#B9A99C')
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)

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

        Text('👨‍👩‍👧')
          .fontSize(19)
          .padding(5)
          .onClick(() => {
            this.onNoticeTap();
          })
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 10 })

      Row({ space: 8 }) {
        Text('🔍')
          .fontSize(14)
        Text('找儿歌 / 找摇篮曲 / 找手指操')
          .fontSize(12)
          .fontColor('#B9A99C')
          .layoutWeight(1)
      }
      .width('100%')
      .padding({ left: 14, right: 14, top: 10, bottom: 10 })
      .borderRadius(22)
      .backgroundColor('#FFFFFF')
      .margin({ left: 16, right: 16, top: 10 })

      Row({ space: 10 }) {
        Column({ space: 4 }) {
          Text('🎁')
            .fontSize(22)
          Text('今日儿歌盲盒')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
          Text('抽一首没听过的')
            .fontSize(9)
            .fontColor('rgba(255,255,255,0.85)')
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1.3)
        .padding(14)
        .borderRadius(16)
        .backgroundColor('#FFC94D')

        Column({ space: 4 }) {
          Text('🎧')
            .fontSize(22)
          Text('磨耳朵计划')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor('#55C1A5')
          Text('已坚持 68 天')
            .fontSize(9)
            .fontColor('#B9A99C')
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        .padding(14)
        .borderRadius(16)
        .backgroundColor('#FFFFFF')
      }
      .width('94%')
      .margin({ top: 10, bottom: 12 })
    }
    .width('100%')
    .backgroundColor('#FFEFDF')
  }
}
// ============ 儿歌 Tab(分类筛选 + 歌曲列表 + 播放抽屉 + 收藏弹窗) ============
@Component
struct SongsContent {
  @State curCat: string = '全部'
  @State playingSong: SongItem | null = null
  @State likeIds: number[] = [1, 3, 5, 8, 9, 13]
  @State showPlaySheet: boolean = false
  @State showLikeModal: boolean = false
  @State likeTargetName: string = ''

  filterSongs(cat: string): SongItem[] {
    if (cat === '全部') {
      return KID_SONGS;
    }
    return KID_SONGS.filter((s: SongItem) => s.cat === cat);
  }

  isLiked(id: number): boolean {
    return this.likeIds.indexOf(id) >= 0;
  }

  build() {
    Stack() {
      Scroll() {
        Column() {
          // 分类胶囊
          Scroll() {
            Row({ space: 8 }) {
              ForEach(CAT_CHIPS, (c: string) => {
                Text(c)
                  .fontSize(12)
                  .fontColor(this.curCat === c ? '#FFFFFF' : '#8A7A6E')
                  .padding({ left: 16, right: 16, top: 7, bottom: 7 })
                  .borderRadius(16)
                  .backgroundColor(this.curCat === c ? '#FF8FAB' : '#FFFFFF')
                  .animation({ duration: 180, curve: Curve.EaseOut })
                  .onClick(() => {
                    this.curCat = c;
                  })
              }, (c: string) => c)
            }
            .padding({ left: 16, right: 16 })
          }
          .scrollable(ScrollDirection.Horizontal)
          .scrollBar(BarState.Off)
          .width('100%')
          .margin({ top: 12 })

          // 今日推荐大卡
          Column({ space: 8 }) {
            Row({ space: 10 }) {
              Column() {
                Text('🌙')
                  .fontSize(34)
              }
              .width(64)
              .height(64)
              .borderRadius(20)
              .backgroundColor('#E8F4FF')
              .justifyContent(FlexAlign.Center)

              Column({ space: 4 }) {
                Text('睡前摇篮曲 · 今晚的 8 首')
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#4A3B32')
                Text('小星星洗澡澡 · 月亮船轻轻摇 · 萤火虫提灯笼…')
                  .fontSize(10)
                  .fontColor('#B9A99C')
                  .maxLines(1)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })
                Text('已按豆豆的入睡习惯排序')
                  .fontSize(10)
                  .fontColor('#6FB8FF')
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)

              Column() {
                Text('▶')
                  .fontSize(18)
                  .fontColor('#FFFFFF')
              }
              .width(38)
              .height(38)
              .borderRadius(19)
              .backgroundColor('#FF8FAB')
              .justifyContent(FlexAlign.Center)
              .onClick(() => {
                this.playingSong = KID_SONGS[0];
                this.showPlaySheet = true;
              })
            }
            .width('100%')
          }
          .padding(14)
          .borderRadius(18)
          .backgroundColor('#FFFFFF')
          .margin({ left: 16, right: 16, top: 14 })

          // 歌曲列表
          Row({ space: 6 }) {
            Text('🎵')
              .fontSize(14)
            Text('儿歌乐园')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#4A3B32')
              .layoutWeight(1)
            Text('共 ' + this.filterSongs(this.curCat).length + ' 首')
              .fontSize(10)
              .fontColor('#B9A99C')
          }
          .width('92%')
          .margin({ top: 18, bottom: 8 })

          Column({ space: 10 }) {
            ForEach(this.filterSongs(this.curCat), (s: SongItem) => {
              Row({ space: 12 }) {
                Column() {
                  Text(s.emoji)
                    .fontSize(26)
                }
                .width(52)
                .height(52)
                .borderRadius(16)
                .backgroundColor('#FFF1E6')
                .justifyContent(FlexAlign.Center)

                Column({ space: 3 }) {
                  Text(s.title)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#4A3B32')
                    .maxLines(1)
                    .textOverflow({ overflow: TextOverflow.Ellipsis })
                  Row({ space: 6 }) {
                    Text(s.cat)
                      .fontSize(9)
                      .fontColor('#FF8FAB')
                      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                      .borderRadius(6)
                      .backgroundColor('#FFE9EF')
                    Text(s.age + ' · ' + s.duration + ' · ' + s.plays + '次播放')
                      .fontSize(9)
                      .fontColor('#B9A99C')
                  }
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)

                Text(this.isLiked(s.id) ? '💖' : '🤍')
                  .fontSize(18)
                  .scale(this.isLiked(s.id) ? { x: 1.15, y: 1.15 } : { x: 1, y: 1 })
                  .animation({ duration: 200, curve: Curve.EaseOut })
                  .onClick(() => {
                    if (this.isLiked(s.id)) {
                      this.likeIds = this.likeIds.filter((i: number) => i !== s.id);
                    } else {
                      this.likeIds.push(s.id);
                      this.likeTargetName = s.title;
                      this.showLikeModal = true;
                    }
                  })

                Text('▶')
                  .fontSize(13)
                  .fontColor('#FFFFFF')
                  .width(30)
                  .height(30)
                  .borderRadius(15)
                  .backgroundColor('#55C1A5')
                  .textAlign(TextAlign.Center)
                  .onClick(() => {
                    this.playingSong = s;
                    this.showPlaySheet = true;
                  })
              }
              .width('100%')
              .padding(12)
              .borderRadius(16)
              .backgroundColor('#FFFFFF')
            }, (s: SongItem) => s.id.toString() + this.isLiked(s.id).toString())
          }
          .width('92%')

          Column() {
            Text('到底啦,去儿歌榜看看新歌吧 🏆')
              .fontSize(10)
              .fontColor('#B9A99C')
              .padding({ top: 16, bottom: 16 })
          }
          .width('100%')
        }
        .width('100%')
        .padding({ bottom: 16 })
      }
      .scrollBar(BarState.Off)
      .width('100%')
      .height('100%')
      .align(Alignment.Top)

      // 播放抽屉(底部滑出样式)
      if (this.showPlaySheet && this.playingSong !== null) {
        Column() {
          Column({ space: 2 }) {
            Text('现在播放')
              .fontSize(10)
              .fontColor('#B9A99C')
            Text(this.playingSong.title)
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#4A3B32')
            Text(this.playingSong.cat + ' · ' + this.playingSong.age + ' · 适合 ' + this.playingSong.duration)
              .fontSize(10)
              .fontColor('#FF8FAB')
          }
          .alignItems(HorizontalAlign.Center)
          .width('100%')
          .padding({ top: 20, bottom: 14 })

          Row({ space: 18 }) {
            Text('⏮')
              .fontSize(20)
              .fontColor('#B9A99C')
            Text('⏸')
              .fontSize(22)
              .fontColor('#FFFFFF')
              .width(56)
              .height(56)
              .borderRadius(28)
              .backgroundColor('#FF8FAB')
              .textAlign(TextAlign.Center)
            Text('⏭')
              .fontSize(20)
              .fontColor('#B9A99C')
          }
          .width('100%')
          .justifyContent(FlexAlign.Center)
          .margin({ top: 6, bottom: 10 })

          Row({ space: 6 }) {
            Text('仅童声')
              .fontSize(10)
              .fontColor('#55C1A5')
            Text('|')
              .fontSize(10)
              .fontColor('#E5D5C8')
            Text('慢速 0.8x')
              .fontSize(10)
              .fontColor('#FFC94D')
            Text('|')
              .fontSize(10)
              .fontColor('#E5D5C8')
            Text('定时 30 分钟')
              .fontSize(10)
              .fontColor('#6FB8FF')
          }
          .margin({ bottom: 14 })

          Button() {
            Text('收进收藏夹')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FF8FAB')
          }
          .width('86%')
          .height(42)
          .borderRadius(21)
          .backgroundColor('#FFE9EF')
          .margin({ bottom: 10 })

          Button() {
            Text('关闭')
              .fontSize(13)
              .fontColor('#B9A99C')
          }
          .width('86%')
          .height(38)
          .borderRadius(19)
          .backgroundColor('#FFF7EF')
          .onClick(() => {
            this.showPlaySheet = false;
          })
        }
        .width('100%')
        .padding({ bottom: 20 })
        .borderRadius({ topLeft: 24, topRight: 24 })
        .backgroundColor('#FFFFFF')
        .align(Alignment.Bottom)
        .transition(TransitionEffect.OPACITY.animation({ duration: 200 }).combine(TransitionEffect.translate({ y: 120 })))
      }

      // 收藏成功小弹窗
      if (this.showLikeModal) {
        Column({ space: 10 }) {
          Text('💖')
            .fontSize(36)
          Text('已放进「哄睡必备」')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor('#4A3B32')
          Text(this.likeTargetName + ' 会在磨耳朵时优先出现')
            .fontSize(11)
            .fontColor('#B9A99C')
          Button() {
            Text('好的')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF')
          }
          .width('60%')
          .height(38)
          .borderRadius(19)
          .backgroundColor('#FF8FAB')
          .margin({ top: 6 })
          .onClick(() => {
            this.showLikeModal = false;
          })
        }
        .width('72%')
        .padding(24)
        .borderRadius(22)
        .backgroundColor('#FFFFFF')
        .align(Alignment.Center)
        .transition(TransitionEffect.OPACITY.animation({ duration: 180 }).combine(TransitionEffect.scale({ x: 0.85, y: 0.85 })))
      }
    }
    .width('100%')
    .height('100%')
  }
}
// ============ 磨耳朵 Tab(场景计划宫格 + 定时设置弹窗 + 编辑弹窗) ============
@Component
struct ListenContent {
  @State planIds: number[] = [1, 3, 5]
  @State showTimerModal: boolean = false
  @State showEditModal: boolean = false
  @State timerMin: number = 30
  @State editName: string = ''
  @State editMin: number = 20
  @State curPlan: ScenePlan | null = null

  isOn(id: number): boolean {
    return this.planIds.indexOf(id) >= 0;
  }

  build() {
    Stack() {
      Scroll() {
        Column() {
          // 顶部总览卡
          Column({ space: 8 }) {
            Row({ space: 10 }) {
              Column() {
                Text('🎧')
                  .fontSize(30)
              }
              .width(58)
              .height(58)
              .borderRadius(18)
              .backgroundColor('#E4F7F0')
              .justifyContent(FlexAlign.Center)

              Column({ space: 3 }) {
                Text('今晚磨耳朵计划')
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#4A3B32')
                Text('21:30 自动开始 · 月亮船轻轻摇 等 12 首')
                  .fontSize(10)
                  .fontColor('#B9A99C')
                  .maxLines(1)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })
                Text('定时 ' + this.timerMin + ' 分钟后音量渐弱至关闭')
                  .fontSize(10)
                  .fontColor('#6FB8FF')
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
            }
            .width('100%')

            Row({ space: 10 }) {
              Button() {
                Text('⏱ 调整定时')
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#FFFFFF')
              }
              .layoutWeight(1)
              .height(36)
              .borderRadius(18)
              .backgroundColor('#FFC94D')
              .onClick(() => {
                this.showTimerModal = true;
              })

              Button() {
                Text('▶ 立即开始')
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#FFFFFF')
              }
              .layoutWeight(1)
              .height(36)
              .borderRadius(18)
              .backgroundColor('#55C1A5')
            }
          }
          .width('92%')
          .padding(16)
          .borderRadius(20)
          .backgroundColor('#FFFFFF')
          .margin({ top: 12 })

          // 本周磨耳朵柱状图
          Column({ space: 8 }) {
            Row({ space: 6 }) {
              Text('📊')
                .fontSize(13)
              Text('本周磨耳朵时长(分钟)')
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor('#4A3B32')
                .layoutWeight(1)
              Text('共 364 分钟')
                .fontSize(10)
                .fontColor('#FF8FAB')
            }
            .width('100%')

            Row({  }) {
              ForEach(WEEK_MINUTES, (d: WeekMinute) => {
                Column({ space: 4 }) {
                  Text(d.min.toString())
                    .fontSize(9)
                    .fontColor('#8A7A6E')
                  Column() {
                  }
                  .width(16)
                  .height(minuteBar(d.min))
                  .borderRadius(8)
                  .backgroundColor(d.min >= 60 ? '#FF8FAB' : '#FFD9A8')
                  .animation({ duration: 400, curve: Curve.EaseOut })
                  Text(d.day)
                    .fontSize(10)
                    .fontColor('#B9A99C')
                }
                .alignItems(HorizontalAlign.Center)
                .layoutWeight(1)
              }, (d: WeekMinute) => d.day)
            }
            .width('100%')
            .height(110)
            .alignItems(VerticalAlign.Bottom)

            Text('周六陪听最多,共 78 分钟 🎉')
              .fontSize(10)
              .fontColor('#B9A99C')
          }
          .width('92%')
          .padding(16)
          .borderRadius(20)
          .backgroundColor('#FFFFFF')
          .margin({ top: 12 })

          // 场景计划列表
          Row({ space: 6 }) {
            Text('🧩')
              .fontSize(14)
            Text('场景计划')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#4A3B32')
              .layoutWeight(1)
            Text(this.planIds.length + ' 个进行中')
              .fontSize(10)
              .fontColor('#B9A99C')
          }
          .width('92%')
          .margin({ top: 18, bottom: 8 })

          Column({ space: 10 }) {
            ForEach(SCENE_PLANS, (p: ScenePlan) => {
              Row({ space: 12 }) {
                Column() {
                  Text(p.emoji)
                    .fontSize(24)
                }
                .width(50)
                .height(50)
                .borderRadius(16)
                .backgroundColor(this.isOn(p.id) ? '#FFF1E6' : '#F6F1EC')
                .justifyContent(FlexAlign.Center)

                Column({ space: 3 }) {
                  Row({ space: 6 }) {
                    Text(p.name)
                      .fontSize(13)
                      .fontWeight(FontWeight.Bold)
                      .fontColor('#4A3B32')
                    if (this.isOn(p.id)) {
                      Text('进行中')
                        .fontSize(8)
                        .fontColor('#FFFFFF')
                        .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                        .borderRadius(6)
                        .backgroundColor('#55C1A5')
                    }
                  }
                  Text(p.desc + ' · ' + p.songs)
                    .fontSize(10)
                    .fontColor('#B9A99C')
                    .maxLines(1)
                    .textOverflow({ overflow: TextOverflow.Ellipsis })
                  // 时长进度条
                  Column() {
                    Column() {
                    }
                    .width(Math.min(p.min, 40) / 40 * 100 + '%')
                    .height(4)
                    .borderRadius(2)
                    .backgroundColor('#FFC94D')
                    .animation({ duration: 300 })
                  }
                  .width('100%')
                  .height(4)
                  .borderRadius(2)
                  .backgroundColor('#F6F1EC')
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)

                Column({ space: 6 }) {
                  Text('⚙️')
                    .fontSize(15)
                    .onClick(() => {
                      this.curPlan = p;
                      this.editName = p.name;
                      this.editMin = p.min;
                      this.showEditModal = true;
                    })
                  Text(this.isOn(p.id) ? '⏹' : '▶')
                    .fontSize(14)
                    .fontColor('#FFFFFF')
                    .width(28)
                    .height(28)
                    .borderRadius(14)
                    .backgroundColor(this.isOn(p.id) ? '#FF8FAB' : '#55C1A5')
                    .textAlign(TextAlign.Center)
                    .onClick(() => {
                      if (this.isOn(p.id)) {
                        this.planIds = this.planIds.filter((i: number) => i !== p.id);
                      } else {
                        this.planIds.push(p.id);
                      }
                    })
                }
                .alignItems(HorizontalAlign.Center)
              }
              .width('100%')
              .padding(12)
              .borderRadius(16)
              .backgroundColor('#FFFFFF')
            }, (p: ScenePlan) => p.id.toString() + this.isOn(p.id).toString())
          }
          .width('92%')

          Column() {
            Text('磨耳朵小贴士:0-3 岁每次不超过 30 分钟效果最好 💡')
              .fontSize(10)
              .fontColor('#B9A99C')
              .padding({ top: 16, bottom: 16 })
          }
          .width('100%')
        }
        .width('100%')
        .padding({ bottom: 16 })
      }
      .scrollBar(BarState.Off)
      .width('100%')
      .height('100%')
      .align(Alignment.Top)

      // 定时设置弹窗(分段选择样式)
      if (this.showTimerModal) {
        Column() {
          Text('⏰ 定时关闭设置')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#4A3B32')
            .padding({ top: 20 })

          Row({ space: 8 }) {
            ForEach([15, 30, 45], (m: number) => {
              Column({ space: 3 }) {
                Text(m.toString())
                  .fontSize(18)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(this.timerMin === m ? '#FF8FAB' : '#8A7A6E')
                Text('分钟')
                  .fontSize(9)
                  .fontColor('#B9A99C')
              }
              .layoutWeight(1)
              .padding({ top: 12, bottom: 12 })
              .borderRadius(14)
              .backgroundColor(this.timerMin === m ? '#FFE9EF' : '#FFF7EF')
              .border(this.timerMin === m ? { width: 2, color: '#FF8FAB' } : { width: 2, color: '#FFF7EF' })
              .animation({ duration: 160 })
              .onClick(() => {
                this.timerMin = m;
              })
            }, (m: number) => m.toString())
          }
          .width('100%')
          .margin({ top: 14, left: 20, right: 20 })

          Column({ space: 6 }) {
            Text('温柔结束:最后 3 分钟音量渐弱,不打断睡意')
              .fontSize(10)
              .fontColor('#55C1A5')
            Text('结束后自动锁定屏幕,防止宝宝乱点')
              .fontSize(10)
              .fontColor('#6FB8FF')
          }
          .margin({ top: 12 })

          Row({ space: 10 }) {
            Button() {
              Text('取消')
                .fontSize(13)
                .fontColor('#B9A99C')
            }
            .layoutWeight(1)
            .height(40)
            .borderRadius(20)
            .backgroundColor('#FFF7EF')
            .onClick(() => {
              this.showTimerModal = false;
            })

            Button() {
              Text('保存定时')
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
            }
            .layoutWeight(1)
            .height(40)
            .borderRadius(20)
            .backgroundColor('#FF8FAB')
            .onClick(() => {
              this.showTimerModal = false;
            })
          }
          .width('86%')
          .margin({ top: 18, bottom: 20 })
        }
        .width('84%')
        .borderRadius(24)
        .backgroundColor('#FFFFFF')
        .align(Alignment.Center)
        .transition(TransitionEffect.OPACITY.animation({ duration: 180 }).combine(TransitionEffect.scale({ x: 0.9, y: 0.9 })))
      }

      // 编辑场景弹窗(表单样式)
      if (this.showEditModal && this.curPlan !== null) {
        Column({ space: 12 }) {
          Text('⚙️ 修改场景计划')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#4A3B32')
            .padding({ top: 20 })

          Column({ space: 4 }) {
            Text('场景名称')
              .fontSize(11)
              .fontColor('#8A7A6E')
            TextInput({ text: this.editName, placeholder: '给场景起个名字' })
              .fontSize(13)
              .fontColor('#4A3B32')
              .height(40)
              .borderRadius(12)
              .backgroundColor('#FFF7EF')
              .onChange((v: string) => {
                this.editName = v;
              })
          }
          .width('84%')
          .alignItems(HorizontalAlign.Start)

          Column({ space: 4 }) {
            Text('播放时长:' + this.editMin + ' 分钟')
              .fontSize(11)
              .fontColor('#8A7A6E')
            Row({ space: 8 }) {
              ForEach([10, 20, 30, 40], (m: number) => {
                Text(m.toString())
                  .fontSize(12)
                  .fontColor(this.editMin === m ? '#FFFFFF' : '#8A7A6E')
                  .padding({ left: 14, right: 14, top: 6, bottom: 6 })
                  .borderRadius(14)
                  .backgroundColor(this.editMin === m ? '#FFC94D' : '#FFF7EF')
                  .onClick(() => {
                    this.editMin = m;
                  })
              }, (m: number) => m.toString())
            }
          }
          .width('84%')
          .alignItems(HorizontalAlign.Start)

          Row({ space: 10 }) {
            Button() {
              Text('放弃修改')
                .fontSize(13)
                .fontColor('#B9A99C')
            }
            .layoutWeight(1)
            .height(40)
            .borderRadius(20)
            .backgroundColor('#FFF7EF')
            .onClick(() => {
              this.showEditModal = false;
            })

            Button() {
              Text('保存计划')
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
            }
            .layoutWeight(1)
            .height(40)
            .borderRadius(20)
            .backgroundColor('#55C1A5')
            .onClick(() => {
              this.showEditModal = false;
            })
          }
          .width('84%')
          .margin({ top: 6, bottom: 20 })
        }
        .width('88%')
        .borderRadius(24)
        .backgroundColor('#FFFFFF')
        .align(Alignment.Center)
        .transition(TransitionEffect.OPACITY.animation({ duration: 180 }).combine(TransitionEffect.scale({ x: 0.9, y: 0.9 })))
      }
    }
    .width('100%')
    .height('100%')
  }
}
// ============ 收藏 Tab(双列收藏夹卡片 + 新建/重命名/删除弹窗组合) ============
@Component
struct FavoriteContent {
  @State folders: FavFolder[] = FAV_FOLDERS
  @State showCreateModal: boolean = false
  @State showRenameModal: boolean = false
  @State showDeleteModal: boolean = false
  @State newName: string = ''
  @State newEmoji: string = '🌟'
  @State renameIdx: number = -1
  @State renameText: string = ''
  @State deleteIdx: number = -1
  @State deleteName: string = ''

  build() {
    Stack() {
      Scroll() {
        Column() {
          // 顶部概览
          Row({ space: 12 }) {
            Column() {
              Text('82')
                .fontSize(26)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FF8FAB')
              Text('收藏儿歌')
                .fontSize(10)
                .fontColor('#B9A99C')
            }
            .layoutWeight(1)
            .padding({ top: 14, bottom: 14 })
            .borderRadius(16)
            .backgroundColor('#FFFFFF')

            Column() {
              Text('6')
                .fontSize(26)
                .fontWeight(FontWeight.Bold)
                .fontColor('#55C1A5')
              Text('收藏夹')
                .fontSize(10)
                .fontColor('#B9A99C')
            }
            .layoutWeight(1)
            .padding({ top: 14, bottom: 14 })
            .borderRadius(16)
            .backgroundColor('#FFFFFF')

            Column() {
              Text('68')
                .fontSize(26)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFC94D')
              Text('连续打卡')
                .fontSize(10)
                .fontColor('#B9A99C')
            }
            .layoutWeight(1)
            .padding({ top: 14, bottom: 14 })
            .borderRadius(16)
            .backgroundColor('#FFFFFF')
          }
          .width('92%')
          .margin({ top: 12 })

          // 新建入口条
          Row({ space: 8 }) {
            Text('➕')
              .fontSize(14)
            Text('新建一个收藏夹,装满豆豆的爱听')
              .fontSize(12)
              .fontColor('#8A7A6E')
              .layoutWeight(1)
            Text('创建')
              .fontSize(12)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF')
              .padding({ left: 14, right: 14, top: 6, bottom: 6 })
              .borderRadius(14)
              .backgroundColor('#FF8FAB')
              .onClick(() => {
                this.newName = '';
                this.showCreateModal = true;
              })
          }
          .width('92%')
          .padding(14)
          .borderRadius(16)
          .backgroundColor('#FFFFFF')
          .margin({ top: 12 })

          // 双列收藏夹网格
          Row({ space: 6 }) {
            Text('💖')
              .fontSize(14)
            Text('全家收藏夹')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#4A3B32')
              .layoutWeight(1)
          }
          .width('92%')
          .margin({ top: 18, bottom: 8 })

          Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
            ForEach(this.folders, (f: FavFolder, idx: number) => {
              Column({ space: 8 }) {
                Column({ space: 4 }) {
                  Text(f.emoji)
                    .fontSize(30)
                  Text(f.count + ' 首')
                    .fontSize(10)
                    .fontColor('#B9A99C')
                }
                .width('100%')
                .padding({ top: 14, bottom: 14 })
                .borderRadius(14)
                .backgroundColor('#FFF7EF')
                .alignItems(HorizontalAlign.Center)

                Text(f.name)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#4A3B32')
                  .maxLines(1)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })

                Row({ space: 8 }) {
                  Text(f.open === '私密' ? '🔒 私密' : '👨‍👩‍👧 家庭共享')
                    .fontSize(9)
                    .fontColor(f.open === '私密' ? '#B9A99C' : '#55C1A5')
                    .layoutWeight(1)
                  Text('✏️')
                    .fontSize(13)
                    .onClick(() => {
                      this.renameIdx = idx;
                      this.renameText = f.name;
                      this.showRenameModal = true;
                    })
                  Text('🗑')
                    .fontSize(13)
                    .onClick(() => {
                      this.deleteIdx = idx;
                      this.deleteName = f.name;
                      this.showDeleteModal = true;
                    })
                }
                .width('100%')
              }
              .width('48.5%')
              .padding(12)
              .borderRadius(18)
              .backgroundColor('#FFFFFF')
              .margin({ bottom: 10 })
            }, (f: FavFolder) => f.id.toString())
          }
          .width('92%')

          Column() {
            Text('收藏夹会同步到全家人的设备上 💕')
              .fontSize(10)
              .fontColor('#B9A99C')
              .padding({ top: 14, bottom: 16 })
          }
          .width('100%')
        }
        .width('100%')
        .padding({ bottom: 16 })
      }
      .scrollBar(BarState.Off)
      .width('100%')
      .height('100%')
      .align(Alignment.Top)

      // 新建收藏夹弹窗(表情选择样式)
      if (this.showCreateModal) {
        Column({ space: 12 }) {
          Text('➕ 新建收藏夹')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#4A3B32')
            .padding({ top: 20 })

          Column({ space: 4 }) {
            Text('收藏夹名字')
              .fontSize(11)
              .fontColor('#8A7A6E')
            TextInput({ text: this.newName, placeholder: '例如:下雨天的歌' })
              .fontSize(13)
              .fontColor('#4A3B32')
              .height(40)
              .borderRadius(12)
              .backgroundColor('#FFF7EF')
              .onChange((v: string) => {
                this.newName = v;
              })
          }
          .width('84%')
          .alignItems(HorizontalAlign.Start)

          Column({ space: 6 }) {
            Text('选个封面表情')
              .fontSize(11)
              .fontColor('#8A7A6E')
            Row({ space: 8 }) {
              ForEach(['🌟', '🌙', '🐣', '🍓', '🌈', '🎵'], (e: string) => {
                Text(e)
                  .fontSize(22)
                  .width(44)
                  .height(44)
                  .borderRadius(14)
                  .textAlign(TextAlign.Center)
                  .backgroundColor(this.newEmoji === e ? '#FFE9EF' : '#FFF7EF')
                  .border(this.newEmoji === e ? { width: 2, color: '#FF8FAB' } : { width: 2, color: '#FFF7EF' })
                  .onClick(() => {
                    this.newEmoji = e;
                  })
              }, (e: string) => e)
            }
          }
          .width('84%')
          .alignItems(HorizontalAlign.Start)

          Row({ space: 10 }) {
            Button() {
              Text('取消')
                .fontSize(13)
                .fontColor('#B9A99C')
            }
            .layoutWeight(1)
            .height(40)
            .borderRadius(20)
            .backgroundColor('#FFF7EF')
            .onClick(() => {
              this.showCreateModal = false;
            })

            Button() {
              Text('创建')
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
            }
            .layoutWeight(1)
            .height(40)
            .borderRadius(20)
            .backgroundColor('#FFC94D')
            .onClick(() => {
              this.folders.push({ id: 100 + this.folders.length, name: this.newName === '' ? '未命名夹子' : this.newName, emoji: this.newEmoji, count: 0, open: '私密' });
              this.showCreateModal = false;
            })
          }
          .width('84%')
          .margin({ top: 6, bottom: 20 })
        }
        .width('88%')
        .borderRadius(24)
        .backgroundColor('#FFFFFF')
        .align(Alignment.Center)
        .transition(TransitionEffect.OPACITY.animation({ duration: 180 }).combine(TransitionEffect.scale({ x: 0.9, y: 0.9 })))
      }

      // 重命名弹窗
      if (this.showRenameModal) {
        Column({ space: 12 }) {
          Text('✏️ 重命名收藏夹')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#4A3B32')
            .padding({ top: 20 })

          TextInput({ text: this.renameText, placeholder: '输入新名字' })
            .fontSize(13)
            .fontColor('#4A3B32')
            .height(42)
            .borderRadius(12)
            .backgroundColor('#FFF7EF')
            .onChange((v: string) => {
              this.renameText = v;
            })
            .width('84%')

          Text('重命名后全家人都会看到新名字')
            .fontSize(10)
            .fontColor('#B9A99C')

          Row({ space: 10 }) {
            Button() {
              Text('取消')
                .fontSize(13)
                .fontColor('#B9A99C')
            }
            .layoutWeight(1)
            .height(40)
            .borderRadius(20)
            .backgroundColor('#FFF7EF')
            .onClick(() => {
              this.showRenameModal = false;
            })

            Button() {
              Text('确认改名')
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
            }
            .layoutWeight(1)
            .height(40)
            .borderRadius(20)
            .backgroundColor('#6FB8FF')
            .onClick(() => {
              if (this.renameIdx >= 0 && this.renameIdx < this.folders.length) {
                this.folders[this.renameIdx] = { id: this.folders[this.renameIdx].id, name: this.renameText === '' ? this.folders[this.renameIdx].name : this.renameText, emoji: this.folders[this.renameIdx].emoji, count: this.folders[this.renameIdx].count, open: this.folders[this.renameIdx].open };
              }
              this.showRenameModal = false;
            })
          }
          .width('84%')
          .margin({ top: 6, bottom: 20 })
        }
        .width('86%')
        .borderRadius(24)
        .backgroundColor('#FFFFFF')
        .align(Alignment.Center)
        .transition(TransitionEffect.OPACITY.animation({ duration: 180 }).combine(TransitionEffect.scale({ x: 0.9, y: 0.9 })))
      }

      // 删除危险确认弹窗
      if (this.showDeleteModal) {
        Column({ space: 10 }) {
          Text('🗑')
            .fontSize(36)
            .padding({ top: 20 })
          Text('删除收藏夹?')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#E85D75')
          Text('「' + this.deleteName + '」将被删除,夹内歌曲不会从曲库移除,仅解除整理关系。')
            .fontSize(11)
            .fontColor('#B9A99C')
            .textAlign(TextAlign.Center)
            .padding({ left: 24, right: 24 })

          Row({ space: 10 }) {
            Button() {
              Text('我再想想')
                .fontSize(13)
                .fontColor('#8A7A6E')
            }
            .layoutWeight(1)
            .height(40)
            .borderRadius(20)
            .backgroundColor('#FFF7EF')
            .onClick(() => {
              this.showDeleteModal = false;
            })

            Button() {
              Text('确认删除')
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
            }
            .layoutWeight(1)
            .height(40)
            .borderRadius(20)
            .backgroundColor('#E85D75')
            .onClick(() => {
              if (this.deleteIdx >= 0 && this.deleteIdx < this.folders.length) {
                this.folders.splice(this.deleteIdx, 1);
              }
              this.showDeleteModal = false;
            })
          }
          .width('84%')
          .margin({ top: 8, bottom: 20 })
        }
        .width('80%')
        .borderRadius(24)
        .backgroundColor('#FFFFFF')
        .align(Alignment.Center)
        .transition(TransitionEffect.OPACITY.animation({ duration: 180 }).combine(TransitionEffect.scale({ x: 0.9, y: 0.9 })))
      }
    }
    .width('100%')
    .height('100%')
  }
}
// ============ 成长 Tab(打卡日历 + 唱会进度 + 打卡弹窗 + 分享弹窗) ============
@Component
struct GrowContent {
  @State dayChecked: number[] = [1, 2, 3, 4, 5]
  @State showCheckModal: boolean = false
  @State showShareModal: boolean = false
  @State checkSong: string = ''

  isChecked(d: number): boolean {
    return this.dayChecked.indexOf(d) >= 0;
  }

  build() {
    Stack() {
      Scroll() {
        Column() {
          // 宝贝成长卡
          Row({ space: 14 }) {
            Column() {
              Text('🐣')
                .fontSize(38)
            }
            .width(74)
            .height(74)
            .borderRadius(24)
            .backgroundColor('#E8F4FF')
            .justifyContent(FlexAlign.Center)

            Column({ space: 5 }) {
              Text('豆豆的磨耳朵成长记')
                .fontSize(15)
                .fontWeight(FontWeight.Bold)
                .fontColor('#4A3B32')
              Row({ space: 6 }) {
                Text('2 岁 8 个月')
                  .fontSize(10)
                  .fontColor('#6FB8FF')
                  .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                  .borderRadius(8)
                  .backgroundColor('#E8F4FF')
                Text('已会唱 23 首')
                  .fontSize(10)
                  .fontColor('#FF8FAB')
                  .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                  .borderRadius(8)
                  .backgroundColor('#FFE9EF')
              }
              Text('距离「小小歌唱家」徽章还差 2 首 🎖')
                .fontSize(10)
                .fontColor('#B9A99C')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
          }
          .width('92%')
          .padding(16)
          .borderRadius(20)
          .backgroundColor('#FFFFFF')
          .margin({ top: 12 })

          // 本月打卡日历
          Column({ space: 10 }) {
            Row({ space: 6 }) {
              Text('📅')
                .fontSize(13)
              Text('本月磨耳朵打卡')
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor('#4A3B32')
                .layoutWeight(1)
              Text(this.dayChecked.length + ' / 27 天')
                .fontSize(10)
                .fontColor('#55C1A5')
            }
            .width('100%')

            Flex({ wrap: FlexWrap.Wrap }) {
              ForEach([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27], (d: number) => {
                Text(this.isChecked(d) ? '⭐' : d.toString())
                  .fontSize(11)
                  .fontColor(this.isChecked(d) ? '#FFC94D' : '#B9A99C')
                  .width(34)
                  .height(34)
                  .margin(3)
                  .borderRadius(12)
                  .backgroundColor(this.isChecked(d) ? '#FFF3D6' : '#F6F1EC')
                  .textAlign(TextAlign.Center)
                  .scale(this.isChecked(d) ? { x: 1.08, y: 1.08 } : { x: 1, y: 1 })
                  .animation({ duration: 180, curve: Curve.EaseOut })
                  .onClick(() => {
                    if (!this.isChecked(d)) {
                      this.checkSong = '小星星洗澡澡';
                      this.showCheckModal = true;
                    }
                  })
              }, (d: number) => d.toString() + this.isChecked(d).toString())
            }
            .width('100%')

            Text('连续打卡 5 天,再坚持 2 天就能点亮「坚持一周」勋章 ✨')
              .fontSize(10)
              .fontColor('#B9A99C')
          }
          .width('92%')
          .padding(16)
          .borderRadius(20)
          .backgroundColor('#FFFFFF')
          .margin({ top: 12 })

          // 能力成长进度条
          Column({ space: 12 }) {
            Text('🌱 能力成长曲线')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor('#4A3B32')
              .width('100%')

            ForEach(SKILL_STATS, (s: SkillStat) => {
              Row({ space: 10 }) {
                Text(s.emoji)
                  .fontSize(16)
                Column({ space: 4 }) {
                  Row({ space: 6 }) {
                    Text(s.name)
                      .fontSize(11)
                      .fontColor('#8A7A6E')
                      .layoutWeight(1)
                    Text(s.percent + '%')
                      .fontSize(11)
                      .fontWeight(FontWeight.Bold)
                      .fontColor('#FF8FAB')
                  }
                  .width('100%')

                  Column() {
                    Column() {
                    }
                    .width(s.percent + '%')
                    .height(6)
                    .borderRadius(3)
                    .backgroundColor(s.percent >= 75 ? '#FF8FAB' : '#FFD9A8')
                    .animation({ duration: 400, curve: Curve.EaseOut })
                  }
                  .width('100%')
                  .height(6)
                  .borderRadius(3)
                  .backgroundColor('#F6F1EC')
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)
              }
              .width('100%')
            }, (s: SkillStat) => s.name)
          }
          .width('92%')
          .padding(16)
          .borderRadius(20)
          .backgroundColor('#FFFFFF')
          .margin({ top: 12 })

          // 成长记录列表
          Row({ space: 6 }) {
            Text('📖')
              .fontSize(14)
            Text('最近跟唱记录')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#4A3B32')
              .layoutWeight(1)
            Text('分享')
              .fontSize(11)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF')
              .padding({ left: 12, right: 12, top: 5, bottom: 5 })
              .borderRadius(12)
              .backgroundColor('#FFC94D')
              .onClick(() => {
                this.showShareModal = true;
              })
          }
          .width('92%')
          .margin({ top: 18, bottom: 8 })

          Column({ space: 10 }) {
            ForEach(GROW_LOGS, (g: GrowLog) => {
              Row({ space: 12 }) {
                Column() {
                  Text(g.medal)
                    .fontSize(22)
                }
                .width(46)
                .height(46)
                .borderRadius(14)
                .backgroundColor('#FFF7EF')
                .justifyContent(FlexAlign.Center)

                Column({ space: 3 }) {
                  Text(g.song)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#4A3B32')
                    .maxLines(1)
                    .textOverflow({ overflow: TextOverflow.Ellipsis })
                  Text(g.date + ' · 跟唱评分 ' + g.score + ' 分')
                    .fontSize(10)
                    .fontColor('#B9A99C')
                  // 评分进度条
                  Column() {
                    Column() {
                    }
                    .width(g.score + '%')
                    .height(4)
                    .borderRadius(2)
                    .backgroundColor(scoreColor(g.score))
                    .animation({ duration: 300 })
                  }
                  .width('100%')
                  .height(4)
                  .borderRadius(2)
                  .backgroundColor('#F6F1EC')
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)

                Column() {
                  Text(g.score.toString())
                    .fontSize(16)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(scoreColor(g.score))
                }
              }
              .width('100%')
              .padding(12)
              .borderRadius(16)
              .backgroundColor('#FFFFFF')
            }, (g: GrowLog) => g.id.toString())
          }
          .width('92%')

          Column() {
            Text('每天一首歌,成长看得见 🌻')
              .fontSize(10)
              .fontColor('#B9A99C')
              .padding({ top: 16, bottom: 16 })
          }
          .width('100%')
        }
        .width('100%')
        .padding({ bottom: 16 })
      }
      .scrollBar(BarState.Off)
      .width('100%')
      .height('100%')
      .align(Alignment.Top)

      // 今日打卡弹窗
      if (this.showCheckModal) {
        Column({ space: 10 }) {
          Text('🌟')
            .fontSize(40)
            .padding({ top: 20 })
          Text('今日磨耳朵打卡')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#4A3B32')
          Text('完成《' + this.checkSong + '》跟唱,为豆豆点亮一颗小星星')
            .fontSize(11)
            .fontColor('#B9A99C')
            .textAlign(TextAlign.Center)
            .padding({ left: 24, right: 24 })

          Row({ space: 12 }) {
            Column({ space: 2 }) {
              Text('5')
                .fontSize(18)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FF8FAB')
              Text('连续天数')
                .fontSize(9)
                .fontColor('#B9A99C')
            }
            .layoutWeight(1)
            .padding({ top: 10, bottom: 10 })
            .borderRadius(14)
            .backgroundColor('#FFF7EF')

            Column({ space: 2 }) {
              Text('27')
                .fontSize(18)
                .fontWeight(FontWeight.Bold)
                .fontColor('#55C1A5')
              Text('本月天数')
                .fontSize(9)
                .fontColor('#B9A99C')
            }
            .layoutWeight(1)
            .padding({ top: 10, bottom: 10 })
            .borderRadius(14)
            .backgroundColor('#FFF7EF')

            Column({ space: 2 }) {
              Text('23')
                .fontSize(18)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFC94D')
              Text('会唱总数')
                .fontSize(9)
                .fontColor('#B9A99C')
            }
            .layoutWeight(1)
            .padding({ top: 10, bottom: 10 })
            .borderRadius(14)
            .backgroundColor('#FFF7EF')
          }
          .width('84%')
          .margin({ top: 6 })

          Button() {
            Text('点亮小星星 ⭐')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF')
          }
          .width('60%')
          .height(42)
          .borderRadius(21)
          .backgroundColor('#FFC94D')
          .margin({ top: 10, bottom: 20 })
          .onClick(() => {
            this.dayChecked.push(6);
            this.showCheckModal = false;
          })
        }
        .width('82%')
        .borderRadius(24)
        .backgroundColor('#FFFFFF')
        .align(Alignment.Center)
        .transition(TransitionEffect.OPACITY.animation({ duration: 180 }).combine(TransitionEffect.scale({ x: 0.88, y: 0.88 })))
      }

      // 分享成绩单弹窗
      if (this.showShareModal) {
        Column({ space: 10 }) {
          Text('🏅')
            .fontSize(38)
            .padding({ top: 20 })
          Text('生成豆豆的成长周报')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#4A3B32')
          Text('本周磨耳朵 364 分钟 · 会唱 5 首新歌 · 打卡 5 天')
            .fontSize(11)
            .fontColor('#55C1A5')
            .textAlign(TextAlign.Center)
            .padding({ left: 20, right: 20 })

          Column({ space: 6 }) {
            Text('🖼 带上海报封面')
              .fontSize(11)
              .fontColor('#8A7A6E')
            Text('🔗 复制成绩链接')
              .fontSize(11)
              .fontColor('#8A7A6E')
            Text('👨‍👩‍👧 发到家庭群')
              .fontSize(11)
              .fontColor('#8A7A6E')
          }
          .padding(14)
          .borderRadius(16)
          .backgroundColor('#FFF7EF')
          .margin({ top: 4 })

          Button() {
            Text('关闭')
              .fontSize(13)
              .fontColor('#B9A99C')
          }
          .width('60%')
          .height(38)
          .borderRadius(19)
          .backgroundColor('#FFF7EF')
          .margin({ top: 6, bottom: 20 })
          .onClick(() => {
            this.showShareModal = false;
          })
        }
        .width('78%')
        .borderRadius(24)
        .backgroundColor('#FFFFFF')
        .align(Alignment.Center)
        .transition(TransitionEffect.OPACITY.animation({ duration: 180 }).combine(TransitionEffect.scale({ x: 0.88, y: 0.88 })))
      }
    }
    .width('100%')
    .height('100%')
  }
}
// ============ 儿歌榜 Tab(排行榜 + 投票弹窗 + 换一批确认) ============
@Component
struct ChartContent {
  @State voteIds: number[] = [1, 4]
  @State showVoteModal: boolean = false
  @State showResetModal: boolean = false

  isVoted(id: number): boolean {
    return this.voteIds.indexOf(id) >= 0;
  }

  build() {
    Stack() {
      Scroll() {
        Column() {
          // 榜单头卡
          Column({ space: 6 }) {
            Text('🏆 星球儿歌榜')
              .fontSize(17)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF')
            Text('每小时更新 · 由 32 万个家庭的播放生成')
              .fontSize(10)
              .fontColor('rgba(255,255,255,0.8)')
          }
          .width('92%')
          .padding({ top: 18, bottom: 18 })
          .borderRadius(20)
          .backgroundColor('#FF8FAB')
          .alignItems(HorizontalAlign.Center)
          .margin({ top: 12 })

          // 前三甲展示台
          Row({ space: 8 }) {
            ForEach(CHART_SONGS.slice(0, 3), (s: ChartSong) => {
              Column({ space: 5 }) {
                Text(s.rank === 1 ? '👑' : (s.rank === 2 ? '🥈' : '🥉'))
                  .fontSize(26)
                Text(s.title)
                  .fontSize(11)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#4A3B32')
                  .maxLines(1)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })
                Text(s.singer)
                  .fontSize(9)
                  .fontColor('#B9A99C')
                Text('🔥 ' + s.hots)
                  .fontSize(9)
                  .fontColor('#FF8FAB')
              }
              .layoutWeight(1)
              .padding({ top: 14, bottom: 14, left: 4, right: 4 })
              .borderRadius(16)
              .backgroundColor(s.rank === 1 ? '#FFF3D6' : '#FFFFFF')
            }, (s: ChartSong) => s.id.toString())
          }
          .width('92%')
          .margin({ top: 12 })

          // 投票入口
          Row({ space: 8 }) {
            Text('🗳')
              .fontSize(14)
            Text('本周我最爱:可投 3 首,已投 ' + this.voteIds.length + ' 首')
              .fontSize(11)
              .fontColor('#8A7A6E')
              .layoutWeight(1)
            Text('去投票')
              .fontSize(11)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF')
              .padding({ left: 12, right: 12, top: 5, bottom: 5 })
              .borderRadius(12)
              .backgroundColor('#6FB8FF')
              .onClick(() => {
                this.showVoteModal = true;
              })
          }
          .width('92%')
          .padding(12)
          .borderRadius(16)
          .backgroundColor('#FFFFFF')
          .margin({ top: 12 })

          // 榜单列表
          Row({ space: 6 }) {
            Text('📈')
              .fontSize(14)
            Text('完整榜单')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#4A3B32')
              .layoutWeight(1)
            Text('重置我的投票')
              .fontSize(10)
              .fontColor('#B9A99C')
              .onClick(() => {
                this.showResetModal = true;
              })
          }
          .width('92%')
          .margin({ top: 18, bottom: 8 })

          Column({ space: 10 }) {
            ForEach(CHART_SONGS, (s: ChartSong) => {
              Row({ space: 12 }) {
                Column() {
                  Text(s.rank.toString())
                    .fontSize(16)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(rankColor(s.rank))
                }
                .width(34)

                Column() {
                  Text('🎵')
                    .fontSize(20)
                }
                .width(44)
                .height(44)
                .borderRadius(14)
                .backgroundColor('#FFF1E6')
                .justifyContent(FlexAlign.Center)

                Column({ space: 3 }) {
                  Row({ space: 6 }) {
                    Text(s.title)
                      .fontSize(13)
                      .fontWeight(FontWeight.Bold)
                      .fontColor('#4A3B32')
                      .maxLines(1)
                      .textOverflow({ overflow: TextOverflow.Ellipsis })
                      .layoutWeight(1)
                    Text(trendIcon(s.trend))
                      .fontSize(13)
                      .fontWeight(FontWeight.Bold)
                      .fontColor(trendColor(s.trend))
                  }
                  .width('100%')

                  Row({ space: 8 }) {
                    Text(s.singer)
                      .fontSize(10)
                      .fontColor('#B9A99C')
                    Text(s.weeks)
                      .fontSize(9)
                      .fontColor('#B9A99C')
                      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                      .borderRadius(6)
                      .backgroundColor('#F6F1EC')
                  }
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)

                Column({ space: 4 }) {
                  Text(this.isVoted(s.id) ? '💗' : '🗳')
                    .fontSize(17)
                    .scale(this.isVoted(s.id) ? { x: 1.2, y: 1.2 } : { x: 1, y: 1 })
                    .animation({ duration: 200, curve: Curve.EaseOut })
                    .onClick(() => {
                      if (this.voteIds.length >= 3 && !this.isVoted(s.id)) {
                        this.showVoteModal = true;
                      } else if (this.isVoted(s.id)) {
                        this.voteIds = this.voteIds.filter((i: number) => i !== s.id);
                      } else {
                        this.voteIds.push(s.id);
                      }
                    })
                  Text(s.hots)
                    .fontSize(8)
                    .fontColor('#B9A99C')
                }
                .alignItems(HorizontalAlign.Center)
              }
              .width('100%')
              .padding(12)
              .borderRadius(16)
              .backgroundColor('#FFFFFF')
            }, (s: ChartSong) => s.id.toString() + this.isVoted(s.id).toString())
          }
          .width('92%')

          Column() {
            Text('投票会在每周一 0 点结算,冠军进首页大轮播 🎉')
              .fontSize(10)
              .fontColor('#B9A99C')
              .padding({ top: 16, bottom: 16 })
          }
          .width('100%')
        }
        .width('100%')
        .padding({ bottom: 16 })
      }
      .scrollBar(BarState.Off)
      .width('100%')
      .height('100%')
      .align(Alignment.Top)

      // 投票规则弹窗
      if (this.showVoteModal) {
        Column({ space: 12 }) {
          Text('🗳 投票规则')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#4A3B32')
            .padding({ top: 20 })

          Column({ space: 8 }) {
            Row({ space: 8 }) {
              Text('1️⃣')
                .fontSize(14)
              Text('每位家长每周最多投 3 首儿歌')
                .fontSize(11)
                .fontColor('#8A7A6E')
                .layoutWeight(1)
            }
            .width('100%')

            Row({ space: 8 }) {
              Text('2️⃣')
                .fontSize(14)
              Text('投票满 3 首可领「星币」兑换绘本')
                .fontSize(11)
                .fontColor('#8A7A6E')
                .layoutWeight(1)
            }
            .width('100%')

            Row({ space: 8 }) {
              Text('3️⃣')
                .fontSize(14)
              Text('宝宝跟唱过的歌曲投票权重翻倍')
                .fontSize(11)
                .fontColor('#8A7A6E')
                .layoutWeight(1)
            }
            .width('100%')
          }
          .width('84%')
          .padding(14)
          .borderRadius(16)
          .backgroundColor('#FFF7EF')

          Text('当前已投 ' + this.voteIds.length + ' / 3 首')
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor('#6FB8FF')

          Button() {
            Text('知道啦')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF')
          }
          .width('60%')
          .height(40)
          .borderRadius(20)
          .backgroundColor('#6FB8FF')
          .margin({ top: 4, bottom: 20 })
          .onClick(() => {
            this.showVoteModal = false;
          })
        }
        .width('82%')
        .borderRadius(24)
        .backgroundColor('#FFFFFF')
        .align(Alignment.Center)
        .transition(TransitionEffect.OPACITY.animation({ duration: 180 }).combine(TransitionEffect.scale({ x: 0.9, y: 0.9 })))
      }

      // 重置投票确认弹窗
      if (this.showResetModal) {
        Column({ space: 10 }) {
          Text('🔄')
            .fontSize(36)
            .padding({ top: 20 })
          Text('清空本周投票?')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#4A3B32')
          Text('已投的 ' + this.voteIds.length + ' 首将全部撤回,星币进度也会回退。')
            .fontSize(11)
            .fontColor('#B9A99C')
            .textAlign(TextAlign.Center)
            .padding({ left: 24, right: 24 })

          Row({ space: 10 }) {
            Button() {
              Text('先不清空')
                .fontSize(13)
                .fontColor('#8A7A6E')
            }
            .layoutWeight(1)
            .height(40)
            .borderRadius(20)
            .backgroundColor('#FFF7EF')
            .onClick(() => {
              this.showResetModal = false;
            })

            Button() {
              Text('清空重来')
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
            }
            .layoutWeight(1)
            .height(40)
            .borderRadius(20)
            .backgroundColor('#6FB8FF')
            .onClick(() => {
              this.voteIds = [];
              this.showResetModal = false;
            })
          }
          .width('84%')
          .margin({ top: 8, bottom: 20 })
        }
        .width('80%')
        .borderRadius(24)
        .backgroundColor('#FFFFFF')
        .align(Alignment.Center)
        .transition(TransitionEffect.OPACITY.animation({ duration: 180 }).combine(TransitionEffect.scale({ x: 0.9, y: 0.9 })))
      }
    }
    .width('100%')
    .height('100%')
  }
}
// ============ 我的 Tab(陪伴柱状图 + 宝贝资料编辑 + 关于 + 退出登录) ============
@Component
struct MeKidContent {
  @State showEditModal: boolean = false
  @State showAboutModal: boolean = false
  @State showLogoutModal: boolean = false
  @State kidName: string = '豆豆'
  @State kidAge: string = '2 岁 8 个月'
  @State kidAvatar: string = '🐣'

  build() {
    Stack() {
      Scroll() {
        Column() {
          // 家庭卡
          Row({ space: 14 }) {
            Column() {
              Text(this.kidAvatar)
                .fontSize(40)
            }
            .width(80)
            .height(80)
            .borderRadius(26)
            .backgroundColor('#E8F4FF')
            .justifyContent(FlexAlign.Center)

            Column({ space: 5 }) {
              Text(this.kidName + ' 的音乐小屋')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#4A3B32')
              Text(this.kidAge + ' · 磨耳朵第 68 天 · 家庭会员')
                .fontSize(10)
                .fontColor('#FF8FAB')
              Row({ space: 6 }) {
                Text('👨 妈妈')
                  .fontSize(9)
                  .fontColor('#8A7A6E')
                  .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                  .borderRadius(8)
                  .backgroundColor('#FFF7EF')
                Text('👩 爸爸')
                  .fontSize(9)
                  .fontColor('#8A7A6E')
                  .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                  .borderRadius(8)
                  .backgroundColor('#FFF7EF')
                Text('👵 外婆')
                  .fontSize(9)
                  .fontColor('#8A7A6E')
                  .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                  .borderRadius(8)
                  .backgroundColor('#FFF7EF')
              }
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Text('✏️')
              .fontSize(16)
              .padding(8)
              .borderRadius(14)
              .backgroundColor('#FFFFFF')
              .onClick(() => {
                this.showEditModal = true;
              })
          }
          .width('92%')
          .padding(16)
          .borderRadius(20)
          .backgroundColor('#FFFFFF')
          .margin({ top: 12 })

          // 数据三连
          Row({ space: 10 }) {
            Column({ space: 3 }) {
              Text('364')
                .fontSize(20)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FF8FAB')
              Text('本周陪伴分钟')
                .fontSize(9)
                .fontColor('#B9A99C')
            }
            .layoutWeight(1)
            .padding({ top: 14, bottom: 14 })
            .borderRadius(16)
            .backgroundColor('#FFFFFF')

            Column({ space: 3 }) {
              Text('23')
                .fontSize(20)
                .fontWeight(FontWeight.Bold)
                .fontColor('#55C1A5')
              Text('累计会唱')
                .fontSize(9)
                .fontColor('#B9A99C')
            }
            .layoutWeight(1)
            .padding({ top: 14, bottom: 14 })
            .borderRadius(16)
            .backgroundColor('#FFFFFF')

            Column({ space: 3 }) {
              Text('4')
                .fontSize(20)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFC94D')
              Text('成长勋章')
                .fontSize(9)
                .fontColor('#B9A99C')
            }
            .layoutWeight(1)
            .padding({ top: 14, bottom: 14 })
            .borderRadius(16)
            .backgroundColor('#FFFFFF')
          }
          .width('92%')
          .margin({ top: 12 })

          // 每日陪伴柱状图
          Column({ space: 8 }) {
            Row({ space: 6 }) {
              Text('📊')
                .fontSize(13)
              Text('每日陪伴时长(分钟)')
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor('#4A3B32')
                .layoutWeight(1)
              Text('本周 +18%')
                .fontSize(10)
                .fontColor('#55C1A5')
            }
            .width('100%')

            Row({}) {
              ForEach(WEEK_MINUTES, (d: WeekMinute) => {
                Column({ space: 4 }) {
                  Text(d.min.toString())
                    .fontSize(9)
                    .fontColor('#8A7A6E')
                  Column() {
                  }
                  .width(18)
                  .height(minuteBar(d.min))
                  .borderRadius(9)
                  .backgroundColor(d.min >= 60 ? '#FF8FAB' : (d.min >= 40 ? '#FFC94D' : '#B9DBF5'))
                  .animation({ duration: 400, curve: Curve.EaseOut })
                  Text(d.day)
                    .fontSize(10)
                    .fontColor('#B9A99C')
                }
                .alignItems(HorizontalAlign.Center)
                .layoutWeight(1)
              }, (d: WeekMinute) => d.day)
            }
            .width('100%')
            .height(112)
            .alignItems(VerticalAlign.Bottom)

            Text('周六户外陪听 78 分钟,是本周最专注的一天 🌈')
              .fontSize(10)
              .fontColor('#B9A99C')
          }
          .width('92%')
          .padding(16)
          .borderRadius(20)
          .backgroundColor('#FFFFFF')
          .margin({ top: 12 })

          // 功能列表
          Column({ space: 0 }) {
            Row({ space: 12 }) {
              Text('🔔')
                .fontSize(16)
              Text('播放提醒设置')
                .fontSize(13)
                .fontColor('#4A3B32')
                .layoutWeight(1)
              Text('每天 21:30')
                .fontSize(10)
                .fontColor('#B9A99C')
              Text('›')
                .fontSize(14)
                .fontColor('#D8CCBF')
            }
            .width('100%')
            .padding(16)

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

            Row({ space: 12 }) {
              Text('📥')
                .fontSize(16)
              Text('离线下载儿歌')
                .fontSize(13)
                .fontColor('#4A3B32')
                .layoutWeight(1)
              Text('已缓存 64 首')
                .fontSize(10)
                .fontColor('#B9A99C')
              Text('›')
                .fontSize(14)
                .fontColor('#D8CCBF')
            }
            .width('100%')
            .padding(16)

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

            Row({ space: 12 }) {
              Text('🔒')
                .fontSize(16)
              Text('儿童保护模式')
                .fontSize(13)
                .fontColor('#4A3B32')
                .layoutWeight(1)
              Text('已开启')
                .fontSize(10)
                .fontColor('#55C1A5')
              Text('›')
                .fontSize(14)
                .fontColor('#D8CCBF')
            }
            .width('100%')
            .padding(16)

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

            Row({ space: 12 }) {
              Text('ℹ️')
                .fontSize(16)
              Text('关于童谣星球')
                .fontSize(13)
                .fontColor('#4A3B32')
                .layoutWeight(1)
              Text('v3.2.1')
                .fontSize(10)
                .fontColor('#B9A99C')
              Text('›')
                .fontSize(14)
                .fontColor('#D8CCBF')
            }
            .width('100%')
            .padding(16)
            .onClick(() => {
              this.showAboutModal = true;
            })
          }
          .width('92%')
          .borderRadius(20)
          .backgroundColor('#FFFFFF')
          .margin({ top: 12 })

          // 退出登录
          Button() {
            Text('退出登录')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor('#E85D75')
          }
          .width('92%')
          .height(44)
          .borderRadius(22)
          .backgroundColor('#FFFFFF')
          .margin({ top: 16 })
          .onClick(() => {
            this.showLogoutModal = true;
          })

          Column() {
            Text('童谣星球 · 让每一段旋律都陪着长大')
              .fontSize(10)
              .fontColor('#D8CCBF')
              .padding({ top: 18, bottom: 16 })
          }
          .width('100%')
        }
        .width('100%')
        .padding({ bottom: 16 })
      }
      .scrollBar(BarState.Off)
      .width('100%')
      .height('100%')
      .align(Alignment.Top)

      // 宝贝资料编辑弹窗
      if (this.showEditModal) {
        Column({ space: 12 }) {
          Text('✏️ 编辑宝贝资料')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#4A3B32')
            .padding({ top: 20 })

          Row({ space: 10 }) {
            ForEach(['🐣', '🐰', '🐨', '🐯'], (a: string) => {
              Text(a)
                .fontSize(24)
                .width(50)
                .height(50)
                .borderRadius(16)
                .textAlign(TextAlign.Center)
                .backgroundColor(this.kidAvatar === a ? '#FFE9EF' : '#FFF7EF')
                .border(this.kidAvatar === a ? { width: 2, color: '#FF8FAB' } : { width: 2, color: '#FFF7EF' })
                .onClick(() => {
                  this.kidAvatar = a;
                })
            }, (a: string) => a)
          }
          .margin({ top: 4 })

          Column({ space: 4 }) {
            Text('昵称')
              .fontSize(11)
              .fontColor('#8A7A6E')
            TextInput({ text: this.kidName, placeholder: '宝贝的小名' })
              .fontSize(13)
              .fontColor('#4A3B32')
              .height(40)
              .borderRadius(12)
              .backgroundColor('#FFF7EF')
              .onChange((v: string) => {
                this.kidName = v;
              })
          }
          .width('84%')
          .alignItems(HorizontalAlign.Start)

          Column({ space: 4 }) {
            Text('年龄段')
              .fontSize(11)
              .fontColor('#8A7A6E')
            Row({ space: 8 }) {
              ForEach(['0-1岁', '1-2岁', '2-3岁', '3-6岁'], (g: string) => {
                Text(g)
                  .fontSize(11)
                  .fontColor(this.kidAge.indexOf(g.replace('岁', '')) >= 0 ? '#FFFFFF' : '#8A7A6E')
                  .padding({ left: 12, right: 12, top: 7, bottom: 7 })
                  .borderRadius(14)
                  .backgroundColor(this.kidAge.indexOf(g.replace('岁', '')) >= 0 ? '#FF8FAB' : '#FFF7EF')
                  .onClick(() => {
                    this.kidAge = g;
                  })
              }, (g: string) => g)
            }
          }
          .width('84%')
          .alignItems(HorizontalAlign.Start)

          Row({ space: 10 }) {
            Button() {
              Text('取消')
                .fontSize(13)
                .fontColor('#B9A99C')
            }
            .layoutWeight(1)
            .height(40)
            .borderRadius(20)
            .backgroundColor('#FFF7EF')
            .onClick(() => {
              this.showEditModal = false;
            })

            Button() {
              Text('保存资料')
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
            }
            .layoutWeight(1)
            .height(40)
            .borderRadius(20)
            .backgroundColor('#FF8FAB')
            .onClick(() => {
              this.showEditModal = false;
            })
          }
          .width('84%')
          .margin({ top: 6, bottom: 20 })
        }
        .width('88%')
        .borderRadius(24)
        .backgroundColor('#FFFFFF')
        .align(Alignment.Center)
        .transition(TransitionEffect.OPACITY.animation({ duration: 180 }).combine(TransitionEffect.scale({ x: 0.9, y: 0.9 })))
      }

      // 关于弹窗
      if (this.showAboutModal) {
        Column({ space: 10 }) {
          Text('🎶')
            .fontSize(38)
            .padding({ top: 20 })
          Text('童谣星球 KIDTUNE')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#4A3B32')
          Text('v3.2.1 · 制作团队:星球童谣工作室')
            .fontSize(11)
            .fontColor('#B9A99C')
          Text('2000+ 首经典与新创儿歌\n分龄推荐 · 磨耳朵计划 · 成长记录')
            .fontSize(10)
            .fontColor('#B9A99C')
            .textAlign(TextAlign.Center)
            .padding({ left: 24, right: 24 })

          Button() {
            Text('关闭')
              .fontSize(13)
              .fontColor('#8A7A6E')
          }
          .width('60%')
          .height(38)
          .borderRadius(19)
          .backgroundColor('#FFF7EF')
          .margin({ top: 8, bottom: 20 })
          .onClick(() => {
            this.showAboutModal = false;
          })
        }
        .width('78%')
        .borderRadius(24)
        .backgroundColor('#FFFFFF')
        .align(Alignment.Center)
        .transition(TransitionEffect.OPACITY.animation({ duration: 180 }).combine(TransitionEffect.scale({ x: 0.88, y: 0.88 })))
      }

      // 退出登录确认
      if (this.showLogoutModal) {
        Column({ space: 10 }) {
          Text('👋')
            .fontSize(36)
            .padding({ top: 20 })
          Text('确定要退出登录吗?')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#4A3B32')
          Text('磨耳朵计划与打卡记录会保留在账号里,随时回来继续。')
            .fontSize(11)
            .fontColor('#B9A99C')
            .textAlign(TextAlign.Center)
            .padding({ left: 24, right: 24 })

          Row({ space: 10 }) {
            Button() {
              Text('再看看')
                .fontSize(13)
                .fontColor('#8A7A6E')
            }
            .layoutWeight(1)
            .height(40)
            .borderRadius(20)
            .backgroundColor('#FFF7EF')
            .onClick(() => {
              this.showLogoutModal = false;
            })

            Button() {
              Text('退出')
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
            }
            .layoutWeight(1)
            .height(40)
            .borderRadius(20)
            .backgroundColor('#E85D75')
            .onClick(() => {
              this.showLogoutModal = false;
            })
          }
          .width('84%')
          .margin({ top: 8, bottom: 20 })
        }
        .width('80%')
        .borderRadius(24)
        .backgroundColor('#FFFFFF')
        .align(Alignment.Center)
        .transition(TransitionEffect.OPACITY.animation({ duration: 180 }).combine(TransitionEffect.scale({ x: 0.9, y: 0.9 })))
      }
    }
    .width('100%')
    .height('100%')
  }
}
Logo

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

更多推荐