HarmonyOS 6.1 实战:步进器 UI 组件封装思路:−/+按钮驱动 @State 数值、上下限边界判断、动态提示文案切换及步进步长配置的交互设计
本文深度解析 童谣星球 KIDTUNE——一款面向0-6岁亲子家庭的儿歌磨耳朵应用。项目采用 HarmonyOS ArkTS 声明式 UI 框架,单文件架构,无外部依赖,通过 6 大 Tab 页面、12 套弹窗组件、属性动画与过渡效果,完整实现儿歌分类播放、磨耳朵定时计划、收藏夹家庭共享、成长打卡能力追踪、儿歌榜投票互动、个人中心等核心业务闭环。全文约 8 万字,含 14 张图片。

引言:当儿歌遇上鸿蒙生态
儿歌是每个孩子成长路上最早的音乐启蒙。从《小星星》到《两只老虎》,从《拔萝卜》到《数鸭子》,这些经典旋律陪伴了一代又一代人的童年。在移动互联网时代,儿歌不再只是磁带和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 色彩层级与对比度
浅色主题通过三级背景深度和三级文字明度构建清晰的视觉层级:
背景深度三级:
奶油底 #FFF7EF——页面主背景,最浅最暖纯白 #FFFFFF——卡片、弹窗、列表项,比背景略亮形成层次浅奶油 #FFF1E6——封面容器、图标背景,比纯白略暖增加趣味
文字明度三级:
深棕 #4A3B32——标题、歌曲名、核心数据中棕 #8A7A6E——副标题、说明、输入文字浅棕 #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封面,中间为场景名+进行中标签(薄荷绿)+描述+歌曲数+时长进度条(奶油黄),右侧为设置齿轮按钮和播放/停止按钮(进行中为草莓粉停止按钮/未进行为薄荷绿播放按钮)。点击设置按钮弹出场景编辑弹窗,点击播放/停止按钮切换进行中状态。

5.3 收藏 Tab:双列收藏夹 + 新建/重命名/删除
收藏 Tab 提供儿歌收藏的文件夹化管理,支持家庭共享和私密两种可见性。页面从上到下依次为:统计概览三连、新建收藏夹入口条、全家收藏夹双列网格。
统计概览三连:三个等宽白色圆角卡片,分别显示82收藏儿歌(草莓粉数字)、6收藏夹(薄荷绿数字)、68连续打卡(奶油黄数字),下方为对应说明文字。
新建收藏夹入口条:白色圆角卡片,左侧为➕图标+"新建一个收藏夹,装满豆豆的爱听"文字,右侧为草莓粉"创建"按钮。点击弹出新建收藏夹弹窗。
全家收藏夹双列网格:使用 Flex({ wrap: FlexWrap.Wrap }) 实现双列自适应布局,每个收藏夹为白色圆角卡片,上方为emoji封面+歌曲数(浅奶油背景),中间为收藏夹名称,下方为可见性标签(家庭共享薄荷绿/私密浅棕)+编辑铅笔+删除垃圾桶图标。点击编辑弹出重命名弹窗,点击删除弹出删除确认弹窗。

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 函数着色),右侧为评分数字。右上角有"分享"按钮(奶油黄),点击弹出分享成绩弹窗。

5.5 儿歌榜 Tab:排行榜 + 投票互动 + 前三甲
儿歌榜 Tab 提供热门儿歌排行榜和用户投票互动功能。页面从上到下依次为:榜单头卡、前三甲展示台、投票入口条、完整榜单列表。
榜单头卡:草莓粉背景圆角卡片,居中显示"星球儿歌榜"标题(白色大字)+"每小时更新·由32万个家庭的播放生成"说明(白色半透明小字)。
前三甲展示台:三个等宽白色圆角卡片,第1名为奶油黄背景(皇冠👑),第2、3名为白色背景(银牌🥈/铜牌🥉),每个卡片显示排名emoji+歌曲名+歌手+热度🔥。
投票入口条:白色圆角卡片,左侧为🗳图标+"本周我最爱:可投3首,已投2首"文字,右侧为天蓝"去投票"按钮。点击弹出投票规则弹窗。
完整榜单列表:12首歌曲,每条为一行白色圆角卡片,左侧为排名数字(通过 rankColor 函数着色:前3草莓粉/4-8薄荷绿/其他浅棕)+音符emoji封面,中间为歌曲名+趋势箭头(通过 trendIcon/trendColor 函数)+歌手+在榜周数标签,右侧为投票爱心(已投💗带缩放动画/未投🗳)+热度小字。点击投票爱心切换投票状态,已投3首后再点击弹出投票规则弹窗提示。右上角有"重置我的投票"文字按钮,点击弹出重置投票确认弹窗。

5.6 我的 Tab:家庭卡 + 陪伴统计 + 功能列表
我的 Tab 是个人中心,展示家庭信息、陪伴数据和系统设置。页面从上到下依次为:家庭卡、数据三连、每日陪伴柱状图、功能列表、退出登录按钮。
家庭卡:白色圆角卡片,左侧为小鸡emoji头像(天蓝浅背景),中间为"豆豆的音乐小屋"标题+2岁8个月·磨耳朵第68天·家庭会员(草莓粉)+家庭成员标签(妈妈/爸爸/外婆),右侧为编辑铅笔按钮。点击编辑弹出宝贝资料编辑弹窗。
数据三连:三个等宽白色圆角卡片,分别显示364本周陪伴分钟(草莓粉)、23累计会唱(薄荷绿)、4成长勋章(奶油黄),下方为对应说明文字。
每日陪伴柱状图:白色圆角卡片,标题为"每日陪伴时长(分钟)“+本周+18%(薄荷绿),下方为7根柱状图,60分钟以上草莓粉、40-60奶油黄、40以下天蓝,带有400ms缓出生长动画。底部说明"周六户外陪听78分钟,是本周最专注的一天🌈”。
功能列表:白色圆角卡片,包含4个功能项(播放提醒设置/离线下载儿歌/儿童保护模式/关于童谣星球),每项为图标+功能名+右侧状态/版本+箭头,项之间用浅灰分割线分隔。点击"关于童谣星球"弹出关于弹窗。
退出登录按钮:白色圆角按钮,草莓红文字"退出登录",点击弹出退出登录确认弹窗。底部为"童谣星球·让每一段旋律都陪着长大"标语。

第六章 弹窗交互体系深度解析
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上滑,模拟音乐播放器从底部升起的自然感。

6.3 定时设置弹窗:分段选择式时间配置
定时设置弹窗是磨耳朵 Tab 的核心设置组件,采用居中缩放式设计。点击"调整定时"按钮后弹出,包含:
- 标题:⏰定时关闭设置(深棕粗体)
- 分段选择器:15/30/45分钟三个等宽选项,选中态为浅粉背景+草莓粉边框+草莓粉粗体数字,未选中为浅奶油背景+中棕数字,点击切换带有160ms动画
- 说明文字:温柔结束:最后3分钟音量渐弱,不打断睡意(薄荷绿)+结束后自动锁定屏幕,防止宝宝乱点(天蓝)
- 操作按钮:取消(浅棕文字+浅奶油背景)+保存定时(白色文字+草莓粉背景)
弹窗过渡动画为淡入+从0.9倍缩放至1倍,柔和不突兀。

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 动画设计原则
亲子应用的动画设计遵循三个原则:
- 柔和缓慢:所有动画时长在160-400ms之间,缓出曲线(Curve.EaseOut),不使用快速弹跳或剧烈动画
- 缩放为主:主要使用缩放和透明度变化,避免位移动画造成视觉干扰
- 状态反馈:每个可交互元素(爱心/星星/投票/按钮)都有选中态动画,让家长明确感知操作结果
第八章 儿歌曲库与分类体系
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

从华为开发者官网(developer.huawei.com)下载最新版本 DevEco Studio 安装包。运行安装程序后:
- 选择安装路径(建议非系统盘,预留至少 5GB 空间)
- 勾选"创建桌面快捷方式"和"关联 .ets 文件"
- 选择安装组件(默认全选,包含 HarmonyOS SDK、模拟器、Previewer)
- 点击"安装"等待完成(约 10-15 分钟,取决于网络速度)
- 安装完成后启动 DevEco Studio,首次启动会自动检测并配置 Node.js、ohpm、hvigor 等工具链
13.3 配置 HarmonyOS SDK

首次启动 DevEco Studio 后,需要配置 HarmonyOS SDK:
- 进入 File > Settings > HarmonyOS SDK(macOS 为 DevEco Studio > Settings > HarmonyOS SDK)
- 在 SDK Platforms 标签页中,勾选 API 24 及以上版本(HarmonyOS 6.1.1 对应 API 24)
- 在 SDK Tools 标签页中,确认以下工具已勾选:ArkTS、Previewer、Emulator、System Image(模拟器镜像)
- 点击 Apply 按钮,等待 SDK 下载完成(约 2-5GB,取决于勾选的版本数量)
- 下载完成后点击 OK,SDK 配置生效
如果需要使用真机调试,还需在手机上开启"开发者模式"和"USB 调试",并通过 DevEco Studio 的设备管理器连接设备。
13.4 创建项目

配置好 SDK 后,创建一个新的 HarmonyOS 项目:
- 在 DevEco Studio 欢迎页点击 Create Project(或 File > New > Create Project)
- 选择 Empty Ability 模板(空能力模板,最适合单文件应用开发)
- 配置项目参数:
- Project name:项目名称,如 KidtuneApp
- Bundle name:包名,如 com.example.kidtune(需全局唯一)
- Save location:项目保存路径
- Compile SDK:选择 API 24(HarmonyOS 6.1.1)
- Model:选择 Stage 模型(HarmonyOS 推荐的新模型)
- 点击 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 的源码替换到项目中并运行:
- 打开
entry/src/main/ets/pages/Index.ets文件 - 用本文附录中的完整源码(2137.ets)完全替换 Index.ets 的内容
- 确认
module.json5中已注册该页面(默认创建的 Empty Ability 已自动注册 Index.ets) - 连接 HarmonyOS 设备(USB 数据线连接真机并开启 USB 调试),或启动模拟器(Tools > Device Manager > Create Emulator,选择 API 24 的设备镜像)
- 点击工具栏的运行按钮(▶️),或按快捷键 Shift+F10
- 在弹出的设备选择对话框中选择目标设备(真机或模拟器)
- 等待编译构建完成(首次构建约 1-3 分钟,后续增量构建约 30 秒)
- 应用自动安装并启动到设备上,即可看到童谣星球 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 实现弹窗过渡动画,属性动画实现交互动效。
技术亮点
- 单文件零依赖架构:所有代码在一个 2137.ets 文件中,无需配置路由、状态管理库、UI 组件库,开箱即用,降低理解成本和部署门槛
- 6 Tab + 15 弹窗完整交互闭环:从儿歌发现到定时磨耳、从收藏整理到成长记录、从榜单投票到个人中心,形成完整的用户旅程闭环,每个业务场景都有对应的弹窗交互
- 马卡龙奶油亲子风视觉体系:5 色强调色(草莓粉/薄荷绿/奶油黄/天蓝/深草莓红)+ 3 级背景深度 + 3 级文字明度,构建出统一、柔和、适合亲子使用的视觉语言
- 场景化磨耳朵计划:10 种亲子日常场景(晚安哄睡/起床唤醒/乘车安抚/吃饭伴听/午睡小憩/游戏时间/刷牙洗脸/出门散步/亲子共读/情绪安抚),每种场景预设播放时长和歌单,一键启动定时播放
- 成长打卡与能力追踪:打卡日历(星星点亮动画)+ 4 项能力成长曲线(节奏感/音准/词汇量/记忆力)+ 跟唱评分记录(奖牌体系),可视化孩子的音乐成长轨迹
- 纯函数数据转换层:6 个全局纯函数(rankColor/trendIcon/trendColor/scoreColor/minuteBar)处理排名颜色、趋势图标、评分颜色、柱状图高度等数据转换,相同输入永远产生相同输出,易于测试和复用
扩展方向
童谣星球 KIDTUNE 作为演示项目,仍有多个可扩展方向:
- 真实音频播放:当前播放抽屉为 UI 演示,可集成 HarmonyOS 音频播放 API(AVPlayer)实现真实的儿歌音频播放、暂停、上一首/下一首、进度条拖动
- 后端数据对接:当前所有数据为本地 const 常量,可对接后端 API 实现曲库动态加载、用户收藏同步、成长记录云端存储、榜单实时更新
- 个性化推荐算法:基于孩子年龄、收听历史、收藏行为、时间段,实现个性化儿歌推荐和磨耳朵歌单生成
- 家庭成员账号体系:实现多家庭成员账号(爸爸/妈妈/外婆等),每个成员有独立的收藏夹、播放历史、投票记录,家庭共享收藏夹支持权限管理
- 生成成长海报:成长周报分享功能可扩展为生成带二维码的精美海报图片,支持一键分享到微信/QQ等社交平台
- 离线下载与缓存:我的 Tab 的"离线下载儿歌"功能可扩展为真实的音频下载和本地缓存,支持无网络环境下播放
童谣星球 KIDTUNE 展示了 HarmonyOS ArkTS 在亲子教育类应用开发中的强大能力——声明式 UI 的简洁语法、@State 的高效状态管理、属性动画和过渡动画的流畅动效、Previewer 的实时预览,让开发者可以用较少的代码实现丰富的交互体验。希望本文能为 HarmonyOS 开发者和亲子教育产品从业者提供有价值的参考。
本文基于 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%')
}
}
更多推荐



所有评论(0)