播客创作与订阅平台基于 ArkTS 声明式 UI 的鸿蒙音频内容应用深度解析,节目数据(Podcast)和单集数据(Episode)往往需要在列表、详情、创作中心等多个页面之间共享
一、技术背景
声明式 UI 范式是近年来移动端与跨端应用开发领域最为重要的演进方向之一。与传统的命令式 UI 不同,声明式 UI 要求开发者以"状态驱动视图"的思路描述界面:开发者只需声明在某一状态下界面应当呈现成什么样,而框架本身负责在状态发生变化时自动计算差异并完成最小化的 DOM 树更新。这种模式天然契合数据双向绑定与单向数据流的设计哲学,能够极大降低复杂界面的维护成本。在鸿蒙生态中,ArkTS 作为基于 TypeScript 扩展而来的应用开发语言,承载了这一声明式范式的全部能力,并通过 @Component、@State、@Builder、@Observed 等一系列装饰器,将组件化、状态管理与 UI 描述有机融合在一起,让开发者可以用接近自然语言的代码结构描述出高度复杂的页面。
ArkTS 的核心价值在于它在 TypeScript 静态类型系统之上,叠加了面向 UI 的编译期约束与运行时优化。一方面,静态类型让大型项目的重构和协作变得更加安全;另一方面,ArkUI 框架在编译期会对声明式代码进行静态分析,识别出哪些状态变量被哪些组件消费,从而在运行时建立精准的依赖追踪关系。当某个 @State 变量发生变化时,框架只会重新渲染真正依赖该变量的那部分组件子树,而不是整棵组件树刷新。这种细粒度的更新机制,对于包含大量列表项、弹窗、动画的播客类应用尤为关键,因为它直接决定了滑动列表、波形动画、模态切换等高频交互场景下的帧率表现。
@Observed 机制是 ArkUI 状态管理体系中用于跨组件、跨层级传递可观察对象的关键能力。当一个类被 @Observed 装饰后,其实例就具备了被框架深度监听的资格:任何被 @State、@Prop、@ObjectLink 等装饰器引用的 @Observed 实例,其字段变更都会被框架捕获并触发对应视图的刷新。在播客应用中,节目数据(Podcast)和单集数据(Episode)往往需要在列表、详情、创作中心等多个页面之间共享,使用 @Observed 类来承载这些业务实体,可以让数据的增删改与界面的同步刷新变得几乎无感,开发者无需手动调用 notifyDataSetChanged 之类的命令式刷新接口。
从行业特点来看,播客与音频内容创作是一个高度依赖内容消费体验与创作者工具双轮驱动的领域。听众侧需要沉浸式的发现、订阅、收听、排行体验,创作者侧则需要录制、数据看板、栏目管理等生产工具。这类应用通常呈现"重列表、重卡片、重交互态"的界面特征:大量使用列表流来承载节目信息,使用卡片式布局来突出单条内容,并通过波形动画、进度条、柱状图等动态元素来增强音频的"可感知性"。同时,暖色调的视觉主题(如琥珀棕、橙金)在音频类应用中较为常见,因为暖色能传递温暖、陪伴、深夜电台的氛围感,这与播客内容偏情感化、陪伴化的调性高度契合。本文将要剖析的这个项目,正是将这些行业需求与 ArkTS 的声明式能力深度结合的一个完整范例。
二、项目概述
本项目是一个面向播客听众与创作者的全功能音频内容平台,采用底部六 Tab 单排导航的架构,每个 Tab 承载一个完全独立的业务场景,界面布局风格各异,共同构成了从内容发现到内容生产的完整闭环。以下是各功能模块的详细说明。
发现模块:作为应用的首屏入口,发现模块采用"精选大卡 + 分类入口 + 热门列表"的三段式结构。顶部精选区以一张大尺寸卡片突出展示编辑推荐的最新一期播客,包含封面、标题、简介、分类标签、时长、播放量以及"立即收听"和"订阅"两个主操作按钮。中部分类浏览区以两行三列的 chips 网格呈现科技、商业、人文、情感、音乐、脱口秀六大分类,每个分类附带图标与节目数量。底部热门列表通过 ForEach 循环渲染全部播客数据,每条卡片包含头像、标题、主播、时长、播放量、简介描述、标签胶囊以及 NEW 角标。
订阅模块:订阅模块聚焦于"已订阅内容的更新跟踪"。顶部提供"全部"与"未读"两个筛选胶囊,并显示订阅总数。列表区展示订阅的播客频道最新一期节目,通过红色圆点标记未读状态,未读项的播放按钮使用琥珀色高亮,已读项则使用暗色弱化,从而在视觉上形成清晰的信息层级。
录制模块:录制模块模拟了一个完整的录音工作室界面。中心区域实时显示录音状态(录音中/已暂停)、计时器(03:42)以及由 16 根柱条组成的动态波形条,波形条的高度与颜色随录音状态切换而变化。底部提供停止、暂停/继续、保存三个圆形操作按钮。下方还包含麦克风、降噪级别、背景音乐三项录音设置项,每一项均以图标 + 文案 + 当前值 + 箭头的标准列表行样式呈现。
排行模块:排行模块以"大编号榜单"为核心视觉形态。顶部提供周榜、月榜、总榜三个时段切换胶囊。榜单本体通过 ForEach 渲染前十名播客,前三名使用奖牌 emoji(金银铜)作为序号,并以不同颜色和更大字号强化视觉权重;第四名及以后使用数字序号。每条榜单项包含标题、播放量、分类标签以及播放按钮,点击可触发编辑弹窗。
创作模块:创作模块是面向播客创作者的数据与管理中枢。顶部数据看板以三列等宽卡片展示总播放、订阅数、本月收益三项核心指标。中部以柱状图形式展示本周七天的收听时长分布,周六(索引5)使用橙色高亮以突出峰值。下部栏目管理列表展示创作者名下的所有节目,通过不同状态图标(已发布、草稿、录制中)区分节目生命周期,并提供编辑与删除操作入口。底部"新建一期节目"按钮触发新增弹窗。
我的模块:我的模块承载用户个人档案与设置功能。顶部展示用户头像、昵称、认证信息与发布统计。中部三列数据卡片展示订阅数、总收听时长、已发布期数。下方最近收听列表展示历史播放记录,每条提供"继续听"入口。底部设置区包含定时关闭、下载管理、播放设置三项标准列表行。
弹窗系统:项目实现了三套独立的模态弹窗——新增节目弹窗、编辑节目弹窗、删除确认弹窗。三者均采用 Stack 叠层 + 半透明遮罩 + 居中卡片的统一结构,通过独立的布尔状态变量控制显隐,并复用同一个 modalOverlay 构建器来生成遮罩层,体现了良好的复用设计。
三、架构设计与整体技术选型
从架构层面看,本项目采用了"单组件多视图"的组织模式:整个页面由一个 @Entry @Component 主组件 Page823 承载,通过 tabIdx 状态变量在 build() 方法中做条件分支,将六个 @Builder 方法分别渲染到同一块滚动区域中。这种模式的优点在于状态集中管理、切换开销小、适合 Tab 间无深度跳转关系的场景;缺点是当 Tab 数量进一步增多或单 Tab 逻辑过于复杂时,主组件会变得臃肿,此时应考虑拆分为独立的子组件。
在状态管理方面,项目同时使用了 @State 与 @Observed 两套机制。@State 用于管理组件内部的 UI 态,如当前 Tab 索引、波形动画开关、三个弹窗的显隐布尔值、新增/编辑表单的临时输入值等;@Observed 则用于承载需要跨视图共享的业务实体(Podcast、Episode),并通过 @State 持有这些数组引用,从而实现"修改数据即刷新视图"的效果。这种分层的状态设计使得 UI 态与业务态职责清晰,便于后续扩展。
在数据层面,项目采用了"静态初始化数据 + 运行时增删"的策略。所有初始数据以 const 常量数组的形式定义在组件外部(PODCAST_INIT、EPISODE_INIT、RANKING 等),组件通过 @State 持有这些数组的引用作为可变副本。当用户执行删除操作时,直接调用数组的 splice 方法,由于数组本身是 @State 持有的,框架会自动感知变更并刷新列表。这种模式简单直接,适合数据量不大、无需持久化的演示型应用。
在样式层面,项目定义了一套完整的 ColorPalette 接口与 COLORS 常量,将所有颜色集中管理。这种设计有两大好处:一是确保全应用色彩一致性,避免散落的魔法字符串;二是便于后续实现主题切换——只需替换 COLORS 的赋值即可全局换肤。此外,项目大量使用 emoji 作为图标,既规避了图片资源依赖,又保证了跨设备的一致显示。
四、逐段代码深度分析
4.1 色彩系统接口定义

项目的视觉基石是一个严谨定义的色彩接口。ArkTS 作为 TypeScript 的超集,天然支持 interface 类型声明,这里通过 ColorPalette 接口约束了全部 21 个色彩字段的类型为 string。
interface ColorPalette {
bg: string;
card: string;
dark: string;
darkL: string;
title: string;
sub: string;
text3: string;
amber: string;
amberD: string;
brown: string;
brownD: string;
orange: string;
gold: string;
red: string;
green: string;
blue: string;
purple: string;
line: string;
tabOn: string;
mask: string;
chip: string;
}
这个接口的存在意义远不止于类型约束。它实际上是一份"设计系统契约",明确声明了整个应用允许使用的色彩边界。从字段命名可以看出,色彩被划分为几个语义层次:bg/card/dark/darkL 属于背景层,title/sub/text3 属于文字层级(主标题、副标题、三级辅助文字),amber/amberD/brown/brownD/orange/gold 属于品牌主题色族,red/green/blue/purple 属于功能性语义色,line/tabOn/mask/chip 属于装饰与交互态色。
将色彩按语义而非按色值命名,是一种被广泛推崇的设计工程实践。它带来的核心收益是:当设计师调整品牌色时,开发者只需修改常量赋值,而不需要在代码中全局搜索替换十六进制字符串;同时,语义命名也让代码可读性大幅提升——fontColor(COLORS.title) 远比 fontColor('#3D2817') 更能表达意图。
4.2 色彩常量实例化

接口定义之后,紧接着是 COLORS 常量的实例化,将每一个语义字段绑定到具体的色值上。
const COLORS: ColorPalette = {
bg: '#F5EDE0',
card: '#FFFFFF',
dark: '#EBE0D0',
darkL: '#DCCFC0',
title: '#3D2817',
sub: '#7A5C42',
text3: '#A89078',
amber: '#D4912A',
amberD: '#B07518',
brown: '#6B4226',
brownD: '#4A2E18',
orange: '#E8722C',
gold: '#E8B820',
red: '#D04545',
green: '#4A9E6C',
blue: '#3D7DC8',
purple: '#8A5FC0',
line: '#DCCFC0',
tabOn: '#D4912A',
mask: 'rgba(61,40,23,0.55)',
chip: 'rgba(212,145,42,0.12)'
};
从色值组合可以看出,这是一套精心调配的"暖琥珀棕"主题。背景色 #F5EDE0 是一种接近米色的暖白,卡片色使用纯白 #FFFFFF 形成层次,深棕 #3D2817 作为主标题文字色保证了在暖底上的对比度。主品牌色琥珀 #D4912A 与深琥珀 #B07518 构成了渐变与按压态的基础,橙金 #E8722C/#E8B820 用于强调与高亮。
值得注意的是 mask 和 chip 两个字段使用了 rgba 格式而非十六进制,因为它们需要透明度:遮罩层使用 0.55 的半透明深棕来营造弹窗背景的暗化效果,而芯片背景使用 0.12 的极淡琥珀来提供微妙的色调暗示而不喧宾夺主。这种对透明度的精细控制,是高质量 UI 还原的关键细节。
4.3 Tab 导航元数据

底部导航的六个 Tab 通过一个简洁的元数据接口和常量数组来定义。
interface TabMeta {
icon: string;
label: string;
}
const TAB_LIST: TabMeta[] = [
{ icon: '🎙️', label: '发现' },
{ icon: '📥', label: '订阅' },
{ icon: '🔴', label: '录制' },
{ icon: '📊', label: '排行' },
{ icon: '🎨', label: '创作' },
{ icon: '👤', label: '我的' }
];
TabMeta 接口只包含两个字段:icon 用于承载 emoji 图标字符串,label 用于承载中文标签文本。将 Tab 配置抽象为数据数组而非硬编码在组件内部,是一种典型的"数据驱动 UI"实践。这种做法的好处在于:Tab 的增删改只需修改数组,无需触碰渲染逻辑;同时,ForEach 在渲染时可以直接遍历该数组,保证图标与标签的对应关系不会错位。
六个 Tab 的命名也体现了产品功能的完整闭环:发现(内容获取)→ 订阅(内容收藏)→ 录制(内容生产)→ 排行(内容消费参考)→ 创作(创作者管理)→ 我的(个人中心)。这种排列既覆盖了听众侧的核心路径,又为创作者侧提供了独立入口,是一个面向双边平台(听众 + 创作者)的合理信息架构。
4.4 分类元数据与分类列表

与 Tab 元数据类似,内容分类也采用了数据驱动的定义方式。
interface CategoryMeta {
name: string;
icon: string;
count: number;
}
const CAT_LIST: CategoryMeta[] = [
{ name: '科技', icon: '💡', count: 1280 },
{ name: '商业', icon: '📈', count: 860 },
{ name: '人文', icon: '📖', count: 642 },
{ name: '情感', icon: '💜', count: 920 },
{ name: '音乐', icon: '🎵', count: 456 },
{ name: '脱口秀', icon: '😂', count: 780 }
];
CategoryMeta 比 TabMeta 多了一个 count 字段,用于表示该分类下的节目数量。这个字段在发现页的分类 chips 中会被渲染为"1280档"这样的文案,让用户在选择分类前就能感知内容丰富度。六个分类覆盖了播客内容的主要垂直领域,数量为偶数,恰好可以两行三列整齐排列在分类浏览区。
将分类配置外置为常量数组的另一个工程价值在于:当应用需要支持动态分类(从服务端拉取)时,只需将 CAT_LIST 的赋值从静态常量改为异步初始化的状态变量,渲染逻辑无需任何改动。这种"先静态后动态"的渐进式开发策略,在原型阶段非常高效。
4.5 @Observed 播客数据模型

项目中最核心的业务实体是 Podcast 类,它被 @Observed 装饰,承载单条播客节目的全部信息。
@Observed export class Podcast {
id: number;
title: string;
host: string;
avatar: string;
category: string;
duration: string;
playCount: string;
date: string;
desc: string;
tags: string[];
isNew: boolean;
constructor(id: number, title: string, host: string, avatar: string, category: string,
duration: string, playCount: string, date: string, desc: string,
tags: string[], isNew: boolean) {
this.id = id; this.title = title; this.host = host; this.avatar = avatar;
this.category = category; this.duration = duration; this.playCount = playCount;
this.date = date; this.desc = desc; this.tags = tags; this.isNew = isNew;
}
}
@Observed 装饰器的作用是让框架对该类的实例字段建立深度监听。当任何持有该实例的 @State 或 @ObjectLink 变量所引用的字段发生变化时,框架会自动触发依赖该字段的 UI 片段重新渲染。这对于播客列表中"isNew 状态切换"、"播放量更新"等场景至关重要——开发者只需修改 podcast.isNew = false,列表中对应卡片的 NEW 角标就会自动消失。
export 关键字表明这个类可以被其他模块导入复用,体现了模块化设计的考量。构造函数采用全参注入的方式,虽然参数较多(11 个),但通过分号连写的紧凑风格保持了可读性。字段类型的选择也值得关注:duration 和 playCount 使用 string 而非 number,是因为它们已经格式化为"48分钟"、"12.3万"这样的展示态文本,避免了在视图层反复做格式化转换。
4.6 @Observed 节目数据模型
与 Podcast 平行的另一个业务实体是 Episode 类,它代表创作者名下的单期节目。
@Observed export class Episode {
id: number;
title: string;
podcast: string;
duration: string;
date: string;
status: string;
plays: string;
likes: number;
constructor(id: number, title: string, podcast: string, duration: string,
date: string, status: string, plays: string, likes: number) {
this.id = id; this.title = title; this.podcast = podcast; this.duration = duration;
this.date = date; this.status = status; this.plays = plays; this.likes = likes;
}
}
Episode 与 Podcast 的区别在于:Podcast 面向听众侧,强调"发现与收听"的展示信息(avatar、desc、tags、isNew);Episode 面向创作者侧,强调"生产与管理"的元信息(podcast 归属栏目、status 发布状态、plays 播放量、likes 点赞数)。status 字段使用字符串而非枚举,取值包括"已发布"、“草稿中”、"录制中"三种状态,在创作 Tab 中会根据该字段渲染不同的状态图标。
likes 字段使用 number 类型而 plays 使用 string,这是一个有意的设计差异:likes 在界面中以"❤860"的形式展示并可能参与数值比较,因此保留为数字;plays 则展示为"12.3万播放"的格式化文本,无需参与运算,因此直接存为字符串。这种按使用场景选择类型的做法,体现了"够用即可"的实用主义原则。
4.7 初始播客数据集
应用启动时的默认播客数据通过 PODCAST_INIT 常量数组提供,包含 12 条覆盖多分类的节目。
const PODCAST_INIT: Podcast[] = [
new Podcast(1, 'AI时代的创业机会', '科技早知道', '🎧', '科技', '48分钟', '12.3万', '今天',
'从GPT到Agent,AI创业正在经历什么变化?', ['AI', '创业', '趋势'], true),
new Podcast(2, '硅谷老兵的职场心得', '职场加薪站', '🎙️', '商业', '36分钟', '8.6万', '今天',
'在硅谷工作15年,我学到的最重要的事', ['职场', '硅谷'], true),
new Podcast(3, '深夜情感电台 Vol.45', '夜话声纹', '🌙', '情感', '52分钟', '15.2万', '昨天',
'关于孤独、关于爱情、关于那些说不出口的话', ['情感', '深夜'], false),
new Podcast(4, '区块链到底解决了什么', '链上观察', '⛓️', '科技', '40分钟', '3.4万', '昨天',
'脱离炒作,从技术本质看区块链的真实价值', ['区块链', '技术'], false),
new Podcast(5, '90后创业者的失败课', '创业失败学', '📉', '商业', '44分钟', '6.8万', '2天前',
'三次创业三次失败,他总结出的5条铁律', ['创业', '失败'], false),
new Podcast(6, '古典音乐入门指南', '音乐教养', '🎼', '音乐', '38分钟', '2.1万', '2天前',
'从贝多芬到德彪西,一小时爱上古典乐', ['古典', '入门'], false),
new Podcast(7, '脱口秀演员的后台', '笑果电台', '🎤', '脱口秀', '30分钟', '9.2万', '3天前',
'那些没上台的段子,比上台的还好笑', ['脱口秀', '搞笑'], false),
new Podcast(8, '极简主义生活实验', '生活实验室', '🌿', '人文', '42分钟', '4.5万', '3天前',
'一年不买新东西,我的生活发生了什么', ['极简', '生活'], false),
new Podcast(9, '心理学解密拖延症', '心理之声', '🧠', '人文', '46分钟', '7.8万', '4天前',
'你不是懒,你是大脑在保护你', ['心理', '拖延'], false),
new Podcast(10, '音乐人谈创作灵感', '创作背后', '🎸', '音乐', '50分钟', '3.6万', '4天前',
'从一段旋律到一首歌,中间发生了什么', ['音乐', '创作'], false),
new Podcast(11, '投资人的看项目方法论', '投资笔记', '💰', '商业', '55分钟', '5.2万', '5天前',
'一年看500个项目,我如何判断值不值得投', ['投资', '方法论'], false),
new Podcast(12, '那些被误解的历史人物', '历史另一面', '📜', '人文', '48分钟', '6.1万', '5天前',
'曹操不只是奸雄,纣王也不只是暴君', ['历史', '人物'], false)
];
这份数据集的设计非常考究。首先,12 条数据覆盖了科技、商业、情感、音乐、脱口秀、人文全部六个分类,确保发现页的热门列表和分类筛选都有足够内容展示。其次,前两条数据设置了 isNew: true,使得订阅页的未读标记和发现页的 NEW 角标都有真实数据支撑。第三,date 字段从"今天"递减到"5天前",模拟了真实的内容时效性排序。第四,每条数据的 desc 都是一句有信息量的文案,tags 都是两个精准关键词,这些细节让整个应用看起来像一个真实运营的产品而非空壳原型。
数据中 avatar 全部使用 emoji,这是项目的一贯策略——用 emoji 替代图片资源,既减小了包体积,又保证了在任何设备上都能正常渲染,非常适合演示与原型场景。
4.8 初始节目数据集与排行数据
创作者侧的初始节目数据通过 EPISODE_INIT 提供,同时定义了收听统计与排行榜数据。
const EPISODE_INIT: Episode[] = [
new Episode(1, 'AI时代的创业机会', '科技早知道', '48分钟', '今天', '已发布', '12.3万', 860),
new Episode(2, '大模型落地场景分析', '科技早知道', '42分钟', '3天前', '已发布', '8.9万', 620),
new Episode(3, 'Agent架构深度探讨', '科技早知道', '55分钟', '上周', '已发布', '6.4万', 480),
new Episode(4, '下期:具身智能前沿', '科技早知道', '--', '草稿', '草稿中', '0', 0),
new Episode(5, '硅谷老兵的职场心得', '职场加加', '36分钟', '今天', '已发布', '8.6万', 540),
new Episode(6, '跳槽还是留守', '职场加加', '40分钟', '上周', '已发布', '5.2万', 380),
new Episode(7, '面试技巧大全', '职场加加', '38分钟', '2周前', '已发布', '4.1万', 290),
new Episode(8, '下期:远程工作效率', '职场加加', '--', '录制中', '录制中', '0', 0)
];
const LISTEN_DATA: number[] = [45, 60, 30, 90, 75, 120, 50];
const LISTEN_DAYS: string[] = ['一', '二', '三', '四', '五', '六', '日'];
const MAX_LISTEN: number = 120;
const RANKING: string[] = [
'深夜情感电台', 'AI时代的创业机会', '硅谷老兵的职场心得', '脱口秀演员的后台',
'90后创业者的失败课', '心理学解密拖延症', '投资人的看项目方法论', '那些被误解的历史人物',
'极简主义生活实验', '古典音乐入门指南'
];
const RANK_PLAYS: string[] = [
'15.2万', '12.3万', '8.6万', '9.2万', '6.8万', '7.8万', '5.2万', '6.1万', '4.5万', '2.1万'
];
EPISODE_INIT 包含 8 条数据,分属"科技早知道"和"职场加加"两个栏目,每个栏目都包含了已发布、草稿中、录制中三种状态的节目,确保创作页的状态图标渲染逻辑能被完整覆盖。草稿和录制中节目的 duration 设为 '--'、plays 设为 '0'、likes 设为 0,语义上合理。
LISTEN_DATA 是创作页柱状图的数据源,7 个数值代表周一到周日的收听分钟数,MAX_LISTEN 设为 120 作为柱状图的最大刻度基准。数据中周六(索引 5)的值为 120,恰好等于最大值,因此在柱状图中会以橙色满格高亮,形成视觉焦点。RANKING 和 RANK_PLAYS 两个数组通过索引对应,分别承载排行榜的标题与播放量,这种"平行数组"的设计虽然不如对象数组优雅,但在数据量固定且只读的场景下足够简洁。
4.9 分类配色工具函数
catColor 函数将分类名称映射为主题色板中的对应颜色,是实现"分类即色彩"视觉语言的关键。
function catColor(c: string): string {
if (c === '科技') { return COLORS.blue; }
if (c === '商业') { return COLORS.amber; }
if (c === '人文') { return COLORS.brown; }
if (c === '情感') { return COLORS.purple; }
if (c === '音乐') { return COLORS.green; }
return COLORS.orange;
}
这个函数在发现页的热门列表中被调用,用于为每条播客的标签胶囊着色:科技类标签是蓝色、商业类是琥珀色、情感类是紫色、音乐类是绿色,脱口秀类(default 分支)是橙色。通过颜色编码分类,用户在浏览列表时可以仅凭色彩就快速识别内容类型,这是一种符合"前注意视觉处理"认知规律的设计——大脑处理颜色比处理文字快得多。
函数采用 if 链而非 switch 或对象映射,在分类数量较少时这种写法可读性最好。default 分支返回橙色,既覆盖了"脱口秀"分类,也作为未来新增分类的兜底色。这种防御性设计保证了即使数据中出现未预期的分类名,界面也不会因为颜色为空而渲染异常。
4.10 柱状图高度与奖牌逻辑工具函数
两个辅助函数分别服务于创作页柱状图和排行榜样式的计算。
function listenBarH(v: number): number {
return Math.round(v / MAX_LISTEN * 70);
}
function rankMedal(i: number): string {
if (i === 0) { return '🥇'; }
if (i === 1) { return '🥈'; }
if (i === 2) { return '🥉'; }
return (i + 1).toString();
}
function rankColor(i: number): string {
if (i === 0) { return COLORS.gold; }
if (i === 1) { return COLORS.sub; }
if (i === 2) { return COLORS.orange; }
return COLORS.text3;
}
listenBarH 将收听分钟数按比例映射为柱条像素高度,最大高度 70px 对应 MAX_LISTEN(120 分钟)。使用 Math.round 取整避免了亚像素渲染导致的边缘模糊。这个函数在创作页柱状图的 ForEach 中被调用,每根柱条的高度都通过它实时计算。
rankMedal 和 rankColor 是一对配套函数,分别返回排行榜前三名的奖牌 emoji 和对应颜色。rankMedal 对前三名返回金银铜 emoji,第四名起返回数字字符串(注意 i + 1 是因为索引从 0 开始)。rankColor 则为前三名分别返回金色、副标题棕色、橙色,第四名起返回三级文字色。这两个函数在排行 Tab 的 ForEach 中被联合调用,共同构建出"前三名特殊视觉权重"的榜单效果。将样式逻辑提取为纯函数而非内联在模板中,既提高了可读性,也便于单元测试。
4.11 主组件状态声明
主组件 Page823 的状态声明区域是整个应用的"控制中心",所有 UI 态与业务态变量都集中在此。
@Entry
@Component
struct Page823 {
@State tabIdx: number = 0;
@State waveActive: boolean = false;
@State addModal: boolean = false;
@State editModal: boolean = false;
@State delModal: boolean = false;
@State editIdx: number = 0;
@State addTitle: string = '';
@State addCategory: string = '科技';
@State editTitle: string = '';
@State podList: Podcast[] = PODCAST_INIT;
@State epList: Episode[] = EPISODE_INIT;
private timer: number = -1;
@Entry 装饰器标记此组件为页面入口,@Component 声明它是一个自定义组件。@State 装饰的变量意味着它们是响应式的——任何变更都会触发依赖它们的 UI 重新渲染。这些状态变量可以清晰分为四组:导航态(tabIdx)、动画态(waveActive)、弹窗态(addModal/editModal/delModal/editIdx)、表单态(addTitle/addCategory/editTitle)、业务数据态(podList/epList)。
podList 和 epList 直接引用 PODCAST_INIT 和 EPISODE_INIT 常量作为初始值。这里有一个细节:由于数组是引用类型,podList 和 PODCAST_INIT 初始时指向同一块内存。但在本应用中 PODCAST_INIT 始终只读(订阅页和我的页都直接引用 PODCAST_INIT 而非 podList),而 podList 的变更(如果有的话)不会反向污染常量,因为 ArkUI 的状态追踪会处理引用变更。timer 变量使用 private 而非 @State,因为它只用于内部逻辑(定时器 ID),不需要触发 UI 刷新。
4.12 组件生命周期与定时器管理
aboutToAppear 和 aboutToDisappear 是 ArkUI 组件的两个核心生命周期回调,这里用于管理波形动画的定时器。
aboutToAppear(): void {
this.timer = setInterval(() => { this.waveActive = !this.waveActive; }, 500);
}
aboutToDisappear(): void {
if (this.timer > 0) { clearInterval(this.timer); }
}
aboutToAppear 在组件创建后、build() 执行前调用,这里通过 setInterval 创建了一个每 500 毫秒触发一次的定时器,每次触发时将 waveActive 取反。由于 waveActive 是 @State 变量,它的每次翻转都会触发录制页波形条的重新渲染——柱条在"高态"和"低态"之间交替切换,从而形成类似真实录音电平的跳动效果。500 毫秒的间隔是一个平衡点:太快会导致动画闪烁,太慢则失去"录音中"的动态感。
aboutToDisappear 在组件销毁前调用,这里通过 clearInterval 清理定时器,并加了 this.timer > 0 的保护判断(初始值为 -1),避免在定时器未创建时误清理。这种"创建即清理"的对称管理是避免内存泄漏的最佳实践。在鸿蒙应用中,如果组件被销毁但定时器未清理,定时器会继续持有对组件的闭包引用,导致组件无法被垃圾回收,进而造成内存泄漏。这两个回调的配合使用,确保了动画资源随组件生命周期正确分配与释放。
4.13 顶部头部构建:导航栏
头部构建器 header() 是整个页面的视觉起点,包含导航栏、最新播客大卡、订阅横条三个区块。首先看导航栏部分。
@Builder
header() {
Column({ space: 10 }) {
Row({ space: 8 }) {
Text('🎙️').fontSize(16)
Text('声纹纪').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('🔍').fontSize(16)
Text('🔔').fontSize(16)
}
.width('100%')
@Builder 装饰器定义了一个可复用的 UI 构建函数,它不会被框架实例化为独立组件,而是在调用处内联展开。导航栏采用 Row 水平布局:左侧是应用图标 emoji 和品牌名"声纹纪",中间通过一个 Column().layoutWeight(1) 作为弹性占位将左右两侧撑开,右侧是搜索和通知两个图标。这种"左品牌 + 中弹性 + 右操作"的三段式导航栏是移动端的经典模式。
Column().layoutWeight(1) 是一个非常实用的技巧:一个空的、无内容的容器通过 layoutWeight(1) 占据剩余空间,从而实现两侧内容的两端对齐。相比于使用 justifyContent(FlexAlign.SpaceBetween),这种方式更灵活——当中间也需要放置内容时,可以轻松扩展为三段内容。外层 Column({ space: 10 }) 为整个头部提供 10vp 的垂直间距,确保导航栏、大卡、横条之间有舒适的呼吸感。
4.14 头部:最新一期播客大卡
导航栏下方是"最新一期播客大卡",这是头部信息密度最高的区块。
// 最新一期播客大卡
Column({ space: 6 }) {
Row({ space: 10 }) {
Column() {
Text('🎧').fontSize(28)
}
.width(48).height(48).borderRadius(12).backgroundColor(COLORS.chip)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('AI时代的创业机会').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('科技早知道 · 48分钟 · 12.3万播放').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Column() {
Text('▶').fontSize(14).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
}
.width(36).height(36).borderRadius(18).backgroundColor(COLORS.amber)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
}
.width('100%')
}
.width('100%').padding(12).borderRadius(14)
.backgroundColor(COLORS.card)
大卡采用 Row 横向三段布局:左侧是一个 48x48 的圆角方块容器,内嵌一个耳机 emoji 作为封面占位,背景使用 COLORS.chip(淡琥珀)营造柔和的色调底;中间是标题与副信息列,标题使用主标题色加粗,副信息以 9vp 小字号、副标题色展示"栏目 · 时长 · 播放量"三段元信息,用中圆点分隔;右侧是一个 36x36 的圆形播放按钮,白字琥珀底,视觉上是最强的行动召唤点。
卡片的容器样式值得注意:padding(12) 提供内边距,borderRadius(14) 提供圆角,backgroundColor(COLORS.card) 使用纯白卡片底。这些数值与大卡内部的 space: 6 共同构成了卡片的信息层级与留白节奏。整个大卡虽然是静态展示,但通过"封面 + 文字 + 按钮"的经典三段布局,在有限的头部空间内传递了足够多的内容信息。
4.15 头部:订阅横条统计区
头部最后一个区块是"订阅横条",以紧凑的横向布局展示三项核心统计指标。
// 订阅横条
Row({ space: 10 }) {
Column({ space: 1 }) {
Text('28').fontSize(18).fontColor(COLORS.amber).fontWeight(FontWeight.Bold)
Text('订阅').fontSize(8).fontColor(COLORS.sub)
}
Column().width(1).height(24).backgroundColor(COLORS.line)
Column({ space: 1 }) {
Text('6').fontSize(18).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
Text('更新').fontSize(8).fontColor(COLORS.sub)
}
Column().width(1).height(24).backgroundColor(COLORS.line)
Column({ space: 1 }) {
Text('42h').fontSize(18).fontColor(COLORS.brown).fontWeight(FontWeight.Bold)
Text('本周收听').fontSize(8).fontColor(COLORS.sub)
}
Column().layoutWeight(1)
Text('管理订阅 ›').fontSize(9).fontColor(COLORS.amber)
}
.width('100%').padding(10).borderRadius(10)
.backgroundColor(COLORS.card)
}
.width('100%').padding({ left: 14, right: 14, top: 10, bottom: 8 })
.backgroundColor(COLORS.bg)
横条采用 Row 布局,三个统计项之间通过 1px 宽、24px 高的 Column 作为竖向分隔线,分隔线背景色使用 COLORS.line。每个统计项是一个 Column,上方是 18vp 大字号加粗的数值,下方是 8vp 小字号的标签。三个数值分别使用琥珀、橙、棕三种品牌色,既统一于暖色系,又通过色相差异区分不同指标。
右侧通过 Column().layoutWeight(1) 弹性占位后,放置"管理订阅 ›"链接文案,使用琥珀色和 9vp 字号,箭头符号暗示可点击跳转。整个横条的 padding(10) 和 borderRadius(10) 比大卡的 12/14 略小,形成了"大卡为主、横条为辅"的视觉层级。外层 Column 通过 padding({ left: 14, right: 14, top: 10, bottom: 8 }) 为整个头部提供左右边距和上下间距,背景使用 COLORS.bg 与页面整体融合。
4.16 底部 TabBar 导航构建
tabBar() 构建器负责渲染底部六个 Tab 的导航栏,是页面切换的核心交互入口。
@Builder
tabBar() {
Row() {
ForEach(TAB_LIST, (t: TabMeta, i: number) => {
Column({ space: 2 }) {
Text(t.icon).fontSize(18).opacity(this.tabIdx === i ? 1.0 : 0.4)
Text(t.label).fontSize(9)
.fontColor(this.tabIdx === i ? COLORS.tabOn : COLORS.sub)
.fontWeight(this.tabIdx === i ? FontWeight.Bold : FontWeight.Normal)
if (this.tabIdx === i) {
Column().width(16).height(2).backgroundColor(COLORS.tabOn).borderRadius(1)
}
}
.layoutWeight(1).padding({ top: 5, bottom: 5 })
.alignItems(HorizontalAlign.Center)
.onClick(() => { this.tabIdx = i; })
})
}
.width('100%').backgroundColor(COLORS.card)
.padding({ top: 4, bottom: 6 })
.border({ width: { top: 1 }, color: COLORS.line })
}
ForEach 遍历 TAB_LIST 数组,为每个 Tab 生成一个 Column。每个 Tab 的选中态通过三层视觉反馈来强化:图标 opacity 从 0.4 提升到 1.0、标签颜色从副标题色切换为 tabOn 琥珀色、标签字重从 Normal 加粗为 Bold。此外,选中态下还会在标签下方渲染一个 16x2 的小圆角条作为指示器,这个指示器通过 if (this.tabIdx === i) 条件渲染实现"只在选中项显示"。
每个 Tab 通过 layoutWeight(1) 等分宽度,padding({ top: 5, bottom: 5 }) 提供点击热区,onClick 回调将 tabIdx 赋值为当前索引。由于 tabIdx 是 @State 变量,赋值后框架会自动重新渲染整个 TabBar——所有 Tab 的选中态样式都会重新计算,未选中的降级、新选中的高亮。整个 TabBar 容器顶部有 1px 的 COLORS.line 边框,作为与内容区的视觉分隔。
4.17 模态遮罩复用构建器
modalOverlay 是一个高度复用的构建器,为三个弹窗提供统一的半透明遮罩层。
@Builder
modalOverlay(onClose: () => void) {
Column().width('100%').height('100%')
.backgroundColor(COLORS.mask)
.onClick(() => { onClose(); })
}
这个构建器接收一个 onClose 回调函数作为参数,渲染一个铺满父容器的半透明遮罩 Column。遮罩背景使用 COLORS.mask(rgba(61,40,23,0.55)),即 55% 透明度的深棕色,既能压暗背景内容又不完全遮挡。点击遮罩时调用 onClose 回调关闭弹窗——这是模态交互的标准范式:点击遮罩区视为"取消"意图。
将遮罩提取为独立构建器并接收回调参数,是一种优雅的复用设计。如果不这样做,三个弹窗各自需要重复编写遮罩代码,且遮罩的点击逻辑会与弹窗内容耦合在一起。通过参数化回调,遮罩的"点击关闭"行为变得可定制——新增弹窗时只需传入不同的关闭函数。这种"构建器 + 回调参数"的模式,在 ArkTS 中相当于其他框架的"插槽"或"高阶组件"。
4.18 发现 Tab:精选推荐大卡
发现页的第一个内容区块是"精选推荐大卡",以一张视觉突出的卡片展示编辑推荐内容。
@Builder
discoverTab() {
Column({ space: 10 }) {
Text('🎯 精选推荐').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
// 精选大卡
Column({ space: 8 }) {
Text('🎧').fontSize(36)
Text('深夜情感电台 Vol.45').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('关于孤独、关于爱情、关于那些说不出口的话').fontSize(10).fontColor(COLORS.sub)
Row({ space: 8 }) {
Text('🌙 情感').fontSize(9).fontColor(COLORS.purple)
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(6).backgroundColor(COLORS.chip)
Text('52分钟').fontSize(9).fontColor(COLORS.sub)
Text('15.2万播放').fontSize(9).fontColor(COLORS.amber)
}
Row({ space: 8 }) {
Column() {
Text('▶ 立即收听').fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
}
.padding({ left: 20, right: 20, top: 6, bottom: 6 }).borderRadius(14)
.backgroundColor(COLORS.amber)
Column() {
Text('+ 订阅').fontSize(11).fontColor(COLORS.amber)
}
.padding({ left: 20, right: 20, top: 6, bottom: 6 }).borderRadius(14)
.backgroundColor(COLORS.chip)
}
}
.width('100%').padding(20).borderRadius(16)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
精选大卡采用居中对齐的 Column 布局,从上到下依次是:36vp 的大尺寸封面 emoji、14vp 加粗标题、10vp 灰色简介、标签行、操作按钮行。标签行包含一个紫色"情感"胶囊(使用 COLORS.chip 淡底 + 紫色文字)、时长和播放量两个纯文本标签。操作按钮行包含两个等高的胶囊按钮:实心琥珀底白字的"立即收听"和淡底琥珀字的"+ 订阅",一主一辅的视觉权重区分清晰。
整个大卡通过 padding(20) 提供充裕的内边距,borderRadius(16) 的较大圆角增强了"卡片"的漂浮感。alignItems(HorizontalAlign.Center) 让所有子元素水平居中,形成"海报式"的视觉聚焦。这张大卡是发现页的视觉锚点,用户进入页面后第一眼就会被它吸引,因此其信息密度和视觉权重都需要做到最高。
4.19 发现 Tab:分类 chips 网格
精选大卡下方是分类浏览区,以两行三列的 chips 网格呈现六个内容分类。
Text('📂 分类浏览').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
// 分类chips
Row({ space: 8 }) {
ForEach([0, 1, 2], (i: number) => {
Column({ space: 4 }) {
Text(CAT_LIST[i].icon).fontSize(22)
Text(CAT_LIST[i].name).fontSize(10).fontColor(COLORS.title)
Text(CAT_LIST[i].count + '档').fontSize(8).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(12)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
})
}
.width('100%')
Row({ space: 8 }) {
ForEach([3, 4, 5], (i: number) => {
Column({ space: 4 }) {
Text(CAT_LIST[i].icon).fontSize(22)
Text(CAT_LIST[i].name).fontSize(10).fontColor(COLORS.title)
Text(CAT_LIST[i].count + '档').fontSize(8).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(12)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
})
}
.width('100%')
由于 ArkUI 的 ForEach 不直接支持二维网格布局,这里通过两个 Row 各遍历三个索引来实现两行三列的网格。第一个 Row 遍历 [0, 1, 2],第二个 Row 遍历 [3, 4, 5],从 CAT_LIST 中取对应索引的数据。每个分类 chip 是一个 Column:22vp 的 emoji 图标、10vp 的分类名、8vp 的"NNN档"数量标注,三行居中排列。
每个 chip 通过 layoutWeight(1) 等分宽度,padding(10) 和 borderRadius(12) 提供圆润的卡片感。这种"手动分行"的网格实现虽然不如 CSS Grid 优雅,但在 ArkUI 当前的布局能力下是务实的选择。如果分类数量动态变化,可以考虑使用 List 的 lanes 属性或自定义网格组件来实现自动换行,但在分类固定为六个的场景下,当前方案足够清晰。
4.20 发现 Tab:热门播客列表
发现页的主体内容是热门播客列表,通过 ForEach 遍历 podList 渲染每条播客卡片。
Text('🔥 热门播客').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Column({ space: 6 }) {
ForEach(this.podList, (p: Podcast, idx: number) => {
Row({ space: 10 }) {
Column({ space: 3 }) {
Text(p.avatar).fontSize(24)
if (p.isNew) {
Text('NEW').fontSize(7).fontColor('#FFFFFF')
.padding({ left: 3, right: 3, top: 1, bottom: 1 }).borderRadius(3)
.backgroundColor(COLORS.red)
}
}
.width(44).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text(p.title).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
Row({ space: 4 }) {
Text(p.host).fontSize(9).fontColor(COLORS.sub)
Text('·').fontSize(9).fontColor(COLORS.text3)
Text(p.duration).fontSize(9).fontColor(COLORS.sub)
Text('·').fontSize(9).fontColor(COLORS.text3)
Text(p.playCount).fontSize(9).fontColor(COLORS.amber)
}
Text(p.desc).fontSize(9).fontColor(COLORS.sub)
Row({ space: 4 }) {
ForEach(p.tags, (tag: string) => {
Text('#' + tag).fontSize(8).fontColor(catColor(p.category))
.padding({ left: 4, right: 4, top: 1, bottom: 1 }).borderRadius(4)
.backgroundColor(COLORS.chip)
})
}
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Column() {
Text('▶').fontSize(12).fontColor('#FFFFFF')
}
.width(28).height(28).borderRadius(14)
.backgroundColor(COLORS.amber)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
}
.width('100%').padding(10).borderRadius(10)
.backgroundColor(COLORS.card)
})
}
.width('100%')
}
.width('100%')
}
每条播客卡片采用 Row 三段布局:左侧 44vp 宽的封面列,包含 24vp 的 emoji 头像和条件渲染的 NEW 红色角标(仅当 p.isNew 为 true 时显示);中间是 layoutWeight(1) 的内容列,包含标题、元信息行(主播 · 时长 · 播放量,播放量用琥珀色高亮)、简介描述、标签行;右侧是 28x28 的圆形播放按钮。
标签行内部又嵌套了一个 ForEach 遍历 p.tags 数组,每个标签渲染为"#标签名"的胶囊,颜色通过 catColor(p.category) 根据分类动态着色,背景使用 COLORS.chip。这种"ForEach 嵌套 ForEach"的结构在 ArkUI 中完全合法,框架会为每个层级建立独立的渲染追踪。
整个列表通过外层 Column({ space: 6 }) 提供卡片间的垂直间距,每张卡片 padding(10) 和 borderRadius(10) 保持统一。12 条数据全部渲染,由于 Scroll 容器的存在,用户可以纵向滚动浏览全部内容。这是整个应用信息密度最高的列表,也是 @Observed + @State + ForEach 三者协同的典型场景。
4.21 订阅 Tab:更新列表与未读标记
订阅页以紧凑的列表展示已订阅频道的最新更新,核心视觉特征是未读标记。
@Builder
subscribeTab() {
Column({ space: 8 }) {
Row({ space: 6 }) {
Text('全部').fontSize(10).fontColor('#FFFFFF')
.backgroundColor(COLORS.amber)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
Text('未读').fontSize(10).fontColor(COLORS.amber)
.backgroundColor(COLORS.chip)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
Column().layoutWeight(1)
Text('28个订阅').fontSize(9).fontColor(COLORS.sub)
}
.width('100%')
Column({ space: 6 }) {
ForEach([0, 1, 2, 3, 4, 5, 6, 7], (i: number) => {
Row({ space: 10 }) {
Column({ space: 3 }) {
Text(PODCAST_INIT[i].avatar).fontSize(22)
}
.width(40).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Row({ space: 4 }) {
Text(PODCAST_INIT[i].host).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
if (PODCAST_INIT[i].isNew) {
Text('•').fontSize(10).fontColor(COLORS.red)
}
}
Text(PODCAST_INIT[i].title).fontSize(10).fontColor(COLORS.sub)
Text(PODCAST_INIT[i].date + ' · ' + PODCAST_INIT[i].duration).fontSize(8).fontColor(COLORS.text3)
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Column() {
Text('▶').fontSize(10).fontColor('#FFFFFF')
}
.width(24).height(24).borderRadius(12)
.backgroundColor(PODCAST_INIT[i].isNew ? COLORS.amber : COLORS.darkL)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
}
.width('100%').padding(10).borderRadius(10)
.backgroundColor(COLORS.card)
})
}
.width('100%')
}
.width('100%')
}
顶部筛选栏包含"全部"和"未读"两个胶囊,前者为实心琥珀底(选中态),后者为淡底(未选中态),右侧通过弹性占位显示"28个订阅"总数。列表区遍历 PODCAST_INIT 的前 8 条数据,每条采用 Row 三段布局:左侧 40vp 头像列、中间内容列、右侧播放按钮。
未读标记体现在两处:一是主播名右侧的红色圆点(if (PODCAST_INIT[i].isNew) 条件渲染),二是播放按钮的背景色——未读项使用 COLORS.amber(琥珀色高亮),已读项使用 COLORS.darkL(暗色弱化)。这种"色彩降级"的设计让用户无需仔细阅读就能通过颜色快速识别哪些频道有新内容。值得注意的是,订阅页直接引用 PODCAST_INIT 常量而非 podList 状态变量,这意味着订阅页的数据是只读的静态快照,与发现页的可变数据相互独立。
4.22 录制 Tab:录音界面与波形动画
录制页是整个应用最具动态感的部分,核心是录音界面与波形条的动画效果。
@Builder
recordTab() {
Column({ space: 14 }) {
Text('🔴 录音工作室').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
// 录音界面
Column({ space: 10 }) {
Text(this.waveActive ? '🔴 录音中' : '⏸️ 已暂停').fontSize(14)
.fontColor(this.waveActive ? COLORS.red : COLORS.sub)
.fontWeight(FontWeight.Bold)
Text('03:42').fontSize(36).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
// 波形条
Row({ space: 3 }) {
ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], (i: number) => {
Column()
.width(4)
.height(this.waveActive ? (10 + (i % 5) * 8) : 6)
.backgroundColor(this.waveActive ? COLORS.amber : COLORS.darkL)
.borderRadius(2)
})
}
.height(50).justifyContent(FlexAlign.Center)
Row({ space: 16 }) {
Column() {
Text('⏹').fontSize(20).fontColor('#FFFFFF')
}
.width(48).height(48).borderRadius(24)
.backgroundColor(COLORS.red)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
Column() {
Text(this.waveActive ? '⏸' : '▶').fontSize(24).fontColor('#FFFFFF')
}
.width(56).height(56).borderRadius(28)
.backgroundColor(COLORS.amber)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
.onClick(() => { this.waveActive = !this.waveActive; })
Column() {
Text('💾').fontSize(20).fontColor('#FFFFFF')
}
.width(48).height(48).borderRadius(24)
.backgroundColor(COLORS.green)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
.onClick(() => { this.addModal = true; })
}
.width('100%').justifyContent(FlexAlign.Center)
}
.width('100%').padding(20).borderRadius(16)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
录音界面顶部根据 waveActive 状态切换显示"录音中"(红色)或"已暂停"(灰色),下方是 36vp 大字号的计时器"03:42"。波形条由 16 根 Column 柱条组成,每根宽度 4vp,间距 3vp。柱条高度通过 this.waveActive ? (10 + (i % 5) * 8) : 6 计算:录音中时,高度在 10/18/26/34/42vp 之间循环变化(i % 5 产生 0-4 的循环模式),暂停时统一为 6vp 的低高度。颜色也随状态切换——录音中为琥珀色,暂停为暗色。
由于 waveActive 每 500 毫秒被定时器取反,波形条的 16 根柱条会以"高低高低"的模式交替跳动,形成类似真实音频电平表的视觉效果。这是利用 @State 驱动 UI 重渲染来实现"伪动画"的经典手法——虽然不是真正的逐帧动画,但在低频交互场景下视觉效果足够好,且实现成本极低。
底部三个操作按钮采用 Row 居中布局:红色停止按钮(48x48)、琥珀色暂停/继续主按钮(56x56,尺寸更大以突出主操作)、绿色保存按钮。主按钮的 onClick 切换 waveActive,保存按钮的 onClick 打开新增弹窗。三个按钮通过尺寸和颜色的差异形成了清晰的操作层级。
4.23 录制 Tab:录音设置项
录音界面下方是三项录音参数设置,采用标准的列表行样式。
Text('🎚️ 录音设置').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Column({ space: 0 }) {
Row({ space: 10 }) {
Text('🎙️').fontSize(16)
Text('麦克风').fontSize(12).fontColor(COLORS.sub).layoutWeight(1)
Text('内置 ›').fontSize(10).fontColor(COLORS.amber)
}
.width('100%').padding(12).borderRadius(10).backgroundColor(COLORS.card)
Row({ space: 10 }) {
Text('🔊').fontSize(16)
Text('降噪级别').fontSize(12).fontColor(COLORS.sub).layoutWeight(1)
Text('高 ›').fontSize(10).fontColor(COLORS.amber)
}
.width('100%').padding(12).borderRadius(10).backgroundColor(COLORS.card)
Row({ space: 10 }) {
Text('🎵').fontSize(16)
Text('背景音乐').fontSize(12).fontColor(COLORS.sub).layoutWeight(1)
Text('无 ›').fontSize(10).fontColor(COLORS.amber)
}
.width('100%').padding(12).borderRadius(10).backgroundColor(COLORS.card)
}
.width('100%')
}
.width('100%')
}
设置项列表使用 Column({ space: 0 }) 容器(间距为 0,因为每行已有独立的 backgroundColor 和 borderRadius,间距会通过视觉分隔)。每行采用"图标 + 标题 + 当前值 + 箭头"的四段式结构:16vp 的 emoji 图标、12vp 灰色的设置项标题(layoutWeight(1) 占据中间空间)、10vp 琥珀色的当前值、箭头符号暗示可点击进入下一级。
这种列表行样式在移动端极为常见,被形象地称为"设置行"或"导航行"。它的优点是信息结构清晰、扩展性强——新增设置项只需复制一行修改文案即可。当前值为"内置"/“高”/"无"分别对应麦克风设备、降噪等级、背景音乐三项参数的实际取值。虽然当前实现中点击行没有实际跳转逻辑,但视觉上已经完整传达了"可配置"的交互暗示。
4.24 排行 Tab:大编号榜单
排行页以"大编号榜单"为视觉核心,前三名通过奖牌和色彩强化权重。
@Builder
rankingTab() {
Column({ space: 10 }) {
Row({ space: 6 }) {
Text('周榜').fontSize(10).fontColor('#FFFFFF')
.backgroundColor(COLORS.amber)
.padding({ left: 10, right: 10, top: 4, bottom: 4 }).borderRadius(10)
Text('月榜').fontSize(10).fontColor(COLORS.sub)
.backgroundColor(COLORS.card)
.padding({ left: 10, right: 10, top: 4, bottom: 4 }).borderRadius(10)
Text('总榜').fontSize(10).fontColor(COLORS.sub)
.backgroundColor(COLORS.card)
.padding({ left: 10, right: 10, top: 4, bottom: 4 }).borderRadius(10)
}
.width('100%')
Text('🏆 本周播客热榜').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
// 大编号榜单
Column({ space: 6 }) {
ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], (i: number) => {
Row({ space: 10 }) {
Column() {
Text(rankMedal(i)).fontSize(i < 3 ? 22 : 16)
.fontColor(rankColor(i)).fontWeight(FontWeight.Bold)
}
.width(36).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text(RANKING[i]).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
Row({ space: 6 }) {
Text(RANK_PLAYS[i] + '播放').fontSize(9).fontColor(COLORS.amber)
Text('·').fontSize(9).fontColor(COLORS.text3)
Text(['科技', '情感', '商业', '脱口秀', '商业', '人文', '商业', '人文', '人文', '音乐'][i])
.fontSize(9).fontColor(COLORS.sub)
}
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Column() {
Text('▶').fontSize(10).fontColor('#FFFFFF')
}
.width(24).height(24).borderRadius(12)
.backgroundColor(COLORS.amber)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
}
.width('100%').padding(10).borderRadius(10)
.backgroundColor(COLORS.card)
.onClick(() => { this.editIdx = i; this.editModal = true; })
})
}
.width('100%')
}
.width('100%')
}
顶部时段切换栏包含周榜(选中态实心琥珀)、月榜、总榜三个胶囊。榜单通过 ForEach 遍历索引 0-9,从 RANKING 和 RANK_PLAYS 两个平行数组中取数据。每条榜单项的左侧是 36vp 宽的序号列,序号内容通过 rankMedal(i) 返回(前三名奖牌 emoji,其余数字),字号和颜色通过 i < 3 ? 22 : 16 和 rankColor(i) 区分——前三名 22vp 大字号配金/银/铜色,第四名起 16vp 小字号配三级灰色。
中间内容列包含标题、播放量(琥珀色)和分类标签(内联数组取值)。右侧是 24x24 的小型播放按钮。每条榜单项的 onClick 会设置 editIdx 并打开编辑弹窗——虽然排行榜项触发编辑弹窗在业务逻辑上略显牵强,但从演示弹窗系统的角度,它提供了一个额外的弹窗触发入口。这种"为演示而设计"的做法在原型阶段是可以接受的。
4.25 创作 Tab:数据看板与柱状图
创作页的顶部是三项核心指标的数据看板,下方是本周收听时长的柱状图。
@Builder
createTab() {
Column({ space: 12 }) {
Text('🎨 创作者中心').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
// 数据看板
Row({ space: 10 }) {
Column({ space: 2 }) {
Text('48.2万').fontSize(18).fontColor(COLORS.amber).fontWeight(FontWeight.Bold)
Text('总播放').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('2,340').fontSize(18).fontColor(COLORS.green).fontWeight(FontWeight.Bold)
Text('订阅数').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('¥8,560').fontSize(18).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
Text('本月收益').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
}
.width('100%')
Text('📊 本周收听时长').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
// 柱状图
Column({ space: 6 }) {
Row({ space: 4 }) {
ForEach([0, 1, 2, 3, 4, 5, 6], (d: number) => {
Column({ space: 3 }) {
Text(LISTEN_DATA[d].toString()).fontSize(8).fontColor(COLORS.amber)
Text('分').fontSize(6).fontColor(COLORS.sub)
Column()
.width(18).height(listenBarH(LISTEN_DATA[d]))
.backgroundColor(d === 5 ? COLORS.orange : COLORS.amberD)
.borderRadius({ topLeft: 3, topRight: 3 })
Text(LISTEN_DAYS[d]).fontSize(8).fontColor(COLORS.sub)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
})
}
.width('100%').padding({ top: 8, bottom: 8 })
}
.width('100%').padding(12).borderRadius(12)
.backgroundColor(COLORS.card)
数据看板采用三列等宽 Row 布局,每列是一个居中的 Column:18vp 大字号加粗的数值 + 9vp 小字号的标签。三个数值分别使用琥珀(总播放)、绿(订阅数)、橙(本月收益)三色,通过色彩区分指标维度。数值文案"48.2万"、“2,340”、"¥8,560"已经做了格式化处理,体现了"展示态优先"的数据策略。
柱状图通过 ForEach 遍历 7 天数据,每根柱条是一个 Column:顶部是数值+"分"的标注、中间是 listenBarH 计算高度的彩色柱体(顶部圆角)、底部是星期标签。柱体颜色通过 d === 5 ? COLORS.orange : COLORS.amberD 区分——周六(索引 5)使用橙色高亮,其余使用深琥珀色。这种"单根高亮"的设计让用户一眼就能识别本周的收听峰值。柱状图外层卡片 padding(12) 和 borderRadius(12) 与看板卡片保持视觉一致。
4.26 创作 Tab:栏目管理与增删改
创作页的核心功能区是栏目管理列表,承载创作者名下所有节目的状态展示与编辑删除操作。
Text('📝 我的栏目管理').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
// 栏目列表
Column({ space: 6 }) {
ForEach(this.epList, (e: Episode, idx: number) => {
Row({ space: 10 }) {
Column({ space: 3 }) {
Text(e.status === '已发布' ? '✅' : (e.status === '草稿中' ? '📝' : '🔴')).fontSize(16)
}
.width(32).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text(e.title).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
Row({ space: 4 }) {
Text(e.podcast).fontSize(9).fontColor(COLORS.sub)
Text('·').fontSize(9).fontColor(COLORS.text3)
Text(e.date).fontSize(9).fontColor(COLORS.sub)
Text('·').fontSize(9).fontColor(COLORS.text3)
Text(e.duration).fontSize(9).fontColor(COLORS.sub)
}
if (e.status === '已发布') {
Row({ space: 4 }) {
Text(e.plays + '播放').fontSize(8).fontColor(COLORS.amber)
Text('❤' + e.likes).fontSize(8).fontColor(COLORS.red)
}
}
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text('✏️').fontSize(14).fontColor(COLORS.amber)
.onClick(() => { this.editIdx = idx; this.editModal = true; })
Text('🗑️').fontSize(14).fontColor(COLORS.red)
.onClick(() => { this.editIdx = idx; this.delModal = true; })
}
.width('100%').padding(10).borderRadius(10)
.backgroundColor(COLORS.card)
})
}
.width('100%')
Column() {
Text('+ 新建一期节目').fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
}
.width('100%').padding(12).borderRadius(10)
.backgroundColor(COLORS.amber)
.alignItems(HorizontalAlign.Center)
.onClick(() => { this.addModal = true; })
}
.width('100%')
}
列表遍历 epList 状态数组,每条节目卡片采用 Row 布局:左侧 32vp 宽的状态图标列,通过三元表达式根据 e.status 渲染不同 emoji(已发布绿色勾、草稿中记事本、录制中红点);中间是内容列,包含标题、元信息行(栏目 · 日期 · 时长)以及条件渲染的播放量与点赞数(仅已发布节目显示);右侧是编辑和删除两个操作图标,分别绑定 editModal 和 delModal 的打开逻辑。
编辑图标的 onClick 将 editIdx 设为当前索引后打开编辑弹窗,删除图标同理打开删除确认弹窗。这里的关键设计是"先记录索引再开弹窗"——弹窗内容通过 editIdx 从 epList 中读取对应数据,从而实现"点击哪条就编辑/删除哪条"的精确操作。底部"新建一期节目"按钮是全宽的琥珀色实心按钮,点击打开新增弹窗。
4.27 我的 Tab:个人档案与收听历史
我的页承载用户身份信息、统计数据、收听历史与设置入口。
@Builder
profileTab() {
Column({ space: 12 }) {
Column({ space: 8 }) {
Row({ space: 12 }) {
Column() {
Text('🎧').fontSize(32)
}
.width(56).height(56).borderRadius(28).backgroundColor(COLORS.chip)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('声音旅人').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('播客爱好者 · 创作者认证').fontSize(10).fontColor(COLORS.sub)
Text('🎙️ 已发布8期 · 总播放48.2万').fontSize(9).fontColor(COLORS.amber)
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
}
.width('100%')
}
.width('100%').padding(14).borderRadius(16).backgroundColor(COLORS.card)
Row({ space: 10 }) {
Column({ space: 2 }) {
Text('28').fontSize(20).fontColor(COLORS.amber).fontWeight(FontWeight.Bold)
Text('订阅').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('156h').fontSize(20).fontColor(COLORS.brown).fontWeight(FontWeight.Bold)
Text('总收听').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('8').fontSize(20).fontColor(COLORS.green).fontWeight(FontWeight.Bold)
Text('已发布').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
}
.width('100%')
Text('🕐 最近收听').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Column({ space: 6 }) {
ForEach([0, 1, 2, 3, 4], (i: number) => {
Row({ space: 10 }) {
Text(PODCAST_INIT[i].avatar).fontSize(20)
Column({ space: 2 }) {
Text(PODCAST_INIT[i].title).fontSize(11).fontColor(COLORS.title)
Text(PODCAST_INIT[i].host + ' · ' + PODCAST_INIT[i].duration).fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text('继续听').fontSize(9).fontColor(COLORS.amber)
Text('▶').fontSize(10).fontColor(COLORS.amber)
}
.width('100%').padding(10).borderRadius(10)
.backgroundColor(COLORS.card)
})
}
.width('100%')
Text('⚙️ 设置').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Column({ space: 0 }) {
Row({ space: 10 }) {
Text('🌙').fontSize(16)
Text('定时关闭').fontSize(12).fontColor(COLORS.sub).layoutWeight(1)
Text('30分钟 ›').fontSize(10).fontColor(COLORS.amber)
}
.width('100%').padding(12).borderRadius(10).backgroundColor(COLORS.card)
Row({ space: 10 }) {
Text('📥').fontSize(16)
Text('下载管理').fontSize(12).fontColor(COLORS.sub).layoutWeight(1)
Text('12期 ›').fontSize(10).fontColor(COLORS.amber)
}
.width('100%').padding(12).borderRadius(10).backgroundColor(COLORS.card)
Row({ space: 10 }) {
Text('⚙️').fontSize(16)
Text('播放设置').fontSize(12).fontColor(COLORS.sub).layoutWeight(1)
Text('›').fontSize(10).fontColor(COLORS.amber)
}
.width('100%').padding(12).borderRadius(10).backgroundColor(COLORS.card)
}
.width('100%')
}
.width('100%')
}
个人档案区采用 56x56 圆形头像 + 三行文本信息的布局,头像背景使用 COLORS.chip 淡琥珀底,文本包含昵称(加粗)、身份认证(副色)、发布统计(琥珀色)。下方三列数据卡片(订阅 28、总收听 156h、已发布 8)与创作页看板风格一致,但数值字号更大(20vp vs 18vp),因为个人页的数据是"自我视角"的核心信息。
最近收听列表遍历 PODCAST_INIT 前 5 条,每条采用"头像 + 标题/主播 · 时长 + 继续听 + 播放图标"的紧凑布局。"继续听"文案暗示了断点续播的功能预期。底部设置区与录制页的设置项样式完全一致(图标 + 标题 + 当前值 + 箭头),保证了全应用设置行样式的统一。三项设置分别为定时关闭(30分钟)、下载管理(12期)、播放设置,覆盖了音频消费的核心偏好。
4.28 build() 主布局与 Stack 叠层
build() 方法是整个组件的渲染入口,通过 Stack 叠层将内容层、波形特效层和三个弹窗层组合在一起。
build() {
Stack({ alignContent: Alignment.Center }) {
Column() {
this.header()
Scroll() {
Column({ space: 10 }) {
if (this.tabIdx === 0) { this.discoverTab() }
else if (this.tabIdx === 1) { this.subscribeTab() }
else if (this.tabIdx === 2) { this.recordTab() }
else if (this.tabIdx === 3) { this.rankingTab() }
else if (this.tabIdx === 4) { this.createTab() }
else { this.profileTab() }
}
.width('100%').padding({ left: 14, right: 14, top: 10, bottom: 16 })
}
.layoutWeight(1).scrollBar(BarState.Off).align(Alignment.Top)
this.tabBar()
}
.width('100%').height('100%')
// 波形特效层
if (this.waveActive && this.tabIdx === 2) {
Row({ space: 4 }) {
ForEach([0, 1, 2, 3, 4, 5, 6, 7], (i: number) => {
Column()
.width(3).height(6 + (i % 4) * 4)
.backgroundColor(COLORS.amber).opacity(0.3).borderRadius(2)
})
}
.position({ x: '20%', y: '35%' }).zIndex(1)
}
最内层是一个 Column,垂直排列头部、滚动内容区、底部 TabBar 三部分。滚动内容区使用 Scroll 包裹一个 Column,内部通过 if-else if 链根据 tabIdx 渲染对应的 Tab 构建器。layoutWeight(1) 让滚动区占据头部和 TabBar 之间的全部剩余空间,scrollBar(BarState.Off) 隐藏滚动条以保持视觉整洁,align(Alignment.Top) 让内容从顶部开始排列。
Stack 叠层的第二层是"波形特效层"——当 waveActive 为 true 且当前在录制 Tab(tabIdx === 2)时,渲染一组半透明的波形柱条作为浮层装饰。这组柱条通过 position({ x: '20%', y: '35%' }) 绝对定位在屏幕左上区域,zIndex(1) 确保它浮在内容层之上但不遮挡弹窗。opacity(0.3) 的半透明效果让它作为氛围装饰而非主体内容存在。这种"条件浮层"的用法展示了 Stack 叠层在处理跨层级视觉元素时的灵活性。
4.29 新增节目弹窗
新增弹窗是三个模态之一,提供节目标题输入、分类选择和简介输入的表单。
// ===== 新增节目弹框 =====
if (this.addModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.addModal = false; })
Column({ space: 12 }) {
Row({ space: 8 }) {
Text('🎙️ 新建节目').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('✕').fontSize(16).fontColor(COLORS.sub)
.onClick(() => { this.addModal = false; })
}
.width('100%')
Divider().color(COLORS.line)
Text('节目标题').fontSize(10).fontColor(COLORS.sub)
TextInput({ placeholder: '输入节目标题...', text: this.addTitle })
.onChange((v: string) => { this.addTitle = v; })
.fontSize(12).padding(10).borderRadius(8).backgroundColor(COLORS.dark)
.fontColor(COLORS.title)
Text('选择分类').fontSize(10).fontColor(COLORS.sub)
Row({ space: 6 }) {
ForEach(['科技', '商业', '情感'], (c: string) => {
Text(c).fontSize(10)
.fontColor(this.addCategory === c ? '#FFFFFF' : COLORS.sub)
.backgroundColor(this.addCategory === c ? COLORS.amber : COLORS.dark)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
.onClick(() => { this.addCategory = c; })
})
}
.width('100%')
Text('简介').fontSize(10).fontColor(COLORS.sub)
TextInput({ placeholder: '简要描述节目内容...' })
.fontSize(12).padding(10).borderRadius(8).backgroundColor(COLORS.dark)
.fontColor(COLORS.title)
Row({ space: 10 }) {
Column() { Text('取消').fontSize(11).fontColor(COLORS.sub) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.darkL).alignItems(HorizontalAlign.Center)
.onClick(() => { this.addModal = false; })
Column() { Text('创建节目').fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.amber).alignItems(HorizontalAlign.Center)
.onClick(() => { this.addModal = false; })
}
.width('100%')
}
.width('82%').constraintSize({ maxHeight: '80%' }).padding(16)
.borderRadius(16).backgroundColor(COLORS.card)
}
.width('100%').height('100%').zIndex(999)
}
弹窗通过 if (this.addModal) 条件渲染,当 addModal 为 true 时,一个铺满屏幕的 Stack 被插入到主 Stack 的叠层中。zIndex(999) 确保弹窗浮在所有内容之上。内部先调用 modalOverlay 生成遮罩,再叠加居中的卡片容器。
卡片头部是标题行:“新建节目"标题 + 弹性占位 + 关闭按钮"✕”。Divider 作为分隔线。表单包含三个字段:节目标题(TextInput 绑定 addTitle,onChange 实时同步)、分类选择(三个胶囊按钮,选中态实心琥珀,点击切换 addCategory)、简介(无绑定的 TextInput)。底部是"取消"和"创建节目"两个等宽按钮,取消使用暗色弱化,创建使用琥珀色强调。constraintSize({ maxHeight: '80%' }) 限制卡片最大高度为屏幕的 80%,防止内容过多时溢出屏幕。TextInput 的 onChange 回调是 ArkTS 表单双向绑定的标准写法——用户输入触发回调,回调更新 @State 变量,变量变更又反向同步到 text 属性。
4.30 编辑弹窗与删除确认弹窗
编辑弹窗和删除确认弹窗与新增弹窗结构相似,但各自有差异化的逻辑。
// ===== 编辑弹框 =====
if (this.editModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.editModal = false; })
Column({ space: 12 }) {
Row({ space: 8 }) {
Text('✏️ 编辑节目').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('✕').fontSize(16).fontColor(COLORS.sub)
.onClick(() => { this.editModal = false; })
}
.width('100%')
Divider().color(COLORS.line)
Text('当前: ' + (this.editIdx < this.epList.length ? this.epList[this.editIdx].title
: RANKING[this.editIdx])).fontSize(11).fontColor(COLORS.amber)
Text('新标题').fontSize(10).fontColor(COLORS.sub)
TextInput({ placeholder: '输入新的节目标题', text: this.editTitle })
.onChange((v: string) => { this.editTitle = v; })
.fontSize(12).padding(10).borderRadius(8).backgroundColor(COLORS.dark)
.fontColor(COLORS.title)
Row({ space: 10 }) {
Column() { Text('取消').fontSize(11).fontColor(COLORS.sub) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.darkL).alignItems(HorizontalAlign.Center)
.onClick(() => { this.editModal = false; })
Column() { Text('保存').fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.amber).alignItems(HorizontalAlign.Center)
.onClick(() => { this.editModal = false; })
}
.width('100%')
}
.width('82%').constraintSize({ maxHeight: '70%' }).padding(16)
.borderRadius(16).backgroundColor(COLORS.card)
}
.width('100%').height('100%').zIndex(999)
}
// ===== 删除确认弹框 =====
if (this.delModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.delModal = false; })
Column({ space: 12 }) {
Text('🗑️').fontSize(36)
Text('删除这期节目?').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('已发布的节目删除后无法恢复').fontSize(10).fontColor(COLORS.red)
Row({ space: 10 }) {
Column() { Text('取消').fontSize(11).fontColor(COLORS.sub) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.darkL).alignItems(HorizontalAlign.Center)
.onClick(() => { this.delModal = false; })
Column() { Text('确认删除').fontSize(11).fontColor('#FFFFFF') }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.red).alignItems(HorizontalAlign.Center)
.onClick(() => {
this.epList.splice(this.editIdx, 1);
this.delModal = false;
})
}
.width('100%')
}
.width('76%').constraintSize({ maxHeight: '60%' }).padding(16)
.borderRadius(16).backgroundColor(COLORS.card)
.alignItems(HorizontalAlign.Center)
}
.width('100%').height('100%').zIndex(999)
}
}
.width('100%').height('100%').backgroundColor(COLORS.bg)
}
}
编辑弹窗与新增弹窗的差异在于:它显示"当前标题"(通过 editIdx 从 epList 或 RANKING 中取值,并用三元表达式兼容两种数据源),表单只有"新标题"一个输入框(绑定 editTitle),按钮文案为"保存"而非"创建节目"。editIdx < this.epList.length 的判断很关键——当从创作页打开时 editIdx 是 epList 的有效索引,当从排行页打开时 editIdx 可能超出 epList 范围,此时回退到 RANKING 数组取标题。这种兼容性处理保证了弹窗在不同入口下都能正确显示当前标题。
删除确认弹窗的视觉风格与前两者明显不同:它更小(76% 宽度)、更紧凑,内容居中对齐。顶部是一个 36vp 的大垃圾桶 emoji,下方是红色警告文案"已发布的节目删除后无法恢复"。底部两个按钮中,"确认删除"使用红色背景而非琥珀色,与危险操作的语义一致。删除按钮的 onClick 执行了实际的数据操作:this.epList.splice(this.editIdx, 1) 从数组中移除对应索引的元素。由于 epList 是 @State 持有的数组,splice 会触发框架的数组变更追踪,自动重新渲染创作页的栏目列表——被删除的节目卡片会从列表中消失。这是 @State 数组响应式能力的直接体现。
五、完整流程图
以下流程图展示了从组件生命周期启动、状态初始化、Tab 切换渲染、用户交互到弹窗操作的完整数据流与渲染流程。
六、技术对比表
| 技术维度 | 实现方式 | 设计特点 | 性能考量 |
|---|---|---|---|
| 状态管理 | @State 装饰器管理 UI 态与数据态 | 响应式驱动,状态变更自动触发重渲染 | 框架建立细粒度依赖追踪,仅刷新受影响子树 |
| 跨组件数据共享 | @Observed 装饰 Podcast/Episode 类 | 深度监听对象字段变更,支持多视图同步 | 避免全量 diff,按字段级精准更新 |
| 导航架构 | 单组件 + tabIdx 条件分支渲染六视图 | 状态集中、切换轻量,适合平级 Tab 场景 | 切换时仅重建内容区,头部与 TabBar 复用 |
| 列表渲染 | ForEach 遍历数组生成列表项 | 声明式数据驱动,支持嵌套 ForEach | 框架自动做 key diff,仅更新变更项 |
| 条件渲染 | if 表达式控制弹窗与浮层显隐 | 简单直接,弹窗不存在时不占用渲染树 | 条件为 false 时子树完全跳过,零开销 |
| 动画实现 | setInterval + @State 翻转驱动重渲染 | 伪动画方案,无需动画 API 即可实现动态效果 | 500ms 低频更新,帧率压力小但非逐帧平滑 |
| 样式系统 | ColorPalette 接口 + COLORS 常量集中管理 | 语义化命名,全应用色彩统一 | 常量引用无运行时计算开销 |
| 图标方案 | Emoji 字符替代图片资源 | 零资源依赖,跨设备一致显示 | 无图片解码开销,内存占用极低 |
| 布局策略 | Row/Column + layoutWeight 弹性布局 | 声明式布局,权重分配空间 | 框架一次测量完成布局,无多次回流 |
| 模态弹窗 | Stack 叠层 + 遮罩 + 条件渲染 | 遮罩复用 modalOverlay 构建器,三弹窗统一结构 | zIndex 分层,非激活弹窗不进入渲染树 |
| 表单绑定 | TextInput + onChange 回调更新 @State | 手动双向绑定,状态唯一数据源 | 输入即更新,框架按需重渲染输入框 |
| 数据删除 | 数组 splice + @State 自动感知 | 直接操作数组,框架追踪变更 | splice 触发增量更新,列表项平滑移除 |
| 生命周期 | aboutToAppear/aboutToDisappear 对称管理 | 创建时启动定时器,销毁时清理 | 防止定时器泄漏,组件销毁后无残留回调 |
| 组件复用 | @Builder 构建器提取公共 UI 片段 | 内联展开,无组件实例化开销 | 构建器调用即代码注入,零额外对象创建 |
| 滚动容器 | Scroll + layoutWeight 占满剩余空间 | 内容区自适应高度,滚动条隐藏 | 虚拟滚动由框架托管,长列表内存可控 |
| 分类着色 | catColor 纯函数映射分类到色值 | 函数式风格,无副作用,可独立测试 | 每次渲染调用,但纯函数无缓存需求 |
| 网格布局 | 双 Row 各遍历三索引模拟两行三列 | 手动分行规避 ForEach 不支持二维网格 | 固定行列无动态计算,渲染开销恒定 |
安装DevEco Studio程序

选择目标安装目录:

设置环境变量,但是需要重启一下:

新建一个空白模板:

设置API为24的模板项目:
初始化项目,自动下载相关依赖:

完整代码:
// ============================================================
// 823 声纹纪 · 播客创作与订阅平台(现代行业:播客 / 音频内容创作)
// 头部样式:影视音频头部(最新一期播客大卡 + 订阅横条 + 搜索栏)无动画
// 布局风格:6 个 tab 单排,每个布局完全不同
// 发现=精选大卡+分类chips+热门列表 / 订阅=更新列表+未读标记
// 录制=录音界面模拟+波形条 / 排行=大编号榜单
// 创作=数据看板+栏目管理 / 我的=个人档案+收听历史
// 底部 6 tab 单排
// 主题:暖琥珀棕底 + 深棕 + 橙金
// ============================================================
interface ColorPalette {
bg: string;
card: string;
dark: string;
darkL: string;
title: string;
sub: string;
text3: string;
amber: string;
amberD: string;
brown: string;
brownD: string;
orange: string;
gold: string;
red: string;
green: string;
blue: string;
purple: string;
line: string;
tabOn: string;
mask: string;
chip: string;
}
const COLORS: ColorPalette = {
bg: '#F5EDE0',
card: '#FFFFFF',
dark: '#EBE0D0',
darkL: '#DCCFC0',
title: '#3D2817',
sub: '#7A5C42',
text3: '#A89078',
amber: '#D4912A',
amberD: '#B07518',
brown: '#6B4226',
brownD: '#4A2E18',
orange: '#E8722C',
gold: '#E8B820',
red: '#D04545',
green: '#4A9E6C',
blue: '#3D7DC8',
purple: '#8A5FC0',
line: '#DCCFC0',
tabOn: '#D4912A',
mask: 'rgba(61,40,23,0.55)',
chip: 'rgba(212,145,42,0.12)'
};
interface TabMeta {
icon: string;
label: string;
}
const TAB_LIST: TabMeta[] = [
{ icon: '🎙️', label: '发现' },
{ icon: '📥', label: '订阅' },
{ icon: '🔴', label: '录制' },
{ icon: '📊', label: '排行' },
{ icon: '🎨', label: '创作' },
{ icon: '👤', label: '我的' }
];
interface CategoryMeta {
name: string;
icon: string;
count: number;
}
const CAT_LIST: CategoryMeta[] = [
{ name: '科技', icon: '💡', count: 1280 },
{ name: '商业', icon: '📈', count: 860 },
{ name: '人文', icon: '📖', count: 642 },
{ name: '情感', icon: '💜', count: 920 },
{ name: '音乐', icon: '🎵', count: 456 },
{ name: '脱口秀', icon: '😂', count: 780 }
];
@Observed export class Podcast {
id: number;
title: string;
host: string;
avatar: string;
category: string;
duration: string;
playCount: string;
date: string;
desc: string;
tags: string[];
isNew: boolean;
constructor(id: number, title: string, host: string, avatar: string, category: string, duration: string, playCount: string, date: string, desc: string, tags: string[], isNew: boolean) {
this.id = id; this.title = title; this.host = host; this.avatar = avatar;
this.category = category; this.duration = duration; this.playCount = playCount;
this.date = date; this.desc = desc; this.tags = tags; this.isNew = isNew;
}
}
@Observed export class Episode {
id: number;
title: string;
podcast: string;
duration: string;
date: string;
status: string;
plays: string;
likes: number;
constructor(id: number, title: string, podcast: string, duration: string, date: string, status: string, plays: string, likes: number) {
this.id = id; this.title = title; this.podcast = podcast; this.duration = duration;
this.date = date; this.status = status; this.plays = plays; this.likes = likes;
}
}
const PODCAST_INIT: Podcast[] = [
new Podcast(1, 'AI时代的创业机会', '科技早知道', '🎧', '科技', '48分钟', '12.3万', '今天', '从GPT到Agent,AI创业正在经历什么变化?', ['AI', '创业', '趋势'], true),
new Podcast(2, '硅谷老兵的职场心得', '职场加薪站', '🎙️', '商业', '36分钟', '8.6万', '今天', '在硅谷工作15年,我学到的最重要的事', ['职场', '硅谷'], true),
new Podcast(3, '深夜情感电台 Vol.45', '夜话声纹', '🌙', '情感', '52分钟', '15.2万', '昨天', '关于孤独、关于爱情、关于那些说不出口的话', ['情感', '深夜'], false),
new Podcast(4, '区块链到底解决了什么', '链上观察', '⛓️', '科技', '40分钟', '3.4万', '昨天', '脱离炒作,从技术本质看区块链的真实价值', ['区块链', '技术'], false),
new Podcast(5, '90后创业者的失败课', '创业失败学', '📉', '商业', '44分钟', '6.8万', '2天前', '三次创业三次失败,他总结出的5条铁律', ['创业', '失败'], false),
new Podcast(6, '古典音乐入门指南', '音乐教养', '🎼', '音乐', '38分钟', '2.1万', '2天前', '从贝多芬到德彪西,一小时爱上古典乐', ['古典', '入门'], false),
new Podcast(7, '脱口秀演员的后台', '笑果电台', '🎤', '脱口秀', '30分钟', '9.2万', '3天前', '那些没上台的段子,比上台的还好笑', ['脱口秀', '搞笑'], false),
new Podcast(8, '极简主义生活实验', '生活实验室', '🌿', '人文', '42分钟', '4.5万', '3天前', '一年不买新东西,我的生活发生了什么', ['极简', '生活'], false),
new Podcast(9, '心理学解密拖延症', '心理之声', '🧠', '人文', '46分钟', '7.8万', '4天前', '你不是懒,你是大脑在保护你', ['心理', '拖延'], false),
new Podcast(10, '音乐人谈创作灵感', '创作背后', '🎸', '音乐', '50分钟', '3.6万', '4天前', '从一段旋律到一首歌,中间发生了什么', ['音乐', '创作'], false),
new Podcast(11, '投资人的看项目方法论', '投资笔记', '💰', '商业', '55分钟', '5.2万', '5天前', '一年看500个项目,我如何判断值不值得投', ['投资', '方法论'], false),
new Podcast(12, '那些被误解的历史人物', '历史另一面', '📜', '人文', '48分钟', '6.1万', '5天前', '曹操不只是奸雄,纣王也不只是暴君', ['历史', '人物'], false)
];
const EPISODE_INIT: Episode[] = [
new Episode(1, 'AI时代的创业机会', '科技早知道', '48分钟', '今天', '已发布', '12.3万', 860),
new Episode(2, '大模型落地场景分析', '科技早知道', '42分钟', '3天前', '已发布', '8.9万', 620),
new Episode(3, 'Agent架构深度探讨', '科技早知道', '55分钟', '上周', '已发布', '6.4万', 480),
new Episode(4, '下期:具身智能前沿', '科技早知道', '--', '草稿', '草稿中', '0', 0),
new Episode(5, '硅谷老兵的职场心得', '职场加加', '36分钟', '今天', '已发布', '8.6万', 540),
new Episode(6, '跳槽还是留守', '职场加加', '40分钟', '上周', '已发布', '5.2万', 380),
new Episode(7, '面试技巧大全', '职场加加', '38分钟', '2周前', '已发布', '4.1万', 290),
new Episode(8, '下期:远程工作效率', '职场加加', '--', '录制中', '录制中', '0', 0)
];
const LISTEN_DATA: number[] = [45, 60, 30, 90, 75, 120, 50];
const LISTEN_DAYS: string[] = ['一', '二', '三', '四', '五', '六', '日'];
const MAX_LISTEN: number = 120;
const RANKING: string[] = [
'深夜情感电台', 'AI时代的创业机会', '硅谷老兵的职场心得', '脱口秀演员的后台',
'90后创业者的失败课', '心理学解密拖延症', '投资人的看项目方法论', '那些被误解的历史人物',
'极简主义生活实验', '古典音乐入门指南'
];
const RANK_PLAYS: string[] = [
'15.2万', '12.3万', '8.6万', '9.2万', '6.8万', '7.8万', '5.2万', '6.1万', '4.5万', '2.1万'
];
function catColor(c: string): string {
if (c === '科技') { return COLORS.blue; }
if (c === '商业') { return COLORS.amber; }
if (c === '人文') { return COLORS.brown; }
if (c === '情感') { return COLORS.purple; }
if (c === '音乐') { return COLORS.green; }
return COLORS.orange;
}
function listenBarH(v: number): number {
return Math.round(v / MAX_LISTEN * 70);
}
function rankMedal(i: number): string {
if (i === 0) { return '🥇'; }
if (i === 1) { return '🥈'; }
if (i === 2) { return '🥉'; }
return (i + 1).toString();
}
function rankColor(i: number): string {
if (i === 0) { return COLORS.gold; }
if (i === 1) { return COLORS.sub; }
if (i === 2) { return COLORS.orange; }
return COLORS.text3;
}
@Entry
@Component
struct Page823 {
@State tabIdx: number = 0;
@State waveActive: boolean = false;
@State addModal: boolean = false;
@State editModal: boolean = false;
@State delModal: boolean = false;
@State editIdx: number = 0;
@State addTitle: string = '';
@State addCategory: string = '科技';
@State editTitle: string = '';
@State podList: Podcast[] = PODCAST_INIT;
@State epList: Episode[] = EPISODE_INIT;
private timer: number = -1;
aboutToAppear(): void {
this.timer = setInterval(() => { this.waveActive = !this.waveActive; }, 500);
}
aboutToDisappear(): void {
if (this.timer > 0) { clearInterval(this.timer); }
}
@Builder
header() {
Column({ space: 10 }) {
Row({ space: 8 }) {
Text('🎙️').fontSize(16)
Text('声纹纪').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('🔍').fontSize(16)
Text('🔔').fontSize(16)
}
.width('100%')
// 最新一期播客大卡
Column({ space: 6 }) {
Row({ space: 10 }) {
Column() {
Text('🎧').fontSize(28)
}
.width(48).height(48).borderRadius(12).backgroundColor(COLORS.chip)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('AI时代的创业机会').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('科技早知道 · 48分钟 · 12.3万播放').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Column() {
Text('▶').fontSize(14).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
}
.width(36).height(36).borderRadius(18).backgroundColor(COLORS.amber)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
}
.width('100%')
}
.width('100%').padding(12).borderRadius(14)
.backgroundColor(COLORS.card)
// 订阅横条
Row({ space: 10 }) {
Column({ space: 1 }) {
Text('28').fontSize(18).fontColor(COLORS.amber).fontWeight(FontWeight.Bold)
Text('订阅').fontSize(8).fontColor(COLORS.sub)
}
Column().width(1).height(24).backgroundColor(COLORS.line)
Column({ space: 1 }) {
Text('6').fontSize(18).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
Text('更新').fontSize(8).fontColor(COLORS.sub)
}
Column().width(1).height(24).backgroundColor(COLORS.line)
Column({ space: 1 }) {
Text('42h').fontSize(18).fontColor(COLORS.brown).fontWeight(FontWeight.Bold)
Text('本周收听').fontSize(8).fontColor(COLORS.sub)
}
Column().layoutWeight(1)
Text('管理订阅 ›').fontSize(9).fontColor(COLORS.amber)
}
.width('100%').padding(10).borderRadius(10)
.backgroundColor(COLORS.card)
}
.width('100%').padding({ left: 14, right: 14, top: 10, bottom: 8 })
.backgroundColor(COLORS.bg)
}
@Builder
tabBar() {
Row() {
ForEach(TAB_LIST, (t: TabMeta, i: number) => {
Column({ space: 2 }) {
Text(t.icon).fontSize(18).opacity(this.tabIdx === i ? 1.0 : 0.4)
Text(t.label).fontSize(9)
.fontColor(this.tabIdx === i ? COLORS.tabOn : COLORS.sub)
.fontWeight(this.tabIdx === i ? FontWeight.Bold : FontWeight.Normal)
if (this.tabIdx === i) {
Column().width(16).height(2).backgroundColor(COLORS.tabOn).borderRadius(1)
}
}
.layoutWeight(1).padding({ top: 5, bottom: 5 })
.alignItems(HorizontalAlign.Center)
.onClick(() => { this.tabIdx = i; })
})
}
.width('100%').backgroundColor(COLORS.card)
.padding({ top: 4, bottom: 6 })
.border({ width: { top: 1 }, color: COLORS.line })
}
@Builder
modalOverlay(onClose: () => void) {
Column().width('100%').height('100%')
.backgroundColor(COLORS.mask)
.onClick(() => { onClose(); })
}
// ===== Tab 0: 发现 =====
@Builder
discoverTab() {
Column({ space: 10 }) {
Text('🎯 精选推荐').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
// 精选大卡
Column({ space: 8 }) {
Text('🎧').fontSize(36)
Text('深夜情感电台 Vol.45').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('关于孤独、关于爱情、关于那些说不出口的话').fontSize(10).fontColor(COLORS.sub)
Row({ space: 8 }) {
Text('🌙 情感').fontSize(9).fontColor(COLORS.purple)
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(6).backgroundColor(COLORS.chip)
Text('52分钟').fontSize(9).fontColor(COLORS.sub)
Text('15.2万播放').fontSize(9).fontColor(COLORS.amber)
}
Row({ space: 8 }) {
Column() {
Text('▶ 立即收听').fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
}
.padding({ left: 20, right: 20, top: 6, bottom: 6 }).borderRadius(14)
.backgroundColor(COLORS.amber)
Column() {
Text('+ 订阅').fontSize(11).fontColor(COLORS.amber)
}
.padding({ left: 20, right: 20, top: 6, bottom: 6 }).borderRadius(14)
.backgroundColor(COLORS.chip)
}
}
.width('100%').padding(20).borderRadius(16)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Text('📂 分类浏览').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
// 分类chips
Row({ space: 8 }) {
ForEach([0, 1, 2], (i: number) => {
Column({ space: 4 }) {
Text(CAT_LIST[i].icon).fontSize(22)
Text(CAT_LIST[i].name).fontSize(10).fontColor(COLORS.title)
Text(CAT_LIST[i].count + '档').fontSize(8).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(12)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
})
}
.width('100%')
Row({ space: 8 }) {
ForEach([3, 4, 5], (i: number) => {
Column({ space: 4 }) {
Text(CAT_LIST[i].icon).fontSize(22)
Text(CAT_LIST[i].name).fontSize(10).fontColor(COLORS.title)
Text(CAT_LIST[i].count + '档').fontSize(8).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(12)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
})
}
.width('100%')
Text('🔥 热门播客').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Column({ space: 6 }) {
ForEach(this.podList, (p: Podcast, idx: number) => {
Row({ space: 10 }) {
Column({ space: 3 }) {
Text(p.avatar).fontSize(24)
if (p.isNew) {
Text('NEW').fontSize(7).fontColor('#FFFFFF')
.padding({ left: 3, right: 3, top: 1, bottom: 1 }).borderRadius(3)
.backgroundColor(COLORS.red)
}
}
.width(44).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text(p.title).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
Row({ space: 4 }) {
Text(p.host).fontSize(9).fontColor(COLORS.sub)
Text('·').fontSize(9).fontColor(COLORS.text3)
Text(p.duration).fontSize(9).fontColor(COLORS.sub)
Text('·').fontSize(9).fontColor(COLORS.text3)
Text(p.playCount).fontSize(9).fontColor(COLORS.amber)
}
Text(p.desc).fontSize(9).fontColor(COLORS.sub)
Row({ space: 4 }) {
ForEach(p.tags, (tag: string) => {
Text('#' + tag).fontSize(8).fontColor(catColor(p.category))
.padding({ left: 4, right: 4, top: 1, bottom: 1 }).borderRadius(4)
.backgroundColor(COLORS.chip)
})
}
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Column() {
Text('▶').fontSize(12).fontColor('#FFFFFF')
}
.width(28).height(28).borderRadius(14)
.backgroundColor(COLORS.amber)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
}
.width('100%').padding(10).borderRadius(10)
.backgroundColor(COLORS.card)
})
}
.width('100%')
}
.width('100%')
}
// ===== Tab 1: 订阅 =====
@Builder
subscribeTab() {
Column({ space: 8 }) {
Row({ space: 6 }) {
Text('全部').fontSize(10).fontColor('#FFFFFF')
.backgroundColor(COLORS.amber)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
Text('未读').fontSize(10).fontColor(COLORS.amber)
.backgroundColor(COLORS.chip)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
Column().layoutWeight(1)
Text('28个订阅').fontSize(9).fontColor(COLORS.sub)
}
.width('100%')
Column({ space: 6 }) {
ForEach([0, 1, 2, 3, 4, 5, 6, 7], (i: number) => {
Row({ space: 10 }) {
Column({ space: 3 }) {
Text(PODCAST_INIT[i].avatar).fontSize(22)
}
.width(40).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Row({ space: 4 }) {
Text(PODCAST_INIT[i].host).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
if (PODCAST_INIT[i].isNew) {
Text('•').fontSize(10).fontColor(COLORS.red)
}
}
Text(PODCAST_INIT[i].title).fontSize(10).fontColor(COLORS.sub)
Text(PODCAST_INIT[i].date + ' · ' + PODCAST_INIT[i].duration).fontSize(8).fontColor(COLORS.text3)
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Column() {
Text('▶').fontSize(10).fontColor('#FFFFFF')
}
.width(24).height(24).borderRadius(12)
.backgroundColor(PODCAST_INIT[i].isNew ? COLORS.amber : COLORS.darkL)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
}
.width('100%').padding(10).borderRadius(10)
.backgroundColor(COLORS.card)
})
}
.width('100%')
}
.width('100%')
}
// ===== Tab 2: 录制 =====
@Builder
recordTab() {
Column({ space: 14 }) {
Text('🔴 录音工作室').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
// 录音界面
Column({ space: 10 }) {
Text(this.waveActive ? '🔴 录音中' : '⏸️ 已暂停').fontSize(14)
.fontColor(this.waveActive ? COLORS.red : COLORS.sub)
.fontWeight(FontWeight.Bold)
Text('03:42').fontSize(36).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
// 波形条
Row({ space: 3 }) {
ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], (i: number) => {
Column()
.width(4)
.height(this.waveActive ? (10 + (i % 5) * 8) : 6)
.backgroundColor(this.waveActive ? COLORS.amber : COLORS.darkL)
.borderRadius(2)
})
}
.height(50).justifyContent(FlexAlign.Center)
Row({ space: 16 }) {
Column() {
Text('⏹').fontSize(20).fontColor('#FFFFFF')
}
.width(48).height(48).borderRadius(24)
.backgroundColor(COLORS.red)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
Column() {
Text(this.waveActive ? '⏸' : '▶').fontSize(24).fontColor('#FFFFFF')
}
.width(56).height(56).borderRadius(28)
.backgroundColor(COLORS.amber)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
.onClick(() => { this.waveActive = !this.waveActive; })
Column() {
Text('💾').fontSize(20).fontColor('#FFFFFF')
}
.width(48).height(48).borderRadius(24)
.backgroundColor(COLORS.green)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
.onClick(() => { this.addModal = true; })
}
.width('100%').justifyContent(FlexAlign.Center)
}
.width('100%').padding(20).borderRadius(16)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Text('🎚️ 录音设置').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Column({ space: 0 }) {
Row({ space: 10 }) {
Text('🎙️').fontSize(16)
Text('麦克风').fontSize(12).fontColor(COLORS.sub).layoutWeight(1)
Text('内置 ›').fontSize(10).fontColor(COLORS.amber)
}
.width('100%').padding(12).borderRadius(10).backgroundColor(COLORS.card)
Row({ space: 10 }) {
Text('🔊').fontSize(16)
Text('降噪级别').fontSize(12).fontColor(COLORS.sub).layoutWeight(1)
Text('高 ›').fontSize(10).fontColor(COLORS.amber)
}
.width('100%').padding(12).borderRadius(10).backgroundColor(COLORS.card)
Row({ space: 10 }) {
Text('🎵').fontSize(16)
Text('背景音乐').fontSize(12).fontColor(COLORS.sub).layoutWeight(1)
Text('无 ›').fontSize(10).fontColor(COLORS.amber)
}
.width('100%').padding(12).borderRadius(10).backgroundColor(COLORS.card)
}
.width('100%')
}
.width('100%')
}
// ===== Tab 3: 排行 =====
@Builder
rankingTab() {
Column({ space: 10 }) {
Row({ space: 6 }) {
Text('周榜').fontSize(10).fontColor('#FFFFFF')
.backgroundColor(COLORS.amber)
.padding({ left: 10, right: 10, top: 4, bottom: 4 }).borderRadius(10)
Text('月榜').fontSize(10).fontColor(COLORS.sub)
.backgroundColor(COLORS.card)
.padding({ left: 10, right: 10, top: 4, bottom: 4 }).borderRadius(10)
Text('总榜').fontSize(10).fontColor(COLORS.sub)
.backgroundColor(COLORS.card)
.padding({ left: 10, right: 10, top: 4, bottom: 4 }).borderRadius(10)
}
.width('100%')
Text('🏆 本周播客热榜').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
// 大编号榜单
Column({ space: 6 }) {
ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], (i: number) => {
Row({ space: 10 }) {
Column() {
Text(rankMedal(i)).fontSize(i < 3 ? 22 : 16)
.fontColor(rankColor(i)).fontWeight(FontWeight.Bold)
}
.width(36).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text(RANKING[i]).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
Row({ space: 6 }) {
Text(RANK_PLAYS[i] + '播放').fontSize(9).fontColor(COLORS.amber)
Text('·').fontSize(9).fontColor(COLORS.text3)
Text(['科技', '情感', '商业', '脱口秀', '商业', '人文', '商业', '人文', '人文', '音乐'][i]).fontSize(9).fontColor(COLORS.sub)
}
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Column() {
Text('▶').fontSize(10).fontColor('#FFFFFF')
}
.width(24).height(24).borderRadius(12)
.backgroundColor(COLORS.amber)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
}
.width('100%').padding(10).borderRadius(10)
.backgroundColor(COLORS.card)
.onClick(() => { this.editIdx = i; this.editModal = true; })
})
}
.width('100%')
}
.width('100%')
}
// ===== Tab 4: 创作 =====
@Builder
createTab() {
Column({ space: 12 }) {
Text('🎨 创作者中心').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
// 数据看板
Row({ space: 10 }) {
Column({ space: 2 }) {
Text('48.2万').fontSize(18).fontColor(COLORS.amber).fontWeight(FontWeight.Bold)
Text('总播放').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('2,340').fontSize(18).fontColor(COLORS.green).fontWeight(FontWeight.Bold)
Text('订阅数').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('¥8,560').fontSize(18).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
Text('本月收益').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
}
.width('100%')
Text('📊 本周收听时长').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
// 柱状图
Column({ space: 6 }) {
Row({ space: 4 }) {
ForEach([0, 1, 2, 3, 4, 5, 6], (d: number) => {
Column({ space: 3 }) {
Text(LISTEN_DATA[d].toString()).fontSize(8).fontColor(COLORS.amber)
Text('分').fontSize(6).fontColor(COLORS.sub)
Column()
.width(18).height(listenBarH(LISTEN_DATA[d]))
.backgroundColor(d === 5 ? COLORS.orange : COLORS.amberD)
.borderRadius({ topLeft: 3, topRight: 3 })
Text(LISTEN_DAYS[d]).fontSize(8).fontColor(COLORS.sub)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
})
}
.width('100%').padding({ top: 8, bottom: 8 })
}
.width('100%').padding(12).borderRadius(12)
.backgroundColor(COLORS.card)
Text('📝 我的栏目管理').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
// 栏目列表
Column({ space: 6 }) {
ForEach(this.epList, (e: Episode, idx: number) => {
Row({ space: 10 }) {
Column({ space: 3 }) {
Text(e.status === '已发布' ? '✅' : (e.status === '草稿中' ? '📝' : '🔴')).fontSize(16)
}
.width(32).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text(e.title).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
Row({ space: 4 }) {
Text(e.podcast).fontSize(9).fontColor(COLORS.sub)
Text('·').fontSize(9).fontColor(COLORS.text3)
Text(e.date).fontSize(9).fontColor(COLORS.sub)
Text('·').fontSize(9).fontColor(COLORS.text3)
Text(e.duration).fontSize(9).fontColor(COLORS.sub)
}
if (e.status === '已发布') {
Row({ space: 4 }) {
Text(e.plays + '播放').fontSize(8).fontColor(COLORS.amber)
Text('❤' + e.likes).fontSize(8).fontColor(COLORS.red)
}
}
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text('✏️').fontSize(14).fontColor(COLORS.amber)
.onClick(() => { this.editIdx = idx; this.editModal = true; })
Text('🗑️').fontSize(14).fontColor(COLORS.red)
.onClick(() => { this.editIdx = idx; this.delModal = true; })
}
.width('100%').padding(10).borderRadius(10)
.backgroundColor(COLORS.card)
})
}
.width('100%')
Column() {
Text('+ 新建一期节目').fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
}
.width('100%').padding(12).borderRadius(10)
.backgroundColor(COLORS.amber)
.alignItems(HorizontalAlign.Center)
.onClick(() => { this.addModal = true; })
}
.width('100%')
}
// ===== Tab 5: 我的 =====
@Builder
profileTab() {
Column({ space: 12 }) {
Column({ space: 8 }) {
Row({ space: 12 }) {
Column() {
Text('🎧').fontSize(32)
}
.width(56).height(56).borderRadius(28).backgroundColor(COLORS.chip)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('声音旅人').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('播客爱好者 · 创作者认证').fontSize(10).fontColor(COLORS.sub)
Text('🎙️ 已发布8期 · 总播放48.2万').fontSize(9).fontColor(COLORS.amber)
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
}
.width('100%')
}
.width('100%').padding(14).borderRadius(16).backgroundColor(COLORS.card)
Row({ space: 10 }) {
Column({ space: 2 }) {
Text('28').fontSize(20).fontColor(COLORS.amber).fontWeight(FontWeight.Bold)
Text('订阅').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('156h').fontSize(20).fontColor(COLORS.brown).fontWeight(FontWeight.Bold)
Text('总收听').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('8').fontSize(20).fontColor(COLORS.green).fontWeight(FontWeight.Bold)
Text('已发布').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
}
.width('100%')
Text('🕐 最近收听').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Column({ space: 6 }) {
ForEach([0, 1, 2, 3, 4], (i: number) => {
Row({ space: 10 }) {
Text(PODCAST_INIT[i].avatar).fontSize(20)
Column({ space: 2 }) {
Text(PODCAST_INIT[i].title).fontSize(11).fontColor(COLORS.title)
Text(PODCAST_INIT[i].host + ' · ' + PODCAST_INIT[i].duration).fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text('继续听').fontSize(9).fontColor(COLORS.amber)
Text('▶').fontSize(10).fontColor(COLORS.amber)
}
.width('100%').padding(10).borderRadius(10)
.backgroundColor(COLORS.card)
})
}
.width('100%')
Text('⚙️ 设置').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Column({ space: 0 }) {
Row({ space: 10 }) {
Text('🌙').fontSize(16)
Text('定时关闭').fontSize(12).fontColor(COLORS.sub).layoutWeight(1)
Text('30分钟 ›').fontSize(10).fontColor(COLORS.amber)
}
.width('100%').padding(12).borderRadius(10).backgroundColor(COLORS.card)
Row({ space: 10 }) {
Text('📥').fontSize(16)
Text('下载管理').fontSize(12).fontColor(COLORS.sub).layoutWeight(1)
Text('12期 ›').fontSize(10).fontColor(COLORS.amber)
}
.width('100%').padding(12).borderRadius(10).backgroundColor(COLORS.card)
Row({ space: 10 }) {
Text('⚙️').fontSize(16)
Text('播放设置').fontSize(12).fontColor(COLORS.sub).layoutWeight(1)
Text('›').fontSize(10).fontColor(COLORS.amber)
}
.width('100%').padding(12).borderRadius(10).backgroundColor(COLORS.card)
}
.width('100%')
}
.width('100%')
}
build() {
Stack({ alignContent: Alignment.Center }) {
Column() {
this.header()
Scroll() {
Column({ space: 10 }) {
if (this.tabIdx === 0) { this.discoverTab() }
else if (this.tabIdx === 1) { this.subscribeTab() }
else if (this.tabIdx === 2) { this.recordTab() }
else if (this.tabIdx === 3) { this.rankingTab() }
else if (this.tabIdx === 4) { this.createTab() }
else { this.profileTab() }
}
.width('100%').padding({ left: 14, right: 14, top: 10, bottom: 16 })
}
.layoutWeight(1).scrollBar(BarState.Off).align(Alignment.Top)
this.tabBar()
}
.width('100%').height('100%')
// 波形特效层
if (this.waveActive && this.tabIdx === 2) {
Row({ space: 4 }) {
ForEach([0, 1, 2, 3, 4, 5, 6, 7], (i: number) => {
Column()
.width(3).height(6 + (i % 4) * 4)
.backgroundColor(COLORS.amber).opacity(0.3).borderRadius(2)
})
}
.position({ x: '20%', y: '35%' }).zIndex(1)
}
// ===== 新增节目弹框 =====
if (this.addModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.addModal = false; })
Column({ space: 12 }) {
Row({ space: 8 }) {
Text('🎙️ 新建节目').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('✕').fontSize(16).fontColor(COLORS.sub)
.onClick(() => { this.addModal = false; })
}
.width('100%')
Divider().color(COLORS.line)
Text('节目标题').fontSize(10).fontColor(COLORS.sub)
TextInput({ placeholder: '输入节目标题...', text: this.addTitle })
.onChange((v: string) => { this.addTitle = v; })
.fontSize(12).padding(10).borderRadius(8).backgroundColor(COLORS.dark)
.fontColor(COLORS.title)
Text('选择分类').fontSize(10).fontColor(COLORS.sub)
Row({ space: 6 }) {
ForEach(['科技', '商业', '情感'], (c: string) => {
Text(c).fontSize(10)
.fontColor(this.addCategory === c ? '#FFFFFF' : COLORS.sub)
.backgroundColor(this.addCategory === c ? COLORS.amber : COLORS.dark)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
.onClick(() => { this.addCategory = c; })
})
}
.width('100%')
Text('简介').fontSize(10).fontColor(COLORS.sub)
TextInput({ placeholder: '简要描述节目内容...' })
.fontSize(12).padding(10).borderRadius(8).backgroundColor(COLORS.dark)
.fontColor(COLORS.title)
Row({ space: 10 }) {
Column() { Text('取消').fontSize(11).fontColor(COLORS.sub) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.darkL).alignItems(HorizontalAlign.Center)
.onClick(() => { this.addModal = false; })
Column() { Text('创建节目').fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.amber).alignItems(HorizontalAlign.Center)
.onClick(() => { this.addModal = false; })
}
.width('100%')
}
.width('82%').constraintSize({ maxHeight: '80%' }).padding(16)
.borderRadius(16).backgroundColor(COLORS.card)
}
.width('100%').height('100%').zIndex(999)
}
// ===== 编辑弹框 =====
if (this.editModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.editModal = false; })
Column({ space: 12 }) {
Row({ space: 8 }) {
Text('✏️ 编辑节目').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('✕').fontSize(16).fontColor(COLORS.sub)
.onClick(() => { this.editModal = false; })
}
.width('100%')
Divider().color(COLORS.line)
Text('当前: ' + (this.editIdx < this.epList.length ? this.epList[this.editIdx].title : RANKING[this.editIdx])).fontSize(11).fontColor(COLORS.amber)
Text('新标题').fontSize(10).fontColor(COLORS.sub)
TextInput({ placeholder: '输入新的节目标题', text: this.editTitle })
.onChange((v: string) => { this.editTitle = v; })
.fontSize(12).padding(10).borderRadius(8).backgroundColor(COLORS.dark)
.fontColor(COLORS.title)
Row({ space: 10 }) {
Column() { Text('取消').fontSize(11).fontColor(COLORS.sub) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.darkL).alignItems(HorizontalAlign.Center)
.onClick(() => { this.editModal = false; })
Column() { Text('保存').fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.amber).alignItems(HorizontalAlign.Center)
.onClick(() => { this.editModal = false; })
}
.width('100%')
}
.width('82%').constraintSize({ maxHeight: '70%' }).padding(16)
.borderRadius(16).backgroundColor(COLORS.card)
}
.width('100%').height('100%').zIndex(999)
}
// ===== 删除确认弹框 =====
if (this.delModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.delModal = false; })
Column({ space: 12 }) {
Text('🗑️').fontSize(36)
Text('删除这期节目?').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('已发布的节目删除后无法恢复').fontSize(10).fontColor(COLORS.red)
Row({ space: 10 }) {
Column() { Text('取消').fontSize(11).fontColor(COLORS.sub) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.darkL).alignItems(HorizontalAlign.Center)
.onClick(() => { this.delModal = false; })
Column() { Text('确认删除').fontSize(11).fontColor('#FFFFFF') }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.red).alignItems(HorizontalAlign.Center)
.onClick(() => {
this.epList.splice(this.editIdx, 1);
this.delModal = false;
})
}
.width('100%')
}
.width('76%').constraintSize({ maxHeight: '60%' }).padding(16)
.borderRadius(16).backgroundColor(COLORS.card)
.alignItems(HorizontalAlign.Center)
}
.width('100%').height('100%').zIndex(999)
}
}
.width('100%').height('100%').backgroundColor(COLORS.bg)
}
}
七、总结

从整体架构来看,本项目展示了一个典型的"单组件多视图"鸿蒙应用的原型实现模式。通过一个 @Entry 主组件集中管理全部状态,配合六个 @Builder 方法分别构建不同 Tab 的视图,在 Tab 数量适中、视图间无深度跳转的场景下,这种模式的开发效率最高——开发者无需定义多个组件文件、无需处理组件间通信,所有逻辑都在一个作用域内完成。但当应用规模增长时,这种模式的主组件会迅速膨胀,此时应当果断拆分为子组件,通过 @Prop/@Link/@ObjectLink 建立组件间的数据通道。本项目的代码结构为这种演进预留了空间:每个 Tab 的构建器已经是独立的方法,拆分为独立组件只需将 @Builder 改为 @Component 并补充 props 声明即可。
在状态管理层面,项目同时运用了 @State 和 @Observed 两套机制,分别处理 UI 态和业务态,这种分层设计值得借鉴。@State 适合管理组件私有的、生命周期与组件一致的临时状态(如弹窗开关、表单输入、当前索引);@Observed 适合管理需要跨视图共享的、生命周期独立于组件的业务实体(如播客数据、节目数据)。两者的配合使用,既保证了 UI 态的封装性,又实现了业务态的全局一致性。在实际工程中,如果业务实体需要在更多组件间共享,还可以进一步引入 @Provide/@Consume 或全局状态管理方案(如 AppStorage)来管理。
项目的动画实现策略——使用 setInterval 驱动 @State 翻转来实现"伪动画"——在原型阶段是务实的选择,但在生产环境中应当替换为 ArkUI 的动画能力。animateTo 显式动画、animation 属性动画、transition 组件转场动画等 API 能够提供逐帧平滑的动画效果,且由框架的渲染管线统一调度,性能更优。波形条的理想实现方式是使用 animateTo 让每根柱条的高度在随机值之间做平滑过渡,而非在两个固定高度之间硬切换。此外,setInterval 的 500ms 间隔在高帧率设备上会显得卡顿,应改为更短的间隔配合缓动函数。
在视觉设计层面,项目的"暖琥珀棕"主题与播客行业的"陪伴感"调性高度契合,这提醒我们技术实现应当服务于产品调性。色彩系统的语义化命名(bg/card/title/sub/amber 等)是一种值得坚持的工程实践,它让样式代码自文档化,也让设计师与开发者的沟通有了共同语言。Emoji 图标方案虽然不适合生产(应为不同分辨率提供矢量图标资源),但在原型阶段能极大提升开发效率,且保证了跨平台一致性。在真实项目中,可以使用鸿蒙的 SymbolGlyph 组件或自定义字体图标来替代 emoji,获得更精确的视觉控制。
从数据管理的角度看,项目的"静态常量初始化 + 运行时增删"策略适合演示,但在生产中需要补充数据持久化与服务端同步。当前 epList.splice 的删除操作只修改了内存中的数组,应用重启后数据会恢复初始状态。生产环境应当引入 SQLite 本地数据库或 Preferences 轻量存储来持久化关键数据,并通过网络请求与服务端同步。此外,列表的增删改应当补充乐观更新与冲突回滚机制,确保在网络不稳定时用户体验不中断。
最后,项目的弹窗系统展示了良好的复用设计——modalOverlay 构建器接收回调参数实现遮罩复用,三个弹窗共享统一的"Stack + 遮罩 + 卡片"结构。这种"提取公共结构 + 参数化差异"的复用思路,可以进一步扩展为通用的 Modal 组件:接收 visible、onClose、title、content 等参数,将三个弹窗的差异部分通过 @BuilderParam 插槽注入。这样不仅减少了重复代码,也统一了弹窗的交互规范(动画时长、遮罩透明度、关闭手势等),为后续的体验优化提供了单一修改入口。整体而言,本项目虽然是一个原型级实现,但其在架构分层、状态管理、样式系统、复用设计等方面展现的工程意识,对于鸿蒙 ArkTS 应用的开发实践具有良好的参考价值。
更多推荐



所有评论(0)