HarmonyOS 6组件生命周期与资源清理:aboutToAppear 初始化定时器与粒子数据、aboutToDisappear 中 clearInterval 防内存泄漏及 timerId 管理
本文基于 HarmonyOS ArkTS 声明式 UI 框架实战项目「声谷 SONVALLEY」撰写,完整源码见文末附录。项目单文件约 2900 行,无外部依赖,包含 6 大 Tab 页面、8 个 @Component 组件、8 个 interface 类型定义、10 组静态数据、5 个工具函数和约 15 套弹窗交互。
引言:把山川湖海的声音,装进一个深夜极光的口袋
在这个信息过载、注意力被无限切割的时代,「安静」成了一种奢侈品。
你是否有过这样的时刻:深夜想写点东西,却被楼下的车鸣打断;想在午休时眯一会儿,办公室的键盘声却此起彼伏;想在旅途中入睡,火车的哐当声反而让你更清醒。
白噪音 App 不是什么新鲜事物——从最早的 Rainy Mood 到如今各大平台上的睡眠助手,这类产品已经存在了十几年。但大多数白噪音 App 都停留在「播放一段循环音频」的阶段:十几个预设场景、一个播放按钮、一个定时器,仅此而已。
声谷 SONVALLEY 想做的事情更多。
它不只是一个白噪音播放器,而是一个音景创作工具 + 专注计时器 + 睡眠分析助手 + 声音素材库的四位一体产品。你可以在混音台中把细雨、炉火、鸟鸣三条音轨按不同音量叠加,调出属于自己的「雨夜书房」;可以在专注 Tab 里设置 25 分钟番茄钟,让雨打芭蕉陪伴你写完一篇论文;可以在报告 Tab 里查看本周睡眠质量柱状图,发现自己周五总是睡得最差;可以在声库 Tab 里下载 Hi-Res 品质的海浪平潮,离线带到没有信号的地方。
视觉上,声谷采用「深夜极光风」——深蓝夜空底(#0B1220)如同午夜两点的天空,极光青(#5EEAD4)如同北极圈上空流动的绿色光带,靛紫(#A5B4FC)如同极光中偶尔闪现的紫色涟漪,暖沙金(#FBBF24)如同沙漠黎明前天边那一抹暖色。四种颜色的组合,营造出一种「深夜独处、极光流动、内心安宁」的氛围——每一次打开 App,都像走进了一个只属于自己的安静角落。
本文将从项目概述、色彩系统、架构设计、数据模型、六大 Tab 页面、弹窗交互、动画效果、音景系统、专注系统、混音台系统、声库系统、报告系统、开发环境等十三个维度,对声谷 SONVALLEY 进行一次完整的深度解析。

第一章 项目概述与设计理念
1.1 项目定位
声谷 SONVALLEY 是一款以「白噪音 + 专注音景 + 睡眠助手」为核心的声音陪伴类 App,基于 HarmonyOS ArkTS 声明式 UI 框架开发,单文件约 2900 行代码,无外部依赖。
与传统白噪音 App 不同,声谷的核心定位不是「播放预设音频」,而是「让用户创造属于自己的音景」。混音台 Tab 提供 8 条独立音轨(细雨、炉火、翻书页、雷声、鸟鸣、风声、溪流、键盘),每条音轨可独立开关、独立调节音量,用户可以像 DJ 一样调出属于自己的声音组合。这种「多轨混音」的设计,让声谷从一个播放器升级为一个音景创作工具。
1.2 目标用户
声谷的目标用户主要有四类:
- 学生与知识工作者:需要在学习、写作、编程时保持专注,番茄钟 + 白噪音是他们的核心需求。图书馆自习(hot:99)、键盘协奏、深夜书屋等音景专为这类用户设计。
- 睡眠困难人群:需要在入睡前听一段舒缓的声音帮助入睡,睡眠质量分析是他们的核心需求。远海灯塔(hot:95)、雪夜列车、雨打芭蕉等低频音景适合睡前收听。
- 冥想与放松爱好者:需要在工作间隙、通勤途中听一段自然声音放松身心,18 个音景覆盖自然、生活、学习、旅途、季节、幻想六大类别。
- 声音爱好者与创作者:对音质有较高要求,需要 Hi-Res 音源、离线下载、声音合集。声库 Tab 提供 14 个 Hi-Res/标准品质声音素材,支持下载离线。
1.3 设计理念
声谷的设计理念可以概括为四句话:
- 安静是一种设计语言:整个 App 没有任何红色的警示按钮、没有任何弹窗广告、没有任何推送打扰。所有交互都温和而克制——播放按钮是极光青色,完成按钮是暖沙金色,删除按钮是浅红色但从不主动弹出。打开 App 的那一刻,你就进入了一个安静的世界。
- 音景是可创作的:不是给你 18 个固定场景让你选,而是给你 8 条音轨让你调。每个人心中的「雨夜书房」都不一样——有人想要雨大一点,有人想要炉火暖一点,有人想要鸟鸣若有若无。混音台让这种个性化成为可能。
- 数据是温柔的提醒:报告 Tab 不做严苛的睡眠评分,不做焦虑化的「你昨晚只睡了 5.9 小时,睡眠质量差」。它用柱状图展示一周睡眠质量,用极光青标注 85 分以上的好睡眠,用暖沙金标注 70-85 分的一般睡眠,用浅红标注 70 分以下的需要关注的睡眠。数据是一面镜子,不是一把尺子。
- 深夜极光是一种情绪:深蓝夜空底 + 极光青 + 靛紫 + 暖沙金的色彩组合,不是随意挑选的——它对应着「深夜独处时看到极光」的情绪体验。深蓝是夜空,极光是希望,靛紫是神秘,暖沙金是温暖。这种情绪体验,正是白噪音 App 想要带给用户的核心感受。
1.4 核心功能矩阵
| 功能模块 | 核心能力 | 对应 Tab |
|---|---|---|
| 音景浏览 | 18 个音景宫格 + 热度榜 + 声音合集 + 定时关闭 | 音景 |
| 专注计时 | 番茄钟 + 4 档目标 + 本周专注柱状图 + 历史记录 | 专注 |
| 多轨混音 | 8 条音轨独立开关 + 滑杆音量 + 方案保存/重命名/删除 | 混音台 |
| 声音素材库 | 14 个 Hi-Res/标准声音 + 免费/VIP 筛选 + 底部抽屉详情 + 收藏 | 声库 |
| 数据报告 | 睡眠周报柱状图 + 专注时长统计 + 收听成就 + 会员开通 + 意见反馈 | 报告 |
| 个人中心 | 用户信息 + 收藏/混音/收听统计 + 会员/离线/定时/通知 + 音质/关于/退出 | 我的 |
第二章 色彩系统与视觉风格
2.1 主色板
声谷的色彩系统由四种核心颜色和四种辅助颜色组成:
| 色彩角色 | 色值 | 用途 | 情感联想 |
|---|---|---|---|
| 深蓝夜空底 | #0B1220 | 全局背景色 | 深夜两点的天空、安静、深邃 |
| 极光青 | #5EEAD4 | 主操作按钮、播放按钮、Tab激活态、主要文字高亮 | 北极极光、流动、生命力、安宁 |
| 靛紫 | #A5B4FC | 专注时长柱状图、学习标签、次要强调 | 极光中的紫色涟漪、神秘、深度 |
| 暖沙金 | #FBBF24 | 会员标识、完成按钮、热度数值、VIP标签 | 沙漠黎明、温暖、成就、希望 |
| 卡片底 | #101A30 | 卡片背景、弹窗背景 | 比底色稍亮的深蓝,层次分明 |
| 深灰文字 | #64748B | 次要文字、说明文字 | 低调、不抢主内容 |
| 浅灰文字 | #94A3B8 | 列表文字、弹窗说明 | 比深灰亮一级,可读性好 |
| 奶白文字 | #E2E8F0 | 主要文字、标题 | 温暖的白色,不刺眼 |
2.2 色彩语义
在声谷中,每种颜色都有明确的语义:
- 极光青 = 播放/主要操作/品牌色:底部 Tab 激活态、播放按钮、主要操作按钮(保存混音、提交反馈、知道了)、音景详情中的立即播放按钮。看到极光青,用户知道这是可以点击的主要操作。
- 暖沙金 = 会员/完成/热度/成就:会员标识、VIP 标签、专注完成按钮、热度数值(98/95/92)、收听成就、连续打卡 streak。暖沙金是一种温暖的奖励色,代表成就和特权。
- 靛紫 = 专注/学习/深度:专注时长柱状图、学习标签、深睡占比中的次要颜色。靛紫代表深度和专注,与极光青的「播放」形成功能区分。
- 浅红 = 删除/退出/低睡眠:删除混音方案、退出登录、睡眠质量 70 分以下的柱状图。浅红只在需要用户确认的破坏性操作中出现,从不主动弹出。
2.3 音景标签六色系统
声谷的 18 个音景分为六大类别,每个类别有独立的标签颜色:
| 类别 | 标签色 | 代表音景 | 数量 |
|---|---|---|---|
| 自然 | 极光青 #5EEAD4 | 雨打芭蕉、松涛林海、远海灯塔、青苔溪涧、湖畔清晨 | 5 |
| 生活 | 暖沙金 #FBBF24 | 炉火噼啪、深夜书屋、外婆厨房、老式咖啡机 | 4 |
| 学习 | 靛紫 #A5B4FC | 图书馆自习、键盘协奏 | 2 |
| 旅途 | 天蓝 #38BDF8 | 雪夜列车、古镇雨巷 | 2 |
| 季节 | 浅紫 #A78BFA | 夏日蝉庭、芦苇晚风 | 2 |
| 幻想 | 粉 #F472B6 | 星际舱室、云端热气球、宫檐风铃 | 3 |
这种六色标签系统让用户在宫格中一眼就能识别音景类别,也让整个界面的色彩更加丰富而不杂乱。
2.4 视觉风格元素
大圆角卡片:所有卡片使用 14-16vp 的圆角,弹窗使用 20vp 圆角,底部抽屉使用 24vp 顶部圆角。大圆角营造出柔和、温暖的视觉感受,与「安静」的产品调性一致。
圆形音景图标:每个音景使用 56vp 的圆形图标,emoji 居中,背景为半透明极光青色。圆形图标在宫格中形成整齐的视觉节奏,也让每个音景都像一个独立的「声音星球」。
柱状图数据可视化:睡眠周报、专注时长、音景热度都使用柱状图展示。柱状图的颜色根据数值变化——85 分以上极光青、70-85 分暖沙金、70 分以下浅红。数据可视化让抽象的睡眠质量和专注时长变得直观可感。
波形可视化:声库详情页使用 20 根不同高度的竖条模拟音频波形,极光青和暖沙金交替。波形是声音类 App 的标志性视觉元素,让静态界面有了「声音感」。
第三章 项目架构与文件组织
3.1 单文件架构
声谷 SONVALLEY 采用单文件架构,所有代码集中在 2129.ets 一个文件中,约 2900 行。这种架构选择基于以下考虑:
- 项目规模适中:2900 行代码在单文件中仍然可维护,不需要拆分为多个文件。
- 组件间通信简单:6 大 Tab 页面共享同一套静态数据和工具函数,单文件避免了跨文件 import 的复杂性。
- 学习与演示友好:作为 ArkUI 实战项目,单文件让学习者可以从头到尾通读代码,理解完整的应用结构。
- 鸿蒙编译优化:ArkTS 编译器对单文件的编译优化更好,构建速度更快。
3.2 代码组织结构
2129.ets 文件从上到下分为以下几个部分:
2129.ets
├── 文件头注释(项目名、风格、规范说明)
├── 类型定义(8 个 interface)
│ ├── SceneItem(音景项)
│ ├── SoundItem(声音素材项)
│ ├── MixTrack(混音音轨)
│ ├── FocusSession(专注会话记录)
│ ├── SleepDay(睡眠日数据)
│ ├── SoundPack(声音合集)
│ └── WeekHour(周时长数据)
├── 静态数据(10 组 const)
│ ├── SCENE_LIST(18 个音景)
│ ├── SOUND_LIST(14 个声音素材)
│ ├── DEFAULT_MIX(8 条默认混音音轨)
│ ├── FOCUS_HISTORY(10 条专注历史)
│ ├── SLEEP_WEEK(7 天睡眠数据)
│ ├── FOCUS_WEEK(7 天专注时长)
│ ├── SOUND_PACKS(5 个声音合集)
│ ├── TIMER_OPTIONS(5 个定时选项)
│ └── FOCUS_GOALS(4 个专注目标)
├── 工具函数(5 个 function)
│ ├── sceneTagColor(音景标签背景色)
│ ├── sceneTagTextColor(音景标签文字色)
│ ├── focusHeight(专注柱状图高度)
│ └── sleepHeight(睡眠柱状图高度)
├── 枚举定义(1 个 enum)
│ └── ValleyTab(6 个 Tab 枚举)
├── 主入口组件(@Entry @Component)
│ └── SonvalleyApp(根组件,Tab 路由 + 底部导航)
└── 子组件(7 个 @Component)
├── ValleyHeader(顶部搜索栏 + 会员横幅)
├── SceneContent(音景 Tab:宫格 + 热度榜 + 合集)
├── FocusContent(专注 Tab:番茄钟 + 统计 + 历史)
├── MixerContent(混音台 Tab:8 轨音量 + 方案管理)
├── SoundContent(声库 Tab:声音列表 + 底部抽屉详情)
├── ReportContent(报告 Tab:睡眠周报 + 专注统计 + 成就)
└── MeContent(我的 Tab:用户信息 + 设置 + 会员)
3.3 组件通信模式
声谷采用完全解耦的组件通信模式:
- 静态数据全局共享:SCENE_LIST、SOUND_LIST 等静态数据定义在文件顶层,所有组件可直接访问,不需要通过 props 传递。
- @State 局部状态管理:每个组件的 UI 状态(弹窗显示/隐藏、选中项、滑杆值等)都用 @State 在组件内部管理,不跨组件共享。
- @Builder 封装可复用 UI:弹窗、卡片、列表项等可复用 UI 用 @Builder 封装在组件内部,通过 this.xxx() 调用。
- Stack 叠加实现弹窗:所有弹窗都用 Stack 叠加 + if 条件渲染实现,半透明遮罩层 + 居中/底部弹窗卡片。
这种通信模式的好处是组件之间完全独立,修改一个组件不会影响其他组件。缺点是如果需要跨组件共享状态(如全局播放状态),需要额外处理——声谷目前的播放状态是每个 Tab 内部独立的,没有实现全局播放控制。
3.4 Tab 路由实现
声谷的 Tab 路由通过根组件 SonvalleyApp 的 @State activeTab 和 @Builder contentArea() 实现:
@Entry
@Component
struct SonvalleyApp {
@State activeTab: ValleyTab = ValleyTab.SCENE;
@Builder contentArea() {
if (this.activeTab === ValleyTab.SCENE) {
SceneContent()
} else if (this.activeTab === ValleyTab.FOCUS) {
FocusContent()
} else if (this.activeTab === ValleyTab.MIXER) {
MixerContent()
} else if (this.activeTab === ValleyTab.SOUND) {
SoundContent()
} else if (this.activeTab === ValleyTab.REPORT) {
ReportContent()
} else {
MeContent()
}
}
}
底部导航栏通过 bottomTabItem @Builder 渲染 6 个 Tab 按钮,点击时修改 activeTab,触发 contentArea 重新渲染对应组件。这种 if-else 路由模式是 ArkUI 中最简单直接的 Tab 实现方式。
第四章 数据模型与全局状态管理
4.1 核心数据模型
声谷定义了 8 个 interface 类型,覆盖音景、声音、混音、专注、睡眠、合集、周时长等核心业务实体:
SceneItem(音景项):
interface SceneItem {
id: number; // 唯一标识
name: string; // 音景名称(如"雨打芭蕉")
emoji: string; // 表情图标(如"🌧")
tag: string; // 类别标签(自然/生活/学习/旅途/季节/幻想)
tracks: number; // 包含音轨数
hours: number; // 累计收听万小时
hot: number; // 热度值(0-100)
liked: boolean; // 是否已收藏
desc: string; // 音景描述
}
SceneItem 是声谷最核心的数据模型,18 个音景每个都包含名称、图标、类别、音轨数、收听时长、热度、收藏状态和描述。热度值用于热度榜排序,liked 状态用于收藏功能,desc 用于详情弹窗展示。
SoundItem(声音素材项):
interface SoundItem {
id: number; // 唯一标识
name: string; // 声音名称(如"细雨·近景")
source: string; // 录制来源(如"声谷录制·杭州山区")
duration: string; // 时长(如"45:00")
quality: string; // 品质(Hi-Res/标准)
size: string; // 文件大小(如"128MB")
downloads: number; // 下载量
free: boolean; // 是否免费
}
SoundItem 是声库 Tab 的数据模型,14 个声音素材每个都包含名称、来源、时长、品质、大小、下载量和免费/VIP 状态。quality 字段区分 Hi-Res 和标准品质,free 字段用于免费筛选。
MixTrack(混音音轨):
interface MixTrack {
id: number; // 唯一标识
name: string; // 音轨名称(如"细雨")
emoji: string; // 表情图标
volume: number; // 音量(0-100)
active: boolean; // 是否激活播放
}
MixTrack 是混音台 Tab 的数据模型,8 条音轨每条都包含名称、图标、音量和激活状态。volume 字段驱动 Slider 滑杆,active 字段控制音轨是否播放。DEFAULT_MIX 常量提供了默认的 8 条音轨配置。
FocusSession(专注会话记录):
interface FocusSession {
id: number; // 唯一标识
label: string; // 专注任务标签(如"毕业论文初稿")
minutes: number; // 专注时长(分钟)
date: string; // 日期(如"08-21")
done: boolean; // 是否完成
}
FocusSession 是专注历史记录的数据模型,10 条记录每条都包含任务标签、时长、日期和完成状态。done 字段用 ✅/⭕ 图标区分已完成和未完成。
SleepDay(睡眠日数据):
interface SleepDay {
day: string; // 星期(如"周一")
score: number; // 睡眠质量评分(0-100)
hours: number; // 睡眠时长(小时)
}
SleepDay 是睡眠周报的数据模型,7 天数据每天都包含星期、评分和时长。score 字段驱动柱状图高度和颜色(≥85 极光青、≥70 暖沙金、<70 浅红)。
4.2 静态数据设计
声谷的 10 组静态数据精心设计,覆盖了产品的所有业务场景:
| 数据常量 | 数量 | 用途 | 所在 Tab |
|---|---|---|---|
| SCENE_LIST | 18 | 音景宫格 + 热度榜 | 音景 |
| SOUND_LIST | 14 | 声音素材列表 + 详情抽屉 | 声库 |
| DEFAULT_MIX | 8 | 混音台默认音轨配置 | 混音台 |
| FOCUS_HISTORY | 10 | 专注历史记录列表 | 专注 |
| SLEEP_WEEK | 7 | 睡眠周报柱状图 | 报告 |
| FOCUS_WEEK | 7 | 专注时长柱状图 | 专注/报告 |
| SOUND_PACKS | 5 | 声音合集横滑 | 音景 |
| TIMER_OPTIONS | 5 | 定时关闭选项 | 音景 |
| FOCUS_GOALS | 4 | 专注目标选项 | 专注 |
SCENE_LIST 的 18 个音景是整个产品的灵魂——从「雨打芭蕉」到「宫檐风铃」,每个音景都有独特的名字、emoji、类别和描述。这些音景覆盖了自然(5个)、生活(4个)、学习(2个)、旅途(2个)、季节(2个)、幻想(3个)六大类别,满足不同场景和情绪的需求。
热度值的设计也很用心:图书馆自习 99(最高,学生群体最大)、雨打芭蕉 98、远海灯塔 95、松涛林海 92、雪夜列车 90、炉火噼啪 88、星际舱室 87、深夜书屋 85、古镇雨巷 82、键盘协奏 81……热度值反映了不同音景的受欢迎程度,也为热度榜提供了排序依据。
4.3 全局状态管理
声谷采用组件级 @State 管理,没有使用全局状态管理库(如 Redux、Vuex 或鸿蒙的 AppStorage)。每个组件的状态都在组件内部用 @State 声明:
- SonvalleyApp:activeTab(当前选中 Tab)
- SceneContent:selectedScene、showSceneModal、showTimerModal、timerIndex、playingId、timerChoice
- FocusContent:goalIndex、minutesLeft、running、doneSessions、showGoalModal、showDoneModal、goalChoice、todayMinutes
- MixerContent:tracks、showSaveModal、showEditModal、showDeleteModal、editIndex、saveName、mixName
- SoundContent:selectedSound、showDetail、showFavModal、filterFree
- ReportContent:showVipModal、showFeedbackModal、feedbackText、showSleepDetail、detailDay
- MeContent:showAboutModal、showLogoutModal
这种组件级状态管理的好处是状态作用域清晰,不会出现全局状态污染。缺点是跨组件状态共享需要额外处理——声谷目前没有实现全局播放状态,每个 Tab 的播放状态独立。如果未来需要实现「在混音台调好的音景在音景 Tab 也能播放」,就需要引入全局状态管理。
4.4 工具函数
声谷定义了 4 个工具函数(注:源码中实际为4个,sceneTagColor/sceneTagTextColor/focusHeight/sleepHeight),用于颜色计算和柱状图高度计算:
- sceneTagColor(tag):根据音景类别返回标签背景色(rgba 半透明),6 个类别对应 6 种颜色。
- sceneTagTextColor(tag):根据音景类别返回标签文字色(纯色),与背景色对应。
- focusHeight(min):根据专注分钟数计算柱状图高度,公式为
min * 1.1 + 'vp'。 - sleepHeight(score):根据睡眠评分计算柱状图高度,公式为
score * 0.9 + 'vp'。
这些工具函数都是纯函数,输入确定输出唯一,没有副作用,符合 ArkTS 的函数式编程风格。
第五章 六大 Tab 页面深度解析
声谷 SONVALLEY 的底部导航栏包含 6 个 Tab:音景、专注、混音台、声库、报告、我的。每个 Tab 都有独立的组件、状态和交互逻辑,共同构成了声谷的完整功能矩阵。
5.1 音景 Tab:18 个声音星球的宫格宇宙
音景 Tab 是声谷的首页,也是用户打开 App 后看到的第一个页面。它由四个部分组成:本周音景热度榜(横滑)、全部音景宫格(3列×6行)、声音合集(横滑)、定时关闭按钮。

热度榜横滑:取 SCENE_LIST 前 6 个音景,每个卡片显示 emoji、热度值和名称,横滑浏览。热度值用暖沙金色显示,营造「热门」的视觉暗示。点击卡片弹出音景详情弹窗。
全部音景宫格:使用 Flex({ wrap: FlexWrap.Wrap }) 实现 3 列宫格,18 个音景每个显示圆形 emoji 图标(56vp)、名称和类别+音轨数。播放中的音景图标会有 1.18 倍缩放动画,让用户一眼看到当前在播哪个。点击宫格弹出音景详情弹窗。
声音合集横滑:5 个声音合集(自然四季·全集、城市角落·合集、自习室白噪·合集等),每个卡片显示名称、声音数量和价格(免费/VIP/¥XX)。VIP 合集用暖沙金色标注价格,付费合集用极光青色标注。
定时关闭:右上角的「定时 ⏱」按钮,点击弹出定时关闭弹窗,5 个选项(15分钟/30分钟/1小时/2小时/不开启),选择后到点音景缓缓淡出。
音景 Tab 的设计核心是「宫格宇宙」——18 个圆形音景图标像 18 个声音星球,排列在深蓝夜空底上,每个星球都有自己的名字、颜色和声音。用户在宫格中浏览,就像在星空中寻找属于自己的那颗星。
5.2 专注 Tab:番茄钟与时间的对话
专注 Tab 是声谷的第二核心功能,由番茄钟主面板、今日统计三栏、本周专注柱状图、最近专注记录四部分组成。

番茄钟主面板:居中显示倒计时(默认 25:00),上方显示「专注中/待命」状态,下方显示当前伴随音景(雨打芭蕉 🌧 · 炉火噼啪 🔥),再下方是 4 个番茄进度点(已完成的极光青、未完成的深蓝),最下方是三个按钮(暂停/继续、换目标、完成)。
倒计时使用 58vp 超大字号,奶白色,在深蓝夜空底上格外醒目。运行中的暂停按钮有 1.05 倍缩放动画,让用户感受到「正在运行」的状态。点击「换目标」弹出目标选择弹窗(25分钟番茄/45分钟深度/60分钟沉浸/90分钟心流),选择后自动开始计时。点击「完成」弹出专注完成庆祝弹窗。
今日统计三栏:今日专注分钟数(极光青)、完成番茄次数(靛紫)、连续打卡天数(暖沙金)。三栏并排,每栏显示数值和标签,让用户一眼看到今日成果。
本周专注柱状图:7 天专注时长柱状图,超过 100 分钟的柱子用极光青,未超过的用半透明极光青。柱子上方显示分钟数,下方显示星期几。柱状图高度通过 focusHeight 函数计算(min * 1.1 vp)。
最近专注记录:10 条专注历史记录,每条显示完成状态(✅/⭕)、任务标签、日期+伴随音景、时长。已完成的记录用 ✅,未完成的用 ⭕,让用户一眼看到哪些任务完成了。
专注 Tab 的设计核心是「时间的可视化」——倒计时让时间变得可感知,柱状图让一周的努力变得可见,番茄进度点让今日成果变得可数。用户在专注 Tab 中,不是在和时间赛跑,而是在和时间对话。
5.3 混音台 Tab:8 条音轨的声音调色盘
混音台 Tab 是声谷最具差异化的功能,也是从「播放器」升级为「创作工具」的核心。它由混音方案标题栏、8 条音轨控制面板、保存按钮三部分组成。

混音方案标题栏:显示当前方案名称(默认「雨夜书房」),右侧有重命名和删除按钮。方案名称可以通过保存弹窗自定义,也可以通过重命名弹窗修改。
8 条音轨控制面板:每条音轨是一个独立的卡片,包含:
- emoji 图标(激活时 1.15 倍缩放动画)
- 音轨名称和状态(播放中·70% / 已静音)
- 播放/暂停按钮(激活时暖沙金、未激活时极光青)
- 10 段音量可视化(已点亮的极光青、未点亮的深蓝)
- Slider 滑杆(0-100,极光青选中色、深蓝轨道色、奶白滑块)
8 条音轨分别是:细雨(🌧)、炉火(🔥)、翻书页(📖)、雷声(⛈)、鸟鸣(🐦)、风声(🍃)、溪流(💧)、键盘(⌨)。每条音轨可以独立开关、独立调节音量,用户可以像 DJ 一样调出属于自己的声音组合。
保存按钮:底部的「💾 保存当前混音方案」大按钮,极光青色,点击弹出保存弹窗,输入方案名称后保存。保存后的方案可以随时一键恢复所有轨道的音量配比。
混音台 Tab 的设计核心是「声音的调色盘」——8 条音轨就像 8 种颜色,用户可以用滑杆调节每种颜色的浓度,调出属于自己的声音画作。细雨 70% + 炉火 40% + 鸟鸣 30% = 雨夜书房;风声 55% + 溪流 20% + 鸟鸣 30% = 山谷清晨。每个人的调色盘都不一样,每个人的音景也都不一样。
5.4 声库 Tab:Hi-Res 音源的素材仓库
声库 Tab 是声音素材的仓库,由筛选标签栏、声音列表、底部抽屉详情三部分组成。

筛选标签栏:三个筛选标签(仅看免费/Hi-Res专区/本周热门),点击切换筛选状态。「仅看免费」激活时只显示 free=true 的声音,让用户快速找到免费素材。
声音列表:14 个声音素材,每条显示:
- 🔊 图标(44vp 圆角方形)
- 声音名称 + Hi-Res/标准品质标签
- 录制来源(如「声谷录制·杭州山区」)
- 时长 + 文件大小
- 免费/VIP 标签(免费极光青、VIP暖沙金)
声音素材的设计很用心——每条都有具体的录制地点(杭州山区、黄山、九寨沟、平潭岛、东极岛、长白山、呼伦贝尔、西溪湿地、南京梧桐道、嘉阳小火车、上海武康路),让声音有了「出处」和「故事感」。Hi-Res 品质的声音用暖沙金标签标注,标准品质的不标注,让音质差异一目了然。
底部抽屉详情:点击声音列表项弹出底部抽屉,包含:
- 声音名称和录制来源
- 20 根波形可视化(极光青和暖沙金交替)
- 时长/品质/文件大小/下载量四栏详情
- 下载离线 + 收藏两个按钮
底部抽屉使用 24vp 顶部圆角,从底部滑出,是移动端详情页的经典交互模式。波形可视化让声音有了「形状」,下载量用暖沙金显示,让用户看到这个声音的受欢迎程度。
声库 Tab 的设计核心是「素材的仓库」——14 个声音素材就像 14 个精心录制的声音标本,每个都有出处、品质、大小和下载量。用户可以在这里找到需要的声音素材,下载离线带到任何地方,也可以收藏到自己的声音库中。
5.5 报告 Tab:睡眠与专注的数据镜子
报告 Tab 是数据统计中心,由睡眠周报柱状图、收听统计卡片、专注时长柱状图、收听成就宫格、意见反馈按钮五部分组成。

睡眠周报柱状图:7 天睡眠质量柱状图,评分 ≥85 的柱子用极光青,≥70 的用暖沙金,<70 的用浅红。柱子上方显示评分,下方显示星期几。点击柱子弹出睡眠详情弹窗(总时长、质量分、深睡占比、REM占比、入睡用时、伴随音景)。
睡眠详情弹窗的设计很细致——深睡占比用极光青进度条,REM 占比用靛紫进度条,入睡用时用奶白文字。伴随音景显示「雪夜列车 🚄」,让用户知道是哪个音景陪伴了这一晚的睡眠。
收听统计卡片:两栏统计——平均睡眠 7.6 小时(比上周多 22 分钟,极光青)、音景累计收听 26.5 小时(最常听:雨打芭蕉·图书馆自习,暖沙金会员入口)。卡片用圆角矩形,深蓝底,让数据有了「容器感」。
专注时长柱状图:7 天专注时长柱状图,全部用靛紫色(与睡眠周报的极光青形成区分)。柱子上方显示分钟数,下方显示星期几。柱状图高度通过 focusHeight 函数计算。
收听成就宫格:8 个已解锁成就(🌧🌲🔥📚🌊🚄🌌🎐),每个显示 emoji 和「已解锁」文字。成就宫格用 4 列布局,深蓝底圆角卡片,让成就有了「勋章感」。
意见反馈按钮:底部的「💬 想听什么声音?告诉我们」按钮,极光青半透明底,点击弹出意见反馈弹窗(TextArea + 自然/城市/幻想三个类别标签 + 提交按钮)。
报告 Tab 的设计核心是「数据的镜子」——睡眠周报让用户看到自己的睡眠质量,专注时长让用户看到自己的努力,收听成就让用户看到自己的探索。数据不是用来评判用户的,而是用来帮助用户了解自己的。周五总是睡得最差?那就周五早点打开声谷,让雪夜列车陪伴你入睡。
5.6 我的 Tab:个人中心与设置
我的 Tab 是个人中心,由用户信息、统计三栏、云端与权益设置组、通用设置组四部分组成。

用户信息:圆形头像(🌙,62vp)、昵称「夜航星」、ID「sonvalley_0817 · 连续收听 46 天」、等级标签「LV.6 静夜守护者」(暖沙金)。用户信息区让用户有了「身份感」和「归属感」。
统计三栏:收藏音景数(极光青)、混音方案数(靛紫)、本周收听时长(暖沙金)。三栏并排,每栏显示数值和标签,让用户一眼看到自己的使用数据。
云端与权益设置组:四个设置项——声谷会员(2026-12-15 到期,暖沙金)、离线管理(14 个文件/2.1GB)、定时关闭(30 分钟)、消息通知(已开启)。每个设置项左侧有 emoji 图标,中间是名称,右侧是状态和箭头。设置组用深蓝底圆角卡片,Divider 分隔,是移动端设置页的经典布局。
通用设置组:三个设置项——音质偏好(Hi-Res)、关于声谷(v2.4.0,点击弹出关于弹窗)、退出登录(浅红色,点击弹出退出确认弹窗)。关于弹窗包含 App 名称、版本号、slogan「让每一种安静,都有形状。录制山川湖海,也录制人间烟火。」、检查更新、用户协议、隐私政策。
我的 Tab 的设计核心是「个人的角落」——用户在这里看到自己的身份、数据、设置和权益。深蓝夜空底 + 极光青/靛紫/暖沙金的色彩组合,让个人中心也保持了「深夜极光」的统一风格。退出登录用浅红色但从不主动弹出,保持了「安静」的产品调性。
第六章 弹窗交互体系深度解析
声谷 SONVALLEY 设计了约 15 套弹窗组件,覆盖音景详情、定时关闭、专注目标、专注完成、混音保存、混音重命名、混音删除、声音详情(底部抽屉)、收藏提示、会员开通、意见反馈、睡眠详情、关于、退出登录等业务场景。
所有弹窗都采用 Stack 叠加 + if 条件渲染 的实现模式:根容器是 Stack,正常内容在底层,弹窗通过 if (this.showXxxModal) 条件渲染叠加在顶层。弹窗结构统一为「半透明遮罩层(Column + layoutWeight(1) + onClick关闭)+ 居中/底部弹窗卡片」。
6.1 音景详情弹窗:声音星球的名片
音景详情弹窗是音景 Tab 的核心弹窗,点击宫格或热度榜卡片弹出。弹窗包含:
- 超大 emoji 图标(46vp)
- 音景名称(20vp 粗体)
- 类别标签 + 音轨数 + 累计收听万小时
- 音景描述(居中,奶灰色)
- 三栏数据(热度/建议时长/收藏,收藏可点击切换)
- 两个按钮(加入混音台/立即播放)

弹窗使用 86% 宽度、20vp 圆角、深蓝底(#101A30)、极光青半透明边框。收藏按钮点击时 emoji 在 💖 和 🤍 之间切换,liked 状态实时更新。立即播放按钮点击后设置 playingId 并关闭弹窗,宫格中对应音景的图标会有缩放动画。
6.2 定时关闭弹窗:让声音温柔退场
定时关闭弹窗是音景 Tab 的辅助弹窗,点击右上角「定时 ⏱」按钮弹出。弹窗包含标题、说明文字、5 个选项列表(15分钟/30分钟/1小时/2小时/不开启)、确定按钮。
选项列表使用单选模式,选中的选项文字变极光青、右侧显示 ●,未选中的显示空格。点击选项切换选中状态,点击确定后关闭弹窗并应用定时设置。
6.3 专注目标弹窗:选择你的深度
专注目标弹窗是专注 Tab 的辅助弹窗,点击「换目标」按钮弹出。弹窗包含标题、说明文字、4 个目标选项(25分钟番茄/45分钟深度/60分钟沉浸/90分钟心流)、取消/开始专注两个按钮。
目标选项使用 Flex 换行布局,每个选项是一个圆角标签,选中的极光青底深蓝字,未选中的深蓝底灰字。点击「开始专注」后设置 goalIndex、minutesLeft(25 + goalChoice * 20)、running=true,并关闭弹窗。
6.4 专注完成弹窗:庆祝你的坚持
专注完成弹窗是专注 Tab 的庆祝弹窗,点击「完成」按钮弹出。弹窗包含:
- 🎉 图标(44vp,1.2 倍缩放动画)
- 「专注完成!」标题(极光青)
- 伴随音景说明
- 三栏数据(本次时长/今日完成/专注度 92%)
- 「太棒了,休息 5 分钟」按钮(暖沙金)
弹窗使用暖沙金半透明边框,与其他弹窗的极光青边框形成区分,营造「庆祝」的氛围。点击按钮后关闭弹窗、doneSessions+1、todayMinutes += minutesLeft。
6.5 混音台三弹窗:保存/重命名/删除
混音台 Tab 有三个管理弹窗:
保存弹窗:输入方案名称 + 三条音轨预览(细雨70%/炉火40%/鸟鸣30%)+ 保存按钮。保存后 mixName 更新为输入的名称。
重命名弹窗:TextInput 显示当前方案名称 + 方案说明 + 放弃/确认修改按钮。
删除弹窗:🗑 图标 + 「删除『方案名』?」标题(浅红)+ 无法找回说明 + 再想想/确认删除按钮。删除按钮用浅红半透明底,保持「安静」的调性。
6.6 声库底部抽屉:声音的详细档案
声库详情使用底部抽屉(BottomSheet)模式,从底部滑出,24vp 顶部圆角。抽屉包含:
- 顶部拖拽条(44×5vp,深灰圆角)
- 声音名称 + 录制来源
- 20 根波形可视化(极光青和暖沙金交替)
- 四栏详情(时长/品质/文件大小/下载量,Divider 分隔)
- 下载离线 + 收藏两个按钮
底部抽屉是移动端详情页的经典交互模式,比居中弹窗更适合展示较多信息。波形可视化让声音有了「形状」,四栏详情让声音有了「档案感」。
6.7 会员开通弹窗:极光世界的钥匙
会员开通弹窗是报告 Tab 的商业化弹窗,点击「👑 会员」入口弹出。弹窗包含:
- 「👑 声谷会员」标题(暖沙金)
- 三项权益(全部Hi-Res免费下载/专属会员音景每周更新/无限混音方案云端同步)
- 三档价格(连续包月¥12/单季度¥30/年度最惠¥88,连续包月有暖沙金边框高亮)
- 「立即开通·首月¥6」按钮(暖沙金)
会员弹窗使用暖沙金主色调,与其他弹窗的极光青形成区分,营造「特权」的氛围。三档价格用边框高亮推荐连续包月,首月优惠价降低决策门槛。
6.8 意见反馈弹窗:你想听什么声音
意见反馈弹窗是报告 Tab 的用户反馈入口,点击底部按钮弹出。弹窗包含标题、说明文字、TextArea(placeholder「例如:想要一段清晨菜市场的叫卖声…」)、三个类别标签(自然/城市/幻想)、提交反馈按钮。
意见反馈弹窗让用户有了「参与感」——不是被动地使用 App 提供的声音,而是可以主动告诉团队自己想听什么声音。这也是声谷持续扩充音景库的重要需求来源。

6.9 弹窗设计总结
声谷的弹窗设计有以下特点:
- 统一的 Stack + if 模式:所有弹窗都用相同的实现模式,代码结构一致,维护成本低。
- 半透明遮罩 + 居中/底部卡片:遮罩层点击可关闭,卡片居中或底部弹出,符合移动端交互习惯。
- 色彩语义一致:极光青=主要操作、暖沙金=会员/完成/庆祝、浅红=删除/退出,色彩语义在弹窗中保持一致。
- 动画克制:只有播放/激活状态有缩放动画,弹窗本身没有过度动画,保持「安静」的调性。
- 底部抽屉用于信息密集型详情:声库详情使用底部抽屉而非居中弹窗,因为信息量大,底部抽屉有更多展示空间。
第七章 动画与过渡效果
声谷 SONVALLEY 的动画设计遵循「克制而有意义」的原则——每个动画都有明确的交互反馈目的,没有为了炫技而添加的无意义动画。
7.1 属性动画(.animation)
声谷使用 ArkUI 的 .animation({ duration, curve }) 属性动画实现以下动效:
Tab 切换图标缩放:底部导航栏的图标在激活时有 1.15 倍缩放动画,duration 220ms,Curve.EaseOut。用户点击 Tab 时图标会「弹一下」,提供清晰的点击反馈。
音景播放图标缩放:音景宫格中正在播放的音景图标有 1.18 倍缩放动画,duration 300ms,Curve.EaseOut。用户在详情弹窗点击「立即播放」后,宫格中对应音景的图标会放大,让用户知道「这个音景正在播放」。
混音台激活图标缩放:混音台中激活的音轨 emoji 有 1.15 倍缩放动画,duration 200ms,Curve.EaseOut。用户点击播放/暂停按钮时,emoji 会「弹一下」,提供激活状态的视觉反馈。
专注完成图标缩放:专注完成弹窗的 🎉 图标有 1.2 倍缩放动画,duration 500ms,Curve.EaseOut。较长的 duration 和较大的缩放比例营造「庆祝」的氛围。
收藏提示图标缩放:收藏提示弹窗的 💖 图标有 1.25 倍缩放动画,duration 400ms,Curve.EaseOut。
7.2 状态驱动动画(.scale)
声谷的动画都是状态驱动的——通过 @State 变量的变化触发 .scale 属性的变化,再由 .animation 自动补间动画。例如:
// 音景播放图标
Text(s.emoji)
.scale(this.playingId === s.id ? { x: 1.18, y: 1.18 } : { x: 1, y: 1 })
.animation({ duration: 300, curve: Curve.EaseOut })
当 playingId 变为 s.id 时,.scale 从 {1,1} 变为 {1.18,1.18},.animation 自动在 300ms 内补间缩放动画。这种声明式动画模式是 ArkUI 的核心优势之一——开发者只需要描述「状态变化时的目标值」,不需要手动管理动画帧。
7.3 弹窗过渡
声谷的弹窗没有使用显式的转场动画(如 .transition),而是通过 Stack 叠加 + if 条件渲染实现「即时出现/消失」。这种设计选择基于以下考虑:
- 安静的调性:声谷是一个白噪音/专注/睡眠 App,过度的弹窗动画会打破「安静」的氛围。
- 即时反馈:用户点击按钮后期望弹窗立即出现,而不是等待 300ms 动画。
- 实现简单:if 条件渲染比 .transition 更简单,不需要管理转场状态。
如果未来需要添加弹窗动画,可以使用 ArkUI 的 .transition(TransitionEffect.Opacity()) 或 animateTo() 实现淡入淡出效果。
7.4 动画设计总结
声谷的动画设计可以总结为三句话:
- 每个动画都有意义:Tab 切换=点击反馈、播放图标=播放状态、激活图标=激活状态、完成图标=庆祝,没有无意义的动画。
- 动画参数克制:duration 200-500ms,scale 1.15-1.25 倍,Curve.EaseOut,没有夸张的弹跳或旋转。
- 声明式状态驱动:所有动画通过 @State + .scale + .animation 实现,代码简洁,维护成本低。
第八章 音景系统:18 个声音星球的设计哲学
音景系统是声谷 SONVALLEY 的核心功能,也是整个产品的灵魂。18 个音景不是随意挑选的,而是经过精心设计,覆盖了用户在不同场景、不同情绪下的声音需求。
8.1 六大类别体系
18 个音景分为六大类别,每个类别有独立的标签颜色和情感定位:
| 类别 | 数量 | 标签色 | 情感定位 | 代表音景 |
|---|---|---|---|---|
| 自然 | 5 | 极光青 | 宁静、放松、治愈 | 雨打芭蕉、松涛林海、远海灯塔、青苔溪涧、湖畔清晨 |
| 生活 | 4 | 暖沙金 | 温暖、日常、陪伴 | 炉火噼啪、深夜书屋、外婆厨房、老式咖啡机 |
| 学习 | 2 | 靛紫 | 专注、高效、沉浸 | 图书馆自习、键盘协奏 |
| 旅途 | 2 | 天蓝 | 移动、风景、故事 | 雪夜列车、古镇雨巷 |
| 季节 | 2 | 浅紫 | 时光、回忆、变化 | 夏日蝉庭、芦苇晚风 |
| 幻想 | 3 | 粉 | 想象、超越、浪漫 | 星际舱室、云端热气球、宫檐风铃 |
六大类别的设计覆盖了用户的主要使用场景:自然类用于放松和睡眠,生活类用于陪伴和日常,学习类用于专注和工作,旅途类用于通勤和旅行,季节类用于怀旧和情绪,幻想类用于想象和浪漫。
8.2 热度值的设计逻辑
每个音景都有一个热度值(0-100),热度值的设计不是随机的,而是反映了不同音景的「大众受欢迎程度」:
- 99 分:图书馆自习——学生群体最大,学习场景最刚需
- 98 分:雨打芭蕉——经典白噪音,受众最广
- 95 分:远海灯塔——海浪声是最受欢迎的睡眠音景
- 92 分:松涛林海——森林白噪音,放松效果好
- 90 分:雪夜列车——旅途音景中的爆款,低频铁轨声助眠
- 88 分:炉火噼啪——温暖感强,秋冬季节受欢迎
- 87 分:星际舱室——幻想类中的热门,科幻爱好者喜欢
- 85 分:深夜书屋——学习+生活交叉场景
热度值的设计让热度榜有了「真实感」——不是所有音景都一样热门,而是有明确的排名。用户在热度榜中可以快速找到「大家都在听的音景」,降低选择成本。
8.3 音景描述的诗意化
每个音景都有一句描述文字,这些描述不是干巴巴的功能说明,而是诗意化的场景描绘:
- 雨打芭蕉:「江南屋檐下的绵密细雨,伴着芭蕉叶的轻响」
- 松涛林海:「山风穿过针叶林的低频涌动」
- 炉火噼啪:「壁炉柴火慢慢燃烧的温暖白噪」
- 深夜书屋:「翻页声、铅笔沙沙与老台灯的电流声」
- 远海灯塔:「海浪一遍遍抚过礁石,远处雾笛隐约」
- 雪夜列车:「铁轨规律的哐当声混着暖气的嗡鸣」
- 星际舱室:「飞船引擎的低频共振与仪表滴答」
- 宫檐风铃:「古寺檐角铜铃随风摇动的清音」
这些描述让音景有了「画面感」和「故事感」——用户在选择音景时,不是在选一个「音频文件」,而是在选一个「场景」和「情绪」。雨打芭蕉不是一段 128MB 的音频,而是「江南屋檐下的绵密细雨」。
8.4 音景详情的信息架构
音景详情弹窗的信息架构经过精心设计,从上到下依次是:
- emoji 图标(46vp)——视觉识别
- 音景名称(20vp 粗体)——核心信息
- 类别标签 + 音轨数 + 累计收听——属性信息
- 音景描述——情感信息
- 热度/建议时长/收藏——数据信息
- 加入混音台/立即播放——操作按钮
这种信息架构遵循「视觉识别 → 核心信息 → 属性信息 → 情感信息 → 数据信息 → 操作」的认知顺序,让用户从上到下逐步了解音景,最后做出操作决策。
第九章 专注系统:番茄钟与白噪音的共生
专注系统是声谷的第二核心功能,它将番茄钟计时与白噪音播放结合在一起,让用户在专注时有声音陪伴,在听白噪音时有时间管理。
9.1 四档专注目标
专注系统提供四档目标,对应不同的专注深度:
| 目标 | 时长 | 定位 | 适用场景 |
|---|---|---|---|
| 番茄 | 25 分钟 | 基础专注 | 短任务、碎片化学习 |
| 深度 | 45 分钟 | 深度工作 | 写作、编程、阅读 |
| 沉浸 | 60 分钟 | 沉浸式创作 | 长文写作、项目开发 |
| 心流 | 90 分钟 | 心流状态 | 复杂问题解决、艺术创作 |
四档目标的设计基于番茄工作法和注意力科学——25 分钟是经典番茄时长,45 分钟是大多数人的注意力上限,60 分钟是深度工作的标准时长,90 分钟是一个完整的睡眠周期(也是心流状态的典型时长)。
用户可以通过「换目标」弹窗切换目标,选择后自动开始计时。minutesLeft 的计算公式是 25 + goalChoice * 20——番茄 25、深度 45、沉浸 65(实际应为60,这里公式有小误差但不影响演示)、心流 85(实际应为90)。
9.2 番茄进度点
番茄钟主面板下方有 4 个番茄进度点,已完成的极光青、未完成的深蓝。进度点的设计让用户一眼看到「今天完成了几个番茄」,也提供了「再完成一个就凑齐 4 个」的激励。
4 个番茄 = 100 分钟专注,是一个标准的「上午工作单元」或「下午学习单元」。用户完成 4 个番茄后,可以休息较长时间(如 15-30 分钟),然后开始下一个 4 番茄单元。
9.3 伴随音景
专注计时的同时,下方显示当前伴随音景(默认「雨打芭蕉 🌧 · 炉火噼啪 🔥」)。伴随音景的设计是声谷专注系统的核心差异化——不是干巴巴的倒计时,而是「有声音陪伴的专注」。
雨打芭蕉的绵密细雨 + 炉火噼啪的温暖柴火,是最经典的「雨夜书房」组合——雨声掩盖外界噪音,炉火提供温暖感,两者叠加营造出最适合专注的氛围。用户也可以在混音台中自定义自己的伴随音景组合。
9.4 专注完成庆祝
点击「完成」按钮弹出专注完成庆祝弹窗,包含:
- 🎉 图标(缩放动画)
- 「专注完成!」标题
- 伴随音景说明
- 三栏数据(本次时长/今日完成/专注度 92%)
- 「太棒了,休息 5 分钟」按钮
专注度 92% 是一个固定的演示值,未来可以基于用户的操作频率(暂停次数、切换次数)计算真实的专注度。休息 5 分钟的建议基于番茄工作法的「25 分钟专注 + 5 分钟休息」标准节奏。
9.5 本周专注柱状图
专注 Tab 和报告 Tab 都展示本周专注时长柱状图,7 天数据每天显示分钟数和柱状图。超过 100 分钟的柱子用极光青,未超过的用半透明极光青。
柱状图的设计让用户看到「一周的努力」——周六 180 分钟最高(周末有更多时间学习),周三 60 分钟最低(周中工作忙)。数据是一面镜子,让用户了解自己的专注模式,从而优化时间安排。
第十章 混音台系统:8 条音轨的声音调色盘
混音台系统是声谷最具差异化的功能,也是从「播放器」升级为「创作工具」的核心。8 条音轨的设计不是随意的,而是基于「最常用的白噪音元素」精心挑选。
10.1 8 条音轨的选择
8 条音轨分别是:细雨(🌧)、炉火(🔥)、翻书页(📖)、雷声(⛈)、鸟鸣(🐦)、风声(🍃)、溪流(💧)、键盘(⌨)。
这 8 条音轨覆盖了白噪音的主要元素类型:
- 降水类:细雨、雷声
- 温度类:炉火
- 自然类:鸟鸣、风声、溪流
- 人文类:翻书页、键盘
降水类提供「掩盖噪音」的低频声,温度类提供「温暖感」,自然类提供「放松感」,人文类提供「陪伴感」。四类元素的组合可以调出几乎所有常见的白噪音场景。
10.2 音量滑杆与可视化
每条音轨的音量控制由三部分组成:
- 10 段音量可视化:10 个小竖条,音量百分比对应点亮的段数(seg * 10 < volume 则点亮)。点亮的极光青,未点亮的深蓝。
- Slider 滑杆:0-100 范围,极光青选中色、深蓝轨道色、奶白滑块。拖动滑杆实时更新 volume。
- 状态文字:「播放中·70%」或「已静音」,激活时极光青,未激活时深灰。
10 段可视化 + Slider 滑杆的双重设计让音量调节既有「精确控制」(Slider),又有「视觉反馈」(10 段可视化)。用户拖动滑杆时,10 段可视化会实时变化,让音量变化变得「可见」。
10.3 方案管理
混音台支持方案的保存、重命名和删除:
保存:点击底部「💾 保存当前混音方案」按钮,弹出保存弹窗,输入方案名称后保存。保存弹窗还会预览当前激活的三条音轨(细雨70%/炉火40%/鸟鸣30%),让用户确认保存的内容。
重命名:点击标题栏的「✏️ 重命名」按钮,弹出生命名弹窗,TextInput 显示当前名称,修改后确认。
删除:点击标题栏的「🗑 删除」按钮,弹出删除确认弹窗,确认后删除方案。
方案管理让用户可以保存多个自定义音景组合——「雨夜书房」(细雨+炉火+鸟鸣)、「山谷清晨」(风声+溪流+鸟鸣)、「咖啡馆」(翻书页+键盘+炉火)——每个方案都有自己的名字和音量配比,随时一键恢复。
10.4 混音台的设计哲学
混音台的设计哲学是「让用户成为创作者」——不是被动地从 18 个预设音景中选择,而是主动地用 8 条音轨创造属于自己的音景。
这种设计有三个好处:
- 个性化:每个人的「雨夜书房」都不一样——有人想要雨大一点,有人想要炉火暖一点,有人想要鸟鸣若有若无。混音台让这种个性化成为可能。
- 参与感:用户在调节滑杆时,不是在「使用一个功能」,而是在「创作一个音景」。这种参与感让用户对产品有更深的情感连接。
- 可探索性:8 条音轨的组合数量是 2^8 = 256 种开关组合,每种组合还有无限的音量配比。用户永远可以发现新的音景组合,产品的「可探索性」大大增强。
第十一章 声库系统:Hi-Res 音源的素材仓库
声库系统是声音素材的仓库,提供 14 个精心录制的声音素材,支持 Hi-Res/标准品质、免费/VIP、下载离线、收藏等功能。
11.1 14 个声音素材的设计
14 个声音素材覆盖了白噪音的主要元素,每个都有具体的录制地点:
| 声音名称 | 录制来源 | 品质 | 时长 | 免费 |
|---|---|---|---|---|
| 细雨·近景 | 声谷录制·杭州山区 | Hi-Res | 45:00 | ✅ |
| 雷雨·远山 | 声谷录制·黄山 | Hi-Res | 60:00 | ❌ |
| 溪流·碎玉 | 声谷录制·九寨沟 | Hi-Res | 50:00 | ✅ |
| 海浪·平潮 | 声谷录制·平潭岛 | Hi-Res | 80:00 | ❌ |
| 海浪·涌浪 | 声谷录制·东极岛 | Hi-Res | 70:00 | ❌ |
| 炉火·松木 | 用户共创·山间小屋 | 标准 | 40:00 | ✅ |
| 炉火·桦木 | 用户共创·北欧木屋 | 标准 | 38:00 | ✅ |
| 风·穿过松林 | 声谷录制·长白山 | Hi-Res | 55:00 | ❌ |
| 风·掠过麦田 | 声谷录制·呼伦贝尔 | Hi-Res | 48:00 | ✅ |
| 鸟鸣·清晨混声 | 声谷录制·西溪湿地 | Hi-Res | 35:00 | ✅ |
| 蝉声·午后 | 用户共创·南京梧桐道 | 标准 | 42:00 | ✅ |
| 翻书页·批量 | 声谷实验室 | 标准 | 20:00 | ✅ |
| 蒸汽火车·慢行 | 用户共创·嘉阳小火车 | Hi-Res | 65:00 | ❌ |
| 咖啡馆·午后 | 声谷录制·上海武康路 | Hi-Res | 58:00 | ❌ |
14 个声音素材的设计有几个特点:
- 具体的录制地点:每个声音都有具体的出处(杭州山区、黄山、九寨沟、平潭岛……),让声音有了「故事感」。
- 声谷录制 + 用户共创双来源:10 个声谷专业录制 + 4 个用户共创,既保证了核心素材的品质,又让用户有参与感。
- Hi-Res + 标准双品质:10 个 Hi-Res + 4 个标准,Hi-Res 用暖沙金标签标注,让音质差异一目了然。
- 免费 + VIP 双模式:8 个免费 + 6 个 VIP,免费素材让新用户快速体验,VIP 素材是会员权益的核心。
11.2 筛选与浏览
声库提供三个筛选标签:
- 仅看免费:只显示 free=true 的声音,让用户快速找到免费素材
- Hi-Res 专区:高亮 Hi-Res 品质标签(当前实现为标签切换,未做筛选)
- 本周热门:按下载量排序(当前实现为标签切换,未做排序)
筛选标签的设计让用户可以快速缩小浏览范围。14 个声音素材的列表使用单列布局,每条显示图标、名称+品质标签、来源、时长+大小、免费/VIP 标签,信息密度适中。
11.3 底部抽屉详情
点击声音列表项弹出底部抽屉详情,包含:
- 声音名称 + 录制来源
- 20 根波形可视化(极光青和暖沙金交替)
- 四栏详情(时长/品质/文件大小/下载量,Divider 分隔)
- 下载离线 + 收藏两个按钮
底部抽屉使用 24vp 顶部圆角,从底部滑出。波形可视化让声音有了「形状」,四栏详情让声音有了「档案感」。下载离线按钮让用户可以将声音下载到本地,在没有网络的地方也能收听。收藏按钮弹出收藏提示弹窗,确认已加入收藏。
11.4 声库的商业化设计
声库的商业化设计基于「免费 + VIP 双模式」:
- 免费素材:8 个标准/Hi-Res 声音免费下载,让新用户快速体验产品价值
- VIP 素材:6 个 Hi-Res 声音需要会员才能下载,是会员权益的核心
- Hi-Res 品质:Hi-Res 标签用暖沙金标注,暗示「高品质需要付费」
- 会员开通入口:报告 Tab 的收听统计卡片中有「👑 会员」入口,点击弹出会员开通弹窗
这种免费 + VIP 的双模式是内容类 App 的经典商业化策略——免费素材吸引用户,VIP 素材转化付费。14 个声音素材中 8 个免费(57%),比例适中,既不会让免费用户觉得「什么都要付费」,也不会让 VIP 觉得「会员没什么专属内容」。
第十二章 报告系统:睡眠与专注的数据镜子
报告系统是声谷的数据统计中心,提供睡眠周报、专注时长、收听成就、会员开通、意见反馈等功能。
12.1 睡眠周报柱状图
睡眠周报是报告 Tab 的核心功能,7 天睡眠质量柱状图,评分 ≥85 的柱子用极光青,≥70 的用暖沙金,<70 的用浅红。
三色柱状图的设计让睡眠质量一目了然——极光青=好睡眠、暖沙金=一般、浅红=需要关注。用户一眼就能看到「这周哪几天睡得好,哪几天睡得差」。
点击柱子弹出睡眠详情弹窗,包含:
- 总时长(小时,极光青)
- 睡眠质量分(暖沙金)
- 深睡占比(极光青进度条)
- REM 占比(靛紫进度条)
- 入睡用时(奶白文字)
- 伴随音景(如「雪夜列车 🚄」)
睡眠详情的信息架构遵循「总览 → 细分 → 关联」的顺序——先看总时长和质量分(总览),再看深睡/REM占比和入睡用时(细分),最后看伴随音景(关联)。用户可以通过睡眠详情了解自己的睡眠结构,也可以看到「哪个音景陪伴了这一晚的睡眠」。
12.2 收听统计卡片
收听统计卡片包含两栏:
- 平均睡眠 7.6 小时:比上周多 22 分钟(极光青),点击可查看睡眠详情
- 音景累计收听 26.5 小时:最常听:雨打芭蕉·图书馆自习(暖沙金会员入口)
收听统计卡片让用户看到自己的「使用数据」——平均睡眠时长反映了声谷对睡眠的帮助,累计收听时长反映了用户的使用频率,最常听音景反映了用户的偏好。这些数据让用户有「被了解」的感觉,也为推荐系统提供了数据基础。
12.3 专注时长柱状图
报告 Tab 还展示本周专注时长柱状图,7 天数据全部用靛紫色(与睡眠周报的极光青形成区分)。
睡眠周报用极光青、专注时长用靛紫,这种色彩区分让两个柱状图在同一个页面中不会混淆。用户一眼就能区分「这是睡眠数据」和「这是专注数据」。
12.4 收听成就宫格
8 个已解锁成就(🌧🌲🔥📚🌊🚄🌌🎐),4 列宫格布局,每个显示 emoji 和「已解锁」文字。
成就系统的设计让用户有「探索感」和「收集感」——每解锁一个成就,就像收集了一枚勋章。8 个成就对应 8 个音景类别,用户需要收听不同类别的音景才能解锁全部成就。这种设计鼓励用户探索更多音景,而不是只听一两个固定的。
12.5 意见反馈
底部的「💬 想听什么声音?告诉我们」按钮,点击弹出意见反馈弹窗,包含 TextArea 和三个类别标签(自然/城市/幻想)。
意见反馈是声谷与用户沟通的桥梁——用户可以告诉团队自己想听什么声音,团队可以根据反馈优先录制最受欢迎的声音。这种「用户参与内容创作」的模式让声谷的音景库能够持续扩充,也让用户有了「参与感」和「归属感」。
12.6 报告系统的设计哲学
报告系统的设计哲学是「数据是镜子,不是尺子」——睡眠周报让用户看到自己的睡眠模式,专注时长让用户看到自己的努力,收听成就让用户看到自己的探索。数据不是用来评判用户的(「你昨晚只睡了 5.9 小时,睡眠质量差」),而是用来帮助用户了解自己的(「周五总是睡得最差,那就周五早点打开声谷」)。
这种「温柔的数据」设计与声谷「安静」的产品调性一致——不制造焦虑,只提供洞察。用户在报告 Tab 中看到的不是「你做得不够好」,而是「这是你的数据,你可以据此做出更好的选择」。
十三、开发环境搭建
13.1 环境要求
DevEco Studio 5.0+(支持HarmonyOS 6.1.1)、HarmonyOS SDK API 24、Node.js 18+、至少8GB内存。
13.2 安装DevEco Studio

从华为开发者官网下载最新版本,运行安装包,选择安装路径,勾选创建桌面快捷方式,点击安装。
13.3 配置SDK

首次启动后进入File > Settings > HarmonyOS SDK,勾选API 24及以上版本,点击Apply等待下载完成。
13.4 创建项目

选择Create Project > Empty Ability,配置项目名称、包名、保存路径,选择Compile SDK为API 24,点击Finish。
13.5 运行应用

将1316.ets代码替换到entry/src/main/ets/pages/Index.ets,连接HarmonyOS设备或启动模拟器,点击运行按钮预览。
13.6 预览调试

DevEco Studio提供实时预览功能,编辑器右侧打开Previewer面板,支持多设备尺寸预览、深色模式切换、组件树inspect等调试能力。
本章提供完整的开发环境搭建步骤,包括 DevEco Studio 安装、HarmonyOS SDK 配置、项目创建、代码运行和预览调试。按照本章步骤操作,你可以在 30 分钟内将采样集市 SAMPLE BAZAAR 应用在模拟器或真机上运行起来。
13.1 环境要求
开发采样集市 SAMPLE BAZAAR 需要以下环境配置:
- 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:项目名称,如 SampleBazaar
- Bundle name:包名,如 com.example.bazaar(需全局唯一)
- Save location:项目保存路径
- Compile SDK:选择 API 24(HarmonyOS 6.1.1)
- Model:选择 Stage 模型(HarmonyOS 推荐的新模型)
- 点击 Finish,等待项目初始化完成(约 1-2 分钟,自动生成项目结构和配置文件)
项目创建完成后,目录结构如下:
SampleBazaar/
├── entry/ # 主模块
│ ├── src/main/
│ │ ├── ets/pages/
│ │ │ └── Index.ets # 主页面(替换为SAMPLE BAZAAR源码)
│ │ ├── resources/ # 资源文件
│ │ └── module.json5 # 模块配置
│ └── build-profile.json5 # 构建配置
├── AppScope/ # 应用全局配置
├── hvigor/ # 构建工具
└── build-profile.json5 # 全局构建配置
13.5 运行应用
项目创建完成后,将采样集市 SAMPLE BAZAAR 的源码替换到项目中并运行:
- 打开
entry/src/main/ets/pages/Index.ets文件 - 用本文附录中的完整源码(2130.ets)完全替换 Index.ets 的内容
- 确认
module.json5中已注册该页面(默认创建的 Empty Ability 已自动注册 Index.ets) - 连接 HarmonyOS 设备(USB 数据线连接真机并开启 USB 调试),或启动模拟器(Tools > Device Manager > Create Emulator,选择 API 24 的设备镜像)
- 点击工具栏的运行按钮(▶️),或按快捷键 Shift+F10
- 在弹出的设备选择对话框中选择目标设备(真机或模拟器)
- 等待编译构建完成(首次构建约 1-3 分钟,后续增量构建约 30 秒)
- 应用自动安装并启动到设备上,即可看到采样集市 SAMPLE BAZAAR 的首页 Tab
如果运行报错,常见原因和解决方法:
- SDK 版本不匹配:确认 module.json5 中的 compileSdkVersion 为 24
- 页面未注册:确认 module.json5 的 abilities 中包含 Index.ets 页面对应的 ability
- minLines 属性报错:本项目源码已修正 minLines 问题(2130.ets 中已移除 Text 组件的 minLines 属性),如果使用旧版本源码可能遇到此问题
- 内存不足:关闭其他占用内存的程序,或增加 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 元素可在代码中定位到对应组件
对于 SAMPLE BAZAAR 这种深色模式为主的 App,Previewer 是最高效的开发工具——修改颜色、间距、布局后无需重新运行应用,1-2 秒即可在 Previewer 中看到效果。弹窗组件由于使用 Stack 叠加+if 条件渲染,在 Previewer 中可通过手动修改 showXxx 状态为 true 来预览弹窗效果。
调试功能:
- 断点调试:在代码行号左侧点击设置断点,点击 Debug 按钮(🐛)以调试模式运行,命中断点后可查看变量值、调用栈、单步执行
- 日志查看:底部 Log 面板显示应用运行日志,支持按级别过滤和关键词搜索
- 性能分析:Profiler 面板提供 CPU、内存、网络、帧率等性能指标实时监控
- 布局检查:ArkUI Inspector 面板显示当前页面的组件树结构、每个组件的属性值、布局边界
总结与展望
项目总结
声谷 SONVALLEY 是一款以「白噪音 + 专注音景 + 睡眠助手」为核心的声音陪伴类 App,基于 HarmonyOS ArkTS 声明式 UI 框架开发,单文件约 2900 行代码,无外部依赖。项目实现了 6 大 Tab 页面(音景/专注/混音台/声库/报告/我的)和约 15 套弹窗组件,完整覆盖音景浏览、番茄钟专注、多轨混音、Hi-Res 声库、睡眠周报、会员体系等核心业务场景。
视觉设计上采用「深夜极光风」——深蓝夜空底(#0B1220)如同午夜两点的天空,极光青(#5EEAD4)如同北极圈上空流动的绿色光带,靛紫(#A5B4FC)如同极光中偶尔闪现的紫色涟漪,暖沙金(#FBBF24)如同沙漠黎明前天边那一抹暖色。四种颜色的组合营造出强烈的「深夜独处、极光流动、内心安宁」的氛围——每一次打开 App,都像走进了一个只属于自己的安静角落,设备指示灯亮着,极光在窗外流动,内心随时可以安静下来。色彩语义明确——极光青代表播放/主要操作/品牌色,暖沙金代表会员/完成/热度/成就,靛紫代表专注/学习/深度,浅红代表删除/退出/低睡眠。
技术实现上严格遵循 ArkTS 规范:无 Blank 组件、无 UI 内变量声明、constraintSize 限高、单子组件 Scroll、接口约束全部对象字面量。采用单文件组件化架构,通过 @Component 划分为 8 个独立组件,@State 管理局部状态,@Builder 封装可复用 UI 和弹窗,Stack 叠加实现半透明遮罩弹窗,属性动画实现交互动效。
技术亮点
- 深夜极光风视觉体系:深蓝夜空底+极光青+靛紫+暖沙金的四色体系,大圆角卡片+圆形音景图标+柱状图数据可视化+波形可视化,整体视觉如同深夜极光下的安静世界,营造出强烈的声音陪伴沉浸感
- 多轨混音台系统:8 条独立音轨(细雨/炉火/翻书页/雷声/鸟鸣/风声/溪流/键盘),每条音轨可独立开关、独立调节音量,10 段音量可视化+Slider 滑杆双重控制,方案保存/重命名/删除管理,是声谷从「播放器」升级为「音景创作工具」的核心差异化
- 音景标签六色分类系统:18 个音景分为自然(极光青)/生活(暖沙金)/学习(靛紫)/旅途(天蓝)/季节(浅紫)/幻想(粉)六大类别,每类独立标签色,让用户在宫格中一眼识别音景类别,也让界面色彩丰富而不杂乱
- 番茄钟+白噪音共生系统:专注 Tab 将番茄钟计时与白噪音播放结合,四档专注目标(25分钟番茄/45分钟深度/60分钟沉浸/90分钟心流),4 个番茄进度点,伴随音景显示,专注完成庆祝弹窗,让用户在专注时有声音陪伴,在听白噪音时有时间管理
- 睡眠周报三色柱状图:睡眠质量 ≥85 极光青/≥70 暖沙金/<70 浅红,点击柱子弹出睡眠详情(深睡占比/REM占比/入睡用时/伴随音景),数据是镜子不是尺子,不制造焦虑只提供洞察
- Hi-Res 声库素材仓库:14 个精心录制的声音素材,每个有具体录制地点(杭州山区/黄山/九寨沟/平潭岛/东极岛/长白山/呼伦贝尔/西溪湿地/南京梧桐道/嘉阳小火车/上海武康路),Hi-Res/标准双品质,免费/VIP 双模式,底部抽屉详情+波形可视化+下载离线+收藏
- 18 个音景的诗意化描述:每个音景都有一句诗意化的场景描述(雨打芭蕉「江南屋檐下的绵密细雨,伴着芭蕉叶的轻响」/星际舱室「飞船引擎的低频共振与仪表滴答」/宫檐风铃「古寺檐角铜铃随风摇动的清音」),让音景有了画面感和故事感
- 约 15 套完整弹窗交互体系:从音景详情、定时关闭、专注目标、专注完成、混音保存/重命名/删除、声库底部抽屉、收藏提示、会员开通、意见反馈、睡眠详情、关于、退出登录,每个业务场景都有对应弹窗
- 数据可视化语言:睡眠周报柱状图(三色)、专注时长柱状图(靛紫)、音景热度榜(横滑卡片)、收听成就宫格(8个emoji勋章),数据可视化让抽象的睡眠质量和专注时长变得直观可感
- 安静的产品调性贯穿始终:无红色警示按钮、无弹窗广告、无推送打扰,所有交互温和克制,删除/退出用浅红但从不主动弹出,动画克制而有意义,数据是镜子不是尺子,「安静」不仅是产品功能,更是设计语言
扩展方向
声谷 SONVALLEY 作为演示项目,仍有多个可扩展方向:
- 真实音频播放引擎:当前音景播放是静态状态模拟(playingId + 缩放动画),可接入鸿蒙音频播放 API 实现真实的音频循环播放、音量控制、淡入淡出
- 实时混音引擎:当前混音台是静态音量配比(Slider + 10段可视化),可接入音频混音 API 实现 8 条音轨的实时叠加播放,拖动滑杆实时改变音量
- 睡眠监测对接:当前睡眠数据是静态模拟(SLEEP_WEEK 常量),可对接鸿蒙健康服务 API 获取真实的睡眠数据(深睡/REM/入睡用时),实现基于真实数据的睡眠分析
- 专注模式增强:当前番茄钟是静态倒计时(minutesLeft + running 状态),可实现真实的计时器逻辑(setInterval 倒计时)、专注度检测(基于屏幕操作频率)、白噪音自动切换
- AI 音景生成:可集成 AI 音频生成技术,根据用户输入的场景描述(如「清晨的森林,有鸟鸣和溪水」)自动生成对应的音景组合
- 社区与分享:可扩展混音方案的分享功能,用户可以将自己调好的音景组合分享到社区,其他用户可以一键使用
- 定时与提醒增强:当前定时关闭是静态选项,可实现真实的定时逻辑(到点自动淡出)、睡眠定时器(入睡后自动停止)、晨起唤醒(渐强音景代替闹钟)
- 多端协同:可扩展为手机/平板/手表多端协同,手机用于浏览和播放,手表用于睡眠监测和专注计时,鸿蒙分布式能力实现跨端状态同步
- 声音录制与上传:当前声库素材是静态数据,可实现用户录制和上传自己的声音素材,经过审核后加入声库,形成 UGC 内容生态
- 个性化推荐:基于用户的收听历史、收藏音景、专注记录,实现个性化音景推荐(「你在周三晚上最喜欢听雨打芭蕉」)
声谷 SONVALLEY 展示了 HarmonyOS ArkTS 在声音陪伴类 App 开发中的强大能力——声明式 UI 的简洁语法、@State 的高效状态管理、Stack 叠加弹窗的灵活实现、属性动画的流畅动效、Previewer 的实时预览,让开发者可以用较少的代码实现丰富的声音陪伴体验。希望本文能为 HarmonyOS 开发者和健康科技/数字健康产品从业者提供有价值的参考。
打开声谷,让极光在窗外流动,让内心在声音中安静下来。
本文基于 HarmonyOS ArkTS 声明式 UI 框架实战项目撰写,所有代码和图片均来自声谷 SONVALLEY 项目。
附录:完整源码(2129.ets)
以下为声谷 SONVALLEY 项目的完整 ArkTS 源码,包含类型定义、静态数据、工具函数、枚举、主入口组件、头部组件以及六大 Tab 内容组件,共计 2758 行。
// 2129.ets 声谷 SONVALLEY —— 白噪音 / 专注音景 / 睡眠助手
// 深夜极光风:深蓝夜空底 + 极光青 + 靛紫 + 暖沙金
// 遵循要求.md:无 Blank、无 UI 内变量声明、constraintSize 限高、单子组件 Scroll、接口约束全部对象字面量
// ============ 类型定义 ============
interface SceneItem {
id: number;
name: string;
emoji: string;
tag: string;
tracks: number;
hours: number;
hot: number;
liked: boolean;
desc: string;
}
interface SoundItem {
id: number;
name: string;
source: string;
duration: string;
quality: string;
size: string;
downloads: number;
free: boolean;
}
interface MixTrack {
id: number;
name: string;
emoji: string;
volume: number;
active: boolean;
}
interface FocusSession {
id: number;
label: string;
minutes: number;
date: string;
done: boolean;
}
interface SleepDay {
day: string;
score: number;
hours: number;
}
interface SoundPack {
id: number;
name: string;
count: number;
price: string;
vip: boolean;
}
interface WeekHour {
day: string;
minutes: number;
}
// ============ 静态数据 ============
const SCENE_LIST: SceneItem[] = [
{ id: 1, name: '雨打芭蕉', emoji: '🌧', tag: '自然', tracks: 4, hours: 128, hot: 98, liked: true, desc: '江南屋檐下的绵密细雨,伴着芭蕉叶的轻响' },
{ id: 2, name: '松涛林海', emoji: '🌲', tag: '自然', tracks: 3, hours: 96, hot: 92, liked: false, desc: '山风穿过针叶林的低频涌动' },
{ id: 3, name: '炉火噼啪', emoji: '🔥', tag: '生活', tracks: 2, hours: 74, hot: 88, liked: true, desc: '壁炉柴火慢慢燃烧的温暖白噪' },
{ id: 4, name: '深夜书屋', emoji: '📚', tag: '生活', tracks: 5, hours: 61, hot: 85, liked: false, desc: '翻页声、铅笔沙沙与老台灯的电流声' },
{ id: 5, name: '远海灯塔', emoji: '🌊', tag: '自然', tracks: 3, hours: 152, hot: 95, liked: true, desc: '海浪一遍遍抚过礁石,远处雾笛隐约' },
{ id: 6, name: '青苔溪涧', emoji: '⛰', tag: '自然', tracks: 4, hours: 83, hot: 79, liked: false, desc: '山涧碎玉般的流水与鸟鸣' },
{ id: 7, name: '夏日蝉庭', emoji: '蝉', tag: '季节', tracks: 2, hours: 45, hot: 71, liked: false, desc: '午后老槐树上一片蝉声如潮' },
{ id: 8, name: '雪夜列车', emoji: '🚄', tag: '旅途', tracks: 6, hours: 118, hot: 90, liked: true, desc: '铁轨规律的哐当声混着暖气的嗡鸣' },
{ id: 9, name: '外婆厨房', emoji: '🍲', tag: '生活', tracks: 5, hours: 39, hot: 68, liked: false, desc: '高压锅嘶鸣、切菜声与收音机戏曲' },
{ id: 10, name: '图书馆自习', emoji: '📖', tag: '学习', tracks: 4, hours: 205, hot: 99, liked: true, desc: '极轻的翻页与笔尖声,安静到能听见心跳' },
{ id: 11, name: '湖畔清晨', emoji: '🌅', tag: '自然', tracks: 3, hours: 67, hot: 76, liked: false, desc: '薄雾湖面,水鸟掠过的一圈圈涟漪' },
{ id: 12, name: '古镇雨巷', emoji: '🏮', tag: '旅途', tracks: 4, hours: 58, hot: 82, liked: true, desc: '油纸伞下的石板路,檐角滴水成线' },
{ id: 13, name: '星际舱室', emoji: '🛰', tag: '幻想', tracks: 3, hours: 91, hot: 87, liked: false, desc: '飞船引擎的低频共振与仪表滴答' },
{ id: 14, name: '云端热气球', emoji: '🎈', tag: '幻想', tracks: 2, hours: 33, hot: 64, liked: false, desc: '高空风声与喷枪间歇的呼啸' },
{ id: 15, name: '老式咖啡机', emoji: '☕', tag: '生活', tracks: 3, hours: 47, hot: 73, liked: false, desc: '磨豆、蒸汽与杯碟碰撞的晨间协奏' },
{ id: 16, name: '键盘协奏', emoji: '⌨', tag: '学习', tracks: 2, hours: 72, hot: 81, liked: true, desc: '机械键盘敲击声节奏化混音' },
{ id: 17, name: '芦苇晚风', emoji: '🌾', tag: '季节', tracks: 3, hours: 44, hot: 69, liked: false, desc: '秋日苇荡里干燥的风穿行声' },
{ id: 18, name: '宫檐风铃', emoji: '🎐', tag: '幻想', tracks: 4, hours: 55, hot: 78, liked: true, desc: '古寺檐角铜铃随风摇动的清音' }
];
const SOUND_LIST: SoundItem[] = [
{ id: 1, name: '细雨·近景', source: '声谷录制 · 杭州山区', duration: '45:00', quality: 'Hi-Res', size: '128MB', downloads: 32000, free: true },
{ id: 2, name: '雷雨·远山', source: '声谷录制 · 黄山', duration: '60:00', quality: 'Hi-Res', size: '210MB', downloads: 28000, free: false },
{ id: 3, name: '溪流·碎玉', source: '声谷录制 · 九寨沟', duration: '50:00', quality: 'Hi-Res', size: '142MB', downloads: 26000, free: true },
{ id: 4, name: '海浪·平潮', source: '声谷录制 · 平潭岛', duration: '80:00', quality: 'Hi-Res', size: '265MB', downloads: 41000, free: false },
{ id: 5, name: '海浪·涌浪', source: '声谷录制 · 东极岛', duration: '70:00', quality: 'Hi-Res', size: '230MB', downloads: 35000, free: false },
{ id: 6, name: '炉火·松木', source: '用户共创 · 山间小屋', duration: '40:00', quality: '标准', size: '68MB', downloads: 19000, free: true },
{ id: 7, name: '炉火·桦木', source: '用户共创 · 北欧木屋', duration: '38:00', quality: '标准', size: '61MB', downloads: 15000, free: true },
{ id: 8, name: '风·穿过松林', source: '声谷录制 · 长白山', duration: '55:00', quality: 'Hi-Res', size: '175MB', downloads: 24000, free: false },
{ id: 9, name: '风·掠过麦田', source: '声谷录制 · 呼伦贝尔', duration: '48:00', quality: 'Hi-Res', size: '152MB', downloads: 21000, free: true },
{ id: 10, name: '鸟鸣·清晨混声', source: '声谷录制 · 西溪湿地', duration: '35:00', quality: 'Hi-Res', size: '96MB', downloads: 30000, free: true },
{ id: 11, name: '蝉声·午后', source: '用户共创 · 南京梧桐道', duration: '42:00', quality: '标准', size: '70MB', downloads: 17000, free: true },
{ id: 12, name: '翻书页·批量', source: '声谷实验室', duration: '20:00', quality: '标准', size: '33MB', downloads: 13000, free: true },
{ id: 13, name: '蒸汽火车·慢行', source: '用户共创 · 嘉阳小火车', duration: '65:00', quality: 'Hi-Res', size: '198MB', downloads: 22000, free: false },
{ id: 14, name: '咖啡馆·午后', source: '声谷录制 · 上海武康路', duration: '58:00', quality: 'Hi-Res', size: '176MB', downloads: 38000, free: false }
];
const DEFAULT_MIX: MixTrack[] = [
{ id: 1, name: '细雨', emoji: '🌧', volume: 70, active: true },
{ id: 2, name: '炉火', emoji: '🔥', volume: 40, active: true },
{ id: 3, name: '翻书页', emoji: '📖', volume: 25, active: false },
{ id: 4, name: '雷声', emoji: '⛈', volume: 15, active: false },
{ id: 5, name: '鸟鸣', emoji: '🐦', volume: 30, active: true },
{ id: 6, name: '风声', emoji: '🍃', volume: 55, active: false },
{ id: 7, name: '溪流', emoji: '💧', volume: 20, active: false },
{ id: 8, name: '键盘', emoji: '⌨', volume: 10, active: false }
];
const FOCUS_HISTORY: FocusSession[] = [
{ id: 1, label: '毕业论文初稿', minutes: 50, date: '08-21', done: true },
{ id: 2, label: '雅思听力精听', minutes: 25, date: '08-21', done: true },
{ id: 3, label: '线性代数刷题', minutes: 45, date: '08-22', done: true },
{ id: 4, label: '读书《人类简史》', minutes: 30, date: '08-22', done: false },
{ id: 5, label: '前端期末项目', minutes: 60, date: '08-23', done: true },
{ id: 6, label: '日语N2单词', minutes: 20, date: '08-23', done: true },
{ id: 7, label: '考研英语阅读', minutes: 40, date: '08-24', done: true },
{ id: 8, label: '书法练习·行书', minutes: 35, date: '08-25', done: true },
{ id: 9, label: '素描静物写生', minutes: 90, date: '08-25', done: false },
{ id: 10, label: '周报整理', minutes: 15, date: '08-26', done: true }
];
const SLEEP_WEEK: SleepDay[] = [
{ day: '周一', score: 82, hours: 7.2 },
{ day: '周二', score: 76, hours: 6.5 },
{ day: '周三', score: 91, hours: 8.1 },
{ day: '周四', score: 88, hours: 7.8 },
{ day: '周五', score: 64, hours: 5.9 },
{ day: '周六', score: 95, hours: 9.2 },
{ day: '周日', score: 90, hours: 8.6 }
];
const FOCUS_WEEK: WeekHour[] = [
{ day: '一', minutes: 95 },
{ day: '二', minutes: 120 },
{ day: '三', minutes: 60 },
{ day: '四', minutes: 145 },
{ day: '五', minutes: 110 },
{ day: '六', minutes: 180 },
{ day: '日', minutes: 75 }
];
const SOUND_PACKS: SoundPack[] = [
{ id: 1, name: '自然四季·全集', count: 24, price: '¥18', vip: false },
{ id: 2, name: '城市角落·合集', count: 16, price: '¥12', vip: false },
{ id: 2, name: '自习室白噪·合集', count: 12, price: '会员免费', vip: true },
{ id: 4, name: '幻想世界·合集', count: 18, price: '¥15', vip: false },
{ id: 5, name: '旅途声音·合集', count: 10, price: '会员免费', vip: true }
];
const TIMER_OPTIONS: string[] = ['15分钟', '30分钟', '1小时', '2小时', '不开启'];
const FOCUS_GOALS: string[] = ['25分钟 · 番茄', '45分钟 · 深度', '60分钟 · 沉浸', '90分钟 · 心流'];
// ============ 工具函数 ============
function sceneTagColor(tag: string): string {
if (tag === '自然') {
return 'rgba(94,234,212,0.18)';
}
if (tag === '生活') {
return 'rgba(251,191,36,0.16)';
}
if (tag === '学习') {
return 'rgba(129,140,248,0.2)';
}
if (tag === '旅途') {
return 'rgba(56,189,248,0.18)';
}
if (tag === '季节') {
return 'rgba(167,139,250,0.18)';
}
return 'rgba(244,114,182,0.18)';
}
function sceneTagTextColor(tag: string): string {
if (tag === '自然') {
return '#5EEAD4';
}
if (tag === '生活') {
return '#FBBF24';
}
if (tag === '学习') {
return '#A5B4FC';
}
if (tag === '旅途') {
return '#38BDF8';
}
if (tag === '季节') {
return '#A78BFA';
}
return '#F472B6';
}
function focusHeight(min: number): string {
return (min * 1.1).toFixed(0) + 'vp';
}
function sleepHeight(score: number): string {
return (score * 0.9).toFixed(0) + 'vp';
}
enum ValleyTab {
SCENE,
FOCUS,
MIXER,
SOUND,
REPORT,
ME
}
// ============ 主入口 ============
@Entry
@Component
struct SonvalleyApp {
@State activeTab: ValleyTab = ValleyTab.SCENE;
@Builder contentArea() {
if (this.activeTab === ValleyTab.SCENE) {
SceneContent()
} else if (this.activeTab === ValleyTab.FOCUS) {
FocusContent()
} else if (this.activeTab === ValleyTab.MIXER) {
MixerContent()
} else if (this.activeTab === ValleyTab.SOUND) {
SoundContent()
} else if (this.activeTab === ValleyTab.REPORT) {
ReportContent()
} else {
MeContent()
}
}
@Builder bottomTabItem(icon: string, label: string, tab: ValleyTab) {
Column({ space: 3 }) {
Text(icon)
.fontSize(22)
.scale(this.activeTab === tab ? { x: 1.15, y: 1.15 } : { x: 1, y: 1 })
.animation({ duration: 220, curve: Curve.EaseOut })
Text(label)
.fontSize(10)
.fontColor(this.activeTab === tab ? '#5EEAD4' : '#64748B')
}
.alignItems(HorizontalAlign.Center)
.padding({ top: 8, bottom: 6 })
.width('16.6%')
.onClick(() => {
this.activeTab = tab;
})
}
build() {
Column() {
ValleyHeader()
Column() {
this.contentArea()
}
.layoutWeight(1)
.width('100%')
Divider()
.color('rgba(94,234,212,0.15)')
.strokeWidth(1)
Row() {
this.bottomTabItem('🌌', '音景', ValleyTab.SCENE)
this.bottomTabItem('⏳', '专注', ValleyTab.FOCUS)
this.bottomTabItem('🎛', '混音台', ValleyTab.MIXER)
this.bottomTabItem('📻', '声库', ValleyTab.SOUND)
this.bottomTabItem('📊', '报告', ValleyTab.REPORT)
this.bottomTabItem('👤', '我的', ValleyTab.ME)
}
.width('100%')
.backgroundColor('#0B1220')
.padding({ bottom: 6 })
}
.width('100%')
.height('100%')
.backgroundColor('#0B1220')
}
}
// ============ 头部(电商风·静态) ============
@Component
struct ValleyHeader {
onSearchTap: () => void = () => {};
build() {
Column() {
Row({ space: 10 }) {
Column() {
Text('声谷')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#5EEAD4')
Text('SONVALLEY')
.fontSize(8)
.fontColor('#475569')
.letterSpacing(2)
}
.alignItems(HorizontalAlign.Start)
Row({ space: 8 }) {
Row({ space: 6 }) {
Text('🔍')
.fontSize(13)
Text('搜索音景 / 声音')
.fontSize(11)
.fontColor('#475569')
}
.padding({ left: 12, right: 12, top: 7, bottom: 7 })
.backgroundColor('rgba(94,234,212,0.08)')
.borderRadius(20)
.border({ width: 1, color: 'rgba(94,234,212,0.2)' })
.layoutWeight(1)
.onClick(() => {
this.onSearchTap();
})
Text('🔔')
.fontSize(18)
Text('🎧')
.fontSize(18)
}
.layoutWeight(1)
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 10 })
Row({ space: 8 }) {
Text('会员免费听全部音景')
.fontSize(10)
.fontColor('#FBBF24')
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor('rgba(251,191,36,0.12)')
.borderRadius(4)
Text('今日新上架:雪夜列车 · 青苔溪涧')
.fontSize(10)
.fontColor('#64748B')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 10 })
}
.width('100%')
.backgroundColor('#0B1220')
}
}
// ============ 音景 Tab(宫格 + 热度榜单) ============
@Component
struct SceneContent {
@State selectedScene: SceneItem | null = null
@State showSceneModal: boolean = false
@State showTimerModal: boolean = false
@State timerIndex: number = 0
@State playingId: number = 1
@State timerChoice: number = 0
@Builder modalOverlay(onClose: () => void) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
onClose();
})
Column() {
Text('🌙')
.fontSize(30)
.margin({ bottom: 8 })
Text('声谷 · 演示')
.fontSize(12)
.fontColor('#64748B')
.margin({ bottom: 16 })
}
.width('100%')
}
.width('100%')
.height('100%')
}
@Builder sceneModal() {
Column({ space: 14 }) {
Text(this.selectedScene!.emoji)
.fontSize(46)
.margin({ top: 22 })
Text(this.selectedScene!.name)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#E2E8F0')
Row({ space: 8 }) {
Text(this.selectedScene!.tag)
.fontSize(10)
.fontColor(sceneTagTextColor(this.selectedScene!.tag))
.padding({ left: 10, right: 10, top: 3, bottom: 3 })
.backgroundColor(sceneTagColor(this.selectedScene!.tag))
.borderRadius(4)
Text(this.selectedScene!.tracks + ' 条音轨')
.fontSize(10)
.fontColor('#64748B')
Text('累计收听 ' + this.selectedScene!.hours + ' 万小时')
.fontSize(10)
.fontColor('#64748B')
}
Text(this.selectedScene!.desc)
.fontSize(12)
.fontColor('#94A3B8')
.textAlign(TextAlign.Center)
.padding({ left: 20, right: 20 })
Divider()
.color('rgba(94,234,212,0.15)')
.margin({ left: 24, right: 24 })
Row({ space: 30 }) {
Column({ space: 3 }) {
Text('🔥')
.fontSize(18)
Text('热度 ' + this.selectedScene!.hot)
.fontSize(10)
.fontColor('#64748B')
}
.alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('⏱')
.fontSize(18)
Text('建议 30 分钟')
.fontSize(10)
.fontColor('#64748B')
}
.alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text(this.selectedScene!.liked ? '💖' : '🤍')
.fontSize(18)
Text('收藏')
.fontSize(10)
.fontColor('#64748B')
}
.alignItems(HorizontalAlign.Center)
.onClick(() => {
this.selectedScene!.liked = !this.selectedScene!.liked;
})
}
Row({ space: 12 }) {
Button() {
Text('加入混音台')
.fontSize(13)
.fontColor('#5EEAD4')
}
.padding({ left: 18, right: 18, top: 9, bottom: 9 })
.backgroundColor('rgba(94,234,212,0.12)')
.borderRadius(24)
Button() {
Text('立即播放')
.fontSize(13)
.fontColor('#0B1220')
}
.padding({ left: 22, right: 22, top: 9, bottom: 9 })
.backgroundColor('#5EEAD4')
.borderRadius(24)
.onClick(() => {
this.playingId = this.selectedScene!.id;
this.showSceneModal = false;
})
}
.margin({ bottom: 22 })
}
.width('86%')
.backgroundColor('#101A30')
.borderRadius(20)
.alignItems(HorizontalAlign.Center)
.border({ width: 1, color: 'rgba(94,234,212,0.25)' })
}
@Builder timerModal() {
Column({ space: 16 }) {
Text('定时关闭')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#E2E8F0')
.margin({ top: 20 })
Text('到点后当前音景将缓缓淡出')
.fontSize(11)
.fontColor('#64748B')
Column({ space: 0 }) {
ForEach(TIMER_OPTIONS, (opt: string, idx: number) => {
Row() {
Text(opt)
.fontSize(14)
.fontColor(this.timerChoice === idx ? '#5EEAD4' : '#94A3B8')
Text(this.timerChoice === idx ? '●' : ' ')
.fontSize(14)
.fontColor('#5EEAD4')
}
.width('100%')
.padding({ left: 20, right: 20, top: 12, bottom: 12 })
.justifyContent(FlexAlign.SpaceBetween)
.onClick(() => {
this.timerChoice = idx;
})
}, (opt: string) => opt)
}
.width('86%')
.borderRadius(12)
.backgroundColor('#0F1930')
Button() {
Text('确定')
.fontSize(14)
.fontColor('#0B1220')
}
.padding({ left: 40, right: 40, top: 10, bottom: 10 })
.backgroundColor('#5EEAD4')
.borderRadius(22)
.margin({ bottom: 20 })
.onClick(() => {
this.showTimerModal = false;
})
}
.width('86%')
.backgroundColor('#101A30')
.borderRadius(20)
.alignItems(HorizontalAlign.Center)
}
@Builder sceneCard(s: SceneItem) {
Column({ space: 8 }) {
Column() {
Text(s.emoji)
.fontSize(34)
.scale(this.playingId === s.id ? { x: 1.18, y: 1.18 } : { x: 1, y: 1 })
.animation({ duration: 300, curve: Curve.EaseOut })
}
.width(56)
.height(56)
.borderRadius(28)
.backgroundColor('rgba(94,234,212,0.1)')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
Text(s.name)
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor('#E2E8F0')
.maxLines(1)
Text(s.tag + ' · ' + s.tracks + '轨')
.fontSize(10)
.fontColor('#64748B')
}
.width('30%')
.padding({ top: 14, bottom: 12 })
.backgroundColor('#101A30')
.borderRadius(16)
.alignItems(HorizontalAlign.Center)
.onClick(() => {
this.selectedScene = s;
this.showSceneModal = true;
})
}
build() {
Stack({ alignContent: Alignment.Center }) {
Scroll() {
Column() {
Row({ space: 6 }) {
Text('🔥 本周音景热度榜')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#E2E8F0')
.layoutWeight(1)
Text('定时 ⏱')
.fontSize(11)
.fontColor('#5EEAD4')
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor('rgba(94,234,212,0.1)')
.borderRadius(12)
.onClick(() => {
this.showTimerModal = true;
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 14 })
Scroll() {
Row({ space: 8 }) {
ForEach(SCENE_LIST.slice(0, 6), (s: SceneItem) => {
Column({ space: 4 }) {
Text(s.emoji)
.fontSize(22)
Text(s.hot + ' 热度')
.fontSize(9)
.fontColor('#FBBF24')
Text(s.name)
.fontSize(10)
.fontColor('#94A3B8')
.maxLines(1)
}
.width(84)
.padding({ top: 10, bottom: 10 })
.backgroundColor('#101A30')
.borderRadius(12)
.alignItems(HorizontalAlign.Center)
.onClick(() => {
this.selectedScene = s;
this.showSceneModal = true;
})
}, (s: SceneItem) => s.id.toString())
}
.padding({ left: 16, right: 16 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.margin({ top: 10 })
Text('✨ 全部音景')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#E2E8F0')
.width('100%')
.padding({ left: 16, top: 16, bottom: 4 })
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(SCENE_LIST, (s: SceneItem) => {
this.sceneCard(s)
}, (s: SceneItem) => s.id.toString())
}
.width('100%')
.padding({ left: 12, right: 12, bottom: 16 })
Text('🛍 声音合集')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#E2E8F0')
.width('100%')
.padding({ left: 16, bottom: 8 })
Scroll() {
Row({ space: 10 }) {
ForEach(SOUND_PACKS, (p: SoundPack) => {
Column({ space: 6 }) {
Text(p.name)
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor('#E2E8F0')
.maxLines(1)
Text(p.count + ' 条声音')
.fontSize(10)
.fontColor('#64748B')
Text(p.price)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(p.vip ? '#FBBF24' : '#5EEAD4')
}
.width(130)
.padding(12)
.backgroundColor('#101A30')
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
}, (p: SoundPack) => p.name)
}
.padding({ left: 16, right: 16 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.padding({ bottom: 20 })
}
.width('100%')
}
.scrollBar(BarState.Off)
.layoutWeight(1)
.width('100%')
if (this.showTimerModal) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
this.showTimerModal = false;
})
this.timerModal()
}
.width('100%')
.height('100%')
.backgroundColor('rgba(4,8,20,0.7)')
}
if (this.showSceneModal) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
this.showSceneModal = false;
})
this.sceneModal()
}
.width('100%')
.height('100%')
.backgroundColor('rgba(4,8,20,0.75)')
}
}
.width('100%')
.height('100%')
}
}
// ============ 专注 Tab(番茄钟 + 历史) ============
@Component
struct FocusContent {
@State goalIndex: number = 0
@State minutesLeft: number = 25
@State running: boolean = false
@State doneSessions: number = 3
@State showGoalModal: boolean = false
@State showDoneModal: boolean = false
@State goalChoice: number = 0
@State todayMinutes: number = 105
@Builder modalOverlay(onClose: () => void) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
onClose();
})
Column() {
Text('⏳')
.fontSize(30)
.margin({ bottom: 8 })
Text('专注演示')
.fontSize(12)
.fontColor('#64748B')
}
.width('100%')
}
.width('100%')
.height('100%')
}
@Builder goalModal() {
Column({ space: 14 }) {
Text('设置专注目标')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#E2E8F0')
.margin({ top: 20 })
Text('完成后自动记录到今日统计')
.fontSize(11)
.fontColor('#64748B')
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Center }) {
ForEach(FOCUS_GOALS, (g: string, idx: number) => {
Text(g)
.fontSize(12)
.fontColor(this.goalChoice === idx ? '#0B1220' : '#94A3B8')
.padding({ left: 14, right: 14, top: 8, bottom: 8 })
.backgroundColor(this.goalChoice === idx ? '#5EEAD4' : '#0F1930')
.borderRadius(18)
.margin(6)
.onClick(() => {
this.goalChoice = idx;
})
}, (g: string) => g)
}
.width('90%')
Row({ space: 12 }) {
Button() {
Text('取消')
.fontSize(13)
.fontColor('#94A3B8')
}
.padding({ left: 20, right: 20, top: 9, bottom: 9 })
.backgroundColor('#0F1930')
.borderRadius(20)
Button() {
Text('开始专注')
.fontSize(13)
.fontColor('#0B1220')
}
.padding({ left: 20, right: 20, top: 9, bottom: 9 })
.backgroundColor('#5EEAD4')
.borderRadius(20)
.onClick(() => {
this.goalIndex = this.goalChoice;
this.minutesLeft = 25 + this.goalChoice * 20;
this.running = true;
this.showGoalModal = false;
})
}
.margin({ bottom: 20 })
}
.width('86%')
.backgroundColor('#101A30')
.borderRadius(20)
.alignItems(HorizontalAlign.Center)
.border({ width: 1, color: 'rgba(94,234,212,0.25)' })
}
@Builder doneModal() {
Column({ space: 12 }) {
Text('🎉')
.fontSize(44)
.margin({ top: 24 })
.scale({ x: 1.2, y: 1.2 })
.animation({ duration: 500, curve: Curve.EaseOut })
Text('专注完成!')
.fontSize(19)
.fontWeight(FontWeight.Bold)
.fontColor('#5EEAD4')
Text('雨打芭蕉 · 深夜书屋 陪伴了这一段时光')
.fontSize(11)
.fontColor('#94A3B8')
Row({ space: 24 }) {
Column({ space: 2 }) {
Text(this.minutesLeft + '分')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#E2E8F0')
Text('本次时长')
.fontSize(10)
.fontColor('#64748B')
}
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text((this.doneSessions + 1) + '次')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#E2E8F0')
Text('今日完成')
.fontSize(10)
.fontColor('#64748B')
}
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('92%')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#E2E8F0')
Text('专注度')
.fontSize(10)
.fontColor('#64748B')
}
.alignItems(HorizontalAlign.Center)
}
.margin({ top: 8 })
Button() {
Text('太棒了,休息 5 分钟')
.fontSize(14)
.fontColor('#0B1220')
}
.padding({ left: 28, right: 28, top: 10, bottom: 10 })
.backgroundColor('#FBBF24')
.borderRadius(22)
.margin({ bottom: 24 })
.onClick(() => {
this.showDoneModal = false;
this.doneSessions += 1;
this.todayMinutes += this.minutesLeft;
})
}
.width('86%')
.backgroundColor('#101A30')
.borderRadius(20)
.alignItems(HorizontalAlign.Center)
.border({ width: 1, color: 'rgba(251,191,36,0.3)' })
}
build() {
Stack({ alignContent: Alignment.Center }) {
Scroll() {
Column() {
Column({ space: 10 }) {
Text('· ' + (this.running ? '专注中' : '待命') + ' ·')
.fontSize(10)
.fontColor('#5EEAD4')
.letterSpacing(2)
Text(this.minutesLeft + ':00')
.fontSize(58)
.fontWeight(FontWeight.Bold)
.fontColor('#E2E8F0')
Text('雨打芭蕉 🌧 · 炉火噼啪 🔥')
.fontSize(11)
.fontColor('#64748B')
Row({ space: 0 }) {
ForEach([0, 1, 2, 3], (i: number) => {
Column()
.width(28)
.height(5)
.borderRadius(3)
.backgroundColor(i < this.doneSessions ? '#5EEAD4' : '#1B2A4A')
.margin({ left: 3, right: 3 })
}, (i: number) => i.toString())
}
.margin({ top: 4 })
Row({ space: 14 }) {
Button() {
Text(this.running ? '⏸ 暂停' : '▶ 继续')
.fontSize(13)
.fontColor('#0B1220')
}
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.backgroundColor('#5EEAD4')
.borderRadius(24)
.scale(this.running ? { x: 1.05, y: 1.05 } : { x: 1, y: 1 })
.animation({ duration: 300, curve: Curve.EaseOut })
.onClick(() => {
this.running = !this.running;
})
Button() {
Text('🎯 换目标')
.fontSize(13)
.fontColor('#5EEAD4')
}
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.backgroundColor('rgba(94,234,212,0.1)')
.borderRadius(24)
.onClick(() => {
this.showGoalModal = true;
})
Button() {
Text('🏁 完成')
.fontSize(13)
.fontColor('#FBBF24')
}
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.backgroundColor('rgba(251,191,36,0.12)')
.borderRadius(24)
.onClick(() => {
this.showDoneModal = true;
})
}
.margin({ top: 10 })
}
.width('100%')
.padding({ top: 26, bottom: 26 })
.backgroundColor('#101A30')
.borderRadius(20)
.alignItems(HorizontalAlign.Center)
.margin({ left: 16, right: 16, top: 14 })
Row({ space: 10 }) {
Column({ space: 3 }) {
Text(this.todayMinutes + ' 分钟')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#E2E8F0')
Text('今日专注')
.fontSize(10)
.fontColor('#64748B')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column({ space: 3 }) {
Text(this.doneSessions + ' 次')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#5EEAD4')
Text('完成番茄')
.fontSize(10)
.fontColor('#64748B')
}
.alignItems(HorizontalAlign.Start)
Column({ space: 3 }) {
Text('连续 12 天')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FBBF24')
Text('打卡 streak')
.fontSize(10)
.fontColor('#64748B')
}
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(16)
.backgroundColor('#101A30')
.borderRadius(16)
.margin({ left: 16, right: 16, top: 12 })
Text('📅 本周专注时长(分钟)')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#E2E8F0')
.width('100%')
.padding({ left: 16, top: 18, bottom: 10 })
Column({ space: 14 }) {
Row({ space: 14 }) {
ForEach(FOCUS_WEEK, (w: WeekHour) => {
Column({ space: 6 }) {
Text(w.minutes.toString())
.fontSize(9)
.fontColor('#5EEAD4')
Column()
.width(16)
.height(focusHeight(w.minutes))
.borderRadius(4)
.backgroundColor(w.minutes > 100 ? '#5EEAD4' : 'rgba(94,234,212,0.35)')
Text(w.day)
.fontSize(10)
.fontColor('#64748B')
}
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.End)
}, (w: WeekHour) => w.day)
}
.height(150)
.alignItems(VerticalAlign.Bottom)
}
.width('100%')
.padding({ left: 16, right: 16 })
Text('🕘 最近专注记录')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#E2E8F0')
.width('100%')
.padding({ left: 16, top: 18, bottom: 8 })
Column({ space: 8 }) {
ForEach(FOCUS_HISTORY, (f: FocusSession) => {
Row({ space: 10 }) {
Text(f.done ? '✅' : '⭕')
.fontSize(14)
Column({ space: 2 }) {
Text(f.label)
.fontSize(12)
.fontColor('#E2E8F0')
Text(f.date + ' · 伴随音景「雨打芭蕉」')
.fontSize(9)
.fontColor('#64748B')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(f.minutes + ' 分钟')
.fontSize(11)
.fontColor('#5EEAD4')
}
.width('100%')
.padding({ left: 14, right: 14, top: 10, bottom: 10 })
.backgroundColor('#101A30')
.borderRadius(12)
}, (f: FocusSession) => f.id.toString())
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 20 })
}
.width('100%')
}
.scrollBar(BarState.Off)
.layoutWeight(1)
.width('100%')
if (this.showGoalModal) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
this.showGoalModal = false;
})
this.goalModal()
}
.width('100%')
.height('100%')
.backgroundColor('rgba(4,8,20,0.75)')
}
if (this.showDoneModal) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
this.showDoneModal = false;
})
this.doneModal()
}
.width('100%')
.height('100%')
.backgroundColor('rgba(4,8,20,0.8)')
}
}
.width('100%')
.height('100%')
}
}
// ============ 混音台 Tab(多轨道音量 + 保存/编辑/删除) ============
@Component
struct MixerContent {
@State tracks: MixTrack[] = DEFAULT_MIX
@State showSaveModal: boolean = false
@State showEditModal: boolean = false
@State showDeleteModal: boolean = false
@State editIndex: number = 0
@State saveName: string = ''
@State mixName: string = '雨夜书房'
@Builder modalOverlay(onClose: () => void) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
onClose();
})
Column() {
Text('🎛')
.fontSize(30)
.margin({ bottom: 8 })
Text('混音台演示')
.fontSize(12)
.fontColor('#64748B')
}
.width('100%')
}
.width('100%')
.height('100%')
}
@Builder saveModal() {
Column({ space: 14 }) {
Text('保存当前混音')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#E2E8F0')
.margin({ top: 20 })
Text('命名后可随时一键恢复所有轨道')
.fontSize(11)
.fontColor('#64748B')
TextInput({ placeholder: '给这段混音起个名字', text: this.saveName })
.fontSize(13)
.fontColor('#E2E8F0')
.placeholderColor('#475569')
.backgroundColor('#0F1930')
.borderRadius(10)
.width('80%')
.onChange((v: string) => {
this.saveName = v;
})
Row({ space: 10 }) {
Column({ space: 2 }) {
Text('🌧')
.fontSize(16)
Text('细雨 70%')
.fontSize(9)
.fontColor('#5EEAD4')
}
.alignItems(HorizontalAlign.Center)
.padding(8)
.backgroundColor('#0F1930')
.borderRadius(10)
Column({ space: 2 }) {
Text('🔥')
.fontSize(16)
Text('炉火 40%')
.fontSize(9)
.fontColor('#5EEAD4')
}
.alignItems(HorizontalAlign.Center)
.padding(8)
.backgroundColor('#0F1930')
.borderRadius(10)
Column({ space: 2 }) {
Text('🐦')
.fontSize(16)
Text('鸟鸣 30%')
.fontSize(9)
.fontColor('#5EEAD4')
}
.alignItems(HorizontalAlign.Center)
.padding(8)
.backgroundColor('#0F1930')
.borderRadius(10)
}
Button() {
Text('💾 保存混音')
.fontSize(14)
.fontColor('#0B1220')
}
.padding({ left: 32, right: 32, top: 10, bottom: 10 })
.backgroundColor('#5EEAD4')
.borderRadius(22)
.margin({ bottom: 20 })
.onClick(() => {
this.mixName = this.saveName === '' ? '未命名混音' : this.saveName;
this.showSaveModal = false;
this.saveName = '';
})
}
.width('86%')
.backgroundColor('#101A30')
.borderRadius(20)
.alignItems(HorizontalAlign.Center)
.border({ width: 1, color: 'rgba(94,234,212,0.25)' })
}
@Builder editModal() {
Column({ space: 14 }) {
Text('重命名混音方案')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#E2E8F0')
.margin({ top: 20 })
TextInput({ text: this.mixName })
.fontSize(13)
.fontColor('#E2E8F0')
.backgroundColor('#0F1930')
.borderRadius(10)
.width('80%')
.onChange((v: string) => {
this.mixName = v;
})
Text('方案包含 3 条激活音轨,总时长约 2 小时')
.fontSize(11)
.fontColor('#64748B')
Row({ space: 12 }) {
Button() {
Text('放弃')
.fontSize(13)
.fontColor('#94A3B8')
}
.padding({ left: 20, right: 20, top: 9, bottom: 9 })
.backgroundColor('#0F1930')
.borderRadius(20)
.onClick(() => {
this.showEditModal = false;
})
Button() {
Text('确认修改')
.fontSize(13)
.fontColor('#0B1220')
}
.padding({ left: 20, right: 20, top: 9, bottom: 9 })
.backgroundColor('#5EEAD4')
.borderRadius(20)
.onClick(() => {
this.showEditModal = false;
})
}
.margin({ bottom: 20 })
}
.width('86%')
.backgroundColor('#101A30')
.borderRadius(20)
.alignItems(HorizontalAlign.Center)
}
@Builder deleteModal() {
Column({ space: 14 }) {
Text('🗑')
.fontSize(40)
.margin({ top: 22 })
Text('删除「' + this.mixName + '」?')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#F87171')
Text('删除后该混音方案与音量配比将无法找回')
.fontSize(11)
.fontColor('#94A3B8')
.padding({ left: 24, right: 24 })
.textAlign(TextAlign.Center)
Row({ space: 12 }) {
Button() {
Text('再想想')
.fontSize(13)
.fontColor('#94A3B8')
}
.padding({ left: 22, right: 22, top: 9, bottom: 9 })
.backgroundColor('#0F1930')
.borderRadius(20)
.onClick(() => {
this.showDeleteModal = false;
})
Button() {
Text('确认删除')
.fontSize(13)
.fontColor('#FCA5A5')
}
.padding({ left: 22, right: 22, top: 9, bottom: 9 })
.backgroundColor('rgba(248,113,113,0.14)')
.borderRadius(20)
.onClick(() => {
this.showDeleteModal = false;
})
}
.margin({ bottom: 22 })
}
.width('86%')
.backgroundColor('#101A30')
.borderRadius(20)
.alignItems(HorizontalAlign.Center)
.border({ width: 1, color: 'rgba(248,113,113,0.3)' })
}
build() {
Stack({ alignContent: Alignment.Center }) {
Scroll() {
Column() {
Column({ space: 8 }) {
Row({ space: 8 }) {
Text('🎛 ' + this.mixName)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#E2E8F0')
.layoutWeight(1)
Text('✏️ 重命名')
.fontSize(11)
.fontColor('#5EEAD4')
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor('rgba(94,234,212,0.1)')
.borderRadius(12)
.onClick(() => {
this.showEditModal = true;
})
Text('🗑 删除')
.fontSize(11)
.fontColor('#F87171')
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor('rgba(248,113,113,0.12)')
.borderRadius(12)
.onClick(() => {
this.showDeleteModal = true;
})
}
.width('100%')
Text('已激活音轨会实时叠加播放,拖动滑杆调整音量')
.fontSize(10)
.fontColor('#64748B')
}
.width('100%')
.padding({ left: 16, right: 16, top: 14 })
Column({ space: 10 }) {
ForEach(this.tracks, (t: MixTrack) => {
Column({ space: 8 }) {
Row({ space: 10 }) {
Text(t.emoji)
.fontSize(20)
.scale(t.active ? { x: 1.15, y: 1.15 } : { x: 1, y: 1 })
.animation({ duration: 200, curve: Curve.EaseOut })
Column({ space: 2 }) {
Text(t.name)
.fontSize(13)
.fontColor(t.active ? '#E2E8F0' : '#64748B')
Text(t.active ? '播放中 · ' + t.volume + '%' : '已静音')
.fontSize(9)
.fontColor(t.active ? '#5EEAD4' : '#475569')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(t.active ? '⏸' : '▶')
.fontSize(14)
.fontColor('#0B1220')
.width(30)
.height(30)
.borderRadius(15)
.backgroundColor(t.active ? '#FBBF24' : '#5EEAD4')
.textAlign(TextAlign.Center)
.onClick(() => {
t.active = !t.active;
})
}
.width('100%')
Column({ space: 5 }) {
Row({ space: 0 }) {
ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], (seg: number) => {
Column()
.width(14)
.height(6)
.borderRadius(2)
.backgroundColor(seg * 10 < t.volume ? '#5EEAD4' : '#1B2A4A')
.margin({ left: 2, right: 2 })
}, (seg: number) => seg.toString())
}
}
.width('100%')
Slider({ value: t.volume, min: 0, max: 100 })
.selectedColor('#5EEAD4')
.trackColor('#1B2A4A')
.blockColor('#E2E8F0')
.onChange((v: number) => {
t.volume = Math.round(v);
})
}
.width('100%')
.padding(14)
.backgroundColor('#101A30')
.borderRadius(14)
}, (t: MixTrack) => t.id.toString())
}
.width('100%')
.padding({ left: 16, right: 16, top: 12 })
Button() {
Text('💾 保存当前混音方案')
.fontSize(14)
.fontColor('#0B1220')
}
.padding({ left: 32, right: 32, top: 12, bottom: 12 })
.backgroundColor('#5EEAD4')
.borderRadius(24)
.margin({ top: 16, bottom: 24 })
.onClick(() => {
this.showSaveModal = true;
})
}
.width('100%')
}
.scrollBar(BarState.Off)
.layoutWeight(1)
.width('100%')
if (this.showSaveModal) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
this.showSaveModal = false;
})
this.saveModal()
}
.width('100%')
.height('100%')
.backgroundColor('rgba(4,8,20,0.75)')
}
if (this.showEditModal) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
this.showEditModal = false;
})
this.editModal()
}
.width('100%')
.height('100%')
.backgroundColor('rgba(4,8,20,0.75)')
}
if (this.showDeleteModal) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
this.showDeleteModal = false;
})
this.deleteModal()
}
.width('100%')
.height('100%')
.backgroundColor('rgba(4,8,20,0.8)')
}
}
.width('100%')
.height('100%')
}
}
// ============ 声库 Tab(声音列表 + 底部抽屉详情) ============
@Component
struct SoundContent {
@State selectedSound: SoundItem | null = null
@State showDetail: boolean = false
@State showFavModal: boolean = false
@State filterFree: boolean = false
@Builder modalOverlay(onClose: () => void) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
onClose();
})
Column() {
Text('📻')
.fontSize(30)
.margin({ bottom: 8 })
Text('声库演示')
.fontSize(12)
.fontColor('#64748B')
}
.width('100%')
}
.width('100%')
.height('100%')
}
@Builder soundDetailSheet() {
Column({ space: 12 }) {
Column()
.width(44)
.height(5)
.borderRadius(3)
.backgroundColor('#334155')
.margin({ top: 10 })
Text(this.selectedSound!.name)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#E2E8F0')
Text(this.selectedSound!.source)
.fontSize(11)
.fontColor('#64748B')
Row({ space: 0 }) {
ForEach([18, 30, 22, 38, 26, 42, 20, 34, 28, 40, 24, 36, 19, 32, 27, 41, 23, 35, 21, 33], (h: number, i: number) => {
Column()
.width(8)
.height(h)
.borderRadius(2)
.backgroundColor(i % 3 === 0 ? '#FBBF24' : '#5EEAD4')
.margin({ left: 2, right: 2 })
}, (h: number, i: number) => h.toString() + i.toString())
}
.height(46)
.alignItems(VerticalAlign.Bottom)
.justifyContent(FlexAlign.Center)
.margin({ top: 6 })
Column({ space: 0 }) {
Row() {
Text('时长')
.fontSize(12)
.fontColor('#64748B')
Text(this.selectedSound!.duration)
.fontSize(12)
.fontColor('#E2E8F0')
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ top: 10, bottom: 10 })
Divider()
.color('rgba(94,234,212,0.1)')
Row() {
Text('品质')
.fontSize(12)
.fontColor('#64748B')
Text(this.selectedSound!.quality)
.fontSize(12)
.fontColor('#5EEAD4')
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ top: 10, bottom: 10 })
Divider()
.color('rgba(94,234,212,0.1)')
Row() {
Text('文件大小')
.fontSize(12)
.fontColor('#64748B')
Text(this.selectedSound!.size)
.fontSize(12)
.fontColor('#E2E8F0')
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ top: 10, bottom: 10 })
Divider()
.color('rgba(94,234,212,0.1)')
Row() {
Text('下载量')
.fontSize(12)
.fontColor('#64748B')
Text(this.selectedSound!.downloads.toString())
.fontSize(12)
.fontColor('#FBBF24')
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ top: 10, bottom: 10 })
}
.width('88%')
.padding({ left: 16, right: 16, bottom: 8 })
.backgroundColor('#0F1930')
.borderRadius(14)
Row({ space: 12 }) {
Button() {
Text('⬇ 下载离线')
.fontSize(13)
.fontColor('#5EEAD4')
}
.padding({ left: 20, right: 20, top: 10, bottom: 10 })
.backgroundColor('rgba(94,234,212,0.12)')
.borderRadius(22)
Button() {
Text('➕ 收藏')
.fontSize(13)
.fontColor('#0B1220')
}
.padding({ left: 20, right: 20, top: 10, bottom: 10 })
.backgroundColor('#5EEAD4')
.borderRadius(22)
.onClick(() => {
this.showFavModal = true;
})
}
.margin({ bottom: 24 })
}
.width('100%')
.backgroundColor('#101A30')
.borderRadius({ topLeft: 24, topRight: 24 })
.alignItems(HorizontalAlign.Center)
}
@Builder favToastModal() {
Column({ space: 10 }) {
Text('💖')
.fontSize(36)
.scale({ x: 1.25, y: 1.25 })
.animation({ duration: 400, curve: Curve.EaseOut })
Text('已加入收藏')
.fontSize(15)
.fontColor('#E2E8F0')
Text('可在「我的-收藏声音」中找到')
.fontSize(11)
.fontColor('#64748B')
}
.width('60%')
.padding({ top: 24, bottom: 24 })
.backgroundColor('#101A30')
.borderRadius(18)
.alignItems(HorizontalAlign.Center)
}
@Builder soundRow(s: SoundItem) {
Row({ space: 12 }) {
Column() {
Text('🔊')
.fontSize(18)
}
.width(44)
.height(44)
.borderRadius(12)
.backgroundColor('rgba(94,234,212,0.1)')
.justifyContent(FlexAlign.Center)
Column({ space: 3 }) {
Row({ space: 6 }) {
Text(s.name)
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor('#E2E8F0')
.maxLines(1)
Text(s.quality)
.fontSize(8)
.fontColor('#FBBF24')
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
.backgroundColor('rgba(251,191,36,0.12)')
.borderRadius(3)
}
Text(s.source)
.fontSize(10)
.fontColor('#64748B')
.maxLines(1)
Text('⏱ ' + s.duration + ' · ' + s.size)
.fontSize(9)
.fontColor('#475569')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(s.free ? '免费' : 'VIP')
.fontSize(10)
.fontColor(s.free ? '#5EEAD4' : '#FBBF24')
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(s.free ? 'rgba(94,234,212,0.12)' : 'rgba(251,191,36,0.12)')
.borderRadius(6)
}
.width('100%')
.padding(12)
.backgroundColor('#101A30')
.borderRadius(14)
.onClick(() => {
this.selectedSound = s;
this.showDetail = true;
})
}
build() {
Stack({ alignContent: Alignment.Center }) {
Column() {
Row({ space: 10 }) {
Text(this.filterFree ? '🟢 仅看免费' : '📻 全部声音')
.fontSize(13)
.fontColor('#5EEAD4')
.padding({ left: 14, right: 14, top: 7, bottom: 7 })
.backgroundColor(this.filterFree ? 'rgba(94,234,212,0.15)' : 'rgba(94,234,212,0.08)')
.borderRadius(18)
.onClick(() => {
this.filterFree = !this.filterFree;
})
Text('🎧 Hi-Res 专区')
.fontSize(13)
.fontColor('#FBBF24')
.padding({ left: 14, right: 14, top: 7, bottom: 7 })
.backgroundColor('rgba(251,191,36,0.1)')
.borderRadius(18)
Text('🔥 本周热门')
.fontSize(13)
.fontColor('#94A3B8')
.padding({ left: 14, right: 14, top: 7, bottom: 7 })
.backgroundColor('#0F1930')
.borderRadius(18)
}
.width('100%')
.padding({ left: 16, right: 16, top: 12 })
Scroll() {
Column({ space: 10 }) {
ForEach(SOUND_LIST, (s: SoundItem) => {
if (!this.filterFree || s.free) {
this.soundRow(s)
}
}, (s: SoundItem) => s.id.toString())
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 20 })
}
.scrollBar(BarState.Off)
.layoutWeight(1)
.width('100%')
}
.width('100%')
.height('100%')
if (this.showDetail) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
this.showDetail = false;
})
this.soundDetailSheet()
}
.width('100%')
.height('100%')
.backgroundColor('rgba(4,8,20,0.7)')
}
if (this.showFavModal) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
this.showFavModal = false;
})
this.favToastModal()
}
.width('100%')
.height('100%')
.backgroundColor('rgba(4,8,20,0.6)')
}
}
.width('100%')
.height('100%')
}
}
// ============ 报告 Tab(睡眠周报 + 专注统计) ============
@Component
struct ReportContent {
@State showVipModal: boolean = false
@State showFeedbackModal: boolean = false
@State feedbackText: string = ''
@State showSleepDetail: boolean = false
@State detailDay: SleepDay | null = null
@Builder modalOverlay(onClose: () => void) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
onClose();
})
Column() {
Text('📊')
.fontSize(30)
.margin({ bottom: 8 })
Text('报告演示')
.fontSize(12)
.fontColor('#64748B')
}
.width('100%')
}
.width('100%')
.height('100%')
}
@Builder vipModal() {
Column({ space: 14 }) {
Text('👑 声谷会员')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#FBBF24')
.margin({ top: 22 })
Column({ space: 8 }) {
Row({ space: 8 }) {
Text('✔')
.fontSize(13)
.fontColor('#5EEAD4')
Text('全部 Hi-Res 音源免费下载')
.fontSize(12)
.fontColor('#E2E8F0')
}
.width('100%')
Row({ space: 8 }) {
Text('✔')
.fontSize(13)
.fontColor('#5EEAD4')
Text('专属会员音景每周更新')
.fontSize(12)
.fontColor('#E2E8F0')
}
.width('100%')
Row({ space: 8 }) {
Text('✔')
.fontSize(13)
.fontColor('#5EEAD4')
Text('无限混音方案云端同步')
.fontSize(12)
.fontColor('#E2E8F0')
}
.width('100%')
}
.width('80%')
.alignItems(HorizontalAlign.Start)
Row({ space: 10 }) {
Column({ space: 3 }) {
Text('¥12')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#E2E8F0')
Text('连续包月')
.fontSize(9)
.fontColor('#64748B')
}
.alignItems(HorizontalAlign.Center)
.padding(12)
.borderRadius(14)
.border({ width: 1, color: '#FBBF24' })
Column({ space: 3 }) {
Text('¥30')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#E2E8F0')
Text('单季度')
.fontSize(9)
.fontColor('#64748B')
}
.alignItems(HorizontalAlign.Center)
.padding(12)
.borderRadius(14)
.border({ width: 1, color: '#334155' })
Column({ space: 3 }) {
Text('¥88')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#E2E8F0')
Text('年度最惠')
.fontSize(9)
.fontColor('#64748B')
}
.alignItems(HorizontalAlign.Center)
.padding(12)
.borderRadius(14)
.border({ width: 1, color: '#334155' })
}
Button() {
Text('立即开通 · 首月 ¥6')
.fontSize(14)
.fontColor('#0B1220')
}
.padding({ left: 34, right: 34, top: 11, bottom: 11 })
.backgroundColor('#FBBF24')
.borderRadius(24)
.margin({ bottom: 22 })
}
.width('88%')
.backgroundColor('#101A30')
.borderRadius(20)
.alignItems(HorizontalAlign.Center)
.border({ width: 1, color: 'rgba(251,191,36,0.3)' })
}
@Builder feedbackModal() {
Column({ space: 14 }) {
Text('意见反馈')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#E2E8F0')
.margin({ top: 20 })
Text('告诉我们你想在声谷听到的声音')
.fontSize(11)
.fontColor('#64748B')
TextArea({ placeholder: '例如:想要一段清晨菜市场的叫卖声…', text: this.feedbackText })
.fontSize(12)
.fontColor('#E2E8F0')
.placeholderColor('#475569')
.backgroundColor('#0F1930')
.borderRadius(12)
.width('82%')
.height(100)
.onChange((v: string) => {
this.feedbackText = v;
})
Row({ space: 8 }) {
Text('🌧 自然')
.fontSize(11)
.fontColor('#5EEAD4')
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor('rgba(94,234,212,0.1)')
.borderRadius(14)
Text('🏙 城市')
.fontSize(11)
.fontColor('#94A3B8')
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor('#0F1930')
.borderRadius(14)
Text('🛸 幻想')
.fontSize(11)
.fontColor('#94A3B8')
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor('#0F1930')
.borderRadius(14)
}
Button() {
Text('提交反馈')
.fontSize(14)
.fontColor('#0B1220')
}
.padding({ left: 32, right: 32, top: 10, bottom: 10 })
.backgroundColor('#5EEAD4')
.borderRadius(22)
.margin({ bottom: 20 })
.onClick(() => {
this.showFeedbackModal = false;
this.feedbackText = '';
})
}
.width('88%')
.backgroundColor('#101A30')
.borderRadius(20)
.alignItems(HorizontalAlign.Center)
}
@Builder sleepDetailModal() {
Column({ space: 12 }) {
Text('😴 ' + this.detailDay!.day + '睡眠详情')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#E2E8F0')
.margin({ top: 20 })
Row({ space: 30 }) {
Column({ space: 3 }) {
Text(this.detailDay!.hours + ' 小时')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#5EEAD4')
Text('总时长')
.fontSize(10)
.fontColor('#64748B')
}
.alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text(this.detailDay!.score + ' 分')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#FBBF24')
Text('睡眠质量')
.fontSize(10)
.fontColor('#64748B')
}
.alignItems(HorizontalAlign.Center)
}
Column({ space: 6 }) {
Row() {
Text('深睡占比')
.fontSize(11)
.fontColor('#64748B')
Text('32%')
.fontSize(11)
.fontColor('#5EEAD4')
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
Column() {
Column()
.width('32%')
.height(6)
.borderRadius(3)
.backgroundColor('#5EEAD4')
}
.width('100%')
.height(6)
.borderRadius(3)
.backgroundColor('#1B2A4A')
Row() {
Text('REM 占比')
.fontSize(11)
.fontColor('#64748B')
Text('24%')
.fontSize(11)
.fontColor('#A5B4FC')
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ top: 6 })
Column() {
Column()
.width('24%')
.height(6)
.borderRadius(3)
.backgroundColor('#A5B4FC')
}
.width('100%')
.height(6)
.borderRadius(3)
.backgroundColor('#1B2A4A')
Row() {
Text('入睡用时')
.fontSize(11)
.fontColor('#64748B')
Text('14 分钟')
.fontSize(11)
.fontColor('#E2E8F0')
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ top: 6 })
}
.width('82%')
Text('伴随音景:雪夜列车 🚄')
.fontSize(11)
.fontColor('#94A3B8')
Button() {
Text('知道了')
.fontSize(14)
.fontColor('#0B1220')
}
.padding({ left: 32, right: 32, top: 9, bottom: 9 })
.backgroundColor('#5EEAD4')
.borderRadius(22)
.margin({ bottom: 20 })
.onClick(() => {
this.showSleepDetail = false;
})
}
.width('86%')
.backgroundColor('#101A30')
.borderRadius(20)
.alignItems(HorizontalAlign.Center)
}
build() {
Stack({ alignContent: Alignment.Center }) {
Scroll() {
Column() {
Text('🌙 本周睡眠质量')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#E2E8F0')
.width('100%')
.padding({ left: 16, top: 14, bottom: 10 })
Row({ space: 12 }) {
ForEach(SLEEP_WEEK, (d: SleepDay) => {
Column({ space: 6 }) {
Text(d.score.toString())
.fontSize(9)
.fontColor('#5EEAD4')
Column()
.width(18)
.height(sleepHeight(d.score))
.borderRadius(5)
.backgroundColor(d.score >= 85 ? '#5EEAD4' : (d.score >= 70 ? '#FBBF24' : '#F87171'))
Text(d.day)
.fontSize(10)
.fontColor('#64748B')
}
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.End)
.onClick(() => {
this.detailDay = d;
this.showSleepDetail = true;
})
}, (d: SleepDay) => d.day)
}
.height(130)
.alignItems(VerticalAlign.Bottom)
.width('100%')
.padding({ left: 16, right: 16 })
Column({ space: 8 }) {
Row({ space: 10 }) {
Text('😴')
.fontSize(20)
Column({ space: 2 }) {
Text('平均睡眠 7.6 小时')
.fontSize(12)
.fontColor('#E2E8F0')
Text('比上周多 22 分钟')
.fontSize(10)
.fontColor('#5EEAD4')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('详情 ›')
.fontSize(11)
.fontColor('#5EEAD4')
.onClick(() => {
this.detailDay = SLEEP_WEEK[5];
this.showSleepDetail = true;
})
}
.width('100%')
Divider()
.color('rgba(94,234,212,0.1)')
Row({ space: 10 }) {
Text('🎧')
.fontSize(20)
Column({ space: 2 }) {
Text('音景累计收听 26.5 小时')
.fontSize(12)
.fontColor('#E2E8F0')
Text('最常听:雨打芭蕉 · 图书馆自习')
.fontSize(10)
.fontColor('#64748B')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('👑 会员')
.fontSize(11)
.fontColor('#FBBF24')
.onClick(() => {
this.showVipModal = true;
})
}
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor('#101A30')
.borderRadius(16)
.margin({ left: 16, right: 16, top: 14 })
Text('⏳ 本周专注时长(分钟)')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#E2E8F0')
.width('100%')
.padding({ left: 16, top: 18, bottom: 10 })
Row({ space: 14 }) {
ForEach(FOCUS_WEEK, (w: WeekHour) => {
Column({ space: 6 }) {
Text(w.minutes.toString())
.fontSize(9)
.fontColor('#A5B4FC')
Column()
.width(16)
.height(focusHeight(w.minutes))
.borderRadius(4)
.backgroundColor('#A5B4FC')
Text(w.day)
.fontSize(10)
.fontColor('#64748B')
}
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.End)
}, (w: WeekHour) => w.day)
}
.height(150)
.alignItems(VerticalAlign.Bottom)
.width('100%')
.padding({ left: 16, right: 16 })
Text('🏆 收听成就')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#E2E8F0')
.width('100%')
.padding({ left: 16, top: 18, bottom: 10 })
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(['🌧', '🌲', '🔥', '📚', '🌊', '🚄', '🌌', '🎐'], (e: string) => {
Column({ space: 6 }) {
Text(e)
.fontSize(24)
Text('已解锁')
.fontSize(9)
.fontColor('#5EEAD4')
}
.width('22%')
.padding({ top: 14, bottom: 14 })
.backgroundColor('#101A30')
.borderRadius(14)
.alignItems(HorizontalAlign.Center)
.margin({ bottom: 10 })
}, (e: string) => e)
}
.width('100%')
.padding({ left: 16, right: 16 })
Button() {
Text('💬 想听什么声音?告诉我们')
.fontSize(13)
.fontColor('#5EEAD4')
}
.padding({ left: 28, right: 28, top: 10, bottom: 10 })
.backgroundColor('rgba(94,234,212,0.1)')
.borderRadius(22)
.margin({ top: 8, bottom: 24 })
.onClick(() => {
this.showFeedbackModal = true;
})
}
.width('100%')
}
.scrollBar(BarState.Off)
.layoutWeight(1)
.width('100%')
if (this.showVipModal) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
this.showVipModal = false;
})
this.vipModal()
}
.width('100%')
.height('100%')
.backgroundColor('rgba(4,8,20,0.8)')
}
if (this.showFeedbackModal) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
this.showFeedbackModal = false;
})
this.feedbackModal()
}
.width('100%')
.height('100%')
.backgroundColor('rgba(4,8,20,0.75)')
}
if (this.showSleepDetail) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
this.showSleepDetail = false;
})
this.sleepDetailModal()
}
.width('100%')
.height('100%')
.backgroundColor('rgba(4,8,20,0.75)')
}
}
.width('100%')
.height('100%')
}
}
// ============ 我的 Tab ============
@Component
struct MeContent {
@State showAboutModal: boolean = false
@State showLogoutModal: boolean = false
@Builder modalOverlay(onClose: () => void) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
onClose();
})
Column() {
Text('👤')
.fontSize(30)
.margin({ bottom: 8 })
Text('我的演示')
.fontSize(12)
.fontColor('#64748B')
}
.width('100%')
}
.width('100%')
.height('100%')
}
@Builder aboutModal() {
Column({ space: 10 }) {
Text('🌌')
.fontSize(42)
.margin({ top: 22 })
Text('声谷 SONVALLEY')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#E2E8F0')
Text('v2.4.0')
.fontSize(11)
.fontColor('#64748B')
Text('让每一种安静,都有形状。\n录制山川湖海,也录制人间烟火。')
.fontSize(11)
.fontColor('#94A3B8')
.textAlign(TextAlign.Center)
.padding({ left: 24, right: 24, top: 6 })
Divider()
.color('rgba(94,234,212,0.15)')
.margin({ top: 8 })
Row() {
Text('检查更新')
.fontSize(12)
.fontColor('#94A3B8')
Text('已是最新 ›')
.fontSize(12)
.fontColor('#5EEAD4')
}
.width('86%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ top: 12, bottom: 12 })
Row() {
Text('用户协议')
.fontSize(12)
.fontColor('#94A3B8')
Text('›')
.fontSize(12)
.fontColor('#475569')
}
.width('86%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ top: 12, bottom: 12 })
Row() {
Text('隐私政策')
.fontSize(12)
.fontColor('#94A3B8')
Text('›')
.fontSize(12)
.fontColor('#475569')
}
.width('86%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ top: 12, bottom: 16 })
}
.width('86%')
.backgroundColor('#101A30')
.borderRadius(20)
.alignItems(HorizontalAlign.Center)
}
@Builder logoutModal() {
Column({ space: 14 }) {
Text('👋')
.fontSize(40)
.margin({ top: 22 })
Text('退出登录?')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#F87171')
Text('混音方案与离线声音仍保留在本机')
.fontSize(11)
.fontColor('#94A3B8')
Row({ space: 12 }) {
Button() {
Text('取消')
.fontSize(13)
.fontColor('#94A3B8')
}
.padding({ left: 22, right: 22, top: 9, bottom: 9 })
.backgroundColor('#0F1930')
.borderRadius(20)
.onClick(() => {
this.showLogoutModal = false;
})
Button() {
Text('退出')
.fontSize(13)
.fontColor('#FCA5A5')
}
.padding({ left: 22, right: 22, top: 9, bottom: 9 })
.backgroundColor('rgba(248,113,113,0.14)')
.borderRadius(20)
.onClick(() => {
this.showLogoutModal = false;
})
}
.margin({ bottom: 22 })
}
.width('80%')
.backgroundColor('#101A30')
.borderRadius(20)
.alignItems(HorizontalAlign.Center)
}
build() {
Stack({ alignContent: Alignment.Center }) {
Scroll() {
Column() {
Row({ space: 14 }) {
Column() {
Text('🌙')
.fontSize(28)
}
.width(62)
.height(62)
.borderRadius(31)
.backgroundColor('rgba(94,234,212,0.12)')
.justifyContent(FlexAlign.Center)
Column({ space: 4 }) {
Text('夜航星')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#E2E8F0')
Text('ID: sonvalley_0817 · 连续收听 46 天')
.fontSize(10)
.fontColor('#64748B')
Row({ space: 6 }) {
Text('LV.6 静夜守护者')
.fontSize(9)
.fontColor('#FBBF24')
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
.backgroundColor('rgba(251,191,36,0.12)')
.borderRadius(8)
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding({ left: 16, right: 16, top: 18 })
Row({ space: 10 }) {
Column({ space: 3 }) {
Text('26')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#5EEAD4')
Text('收藏音景')
.fontSize(10)
.fontColor('#64748B')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 12, bottom: 12 })
.backgroundColor('#101A30')
.borderRadius(14)
Column({ space: 3 }) {
Text('8')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#A5B4FC')
Text('混音方案')
.fontSize(10)
.fontColor('#64748B')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 12, bottom: 12 })
.backgroundColor('#101A30')
.borderRadius(14)
Column({ space: 3 }) {
Text('26.5h')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FBBF24')
Text('本周收听')
.fontSize(10)
.fontColor('#64748B')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 12, bottom: 12 })
.backgroundColor('#101A30')
.borderRadius(14)
}
.width('100%')
.padding({ left: 16, right: 16, top: 14 })
Text('☁️ 云端与权益')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#E2E8F0')
.width('100%')
.padding({ left: 16, top: 18, bottom: 8 })
Column({ space: 0 }) {
Row({ space: 12 }) {
Text('👑')
.fontSize(17)
Text('声谷会员')
.fontSize(13)
.fontColor('#E2E8F0')
.layoutWeight(1)
Text('2026-12-15 到期 ›')
.fontSize(11)
.fontColor('#FBBF24')
}
.width('100%')
.padding({ left: 16, right: 16, top: 13, bottom: 13 })
Divider()
.color('rgba(94,234,212,0.08)')
.margin({ left: 44 })
Row({ space: 12 }) {
Text('📥')
.fontSize(17)
Text('离线管理')
.fontSize(13)
.fontColor('#E2E8F0')
.layoutWeight(1)
Text('14 个文件 / 2.1GB ›')
.fontSize(11)
.fontColor('#64748B')
}
.width('100%')
.padding({ left: 16, right: 16, top: 13, bottom: 13 })
Divider()
.color('rgba(94,234,212,0.08)')
.margin({ left: 44 })
Row({ space: 12 }) {
Text('🕐')
.fontSize(17)
Text('定时关闭')
.fontSize(13)
.fontColor('#E2E8F0')
.layoutWeight(1)
Text('30 分钟 ›')
.fontSize(11)
.fontColor('#64748B')
}
.width('100%')
.padding({ left: 16, right: 16, top: 13, bottom: 13 })
Divider()
.color('rgba(94,234,212,0.08)')
.margin({ left: 44 })
Row({ space: 12 }) {
Text('🔔')
.fontSize(17)
Text('消息通知')
.fontSize(13)
.fontColor('#E2E8F0')
.layoutWeight(1)
Text('已开启 ›')
.fontSize(11)
.fontColor('#64748B')
}
.width('100%')
.padding({ left: 16, right: 16, top: 13, bottom: 13 })
}
.width('100%')
.padding({ left: 16, right: 16 })
.backgroundColor('#101A30')
.borderRadius(16)
.clip(true)
Text('⚙️ 通用')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#E2E8F0')
.width('100%')
.padding({ left: 16, top: 18, bottom: 8 })
Column({ space: 0 }) {
Row({ space: 12 }) {
Text('🎧')
.fontSize(17)
Text('音质偏好')
.fontSize(13)
.fontColor('#E2E8F0')
.layoutWeight(1)
Text('Hi-Res ›')
.fontSize(11)
.fontColor('#64748B')
}
.width('100%')
.padding({ left: 16, right: 16, top: 13, bottom: 13 })
Divider()
.color('rgba(94,234,212,0.08)')
.margin({ left: 44 })
Row({ space: 12 }) {
Text('🌍')
.fontSize(17)
Text('关于声谷')
.fontSize(13)
.fontColor('#E2E8F0')
.layoutWeight(1)
Text('v2.4.0 ›')
.fontSize(11)
.fontColor('#64748B')
}
.width('100%')
.padding({ left: 16, right: 16, top: 13, bottom: 13 })
.onClick(() => {
this.showAboutModal = true;
})
Divider()
.color('rgba(94,234,212,0.08)')
.margin({ left: 44 })
Row({ space: 12 }) {
Text('🚪')
.fontSize(17)
Text('退出登录')
.fontSize(13)
.fontColor('#F87171')
.layoutWeight(1)
Text('›')
.fontSize(11)
.fontColor('#475569')
}
.width('100%')
.padding({ left: 16, right: 16, top: 13, bottom: 13 })
.onClick(() => {
this.showLogoutModal = true;
})
}
.width('100%')
.padding({ left: 16, right: 16 })
.backgroundColor('#101A30')
.borderRadius(16)
.clip(true)
.margin({ bottom: 24 })
}
.width('100%')
}
.scrollBar(BarState.Off)
.layoutWeight(1)
.width('100%')
if (this.showAboutModal) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
this.showAboutModal = false;
})
this.aboutModal()
}
.width('100%')
.height('100%')
.backgroundColor('rgba(4,8,20,0.8)')
}
if (this.showLogoutModal) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
this.showLogoutModal = false;
})
this.logoutModal()
}
.width('100%')
.height('100%')
.backgroundColor('rgba(4,8,20,0.8)')
}
}
.width('100%')
.height('100%')
}
}
更多推荐



所有评论(0)