一、技术前言:HarmonyOS ArkUI 框架与 Speech Kit AI 字幕能力在播客电台场景的技术交汇

在这里插入图片描述

HarmonyOS(鸿蒙操作系统)作为华为自主研发的分布式操作系统,其应用层开发框架 ArkUI 提供了一套全新的声明式 UI 编程范式。ArkUI 基于 ArkTS 语言——这是 TypeScript 的超集,在保持 TypeScript 类型安全优势的同时,增加了 @Entry、@Component、@State、@Builder、@Observed 等装饰器,用于声明组件入口、组件结构、响应式状态、构建器函数和可观察数据模型。开发者只需描述界面的"是什么"状态,框架便会自动管理界面与状态的同步更新,极大降低了复杂 UI 的维护成本。在播客电台这类信息密集型应用中,ArkUI 的声明式模型让开发者可以专注于业务逻辑和数据流的组织,而无需手动管理视图刷新的时机和范围——当用户切换 Tab、选择字幕语言、修改字体颜色时,框架会自动追踪状态依赖关系并精准触发对应 UI 片段的重渲染。

在这里插入图片描述
Speech Kit 是 HarmonyOS 提供的语音与字幕服务能力集,其中 AICaptionComponent 是 HarmonyOS 6.1.1 版本重点增强的 AI 字幕组件。该组件能够实时接收音频流数据,通过端侧 AI 引擎进行语音识别和机器翻译,将语音内容转化为可视化字幕并渲染在组件区域内。在 HarmonyOS 6.1.1 之前,AI 字幕组件的功能相对基础,仅支持简单的语音转文字展示。而 6.1.1 版本为 AICaptionOptions 接口新增了四大关键字段——sourceLanguage(源语言)、targetLanguage(目标语言)、fontSize(字体大小)、fontColor(字体颜色),赋予了开发者对字幕语言方向和视觉样式的完整控制能力。这意味着应用不再局限于"中文语音转中文字幕"的单一模式,而是可以灵活配置"英文播客转中英双语字幕""中文播客转纯文字纪要"等多种场景化字幕方案。

在这里插入图片描述
sourceLanguage 字段用于声明输入音频流的源语言,取值范围为 'zh'(中文)和 'en'(英文)。这个字段直接影响 AI 引擎的语音识别模型选择——当设置为 'zh' 时,引擎加载中文声学模型和语言模型;设置为 'en' 时则切换为英文模型。targetLanguage 字段用于声明字幕输出的目标语言,取值范围为 'zh''en''zh-en'(中英双语)。值得注意的是,当 sourceLanguage 为 'zh'(中文源)时,targetLanguage 被锁定为 'zh',因为中文转中文不存在翻译方向;只有当 sourceLanguage 为 'en'(英文源)时,targetLanguage 才可在三种取值中自由选择,实现"英文转中文"“英文转英文”"英文转中英双语"三种字幕输出模式。fontSize 字段类型为 AICaptionFontSize 枚举,提供 SMALL(小号)、NORMAL(标准)、BIG(大号)、LARGE(超大)四档选择,满足不同视力和场景需求。fontColor 字段类型为 ResourceColor,允许开发者传入任意颜色值来定制字幕文字颜色,从经典白到暖阳黄、薄荷绿、云朵蓝、樱花粉等个性化预设。

在这里插入图片描述
播客电台是近年来内容消费领域增长最快的行业之一。与音乐流媒体不同,播客以长音频对话、访谈、叙事为核心形态,用户场景集中在通勤、家务、运动等"耳朵空闲、眼睛忙碌"的时段。这带来了一个独特的产品挑战:当用户在嘈杂的地铁上无法佩戴耳机时,或者当用户希望对英文播客进行精听学习时,传统的纯音频播放体验远远不够。AI 字幕技术的引入恰好填补了这一空白——它将"听播客"升级为"看播客",让音频内容在视觉维度也可消费。本应用"声波FM·播客电台平台"正是围绕这一核心洞察设计的,它以 Speech Kit 的 AICaptionComponent 为技术中枢,将 AI 字幕能力深度融入播客电台的完整产品体验中。

在这里插入图片描述
本应用的业务场景设计紧密围绕"播客内容的发现、订阅、字幕增强、个人管理"这一用户旅程展开。在电台 Tab,用户通过频道分类宫格浏览深夜谈天、科技早知道、人文漫游记等八大播客频道,通过热门节目横排大卡查看本周热门榜单,通过"正在直播"横条进入实时直播节目。在订阅 Tab,用户管理自己的订阅节目清单,支持新建订阅分组、编辑订阅信息、退订节目,并通过月度收听时长柱状图可视化自己的收听习惯。在 AI 字幕 Tab,这是本应用的技术核心页面,用户可以实时预览 AICaptionComponent 组件效果,通过语言设置卡选择源语言和目标语言组合,通过外观设置卡调整字体大小和字体颜色,通过实时代码预览卡查看当前 AICaptionOptions 的完整结构,通过字幕场景推荐列表一键套用预设的语言组合方案。在我的 Tab,用户信息以声波橙渐变大卡呈现,配以收藏节目数、订阅频道数、收听小时数三格统计数据,下方排列离线节目、收听历史、我的收藏、AI 字幕偏好等功能清单行。

在这里插入图片描述
从设计理念层面看,本应用遵循了"暖色系浅色主题"“四 Tab 差异化布局”“呼吸动画全局贯穿"三大设计原则。暖色系浅色主题意味着全局以暖米白(#FAF6F0)为背景、声波橙(#E8833A)为主强调色、原木棕(#A07850)为辅助色,营造温暖舒适的播客电台氛围——与冷色调科技感不同,暖色调更符合"深夜谈天”"情感疗愈"等播客内容调性。四 Tab 差异化布局意味着每个 Tab 页面的布局结构完全不同——电台是宫格+大卡、订阅是清单行+柱状图、AI 字幕是五区块特性页、我的是渐变大卡+功能行——避免四个 Tab 千篇一律的同质化设计。呼吸动画全局贯穿则通过一个每秒翻转的 breath 布尔状态,统一驱动头部 Banner 图标的透明度脉动、频道宫格图标的闪烁、直播红点的明暗、柱状图柱高的 ±5% 波动,让整个应用拥有"活体感"而非静态冰冷感。

在这里插入图片描述
在技术架构层面,本应用采用了"接口定义-常量声明-辅助函数-数据模型-组件主体"的分层组织模式。ColorPalette 接口集中声明所有颜色字段,COLORS 常量提供浅色暖色系色板实例,确保主题一致性。TabMeta、LangOption、SizeOption 三个接口分别定义 Tab 元数据、语言选项、字号选项的数据结构。辅助函数群(catColor、sizeName、langName、colorName)提供了分类颜色映射、枚举名转换、语言码翻译、颜色名翻译等工具能力。@Observed 装饰的 ChannelItem、ProgramItem、CaptionScene、UserStat 四个数据模型类配合 Mock 数据数组,模拟了真实的数据层。组件主体 Page1113 通过二十余个 @State 变量管理响应式状态,通过 buildCaptionOptions、switchSourceLang、feedDemoAudio 等方法封装核心业务逻辑,通过十余个 @Builder 函数按 UI 区域划分构建逻辑,形成了清晰可维护的工程结构。

二、整体架构流程图

弹窗系统

四大 Tab 页面

核心方法群

状态管理 State

生命周期管理

应用入口

@Entry @Component
声波FM 主页面

aboutToAppear()
启动呼吸动画定时器 1000ms

aboutToDisappear()
清理定时器

currentTab / breath / timer
Tab索引 + 呼吸开关 + 定时器句柄

cateIdx
头部分类 chips 选中索引

addModal / editModal / delModal
三态弹窗开关

editIdx / delIdx / formName / formNote / editName / editNote
表单与索引状态

channelList / programList / sceneList / statList
四大数据列表

captionShown / srcLang / tgtLang / captionSize / captionColor
AI字幕六大状态

captionController / captionReady / captionErrMsg / captionFed
字幕控制器与运行状态

buildCaptionOptions()
组装 AICaptionOptions

switchSourceLang()
切换源语言联动目标语言

feedDemoAudio()
生成 PCM 块写入音频流

openEditSub / saveSub / updateSub / delSub
订阅 CRUD

电台 Tab
频道宫格 + 热门大卡 + 直播横条

订阅 Tab
清单行 + 月度柱状图

AI字幕 Tab
预览 + 语言 + 外观 + 代码 + 场景

我的 Tab
会员渐变大卡 + 功能清单行

panelAdd
新建订阅分组

panelEdit
编辑订阅

panelDel
退订确认

modalOverlay
全屏遮罩

三、模块导入与工程依赖

3.1 Speech Kit 与基础服务 Kit 导入

import { AICaptionComponent, AudioData, AICaptionOptions, AICaptionController, AICaptionFontSize } from '@kit.SpeechKit';
import { BusinessError } from '@kit.BasicServicesKit';

这段导入语句是整个应用功能体系的基础,它从两个 Kit 模块中引入了 AI 字幕服务所需的全部类型。

第一行从 @kit.SpeechKit 导入了五个关键符号。AICaptionComponent 是 AI 字幕的 UI 组件,它是一个可在页面中直接使用的声明式组件,接收 isShown(显示状态)、controller(控制器实例)、options(配置选项)三个参数。AudioData 是音频数据的封装接口,包含 data 字段(Uint8Array 类型),用于向字幕控制器写入 PCM 音频流。AICaptionOptions 是字幕配置选项接口,HarmonyOS 6.1.1 为其新增了 sourceLanguage、targetLanguage、fontSize、fontColor 四大字段。AICaptionController 是字幕控制器类,提供 writeAudio 方法用于实时写入音频数据块。AICaptionFontSize 是字号枚举,包含 SMALL、NORMAL、BIG、LARGE 四个值。

第二行从 @kit.BasicServicesKit 导入了 BusinessError 类型。这是 HarmonyOS 异步 API 统一的错误类型,包含 code(错误码)和 message(错误描述)两个字段。在本应用中,它被用于 AICaptionOptions 的 onError 回调参数类型——当字幕服务发生异常时(如音频格式不支持、AI 引擎初始化失败等),回调函数会收到一个 BusinessError 对象,应用据此展示错误信息并采取恢复策略。

3.2 Kit 模块化设计理念

HarmonyOS 采用 Kit 模块化设计哲学,每个 Kit 封装了一组相关领域的系统能力。@kit.SpeechKit 封装了语音识别、语音合成、AI 字幕等语音相关能力;@kit.BasicServicesKit 封装了错误处理、通用工具等基础服务。应用按需导入所需 Kit,不会引入冗余依赖,这既减小了应用体积,也保证了类型安全的精确性。在本应用中,Speech Kit 的 AI 字幕能力是技术核心,而 BasicServicesKit 的 BusinessError 则为错误处理提供了标准化的类型约束。

四、颜色系统与主题设计

4.1 主题色板接口定义

interface ColorPalette {
  bg: string;
  card: string;
  chip: string;
  title: string;
  sub: string;
  text3: string;
  orange: string;
  orangeD: string;
  brown: string;
  red: string;
  green: string;
  blue: string;
  line: string;
  tabOn: string;
  mask: string;
}

ColorPalette 接口集中声明了页面所有颜色字段,这是一种"主题先行"的设计模式。通过接口定义颜色字段集合,开发者可以确保实现该接口的色板常量不会遗漏任何颜色字段——TypeScript 编译器会在编译阶段检查接口实现的完整性。

接口中的字段涵盖了应用的全部视觉层次。bg 是页面背景色,card 是卡片背景色,chip 是胶囊/标签/输入框背景色,这三者构成了从页面到卡片到控件的三层背景体系。titlesubtext3 是三级文字颜色——主标题、副标题、辅助文字,形成了清晰的文字层次梯度。orangeorangeD 是声波橙的亮暗两档,brown 是原木棕,这三者是主题强调色。redgreenblue 是功能色——红色用于退订/错误/直播,绿色用于已订阅/成功,蓝色用于科技分类标签。line 是分割线颜色,tabOn 是 Tab 选中高亮色,mask 是弹窗遮罩半透明色。

4.2 浅色暖色系色板常量

const COLORS: ColorPalette = {
  bg: '#FAF6F0',
  card: '#FFFFFF',
  chip: '#F3E9DD',
  title: '#3B2E22',
  sub: '#8A7460',
  text3: '#B5A18C',
  orange: '#E8833A',
  orangeD: '#C96A24',
  brown: '#A07850',
  red: '#E85D5D',
  green: '#5BAD6E',
  blue: '#5B8FD9',
  line: '#EDE2D3',
  tabOn: '#E8833A',
  mask: 'rgba(59,46,34,0.5)'
};

COLORS 常量是 ColorPalette 接口的具体实现,定义了一套"暖米白 + 声波橙 + 原木棕"的浅色暖色系主题。背景色 #FAF6F0 是一种带有微弱暖色调的米白色,相比纯白色 #FFFFFF 减少了刺眼感,更适合长时间阅读播客文案。卡片色 #FFFFFF 是纯白,与背景形成微妙对比,使卡片有"浮出"感。chip 色 #F3E9DD 是淡暖米黄,用于胶囊标签和输入框背景,在暖色系中自然过渡。

文字层次方面,主标题 #3B2E22 是深原木棕,副标题 #8A7460 是中暖棕,辅助文字 #B5A18C 是浅暖棕,三级颜色明度递减但色相统一在棕色系,保证了文字层次的可辨识性和主题一致性。声波橙 #E8833A 是鲜艳的橙色,作为主强调色用于按钮、选中态、渐变 Banner;深声波橙 #C96A24 是其深色版,用于渐变起点和深色按钮。原木棕 #A07850 是辅助色,用于次要按钮和场景说明文字。功能色方面,红色 #E85D5D 用于退订操作和错误提示,绿色 #5BAD6E 用于已订阅状态,蓝色 #5B8FD9 用于科技分类标签。遮罩色 rgba(59,46,34,0.5) 使用了 title 色的半透明形式,确保遮罩与主题色协调。

五、常量定义与数据配置

5.1 Tab 元数据接口与导航常量

interface TabMeta {
  icon: string;
  label: string;
}

const TAB_LIST: TabMeta[] = [
  { icon: '📻', label: '电台' },
  { icon: '📌', label: '订阅' },
  { icon: '🗣', label: 'AI字幕' },
  { icon: '👤', label: '我的' }
];

TabMeta 接口定义了底部导航 Tab 的元数据结构,包含 icon(emoji 图标)和 label(标签文字)两个字段。TAB_LIST 常量数组定义了四个 Tab 的具体配置——电台(📻)、订阅(📌)、AI 字幕(🗣)、我的(👤)。这四个 Tab 覆盖了播客电台应用的核心功能维度:内容发现(电台)、内容管理(订阅)、能力增强(AI 字幕)、个人中心(我的)。

使用 emoji 作为 Tab 图标是一种轻量化的视觉方案——无需导入图片资源,直接使用 Unicode 字符即可渲染。每个 Tab 的图标与其标签语义高度匹配:📻 收音机代表电台内容,📌 图钉代表订阅收藏,🗣 说话代表 AI 字幕语音服务,👤 人形代表个人中心。这种设计使得 Tab 栏在不依赖任何图标资源的情况下即可呈现清晰的功能含义。

5.2 头部分类标签常量

const CATE_TAGS: string[] = ['推荐', '科技', '人文', '商业', '情感', '喜剧', '新闻', '纪实'];

CATE_TAGS 定义了头部横滑分类 chips 的文案列表,包含八个播客内容分类。这八个分类覆盖了播客内容的主要类型——推荐是运营精选,科技/人文/商业是知识型内容,情感/喜剧是娱乐型内容,新闻/纪实是资讯型内容。用户通过横滑选择分类标签后,cateIdx 状态更新,选中标签变为声波橙文字+暖米黄背景,未选中标签为棕色文字+白色背景,形成清晰的选中态对比。

5.3 字幕语言选项常量

interface LangOption {
  code: string;
  name: string;
}

const SRC_LANGS: LangOption[] = [
  { code: 'zh', name: '中文' },
  { code: 'en', name: '英文' }
];

const TGT_LANGS_EN: LangOption[] = [
  { code: 'zh', name: '中文' },
  { code: 'en', name: '英文' },
  { code: 'zh-en', name: '中英双语' }
];

LangOption 接口定义了语言选项的数据结构,包含 code(语言码)和 name(展示名)两个字段。SRC_LANGS 是源语言选项列表,对应 AICaptionOptions.sourceLanguage 字段的取值范围——仅支持 'zh'(中文)和 'en'(英文)两种。TGT_LANGS_EN 是英文源时的目标语言选项列表,对应 AICaptionOptions.targetLanguage 字段的取值范围——支持 'zh'(中文)、'en'(英文)和 'zh-en'(中英双语)三种。

这里有一个重要的业务规则:当源语言为中文时,目标语言锁定为中文,不存在翻译方向;只有当源语言为英文时,目标语言才可选择中文、英文或中英双语。因此代码中只定义了 TGT_LANGS_EN(英文源时的目标语言选项),而没有定义 TGT_LANGS_ZH——因为中文源时目标语言只有一个选项,无需提供选择列表。这种设计避免了无效选项的展示,体现了数据驱动的精确性。

5.4 字幕字号与颜色预设常量

interface SizeOption {
  size: AICaptionFontSize;
  name: string;
}

const SIZE_OPTIONS: SizeOption[] = [
  { size: AICaptionFontSize.SMALL, name: '小号' },
  { size: AICaptionFontSize.NORMAL, name: '标准' },
  { size: AICaptionFontSize.BIG, name: '大号' },
  { size: AICaptionFontSize.LARGE, name: '超大' }
];

const CAPTION_FONT_COLORS: string[] = ['#FFFFFF', '#FFE9B0', '#9CE8B5', '#9CD0FF', '#FFB3C1'];

SizeOption 接口定义了字号选项的数据结构,包含 size(AICaptionFontSize 枚举值)和 name(展示名)两个字段。SIZE_OPTIONS 数组列出了 AICaptionFontSize 枚举的全部四档——SMALL(小号)、NORMAL(标准)、BIG(大号)、LARGE(超大),对应 AICaptionOptions.fontSize 字段的取值范围。用户在 AI 字幕 Tab 的外观设置卡中选择字号后,captionSize 状态更新,AICaptionComponent 的字幕文字大小实时变化。

CAPTION_FONT_COLORS 是字幕字体颜色预设数组,包含五种颜色——经典白(#FFFFFF)、暖阳黄(#FFE9B0)、薄荷绿(#9CE8B5)、云朵蓝(#9CD0FF)、樱花粉(#FFB3C1)。这些颜色都是高明度低饱和度的浅色,确保在深色字幕组件背景上具有良好可读性。用户通过五个圆形色块选择颜色后,captionColor 状态更新,AICaptionComponent 的字幕文字颜色实时变化。fontColor 字段的类型是 ResourceColor,它可以接受 string 类型的十六进制颜色值,也可以接受 Resource 资源引用,本应用使用了 string 类型直接传值。

5.5 月度收听柱状图数据常量

const MONTH_IDX: number[] = [0, 1, 2, 3, 4, 5];
const MONTH_LABELS: string[] = ['3月', '4月', '5月', '6月', '7月', '8月'];
const MONTH_HOURS: number[] = [62, 74, 68, 88, 96, 107];
const MONTH_MAX: number = 120;

这四个常量共同定义了订阅 Tab 月度收听时长柱状图的数据。MONTH_IDX 是月份索引数组,用于 ForEach 遍历。MONTH_LABELS 是月份名称,对应 X 轴标签。MONTH_HOURS 是每月收听时长(小时),对应柱状图柱高——数据从 3 月的 62 小时递增到 8 月的 107 小时,呈现明显的增长趋势。MONTH_MAX 是柱状图最大值 120 小时,用于计算柱高百分比——柱高 = MONTH_HOURS[i] / MONTH_MAX * 110(110 为最大柱高像素),确保最大数据 107 小时的柱高为 107/120*110 ≈ 98px,不会超出图表区域。

六、辅助函数群

6.1 分类颜色映射函数

function catColor(cat: string): string {
  if (cat === '科技') { return COLORS.blue; }
  if (cat === '人文') { return COLORS.brown; }
  if (cat === '商业') { return COLORS.green; }
  if (cat === '情感') { return COLORS.red; }
  return COLORS.orange;
}

catColor 函数将播客分类名映射为主题色板中的功能色。科技分类映射为蓝色,象征科技感与理性;人文分类映射为原木棕,象征文化底蕴与历史感;商业分类映射为绿色,象征增长与财富;情感分类映射为红色,象征热情与温度;其余分类(喜剧、新闻、纪实、推荐)映射为声波橙,作为默认色。这个函数在电台 Tab 的热门节目卡片时长胶囊和订阅 Tab 的分类色条中被调用,为不同分类的节目提供即时的视觉区分。通过颜色编码分类信息,用户在浏览节目列表时可以无需阅读文字就快速识别节目类型。

6.2 字号枚举转展示名函数

function sizeName(size: AICaptionFontSize): string {
  if (size === AICaptionFontSize.SMALL) { return 'SMALL'; }
  if (size === AICaptionFontSize.BIG) { return 'BIG'; }
  if (size === AICaptionFontSize.LARGE) { return 'LARGE'; }
  return 'NORMAL';
}

sizeName 函数将 AICaptionFontSize 枚举值转换为对应的英文枚举名字符串。这个函数专门用于 AI 字幕 Tab 的"options 实时代码预览卡"——在该卡片中,fontSize 字段的值不是显示为"小号""标准"等中文展示名,而是显示为 SMALLNORMALBIGLARGE 等英文枚举名,以模拟代码编辑器中的真实枚举值显示效果。通过条件判断逐一匹配枚举值并返回对应字符串,最后的 return 语句作为默认分支返回 'NORMAL',确保所有枚举值都有对应的字符串输出。

6.3 语言码与颜色名转换函数

function langName(code: string): string {
  if (code === 'zh') { return '中文'; }
  if (code === 'en') { return '英文'; }
  return '中英双语';
}

function colorName(c: string): string {
  if (c === CAPTION_FONT_COLORS[0]) { return '经典白'; }
  if (c === CAPTION_FONT_COLORS[1]) { return '暖阳黄'; }
  if (c === CAPTION_FONT_COLORS[2]) { return '薄荷绿'; }
  if (c === CAPTION_FONT_COLORS[3]) { return '云朵蓝'; }
  return '樱花粉';
}

langName 函数将语言码('zh''en''zh-en')转换为中文展示名,用于 AI 字幕 Tab 语言设置卡底部的"当前组合"摘要行。colorName 函数将颜色十六进制值转换为中文名,用于外观设置卡的当前选中颜色说明行——通过比较输入颜色值与 CAPTION_FONT_COLORS 数组元素来匹配对应的中文名。两个函数都采用条件判断+默认返回的模式,确保所有输入值都有对应的输出。这些转换函数的存在使得 UI 展示层可以始终使用用户友好的中文名称,而数据层则使用机器友好的代码值,两者通过函数桥接。

七、数据模型与 Mock 数据

7.1 频道条目模型与数据

@Observed export class ChannelItem {
  icon: string;
  name: string;
  desc: string;
  count: string;

  constructor(icon: string, name: string, desc: string, count: string) {
    this.icon = icon;
    this.name = name;
    this.desc = desc;
    this.count = count;
  }
}

const CHANNEL_LIST: Array<ChannelItem> = [
  new ChannelItem('🌙', '深夜谈天', '凌晨两点的城市树洞', '128 期'),
  new ChannelItem('🚀', '科技早知道', '前沿科技五分钟速览', '362 期'),
  new ChannelItem('📖', '人文漫游记', '城市故事与旧时光', '214 期'),
  new ChannelItem('💼', '商业观察室', '商业案例深度拆解', '186 期'),
  new ChannelItem('💬', '情感疗愈所', '倾听都市心事留言', '243 期'),
  new ChannelItem('🎭', '喜剧开放麦', '下班后的快乐补给站', '157 期'),
  new ChannelItem('📰', '新闻七点半', '每日要闻精编速递', '498 期'),
  new ChannelItem('🔍', '纪实现场', '真实事件深度调查', '132 期')
];

ChannelItem 是频道条目数据模型,使用 @Observed 装饰器声明,包含 icon(emoji 图标)、name(频道名)、desc(一句话简介)、count(节目数量文本)四个字段。@Observed 装饰器使得该类的实例属性变更能够被 ArkUI 框架追踪——当某个 ChannelItem 的属性发生变化时,依赖该属性的 UI 组件会自动刷新。构造函数接受四个参数并赋值给对应属性。

CHANNEL_LIST 是八条频道 Mock 数据,覆盖了深夜谈天、科技早知道、人文漫游记、商业观察室、情感疗愈所、喜剧开放麦、新闻七点半、纪实现场八个频道。每个频道的 emoji 图标与其名称语义高度匹配——🌙 月亮对应深夜谈天,🚀 火箭对应科技早知道,📖 书本对应人文漫游记等。简介文案采用拟人化和场景化的写作风格——“凌晨两点的城市树洞”“前沿科技五分钟速览”“下班后的快乐补给站"等,传达了每个频道的独特调性。节目数量文本使用"期"作为单位,如"128 期”“362 期”,让用户直观了解频道的内容积累程度。

7.2 节目条目模型与数据

@Observed export class ProgramItem {
  title: string;
  host: string;
  dur: string;
  plays: string;
  cat: string;

  constructor(title: string, host: string, dur: string, plays: string, cat: string) {
    this.title = title;
    this.host = host;
    this.dur = dur;
    this.plays = plays;
    this.cat = cat;
  }
}

const PROGRAM_LIST: Array<ProgramItem> = [
  new ProgramItem('深夜谈天 第128期:城市孤独症', '阿岳', '58 分钟', '32万', '情感'),
  new ProgramItem('科技早知道:AI 手机元年来了', '林拾', '42 分钟', '46万', '科技'),
  new ProgramItem('人文漫游记:胡同里的猫与老人', '苏晚', '66 分钟', '21万', '人文'),
  new ProgramItem('商业观察室:街角咖啡店生意经', '老周', '74 分钟', '28万', '商业'),
  new ProgramItem('喜剧开放麦:地铁奇遇合集', '大鹏', '35 分钟', '52万', '喜剧'),
  new ProgramItem('新闻七点半:一周国际观察', '文静', '28 分钟', '39万', '新闻'),
  new ProgramItem('纪实现场:深夜外卖骑手实录', '陈默', '82 分钟', '18万', '纪实'),
  new ProgramItem('深夜谈天 特别篇:给十年后的信', '阿岳', '96 分钟', '25万', '情感')
];

ProgramItem 是节目条目数据模型,同样使用 @Observed 装饰,包含 title(节目标题)、host(主播名)、dur(时长)、plays(播放量文本)、cat(分类)五个字段。这个模型同时服务于电台 Tab 的热门节目大卡和订阅 Tab 的订阅清单行——一份数据两处复用,体现了数据模型的通用性。

PROGRAM_LIST 是八条节目 Mock 数据,每条数据包含完整的节目信息。节目标题采用"频道名+期数/主题"的命名格式,如"深夜谈天 第128期:城市孤独症"“科技早知道:AI 手机元年来了”。主播名采用拟人化的中文昵称——阿岳、林拾、苏晚、老周、大鹏、文静、陈默,每个名字都有独特的人格感。时长从 28 分钟到 96 分钟不等,覆盖了短资讯和长访谈两种播客形态。播放量使用"万"为单位,从 18 万到 52 万。分类标签与 catColor 函数的映射一一对应,确保每个节目在 UI 中都有对应的分类色。

7.3 字幕场景模型与数据

@Observed export class CaptionScene {
  scene: string;
  desc: string;
  src: string;
  tgt: string;

  constructor(scene: string, desc: string, src: string, tgt: string) {
    this.scene = scene;
    this.desc = desc;
    this.src = src;
    this.tgt = src;
    this.tgt = tgt;
  }
}

const SCENE_LIST: Array<CaptionScene> = [
  new CaptionScene('英文播客精听', '高质量访谈配双语字幕', 'en', 'zh-en'),
  new CaptionScene('科技快讯速览', '英文科技资讯秒懂要点', 'en', 'zh'),
  new CaptionScene('中文播客纪要', '谈话节目实时转文字', 'zh', 'zh'),
  new CaptionScene('双语访谈学习', '原文译文对照精听', 'en', 'zh-en'),
  new CaptionScene('通勤无耳机模式', '地铁上也能"看"播客', 'zh', 'zh')
];

CaptionScene 是字幕场景条目数据模型,包含 scene(场景名)、desc(场景说明)、src(推荐源语言)、tgt(推荐目标语言)四个字段。这个模型专门服务于 AI 字幕 Tab 的字幕场景推荐列表——每个场景预设了一组语言组合方案,用户点击"套用"即可一键应用。

SCENE_LIST 是五条场景 Mock 数据,每条数据代表一种典型的播客字幕使用场景。"英文播客精听"场景推荐英文源+中英双语目标,适合高质量英文访谈节目的精听学习;"科技快讯速览"场景推荐英文源+中文目标,适合快速理解英文科技资讯要点;"中文播客纪要"场景推荐中文源+中文目标,适合将谈话节目实时转为文字纪要;"双语访谈学习"场景推荐英文源+中英双语目标,适合原文译文对照学习;"通勤无耳机模式"场景推荐中文源+中文目标,解决地铁上无法戴耳机时"看"播客的需求。这五个场景覆盖了播客字幕的核心使用场景,每个场景的语言组合都经过精心设计,避免了用户面对语言选项时的选择困难。

7.4 用户功能清单模型与数据

@Observed export class UserStat {
  icon: string;
  label: string;
  value: string;
  arrow: boolean;

  constructor(icon: string, label: string, value: string, arrow: boolean) {
    this.icon = icon;
    this.label = label;
    this.value = value;
    this.arrow = arrow;
  }
}

const STAT_LIST: Array<UserStat> = [
  new UserStat('⬇', '离线节目', '24 期 · 已下载', true),
  new UserStat('🕘', '收听历史', '326 期', true),
  new UserStat('⭐', '我的收藏', '158 期', true),
  new UserStat('📻', '我的投稿', '审核中 1 期', true),
  new UserStat('🗣', 'AI 字幕偏好', '源 zh · 目标 zh', true),
  new UserStat('🎁', '声波会员特权', '2027-03-18 到期', true),
  new UserStat('🌙', '定时关闭', '未开启', true),
  new UserStat('⚙', '播放与倍速设置', '智能倍速 1.2x', true)
];

UserStat 是我的页功能清单条目数据模型,包含 icon(功能图标)、label(功能名)、value(状态/数值文本)、arrow(是否显示右箭头)四个字段。arrow 字段为布尔类型,控制是否在行尾显示 箭头——所有条目都设置为 true,表示每项都可点击进入详情页。

STAT_LIST 是八条功能清单 Mock 数据,覆盖了离线节目、收听历史、我的收藏、我的投稿、AI 字幕偏好、声波会员特权、定时关闭、播放与倍速设置八项功能。值得注意的是"AI 字幕偏好"条目的 value 文本为"源 zh · 目标 zh",这与 AI 字幕 Tab 中的默认语言组合保持一致,形成了跨 Tab 的数据联动暗示。每条数据的 value 文本既包含数值也包含状态描述,如"24 期 · 已下载"同时传达了数量和状态信息,"审核中 1 期"传达了投稿审核进度。

八、组件主体:状态管理

8.1 组件声明与基础状态

@Entry
@Component
struct Page1113 {
  @State currentTab: number = 0;
  @State breath: boolean = false;
  @State timer: number = -1;
  @State cateIdx: number = 0;
  @State addModal: boolean = false;
  @State editModal: boolean = false;
  @State delModal: boolean = false;
  @State editIdx: number = 0;
  @State delIdx: number = 0;

Page1113 是应用的主页面组件,使用 @Entry 装饰器标记为页面入口(可用于路由跳转),使用 @Component 装饰器声明为自定义组件。组件内部通过 @State 装饰器声明了一系列响应式状态变量。

currentTab 是当前选中 Tab 的索引(0=电台、1=订阅、2=AI 字幕、3=我的),默认值为 0,即应用启动后首先展示电台 Tab。breath 是呼吸动画的布尔开关,每秒在 true/false 之间翻转,驱动全局多个元素的透明度和尺寸微变。timer 是呼吸动画定时器的句柄,初始值 -1 表示未启动。cateIdx 是头部分类 chips 的选中索引,默认 0 即"推荐"。addModaleditModaldelModal 是三个弹窗的开关布尔值,分别控制新建订阅分组、编辑订阅、退订确认弹窗的显示隐藏。editIdxdelIdx 是当前正在编辑或退订的订阅索引,用于在弹窗操作中定位目标节目。

8.2 数据列表状态

  @State channelList: Array<ChannelItem> = CHANNEL_LIST;
  @State programList: Array<ProgramItem> = PROGRAM_LIST;
  @State sceneList: Array<CaptionScene> = SCENE_LIST;
  @State statList: Array<UserStat> = STAT_LIST;

这四个 @State 变量分别持有频道列表、订阅节目列表、字幕场景列表、功能清单列表的引用。它们初始化为前面定义的 Mock 数据常量。由于这些变量是 @State 装饰的,当数组引用发生变化时(如 push 新元素、splice 删除元素、slice 生成新数组),框架会自动触发 ForEach 列表的重新渲染。同时,由于数据元素类(ChannelItem、ProgramItem、CaptionScene、UserStat)都使用了 @Observed 装饰器,当某个元素的属性发生变化时(如修改节目标题),框架也会追踪到并触发对应列表项的刷新。

8.3 表单状态

  @State formName: string = '';
  @State formNote: string = '';
  @State editName: string = '';
  @State editNote: string = '';

这四个 @State 变量是弹窗表单的输入字段。formNameformNote 是新建订阅分组弹窗的分组名称和备注说明输入框值。editNameeditNote 是编辑订阅弹窗的节目名称和分类标签输入框值。这些变量通过 TextInput 组件的 onChange 回调实时更新,当用户点击保存/创建按钮时,这些值会被读取并应用到数据列表中。

8.4 AI 字幕核心状态

  private captionController: AICaptionController = new AICaptionController();
  @State captionShown: boolean = false;
  @State srcLang: string = 'zh';
  @State tgtLang: string = 'zh';
  @State captionSize: AICaptionFontSize = AICaptionFontSize.NORMAL;
  @State captionColor: string = CAPTION_FONT_COLORS[0];
  @State captionReady: boolean = false;
  @State captionErrMsg: string = '';
  @State captionFed: number = 0;

这是本应用技术核心的 AI 字幕状态群,共九个变量。

captionController 是 AICaptionController 实例,使用 private 修饰符声明(非 @State,因为它不需要触发 UI 刷新,只需被 AICaptionComponent 引用)。它通过 new AICaptionController() 创建,提供 writeAudio 方法用于向字幕服务写入音频流数据。

captionShown 是字幕显示状态,通过 @Link 双向绑定到 AICaptionComponent 的 isShown 参数。当用户点击"开启字幕"按钮时,captionShown 翻转为 true,AICaptionComponent 显示;点击"隐藏字幕"时翻转为 false,组件隐藏。

srcLangtgtLang 是 AICaptionOptions 的 sourceLanguage 和 targetLanguage 字段值。srcLang 默认为 'zh'(中文源),tgtLang 默认为 'zh'(中文目标)。当用户切换源语言为英文时,switchSourceLang 方法会联动将 tgtLang 设置为 'zh-en'(中英双语)。

captionSize 是 AICaptionOptions 的 fontSize 字段值,类型为 AICaptionFontSize 枚举,默认为 NORMAL(标准)。captionColor 是 AICaptionOptions 的 fontColor 字段值,默认为 CAPTION_FONT_COLORS[0] 即经典白 #FFFFFF。

captionReady 是字幕服务就绪状态,在 AICaptionOptions 的 onPrepared 回调中被设置为 true,表示 AI 引擎已初始化完成、可以接收音频数据。captionErrMsg 是错误信息文本,在 onError 回调中被设置,非空时在预览卡底部显示错误提示。captionFed 是已写入音频块计数,每次成功调用 writeAudio 后递增,用于展示字幕服务的活跃程度。

九、组件主体:核心方法

9.1 AICaptionOptions 组装方法

buildCaptionOptions(): AICaptionOptions {
  const opts: AICaptionOptions = {
    initialOpacity: 1,
    sourceLanguage: this.srcLang,
    targetLanguage: this.tgtLang,
    fontSize: this.captionSize,
    fontColor: this.captionColor,
    onPrepared: () => {
      this.captionReady = true;
      this.captionErrMsg = '';
    },
    onError: (error: BusinessError) => {
      this.captionErrMsg = '字幕服务异常 ' + error.code + ':' + error.message;
    }
  };
  return opts;
}

buildCaptionOptions 方法是 AI 字幕功能的核心方法——它将组件的多个 @State 变量组装为一个完整的 AICaptionOptions 对象,体现 HarmonyOS 6.1.1 新增的四大字段。

initialOpacity 设为 1,表示字幕组件初始不透明度为 100%。sourceLanguagethis.srcLang 的当前值('zh''en'),决定 AI 引擎使用中文还是英文语音识别模型。targetLanguagethis.tgtLang 的当前值('zh''en''zh-en'),决定字幕输出的目标语言和翻译方向。fontSizethis.captionSize 的当前枚举值(SMALL/NORMAL/BIG/LARGE),决定字幕文字大小。fontColorthis.captionColor 的当前颜色值,决定字幕文字颜色。

onPrepared 回调在 AI 引擎初始化完成时被调用,将 captionReady 设为 true 并清空错误信息。onError 回调在字幕服务发生异常时被调用,参数为 BusinessError 类型,回调内拼接错误码和错误描述存入 captionErrMsg。这两个回调构成了字幕服务的状态反馈通道——onPrepared 表示"就绪",onError 表示"异常"。

这个方法的关键设计在于:它不是一次性调用的,而是在 AICaptionComponent 的 options 参数中通过 this.buildCaptionOptions() 实时调用。这意味着每当 srcLangtgtLangcaptionSizecaptionColor 任一状态发生变化时,build 方法重新执行,buildCaptionOptions 重新被调用,生成新的 AICaptionOptions 对象传入 AICaptionComponent,字幕组件的配置实时更新——用户切换语言或字号后,字幕效果立即变化,无需重新初始化。

9.2 源语言切换联动方法

switchSourceLang(code: string) {
  this.srcLang = code;
  if (code === 'zh') {
    this.tgtLang = 'zh';
  } else {
    this.tgtLang = 'zh-en';
  }
}

switchSourceLang 方法处理源语言切换时的目标语言联动逻辑。当用户在语言设置卡中点击源语言选项时,此方法被调用。方法首先更新 srcLang 为新值,然后根据新值决定 tgtLang 的联动值。

如果切换为中文源(code === 'zh'),目标语言锁定为 'zh'——因为中文转中文不存在翻译方向,AI 引擎仅做语音识别不做翻译。如果切换为英文源(code === 'en'),目标语言默认设为 'zh-en'(中英双语)——这是英文播客最常见的字幕方案,原文译文同时展示,用户后续可根据需要在中文、英文、中英双语之间切换。

这种联动设计避免了无效的语言组合——用户不会看到"中文源+英文目标"这种不合理的选项。中文源时目标语言区域显示锁定提示行(🔒 图标+"中文源锁定中文"文案),英文源时才显示三选项按钮组。这种动态 UI 适配确保了界面始终展示合理且有效的操作选项。

9.3 演示音频写入方法

feedDemoAudio() {
  const block = new Uint8Array(640);
  for (let i = 0; i < 640; i += 2) {
    const t = (i / 2) / 16000;
    const v = Math.round(Math.sin(2 * Math.PI * 440 * t) * 6000);
    block[i] = v & 0xFF;
    block[i + 1] = (v >> 8) & 0xFF;
  }
  try {
    const audioData: AudioData = { data: block };
    this.captionController.writeAudio(audioData);
    this.captionFed++;
  } catch (e) {
    this.captionErrMsg = '音频写入失败';
  }
}

feedDemoAudio 方法生成一段演示 PCM 音频数据并写入字幕控制器,用于演示 AICaptionComponent 的音频流接收能力。

方法首先创建一个 640 字节的 Uint8Array 块。640 字节对应 320 个 16-bit 采样点(每采样 2 字节),在 16kHz 采样率下对应 20 毫秒的音频——这是语音处理的标准帧长。循环中以步长 2 遍历数组,每次处理一个采样点:计算时间戳 t = (i/2) / 16000(采样点索引除以采样率得到秒数),计算 440Hz 正弦波振幅值 v = sin(2π·440·t) * 6000(440Hz 是标准音 A4 的频率,6000 是振幅缩放因子),将 16-bit 有符号整数的小端字节拆分为两个字节存入数组——v & 0xFF 取低字节,(v >> 8) & 0xFF 取高字节。

生成的 PCM 块封装为 AudioData 对象 { data: block },通过 this.captionController.writeAudio(audioData) 写入字幕服务。成功时递增 captionFed 计数器,失败时设置错误信息。这个方法模拟了真实播客音频流的写入过程——在实际应用中,音频数据应来自麦克风录音或网络音频流,此处用正弦波演示仅用于功能验证。

9.4 订阅 CRUD 方法群

openEditSub(idx: number) {
  this.editIdx = idx;
  this.editName = this.programList[idx].title;
  this.editNote = this.programList[idx].cat;
  this.editModal = true;
}

openEditSub 方法打开编辑订阅弹窗。它接受目标索引参数,将其存入 editIdx,然后从 programList 数组中读取对应节目的标题和分类标签,回填到 editNameeditNote 表单字段中,最后设置 editModal 为 true 打开弹窗。这种"先回填再打开"的设计确保用户在弹窗中看到的是当前节目的现有数据,而非空表单。

saveSub() {
  const name = this.formName === '' ? '未命名节目' : this.formName;
  const note = this.formNote === '' ? '自建' : this.formNote;
  this.programList.push(new ProgramItem(name, '新主播', '时长待定', '0', note));
  this.formName = '';
  this.formNote = '';
  this.addModal = false;
}

saveSub 方法保存新建订阅分组。它对表单字段做空值兜底——名称为空时使用"未命名节目",备注为空时使用"自建"——确保新增数据不会出现空白字段。然后创建新的 ProgramItem 推入 programList 数组,清空表单字段,关闭弹窗。由于 programList@State 数组,push 操作触发数组变化,ForEach 列表自动追加新行。

updateSub() {
  if (this.editIdx >= 0 && this.editIdx < this.programList.length) {
    if (this.editName !== '') {
      this.programList[this.editIdx].title = this.editName;
    }
    if (this.editNote !== '') {
      this.programList[this.editIdx].cat = this.editNote;
    }
    this.programList = this.programList.slice();
  }
  this.editModal = false;
}

updateSub 方法保存编辑订阅。它先做索引边界检查(editIdx 在有效范围内),然后分别检查 editNameeditNote 是否非空——非空时修改对应属性。关键的一行是 this.programList = this.programList.slice()——通过 slice() 生成新数组引用替换原数组。这是因为直接修改数组元素的属性(而非数组引用)可能不会被 ArkUI 框架的 @State 追踪到,通过 slice() 创建新引用确保框架检测到变化并触发 ForEach 重新渲染。

delSub() {
  if (this.delIdx >= 0 && this.delIdx < this.programList.length) {
    this.programList.splice(this.delIdx, 1);
  }
  this.delModal = false;
}

delSub 方法执行退订操作。它检查 delIdx 边界有效性后,通过 splice(this.delIdx, 1) 从数组中删除指定索引的元素。splice 方法直接修改原数组并返回被删除的元素,由于 @State 对 splice 操作的追踪机制,列表会自动移除对应行。最后关闭退订确认弹窗。

9.5 生命周期方法

aboutToAppear() {
  this.timer = setInterval(() => {
    this.breath = !this.breath;
  }, 1000);
}

aboutToDisappear() {
  clearInterval(this.timer);
}

aboutToAppear 是组件即将出现时调用的生命周期钩子。它在组件创建后、build 方法执行前被调用。此处使用 setInterval 创建一个每 1000 毫秒执行一次的定时器,定时器回调将 breath 布尔值在 true 和 false 之间翻转。定时器句柄存入 this.timer 以便后续清理。

aboutToDisappear 是组件即将销毁时调用的生命周期钩子。它通过 clearInterval(this.timer) 清理定时器,防止组件销毁后定时器继续执行导致的内存泄漏和空引用错误。这种"启动-清理"对称的生命周期管理是前端组件的标准实践——任何在 aboutToAppear 中创建的定时器、监听器、订阅者都必须在 aboutToDisappear 中清理。

十、组件主体:页面主构建

10.1 build 方法与页面骨架

build() {
  Stack() {
    Column() {
      this.headerMain()
      Divider().strokeWidth(1).color(COLORS.line)
      Scroll() {
        Column() {
          if (this.currentTab === 0) {
            this.tabRadio()
          } else if (this.currentTab === 1) {
            this.tabSubs()
          } else if (this.currentTab === 2) {
            this.tabCaption()
          } else {
            this.tabMine()
          }
        }
        .padding({ left: 14, right: 14, top: 12, bottom: 12 })
      }
      .layoutWeight(1)
      .scrollBar(BarState.Off)
      this.tabBar()
    }
    .width('100%')
    .height('100%')

    if (this.addModal) {
      this.panelAdd(() => {
        this.addModal = false;
      })
    }
    if (this.editModal) {
      this.panelEdit(() => {
        this.editModal = false;
      })
    }
    if (this.delModal) {
      this.panelDel(() => {
        this.delModal = false;
      })
    }
  }
  .width('100%')
  .height('100%')
  .backgroundColor(COLORS.bg)
}

build 方法是组件的核心构建函数,使用 Stack 作为最外层容器,实现了"主内容层 + 弹窗层"的叠加架构。

Stack 的第一个子元素是 Column,它构成了页面的主内容层。Column 从上到下依次包含:headerMain()(头部 Banner+搜索条+分类 chips)、Divider(分割线)、Scroll(可滚动内容区)、tabBar()(底部导航栏)。Scroll 内部是一个 Column,根据 currentTab 的值条件渲染对应的 Tab 内容——0 渲染电台、1 渲染订阅、2 渲染 AI 字幕、3 渲染我的。Scroll 设置了 layoutWeight(1) 占据头部和底部之间的全部剩余空间,scrollBar(BarState.Off) 隐藏滚动条保持界面整洁。

Stack 的第二部分是三个条件弹窗——当 addModaleditModaldelModal 分别为 true 时,对应的弹窗面板渲染在 Stack 上层,覆盖在主内容之上。每个弹窗接收一个 onClose 回调函数,用于点击遮罩或取消按钮时关闭弹窗。这种"Stack 叠加+条件渲染"的弹窗架构是 ArkUI 实现自定义弹窗的标准模式——相比系统弹窗,它可以完全控制弹窗的样式、动画和交互行为。

整个 Stack 设置了 backgroundColor(COLORS.bg) 即暖米白背景色,确保所有区域的背景统一。

十一、头部 Builder:渐变 Banner 与搜索分类

11.1 渐变 Banner 区域

@Builder
headerMain() {
  Column({ space: 12 }) {
    Column({ space: 10 }) {
      Row() {
        Column({ space: 5 }) {
          Text('早上好,播客通勤人').fontSize(16).fontColor(COLORS.card).fontWeight(FontWeight.Bold)
          Text('声波晨间谈 · 今日已为你备好 3 期新节目')
            .fontSize(9).fontColor(COLORS.card).opacity(0.78)
            .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)

        Column() {
          Text('📻').fontSize(22).opacity(this.breath ? 1 : 0.6)
        }
        .width(44).height(44).borderRadius(22).backgroundColor(COLORS.orangeD)
        .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
      }
      .width('100%')

      Row({ space: 8 }) {
        Text('🎧 在听 12.6 万人').fontSize(9).fontColor(COLORS.card).opacity(0.95)
          .padding({ left: 8, right: 8, top: 3, bottom: 3 }).backgroundColor(COLORS.orangeD).borderRadius(8)
        Text('🔥 新节目上线 26 期').fontSize(9).fontColor(COLORS.card).opacity(0.95)
          .padding({ left: 8, right: 8, top: 3, bottom: 3 }).backgroundColor(COLORS.orangeD).borderRadius(8)
        Text('⭐ 会员专属频道').fontSize(9).fontColor(COLORS.orangeD)
          .padding({ left: 8, right: 8, top: 3, bottom: 3 }).backgroundColor(COLORS.card).borderRadius(8)
      }
      .width('100%')
    }
    .width('100%').padding(16).borderRadius(14)
    .linearGradient({ angle: 135, colors: [[COLORS.orangeD, 0], [COLORS.orange, 1]] })

headerMain 是头部区域的 Builder 函数,它构建了顶部渐变 Banner、搜索条和横滑分类 chips 三个区块。

渐变 Banner 是一个 Column 容器,设置了 135 度角的线性渐变背景——从深声波橙(#C96A24)到声波橙(#E8833A),营造从深到浅的视觉层次。Banner 内部分为两部分。

第一部分是一个 Row,左侧 Column 展示电台问候语"早上好,播客通勤人"(16px 白色粗体)和副标题"声波晨间谈 · 今日已为你备好 3 期新节目"(9px 白色半透明),右侧是一个 44x44 的圆形图标容器,背景为深声波橙,内部放置 📻 emoji(22px),其透明度随 this.breath 在 1 和 0.6 之间切换——当 breath 为 true 时完全不透明,为 false 时半透明,形成"呼吸"闪烁效果。

第二部分是三枚数据胶囊,使用 Row 横向排列。"🎧 在听 12.6 万人"和"🔥 新节目上线 26 期"两枚使用深声波橙背景+白色文字,"⭐ 会员专属频道"使用白色背景+深声波橙文字,形成深浅对比的视觉节奏。每枚胶囊都设置了 borderRadius(8) 圆角和内边距,呈现为药丸形态。

11.2 搜索条与分类 chips

    Row({ space: 8 }) {
      Text('🔍').fontSize(14)
      Text('搜索节目 / 主播 / 频道 / 电台').fontSize(11).fontColor(COLORS.text3).layoutWeight(1)
        .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
      Text('🎙').fontSize(14).onClick(() => {
        this.currentTab = 2;
      })
    }
    .width('100%').padding({ left: 14, right: 14, top: 10, bottom: 10 })
    .backgroundColor(COLORS.card).borderRadius(20)

    Scroll() {
      Row({ space: 8 }) {
        ForEach(CATE_TAGS, (tg: string, idx: number) => {
          Text(tg).fontSize(11)
            .fontColor(this.cateIdx === idx ? COLORS.orange : COLORS.sub)
            .padding({ left: 13, right: 13, top: 6, bottom: 6 })
            .backgroundColor(this.cateIdx === idx ? COLORS.chip : COLORS.card)
            .borderRadius(13)
            .onClick(() => {
              this.cateIdx = idx;
            })
        }, (tg: string) => tg)
      }
    }
    .scrollable(ScrollDirection.Horizontal)
    .scrollBar(BarState.Off)
    .width('100%')
  }
  .width('100%')
  .padding({ left: 14, right: 14, top: 12, bottom: 12 })
  .linearGradient({ angle: 180, colors: [[COLORS.chip, 0], [COLORS.bg, 1]] })
}

搜索条是一个 Row,左侧 🔍 搜索图标、中间搜索占位文本"搜索节目 / 主播 / 频道 / 电台"(灰色,layoutWeight(1) 撑满)、右侧 🎙 麦克风图标。右侧麦克风图标绑定了 onClick 事件——点击后 this.currentTab = 2,直接跳转到 AI 字幕 Tab。这是一个巧妙的产品设计:将搜索栏的语音入口与 AI 字幕功能打通,用户点击麦克风即可进入字幕设置页面。

横滑分类 chips 使用 Scroll+Row+ForEach 实现。Scroll 设置 scrollable(ScrollDirection.Horizontal) 启用水平滚动,scrollBar(BarState.Off) 隐藏滚动条。ForEach 遍历 CATE_TAGS 数组,为每个分类生成一个 Text chip。选中态(cateIdx === idx)使用声波橙文字+暖米黄背景,未选中态使用棕色文字+白色背景,borderRadius(13) 呈现为圆角药丸形态。点击 chip 时更新 cateIdx,选中态实时切换。

整个 headerMain 的最外层 Column 设置了 180 度角的线性渐变背景——从暖米黄(#F3E9DD)到暖米白(#FAF6F0),使头部区域与下方内容区域形成自然的色彩过渡。

十二、电台 Tab:频道宫格与热门大卡

12.1 频道分类宫格

@Builder
tabRadio() {
  Column({ space: 12 }) {
    Row() {
      Text('📻 频道分类').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
      Column().layoutWeight(1)
      Text(this.channelList.length.toString() + ' 个频道').fontSize(9).fontColor(COLORS.text3)
    }
    .width('100%')

    Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
      ForEach(this.channelList, (item: ChannelItem) => {
        Column({ space: 6 }) {
          Column() {
            Text(item.icon).fontSize(22).opacity(this.breath ? 1 : 0.75)
          }
          .width(44).height(44).borderRadius(22).backgroundColor(COLORS.chip)
          .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)

          Text(item.name).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
          Text(item.count).fontSize(8).fontColor(COLORS.sub)
            .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        }
        .width('24%').padding({ top: 10, bottom: 10 })
        .backgroundColor(COLORS.card).borderRadius(12)
        .alignItems(HorizontalAlign.Center)
      }, (item: ChannelItem) => item.name)
    }
    .width('100%')

tabRadio 是电台 Tab 的 Builder 函数,它构建了频道分类宫格、直播横条、热门节目大卡三大区块。

频道分类宫格使用 Flex 容器配合 wrap: FlexWrap.Wrap 实现自动换行布局,justifyContent: FlexAlign.SpaceBetween 使子元素在主轴上均匀分布。每个频道项宽度设为 24%,这样一行可以容纳 4 个频道(4×24%=96%,剩余 4% 由 SpaceBetween 分配为间距),8 个频道分两行展示。

每个频道项是一个 Column,包含三部分:顶部是 44x44 的圆形图标容器(暖米黄背景,emoji 图标 22px,透明度随 breath 在 1 和 0.75 间切换实现呼吸闪烁);中间是频道名(11px 深棕色粗体,maxLines(1) 单行+省略号防止溢出);底部是节目数量文本(8px 暖棕色)。整个频道项设置了白色卡片背景和 12px 圆角,呈现为独立的卡片单元。

ForEach 的第三个参数是键值生成函数 (item: ChannelItem) => item.name,使用频道名作为唯一键——当频道列表数据变化时,框架通过键值匹配确定哪些项需要新增、移除或更新,避免不必要的全量重渲染。

12.2 正在直播横条

    Row({ space: 10 }) {
      Column() {
        Circle().width(8).height(8).fill(COLORS.red).opacity(this.breath ? 1 : 0.45)
      }
      .width(20).height(20).borderRadius(10).backgroundColor(COLORS.chip)
      .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)

      Column({ space: 2 }) {
        Text('正在直播 · 声波夜谈间').fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
          .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        Text('阿岳连线听众:聊聊你的城市夜归路').fontSize(8).fontColor(COLORS.sub)
          .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)

      Text('进入').fontSize(9).fontColor(COLORS.card)
        .padding({ left: 10, right: 10, top: 5, bottom: 5 })
        .backgroundColor(COLORS.orange).borderRadius(9)
    }
    .width('100%').padding(10).backgroundColor(COLORS.card).borderRadius(10)
    .alignItems(VerticalAlign.Center)

正在直播横条是一个 Row,由三部分组成。左侧是一个 20x20 的圆形容器,内部放置一个 8x8 的红色圆点——圆点的透明度随 breath 在 1 和 0.45 间切换,模拟直播信号的闪烁效果,是"正在直播"最直观的视觉标识。中间是节目信息列,包含"正在直播 · 声波夜谈间"标题(11px 深棕色粗体)和"阿岳连线听众:聊聊你的城市夜归路"副标题(8px 暖棕色),使用 layoutWeight(1) 撑满中间区域。右侧是"进入"按钮(9px 白色文字+声波橙背景+9px 圆角),点击可进入直播间。

12.3 热门节目横排大卡

    Row() {
      Text('🔥 热门节目榜').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
      Column().layoutWeight(1)
      Text('按周更新').fontSize(9).fontColor(COLORS.text3)
    }
    .width('100%')

    ForEach(this.programList, (item: ProgramItem, idx: number) => {
      Row({ space: 12 }) {
        Column({ space: 3 }) {
          Text((idx + 1).toString()).fontSize(26).fontColor(COLORS.card).fontWeight(FontWeight.Bold)
            .opacity(this.breath ? 1 : 0.85)
          Text('HOT').fontSize(8).fontColor(COLORS.card).opacity(0.85).letterSpacing(1)
        }
        .width(70).height(82).borderRadius(10)
        .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
        .linearGradient({ angle: 145, colors: [[COLORS.orangeD, 0], [COLORS.orange, 1]] })

        Column({ space: 5 }) {
          Text(item.title).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            .maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
          Text('主播 ' + item.host + ' · ' + item.cat).fontSize(9).fontColor(COLORS.sub)
            .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
          Row({ space: 6 }) {
            Text('⏱ ' + item.dur).fontSize(8).fontColor(catColor(item.cat))
              .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              .backgroundColor(COLORS.chip).borderRadius(6)
            Text('▶ ' + item.plays).fontSize(8).fontColor(COLORS.text3)
              .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              .backgroundColor(COLORS.chip).borderRadius(6)
          }
          .width('100%')
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Start)

        Column() {
          Text('▶').fontSize(13).fontColor(COLORS.orange)
        }
        .width(34).height(34).borderRadius(17).backgroundColor(COLORS.chip)
        .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
      }
      .width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(12)
      .alignItems(VerticalAlign.Center)
    }, (item: ProgramItem) => item.title + item.plays)
  }
  .width('100%')
}

热门节目榜标题行之后是 ForEach 遍历 programList 生成的节目大卡列表。每张大卡是一个 Row,由三部分组成。

左侧是 70x82 像素的渐变封面块,使用 145 度角的线性渐变(从深声波橙到声波橙),内部居中显示大编号(26px 白色粗体,编号 = idx+1)和"HOT"角标(8px 白色,letterSpacing(1) 增加字母间距)。编号的透明度随 breath 在 1 和 0.85 间微变,形成轻微的呼吸效果。

中间是节目信息列,包含节目标题(12px 深棕色粗体,maxLines(2) 两行+省略号)、主播与分类信息行(9px 暖棕色"主播 阿岳 · 情感"格式)、时长与播放量胶囊行。时长胶囊使用 catColor(item.cat) 映射的分类色作为文字颜色——如科技分类为蓝色、情感分类为红色——使不同分类的节目在列表中有即时的色彩区分。播放量胶囊使用辅助文字色。

右侧是 34x34 的圆形播放按钮,声波橙 ▶ 图标+暖米黄背景。整个大卡设置了白色卡片背景和 12px 圆角,padding(12) 内边距。ForEach 的键值函数使用 item.title + item.plays 拼接作为唯一键,确保每个节目项有唯一标识。

十三、订阅 Tab:清单行与柱状图

13.1 订阅标题行与新建入口

@Builder
tabSubs() {
  Column({ space: 12 }) {
    Row() {
      Text('📌 我的订阅').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
      Column().layoutWeight(1)
      Text(this.programList.length.toString() + ' 个节目').fontSize(9).fontColor(COLORS.text3)
      Text('+ 新建').fontSize(9).fontColor(COLORS.orange)
        .padding({ left: 9, right: 9, top: 4, bottom: 4 })
        .backgroundColor(COLORS.chip).borderRadius(8)
        .onClick(() => {
          this.addModal = true;
        })
    }
    .width('100%')

tabSubs 是订阅 Tab 的 Builder 函数,包含订阅清单行和月度柱状图两大区块。标题行展示"📌 我的订阅"标题、节目数量(动态取自 this.programList.length)、"+ 新建"按钮。新建按钮点击后设置 this.addModal = true 打开新建订阅分组弹窗。节目数量随订阅列表的变化而动态更新——新建订阅后数字增加,退订后数字减少。

13.2 订阅节目清单行

    ForEach(this.programList, (item: ProgramItem, idx: number) => {
      Row({ space: 10 }) {
        Column().width(4).height(52).borderRadius(2).backgroundColor(catColor(item.cat))

        Column({ space: 4 }) {
          Row() {
            Text(item.title).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
              .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
              .layoutWeight(1)
            Text('已订阅').fontSize(8).fontColor(COLORS.green)
              .padding({ left: 5, right: 5, top: 1, bottom: 1 })
              .backgroundColor(COLORS.chip).borderRadius(5)
          }
          .width('100%')

          Text(item.host + ' · ' + item.dur + ' · ▶ ' + item.plays + ' · ' + item.cat)
            .fontSize(9).fontColor(COLORS.sub)
            .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })

          Row({ space: 6 }) {
            Text('编辑').fontSize(8).fontColor(COLORS.sub)
              .padding({ left: 10, right: 10, top: 3, bottom: 3 })
              .backgroundColor(COLORS.chip).borderRadius(6)
              .onClick(() => {
                this.openEditSub(idx);
              })
            Text('退订').fontSize(8).fontColor(COLORS.red)
              .padding({ left: 10, right: 10, top: 3, bottom: 3 })
              .backgroundColor(COLORS.chip).borderRadius(6)
              .onClick(() => {
                this.delIdx = idx;
                this.delModal = true;
              })
          }
          .width('100%')
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Start)
      }
      .width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(10)
      .alignItems(VerticalAlign.Center)
    }, (item: ProgramItem) => item.title + item.cat)

每个订阅清单行是一个 Row,由三部分组成。左侧是 4 像素宽的分类色条,颜色由 catColor(item.cat) 映射——科技蓝、人文棕、商业绿、情感红等,通过色条颜色用户可以一眼识别节目分类。中间是信息列,包含三行:标题行(节目标题+绿色"已订阅"标签)、主播与元信息行("阿岳 · 58 分钟 · ▶ 32万 · 情感"格式的一行文本)、操作行("编辑"和"退订"两个迷你按钮)。

"编辑"按钮点击调用 this.openEditSub(idx),该方法回填表单数据并打开编辑弹窗。"退订"按钮点击设置 delIdxdelModal = true,打开退订确认弹窗——退订操作需要二次确认,防止误触退订。这种"编辑直接打开、退订需确认"的不对称设计体现了对数据安全性的考量:编辑是可逆操作可以直接执行,退订是不可逆操作需要确认。

ForEach 的键值函数使用 item.title + item.cat 拼接作为唯一键,当编辑修改了标题或分类后键值变化,框架会精准更新对应行。

13.3 月度收听柱状图

    this.chartCard()
  }
  .width('100%')
}

订阅 Tab 的最后调用了 this.chartCard() 渲染月度收听时长柱状图卡片。chartCard 是一个独立的 Builder 函数,将在下一节详细分析。

十四、月度收听柱状图卡片

@Builder
chartCard() {
  Column({ space: 10 }) {
    Row() {
      Text('📊 月度收听时长').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
      Column().layoutWeight(1)
      Text('单位:小时').fontSize(9).fontColor(COLORS.text3)
    }
    .width('100%')

    Row({ space: 8 }) {
      ForEach(MONTH_IDX, (i: number) => {
        Column({ space: 5 }) {
          Text(MONTH_HOURS[i].toString()).fontSize(8)
            .fontColor(this.breath ? COLORS.orange : COLORS.sub)
          Column().width(18)
            .height(Math.max(20, MONTH_HOURS[i] / MONTH_MAX * 110 * (this.breath ? 1.05 : 0.95)))
            .borderRadius(5)
            .linearGradient({ angle: 180, colors: [[COLORS.orange, 0], [COLORS.orangeD, 1]] })
          Text(MONTH_LABELS[i]).fontSize(8).fontColor(COLORS.text3)
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Center)
      }, (i: number) => 'm' + i.toString())
    }
    .width('100%').alignItems(VerticalAlign.Bottom).height(150)

    Row() {
      Text('近 6 月累计 495 小时').fontSize(8).fontColor(COLORS.sub)
      Column().layoutWeight(1)
      Text('环比 +11.5%').fontSize(8).fontColor(COLORS.green)
    }
    .width('100%')
  }
  .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
}

chartCard 是月度收听时长柱状图的 Builder 函数。标题行展示"📊 月度收听时长"和"单位:小时"标注。

柱状图主体使用 Row+ForEach 实现,遍历 MONTH_IDX 数组为每个月生成一个柱子。每根柱子是一个 Column,从上到下包含三部分:顶部数值文本(8px,颜色随 breath 在声波橙和暖棕色间切换)、中间渐变柱体(18px 宽,高度动态计算)、底部月份标签(8px 辅助文字色)。

柱体高度的计算公式是 Math.max(20, MONTH_HOURS[i] / MONTH_MAX * 110 * (this.breath ? 1.05 : 0.95))MONTH_HOURS[i] / MONTH_MAX * 110 将小时值映射为 0-110 像素的柱高,Math.max(20, ...) 确保最小柱高为 20 像素避免消失,(this.breath ? 1.05 : 0.95) 乘以呼吸系数使柱高在 ±5% 范围内波动——breath 为 true 时柱高放大 5%,为 false 时缩小 5%,所有柱子同步呼吸,营造"数据在跳动"的活体感。柱体使用 180 度线性渐变(从声波橙到深声波橙),borderRadius(5) 圆角,从上到下颜色加深。

Row 设置 alignItems(VerticalAlign.Bottom) 使所有柱子底部对齐,height(150) 限定图表区域高度。底部汇总行展示"近 6 月累计 495 小时"和绿色"环比 +11.5%"增长标识,让用户快速了解收听趋势。

十五、AI 字幕 Tab:Speech Kit 特性页(核心章节)

AI 字幕 Tab 是本应用的技术核心页面,它通过五个区块完整展示了 HarmonyOS 6.1.1 AICaptionComponent 的四大新增字段特性和完整的字幕配置交互流程。以下逐区块深入分析。

15.1 特性简介条

@Builder
tabCaption() {
  Column({ space: 12 }) {
    Row({ space: 8 }) {
      Text('🗣').fontSize(16)
      Column({ space: 2 }) {
        Text('Speech Kit · 场景化语音服务').fontSize(11)
          .fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Text('HarmonyOS 6.1.1:AI字幕支持源语言 / 目标语言 / 字体颜色 / 字体大小')
          .fontSize(8).fontColor(COLORS.title).opacity(0.75)
          .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
    }
    .width('100%').padding(10).borderRadius(10)
    .linearGradient({ angle: 135, colors: [[COLORS.orangeD, 0], [COLORS.orange, 1]] })

特性简介条是 AI 字幕 Tab 的顶部 Banner,使用 135 度线性渐变(从深声波橙到声波橙),与头部 Banner 的渐变风格保持一致。左侧是 🗣 emoji 图标(16px),右侧是标题列——“Speech Kit · 场景化语音服务”(11px 深棕色粗体)和副标题"HarmonyOS 6.1.1:AI字幕支持源语言 / 目标语言 / 字体颜色 / 字体大小"(8px 深棕色半透明)。副标题直接点明了本页面的核心技术要点——四大新增字段,让用户在进入页面第一眼就了解本页面的技术主题。

15.2 区块一:AI 字幕实时预览卡

    Column({ space: 10 }) {
      Row() {
        Text('🗣 AI 字幕实时预览').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Column().layoutWeight(1)
        Text(this.captionReady ? '已就绪' : '初始化中').fontSize(9)
          .fontColor(this.captionReady ? COLORS.green : COLORS.orange)
          .opacity(this.captionReady ? 1 : (this.breath ? 1 : 0.55))
          .padding({ left: 8, right: 8, top: 3, bottom: 3 })
          .backgroundColor(COLORS.chip).borderRadius(8)
      }
      .width('100%')

      AICaptionComponent({
        isShown: this.captionShown,
        controller: this.captionController,
        options: this.buildCaptionOptions()
      })
      .width('100%')
      .height(110)
      .borderRadius(10)
      .border({ width: 1, color: COLORS.line })

      Row({ space: 10 }) {
        Text(this.captionShown ? '隐藏字幕' : '开启字幕').fontSize(12)
          .fontColor(COLORS.card).fontWeight(FontWeight.Bold)
          .layoutWeight(1).textAlign(TextAlign.Center)
          .padding({ top: 9, bottom: 9 })
          .backgroundColor(this.captionShown ? COLORS.orangeD : COLORS.orange)
          .borderRadius(10)
          .onClick(() => {
            this.captionShown = !this.captionShown;
          })

        Row({ space: 5 }) {
          Text('写入演示音频').fontSize(12).fontColor(COLORS.brown)
          Text('×' + this.captionFed.toString()).fontSize(9).fontColor(COLORS.brown)
        }
        .layoutWeight(1).justifyContent(FlexAlign.Center)
        .padding({ top: 9, bottom: 9 })
        .backgroundColor(COLORS.chip).borderRadius(10)
        .onClick(() => {
          this.feedDemoAudio();
        })
      }
      .width('100%')

      if (this.captionErrMsg !== '') {
        Text('⚠ ' + this.captionErrMsg).fontSize(9).fontColor(COLORS.red)
          .width('100%').maxLines(2)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .padding(8).backgroundColor(COLORS.chip).borderRadius(8)
      }
    }
    .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)

预览卡是 AI 字幕 Tab 的核心区块——它直接渲染了 AICaptionComponent 组件。标题行右侧的状态标签根据 captionReady 显示"已就绪"(绿色)或"初始化中"(声波橙+呼吸闪烁),onPrepared 回调触发后标签从橙色变为绿色。

AICaptionComponent 接收三个参数。isShown 绑定 this.captionShown——这是一个 @State 变量,通过 @Link 双向绑定到组件内部的显示状态。当 captionShown 为 true 时组件可见,为 false 时隐藏。controller 绑定 this.captionController——AICaptionController 实例,提供 writeAudio 方法供外部写入音频流。options 绑定 this.buildCaptionOptions()——每次 build 方法执行时重新调用此方法生成新的 AICaptionOptions 对象,确保 sourceLanguage、targetLanguage、fontSize、fontColor 四个字段的最新值实时传入组件。组件设置了 100% 宽度、110 像素高度、10 像素圆角和 1 像素分割色边框。

控制按钮行包含两个按钮。"开启字幕/隐藏字幕"按钮根据 captionShown 状态切换文案和背景色——开启时声波橙背景、隐藏时深声波橙背景,点击翻转 captionShown。"写入演示音频"按钮显示已写入次数(× + captionFed 计数),点击调用 feedDemoAudio() 生成 PCM 数据并写入控制器。两个按钮各占 layoutWeight(1) 即 50% 宽度。

错误信息行是条件渲染——仅当 captionErrMsg 非空时显示。它使用红色文字+暖米黄背景展示"⚠"前缀的错误信息,maxLines(2) 限制两行+省略号。onError 回调触发时设置 captionErrMsg,此行自动显示;onPrepared 回调触发时清空 captionErrMsg,此行自动隐藏。这种条件渲染+回调驱动的错误展示模式,确保错误信息只在有错误时出现,不干扰正常使用。

15.3 区块二:语言设置卡

    Column({ space: 10 }) {
      Text('🌐 语言设置').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)

      Row() {
        Text('源语言 sourceLanguage').fontSize(10).fontColor(COLORS.sub)
        Column().layoutWeight(1)
        Text("取值 'zh' | 'en'").fontSize(8).fontColor(COLORS.text3)
      }
      .width('100%')

      Row({ space: 8 }) {
        ForEach(SRC_LANGS, (l: LangOption) => {
          Text(l.name).fontSize(11)
            .fontColor(this.srcLang === l.code ? COLORS.card : COLORS.sub)
            .fontWeight(this.srcLang === l.code ? FontWeight.Bold : FontWeight.Normal)
            .layoutWeight(1).textAlign(TextAlign.Center)
            .padding({ top: 8, bottom: 8 })
            .backgroundColor(this.srcLang === l.code ? COLORS.orange : COLORS.chip)
            .borderRadius(10)
            .onClick(() => {
              this.switchSourceLang(l.code);
            })
        }, (l: LangOption) => l.code)
      }
      .width('100%')

语言设置卡是 AI 字幕 Tab 的第二大区块,展示 sourceLanguage 和 targetLanguage 两个字段的交互配置。

源语言标题行展示"源语言 sourceLanguage"标签和取值范围说明"取值 ‘zh’ | ‘en’"。下方是两个选项按钮——“中文"和"英文”,使用 ForEach 遍历 SRC_LANGS 数组生成。选中态(srcLang === l.code)使用白色文字+声波橙背景+粗体,未选中态使用棕色文字+暖米黄背景+常规字重。点击选项调用 this.switchSourceLang(l.code),该方法不仅更新 srcLang,还联动调整 tgtLang 的默认值——这是语言组合合理性的关键保障。

      Row() {
        Text('目标语言 targetLanguage').fontSize(10).fontColor(COLORS.sub)
        Column().layoutWeight(1)
        Text(this.srcLang === 'zh' ? '中文源已锁定' : "取值 'zh' | 'en' | 'zh-en'")
          .fontSize(8).fontColor(COLORS.text3)
      }
      .width('100%')

      if (this.srcLang === 'zh') {
        Row({ space: 8 }) {
          Text('🔒').fontSize(13)
          Text('中文源锁定中文:无翻译方向,targetLanguage 固定为 zh')
            .fontSize(10).fontColor(COLORS.text3).layoutWeight(1)
            .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        }
        .width('100%').padding(10).backgroundColor(COLORS.chip).borderRadius(10)
      } else {
        Row({ space: 8 }) {
          ForEach(TGT_LANGS_EN, (l: LangOption) => {
            Text(l.name).fontSize(11)
              .fontColor(this.tgtLang === l.code ? COLORS.card : COLORS.sub)
              .fontWeight(this.tgtLang === l.code ? FontWeight.Bold : FontWeight.Normal)
              .layoutWeight(1).textAlign(TextAlign.Center)
              .padding({ top: 8, bottom: 8 })
              .backgroundColor(this.tgtLang === l.code ? COLORS.orange : COLORS.chip)
              .borderRadius(10)
              .onClick(() => {
                this.tgtLang = l.code;
              })
          }, (l: LangOption) => l.code)
        }
        .width('100%')
      }

目标语言区域采用了动态条件渲染——根据当前源语言的值显示不同的 UI。目标语言标题行的右侧说明文本也是动态的:中文源时显示"中文源已锁定",英文源时显示"取值 ‘zh’ | ‘en’ | ‘zh-en’"。

srcLang === 'zh'(中文源)时,渲染一个锁定提示行——🔒 图标+"中文源锁定中文:无翻译方向,targetLanguage 固定为 zh"文案。这个提示行明确告知用户当前语言组合的限制原因,避免用户困惑为什么目标语言不可选。

srcLang === 'en'(英文源)时,渲染三个目标语言选项按钮——中文、英文、中英双语,使用 ForEach 遍历 TGT_LANGS_EN 数组生成。选中态和未选中态的样式与源语言按钮一致。点击选项直接设置 this.tgtLang = l.code,无需联动逻辑——因为英文源时目标语言的三种取值都是合理的。

      Row({ space: 6 }) {
        Circle().width(6).height(6).fill(COLORS.brown)
        Text('当前组合:源 ' + langName(this.srcLang) + ' → 目标 ' + langName(this.tgtLang))
          .fontSize(9).fontColor(COLORS.sub)
          .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
      }
      .width('100%')
    }
    .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)

语言设置卡底部是当前组合摘要行——原木棕小圆点+"当前组合:源 中文 → 目标 中英双语"格式的文案。摘要文本通过 langName() 函数将语言码转换为中文名,确保用户看到的是友好的中文名称而非代码值。这个摘要行实时反映当前的语言组合状态,当用户切换源语言或目标语言后立即更新。

15.4 区块三:外观设置卡

    Column({ space: 10 }) {
      Text('🎨 外观设置').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)

      Row() {
        Text('字体大小 fontSize').fontSize(10).fontColor(COLORS.sub)
        Column().layoutWeight(1)
        Text('AICaptionFontSize').fontSize(8).fontColor(COLORS.text3)
      }
      .width('100%')

      Row({ space: 8 }) {
        ForEach(SIZE_OPTIONS, (s: SizeOption) => {
          Text(s.name).fontSize(11)
            .fontColor(this.captionSize === s.size ? COLORS.card : COLORS.sub)
            .fontWeight(this.captionSize === s.size ? FontWeight.Bold : FontWeight.Normal)
            .layoutWeight(1).textAlign(TextAlign.Center)
            .padding({ top: 8, bottom: 8 })
            .backgroundColor(this.captionSize === s.size ? COLORS.orange : COLORS.chip)
            .borderRadius(10)
            .onClick(() => {
              this.captionSize = s.size;
            })
        }, (s: SizeOption) => s.name)
      }
      .width('100%')

外观设置卡是 AI 字幕 Tab 的第三大区块,展示 fontSize 和 fontColor 两个字段的交互配置。

字体大小标题行展示"字体大小 fontSize"标签和类型说明"AICaptionFontSize"。下方是四个字号选项按钮——小号、标准、大号、超大,使用 ForEach 遍历 SIZE_OPTIONS 数组生成。每个选项的 s.size 是 AICaptionFontSize 枚举值,选中态通过 captionSize === s.size 判断。点击选项设置 this.captionSize = s.size,build 方法重新执行时 buildCaptionOptions 生成新的 options 对象传入 AICaptionComponent,字幕字号实时变化。

      Row() {
        Text('字体颜色 fontColor').fontSize(10).fontColor(COLORS.sub)
        Column().layoutWeight(1)
        Text('ResourceColor').fontSize(8).fontColor(COLORS.text3)
      }
      .width('100%')

      Row({ space: 12 }) {
        ForEach(CAPTION_FONT_COLORS, (c: string) => {
          Circle().width(26).height(26).fill(c)
            .border({ width: 2, color: this.captionColor === c ? COLORS.orange : COLORS.line })
            .onClick(() => {
              this.captionColor = c;
            })
        }, (c: string) => c)
      }
      .width('100%').justifyContent(FlexAlign.SpaceBetween)

      Row() {
        Circle().width(10).height(10).fill(this.captionColor)
        Text(colorName(this.captionColor) + ' ' + this.captionColor)
          .fontSize(9).fontColor(COLORS.sub).margin({ left: 6 })
        Column().layoutWeight(1)
        Text('作用于字幕原文与译文').fontSize(8).fontColor(COLORS.text3)
      }
      .width('100%')
    }
    .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)

字体颜色标题行展示"字体颜色 fontColor"标签和类型说明"ResourceColor"。下方是五个圆形色块——经典白、暖阳黄、薄荷绿、云朵蓝、樱花粉,使用 ForEach 遍历 CAPTION_FONT_COLORS 数组生成。每个色块是 26x26 的 Circle,填充对应颜色,选中态通过 captionColor === c 判断——选中时添加 2 像素声波橙描边,未选中时使用分割色描边。点击色块设置 this.captionColor = c,字幕颜色实时变化。

色块行使用 justifyContent(FlexAlign.SpaceBetween) 使五个色块在行内均匀分布。底部是当前选中颜色说明行——小圆点(填充当前选中色)+颜色中文名+十六进制值(如"经典白 #FFFFFF")+"作用于字幕原文与译文"标注。颜色中文名通过 colorName() 函数转换,标注说明 fontColor 字段同时影响字幕的原文和译文文字颜色。

15.5 区块四:options 实时代码预览卡

    Column({ space: 10 }) {
      Row() {
        Text('💻 AICaptionOptions 实时代码').fontSize(13)
          .fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Column().layoutWeight(1)
        Text('随设置联动').fontSize(8).fontColor(COLORS.orange)
      }
      .width('100%')

      Column({ space: 5 }) {
        Text('AICaptionOptions = {').fontSize(9).fontColor(COLORS.chip).fontFamily('monospace')
        Row({ space: 4 }) {
          Text('● sourceLanguage:').fontSize(9).fontColor(COLORS.orange).fontFamily('monospace')
          Text("'" + this.srcLang + "'").fontSize(9).fontColor(COLORS.chip).fontFamily('monospace')
        }
        .width('100%')
        Row({ space: 4 }) {
          Text('● targetLanguage:').fontSize(9).fontColor(COLORS.orange).fontFamily('monospace')
          Text("'" + this.tgtLang + "'").fontSize(9).fontColor(COLORS.chip).fontFamily('monospace')
        }
        .width('100%')
        Row({ space: 4 }) {
          Text('● fontSize:').fontSize(9).fontColor(COLORS.orange).fontFamily('monospace')
          Text(sizeName(this.captionSize)).fontSize(9).fontColor(COLORS.green).fontFamily('monospace')
        }
        .width('100%')
        Row({ space: 4 }) {
          Text('● fontColor:').fontSize(9).fontColor(COLORS.orange).fontFamily('monospace')
          Text("'" + this.captionColor + "'").fontSize(9)
            .fontColor(this.captionColor).fontFamily('monospace')
        }
        .width('100%')
        Text('}').fontSize(9).fontColor(COLORS.chip).fontFamily('monospace')
      }
      .width('100%').padding(12).backgroundColor(COLORS.title).borderRadius(10)
      .alignItems(HorizontalAlign.Start)

      Text('★ 6.1.1 新增字段:sourceLanguage / targetLanguage / fontSize / fontColor')
        .fontSize(8).fontColor(COLORS.sub).width('100%')
        .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
    }
    .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)

代码预览卡是一个极具特色的区块——它用 ArkUI 的 Text 组件模拟了一个代码编辑器的显示效果,实时展示当前 AICaptionOptions 对象的完整结构。

标题行右侧的"随设置联动"标签(声波橙色)告知用户:这个代码块的内容会随语言和外观设置的变化而实时更新。深色代码块使用 backgroundColor(COLORS.title) 即深原木棕作为背景,模拟 IDE 深色主题。每行代码使用 fontFamily('monospace') 等宽字体,确保字符对齐。

代码块的内容是模拟的 JavaScript/TypeScript 对象字面量格式。第一行 AICaptionOptions = { 是对象开头。中间四行分别展示四个字段——字段名使用声波橙色高亮(如 ● sourceLanguage:),字段值使用不同颜色:sourceLanguage 和 targetLanguage 的值使用暖米黄色(字符串格式 'zh'),fontSize 的值使用绿色(枚举名格式 NORMAL,通过 sizeName() 函数转换),fontColor 的值使用当前选中颜色本身(fontColor(this.captionColor) 让代码中的颜色值文本以该颜色显示,实现了"所见即所设"的视觉反馈)。最后一行 } 是对象结尾。

底部标注"★ 6.1.1 新增字段:sourceLanguage / targetLanguage / fontSize / fontColor"明确标注了四大字段的版本来源。这个代码预览卡的设计巧妙地将抽象的配置对象可视化为具体的代码文本,让开发者用户可以直观地理解 AICaptionOptions 的结构和当前取值,同时也是一个优秀的技术教学工具。

15.6 区块五:字幕场景推荐列表

    Column({ space: 8 }) {
      Row() {
        Text('🎬 字幕场景推荐').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Column().layoutWeight(1)
        Text('点击套用语言组合').fontSize(8).fontColor(COLORS.text3)
      }
      .width('100%')

      ForEach(this.sceneList, (item: CaptionScene, idx: number) => {
        Row({ space: 10 }) {
          Column().width(4).height(42).borderRadius(2)
            .backgroundColor(idx % 3 === 0 ? COLORS.orange : (idx % 3 === 1 ? COLORS.brown : COLORS.blue))

          Column({ space: 3 }) {
            Text(item.scene).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
              .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
            Text(item.desc).fontSize(9).fontColor(COLORS.sub)
              .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
            Text('源 ' + item.src + ' → 目标 ' + item.tgt).fontSize(9).fontColor(COLORS.brown)
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Start)

          Text('套用 ›').fontSize(9).fontColor(COLORS.orange)
        }
        .width('100%').padding(10).backgroundColor(COLORS.chip).borderRadius(10)
        .alignItems(VerticalAlign.Center)
        .onClick(() => {
          this.switchSourceLang(item.src);
          this.tgtLang = item.tgt;
        })
      }, (item: CaptionScene) => item.scene)
    }
    .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
  }
  .width('100%')
}

字幕场景推荐列表是 AI 字幕 Tab 的第五大区块,它将常见的播客字幕使用场景预设为快捷方案,用户点击即可一键套用。

标题行右侧"点击套用语言组合"标注提示了交互方式。每条场景行是一个 Row,包含三部分。左侧是 4 像素宽的色条,颜色按索引取模 3 轮换——声波橙、原木棕、蓝色三种颜色循环,为列表增加色彩节奏感。中间是场景信息列,包含场景名(12px 深棕色粗体)、场景说明(9px 暖棕色)、语言组合信息(9px 原木棕色"源 en → 目标 zh-en"格式)。右侧是"套用 ›"按钮(声波橙色文字)。

整个行绑定了 onClick 事件——点击后调用 this.switchSourceLang(item.src) 切换源语言(该方法会联动调整目标语言默认值),然后设置 this.tgtLang = item.tgt 覆盖为目标语言推荐值。两步操作确保源语言和目标语言都被正确设置。点击后,预览卡中的 AICaptionComponent 的 options 实时更新,语言设置卡和外观设置卡的选中态也同步切换——整个页面的所有配置区块联动响应,体现了 @State 状态管理的全局响应能力。

十六、我的 Tab 与底部导航

16.1 用户信息与会员渐变大卡

@Builder
tabMine() {
  Column({ space: 12 }) {
    Column({ space: 12 }) {
      Row({ space: 12 }) {
        Column() {
          Text('🎙').fontSize(26)
        }
        .width(54).height(54).borderRadius(27).backgroundColor(COLORS.orangeD)
        .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)

        Column({ space: 4 }) {
          Row({ space: 6 }) {
            Text('麦浪不回头').fontSize(16).fontColor(COLORS.card).fontWeight(FontWeight.Bold)
            Text('声波 VIP').fontSize(8).fontColor(COLORS.orangeD)
              .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              .backgroundColor(COLORS.card).borderRadius(7)
          }
          Text('声波 ID:shengbo_1113 · 已连续收听 96 天')
            .fontSize(9).fontColor(COLORS.card).opacity(0.78)
            .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Start)
      }
      .width('100%')

      Row({ space: 10 }) {
        Column({ space: 3 }) {
          Text('158').fontSize(13).fontColor(COLORS.card).fontWeight(FontWeight.Bold)
          Text('收藏节目').fontSize(8).fontColor(COLORS.card).opacity(0.75)
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Center)

        Column({ space: 3 }) {
          Text('36').fontSize(13).fontColor(COLORS.card).fontWeight(FontWeight.Bold)
          Text('订阅频道').fontSize(8).fontColor(COLORS.card).opacity(0.75)
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Center)

        Column({ space: 3 }) {
          Text('498').fontSize(13).fontColor(COLORS.card).fontWeight(FontWeight.Bold)
          Text('收听小时').fontSize(8).fontColor(COLORS.card).opacity(0.75)
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Center)
      }
      .width('100%').margin({ top: 2 })
    }
    .width('100%').padding(16).borderRadius(14)
    .linearGradient({ angle: 135, colors: [[COLORS.orangeD, 0], [COLORS.orange, 1]] })

tabMine 是我的 Tab 的 Builder 函数。用户信息大卡使用 135 度线性渐变(从深声波橙到声波橙),与头部 Banner 和 AI 字幕简介条的渐变风格统一。大卡内部分为两部分。

第一部分是用户信息行——左侧 54x54 圆形头像容器(深声波橙背景+🎙 emoji),右侧是用户名"麦浪不回头"(16px 白色粗体)+“声波 VIP"标签(白色背景+深声波橙文字)和副信息行"声波 ID:shengbo_1113 · 已连续收听 96 天”(9px 白色半透明)。

第二部分是三格收听数据行——收藏节目(158)、订阅频道(36)、收听小时(498),三格等宽分布。每格包含数值(13px 白色粗体)和标签(8px 白色半透明),垂直居中对齐。这种三格统计布局在移动端个人中心页面中非常常见,它以紧凑的方式展示了用户的核心使用数据。

16.2 功能清单行

    ForEach(this.statList, (item: UserStat) => {
      Row({ space: 10 }) {
        Text(item.icon).fontSize(16)
        Text(item.label).fontSize(11).fontColor(COLORS.title).layoutWeight(1)
          .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        Text(item.value).fontSize(10).fontColor(COLORS.sub)
          .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        if (item.arrow) {
          Text('›').fontSize(14).fontColor(COLORS.text3)
        }
      }
      .width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(10)
    }, (item: UserStat) => item.label)
  }
  .width('100%')
}

功能清单行使用 ForEach 遍历 statList 数组,为每条功能生成一个 Row。每行包含四部分:功能图标(16px emoji)、功能名(11px 深棕色,layoutWeight(1) 撑满中间)、状态/数值文本(10px 暖棕色)、右箭头(14px 辅助文字色,条件渲染——item.arrow 为 true 时显示)。每行设置了白色卡片背景和 10 像素圆角,形成独立的功能入口。八条功能清单覆盖了离线节目、收听历史、收藏、投稿、AI 字幕偏好、会员特权、定时关闭、播放设置等播客应用的核心功能入口。

16.3 底部导航 Tab 栏

@Builder
tabBar() {
  Row() {
    ForEach(TAB_LIST, (t: TabMeta, idx: number) => {
      Column({ space: 3 }) {
        Text(t.icon).fontSize(this.currentTab === idx ? 20 : 17)
          .opacity(this.currentTab === idx ? 1 : 0.65)
        Text(t.label).fontSize(9)
          .fontColor(this.currentTab === idx ? COLORS.tabOn : COLORS.text3)
          .fontWeight(this.currentTab === idx ? FontWeight.Bold : FontWeight.Normal)
      }
      .layoutWeight(1).alignItems(HorizontalAlign.Center)
      .padding({ top: 7, bottom: 7 })
      .onClick(() => {
        this.currentTab = idx;
      })
    }, (t: TabMeta) => t.label)
  }
  .width('100%')
  .backgroundColor(COLORS.card)
  .border({ width: { top: 1 }, color: COLORS.line })
}

tabBar 是底部导航栏的 Builder 函数。使用 Row+ForEach 遍历 TAB_LIST 数组生成四个 Tab 项。每个 Tab 项是一个 Column,包含图标和标签两行。选中态(currentTab === idx)的图标字号为 20px、完全不透明,标签为声波橙色粗体;未选中态的图标字号为 17px、65% 不透明度,标签为辅助文字色常规字重。这种字号+颜色+透明度三重差异使选中态与未选中态的视觉区分非常明显。

每个 Tab 项设置 layoutWeight(1) 等宽分布,padding({ top: 7, bottom: 7 }) 保证足够的点击区域。点击 Tab 项设置 this.currentTab = idx,build 方法重新执行,条件渲染对应的 Tab 内容。整个 Tab 栏设置了白色背景和顶部 1 像素分割线边框,与上方内容区域形成视觉分隔。

十七、弹窗系统

17.1 全屏遮罩 Builder

@Builder
modalOverlay(onClose: () => void) {
  Stack() {
    Column().width('100%').height('100%').backgroundColor(COLORS.mask)
  }
  .width('100%')
  .height('100%')
  .alignContent(Alignment.Center)
  .onClick(() => onClose())
}

modalOverlay 是弹窗系统的公共遮罩组件。它是一个全屏 Stack,内部包含一个全屏 Column 遮罩层,背景色为 COLORS.maskrgba(59,46,34,0.5) 即深棕色 50% 透明度),点击遮罩调用 onClose 回调关闭弹窗。Stack 的 alignContent(Alignment.Center) 确保弹窗面板在遮罩层上居中显示。这种"遮罩+面板"的弹窗架构使得点击遮罩区域可以关闭弹窗——这是移动端弹窗的标准交互模式。

17.2 新建订阅分组弹窗

@Builder
panelAdd(onClose: () => void) {
  Stack() {
    this.modalOverlay(onClose)
    Column({ space: 12 }) {
      Text('新建订阅分组').fontSize(15).fontColor(COLORS.title).fontWeight(FontWeight.Bold)

      Column({ space: 6 }) {
        Text('分组名称').fontSize(9).fontColor(COLORS.sub)
        TextInput({ text: this.formName, placeholder: '如:通勤必听 · 科技频道' })
          .fontSize(11).fontColor(COLORS.title)
          .backgroundColor(COLORS.chip).borderRadius(8)
          .onChange((value: string) => {
            this.formName = value;
          })
      }
      .width('100%').alignItems(HorizontalAlign.Start)

      Column({ space: 6 }) {
        Text('备注说明').fontSize(9).fontColor(COLORS.sub)
        TextInput({ text: this.formNote, placeholder: '如:科技 / 人文 / 喜剧' })
          .fontSize(11).fontColor(COLORS.title)
          .backgroundColor(COLORS.chip).borderRadius(8)
          .onChange((value: string) => {
            this.formNote = value;
          })
      }
      .width('100%').alignItems(HorizontalAlign.Start)

      Row({ space: 10 }) {
        Text('取消').fontSize(12).fontColor(COLORS.sub)
          .layoutWeight(1).textAlign(TextAlign.Center)
          .padding({ top: 9, bottom: 9 }).backgroundColor(COLORS.chip).borderRadius(9)
          .onClick(() => onClose())
        Text('创建').fontSize(12).fontColor(COLORS.card).fontWeight(FontWeight.Bold)
          .layoutWeight(1).textAlign(TextAlign.Center)
          .padding({ top: 9, bottom: 9 }).backgroundColor(COLORS.orange).borderRadius(9)
          .onClick(() => {
            this.saveSub();
          })
      }
      .width('100%')
    }
    .width('78%').padding(16).backgroundColor(COLORS.card).borderRadius(14)
  }
  .width('100%')
  .height('100%')
  .alignContent(Alignment.Center)
}

panelAdd 是新建订阅分组的弹窗面板。它嵌套在 Stack 中——先渲染 modalOverlay 遮罩层,再渲染面板 Column。面板宽度为 78%,居中显示在遮罩上。

面板内部分为标题、分组名称输入区、备注说明输入区、按钮行四个部分。每个输入区包含标签文本和 TextInput 组件——TextInput 的 text 参数绑定 this.formName/this.formNote 状态变量,onChange 回调实时更新对应变量。placeholder 提供输入示例,backgroundColor 使用 chip 色与整体主题协调。

按钮行包含"取消"和"创建"两个按钮,各占 50% 宽度。"取消"使用暖米黄背景+棕色文字,点击调用 onClose 关闭弹窗。"创建"使用声波橙背景+白色粗体文字,点击调用 this.saveSub() 执行保存逻辑——saveSub 方法会将表单数据创建为新的 ProgramItem 推入列表,然后清空表单并关闭弹窗。

17.3 编辑订阅弹窗

@Builder
panelEdit(onClose: () => void) {
  Stack() {
    this.modalOverlay(onClose)
    Column({ space: 12 }) {
      Text('编辑订阅').fontSize(15).fontColor(COLORS.title).fontWeight(FontWeight.Bold)

      Column({ space: 6 }) {
        Text('节目名称').fontSize(9).fontColor(COLORS.sub)
        TextInput({ text: this.editName, placeholder: '节目名称' })
          .fontSize(11).fontColor(COLORS.title)
          .backgroundColor(COLORS.chip).borderRadius(8)
          .onChange((value: string) => {
            this.editName = value;
          })
      }
      .width('100%').alignItems(HorizontalAlign.Start)

      Column({ space: 6 }) {
        Text('分类标签').fontSize(9).fontColor(COLORS.sub)
        TextInput({ text: this.editNote, placeholder: '如:科技 / 人文 / 情感' })
          .fontSize(11).fontColor(COLORS.title)
          .backgroundColor(COLORS.chip).borderRadius(8)
          .onChange((value: string) => {
            this.editNote = value;
          })
      }
      .width('100%').alignItems(HorizontalAlign.Start)

      Row({ space: 10 }) {
        Text('取消').fontSize(12).fontColor(COLORS.sub)
          .layoutWeight(1).textAlign(TextAlign.Center)
          .padding({ top: 9, bottom: 9 }).backgroundColor(COLORS.chip).borderRadius(9)
          .onClick(() => onClose())
        Text('保存修改').fontSize(12).fontColor(COLORS.card).fontWeight(FontWeight.Bold)
          .layoutWeight(1).textAlign(TextAlign.Center)
          .padding({ top: 9, bottom: 9 }).backgroundColor(COLORS.brown).borderRadius(9)
          .onClick(() => {
            this.updateSub();
          })
      }
      .width('100%')
    }
    .width('78%').padding(16).backgroundColor(COLORS.card).borderRadius(14)
  }
  .width('100%')
  .height('100%')
  .alignContent(Alignment.Center)
}

panelEdit 是编辑订阅的弹窗面板,结构与 panelAdd 对称。区别在于:标题为"编辑订阅",输入字段绑定 editName/editNote 变量(在打开弹窗时已由 openEditSub 方法回填了当前节目数据),保存按钮文案为"保存修改",背景色使用原木棕而非声波橙(通过颜色区分编辑与新建操作的视觉语义),点击调用 this.updateSub() 执行更新逻辑。updateSub 方法会修改 programList 中对应索引的节目属性,然后通过 slice() 刷新数组引用触发列表重渲染。

17.4 退订确认弹窗

@Builder
panelDel(onClose: () => void) {
  Stack() {
    this.modalOverlay(onClose)
    Column({ space: 12 }) {
      Text('退订节目').fontSize(15).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
      Text('确认退订该节目吗?退订后将不再接收更新提醒,已下载的离线节目仍会保留在本地。')
        .fontSize(10).fontColor(COLORS.sub)
        .maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
      Row({ space: 10 }) {
        Text('取消').fontSize(12).fontColor(COLORS.sub)
          .layoutWeight(1).textAlign(TextAlign.Center)
          .padding({ top: 9, bottom: 9 }).backgroundColor(COLORS.chip).borderRadius(9)
          .onClick(() => onClose())
        Text('确认退订').fontSize(12).fontColor(COLORS.card).fontWeight(FontWeight.Bold)
          .layoutWeight(1).textAlign(TextAlign.Center)
          .padding({ top: 9, bottom: 9 }).backgroundColor(COLORS.red).borderRadius(9)
          .onClick(() => {
            this.delSub();
          })
      }
      .width('100%')
    }
    .width('78%').padding(16).backgroundColor(COLORS.card).borderRadius(14)
  }
  .width('100%')
  .height('100%')
  .alignContent(Alignment.Center)
}

panelDel 是退订确认弹窗面板。与新建和编辑弹窗不同,退订弹窗没有输入字段,只有一个确认文案——"确认退订该节目吗?退订后将不再接收更新提醒,已下载的离线节目仍会保留在本地。"这段文案既说明了退订的后果(不再接收更新提醒),也安抚了用户的顾虑(已下载的离线节目仍保留),体现了良好的用户体验设计。

按钮行包含"取消"和"确认退订"两个按钮。"确认退订"使用红色背景+白色粗体文字——红色作为危险操作的视觉警示色,明确告知用户此操作具有破坏性。点击确认退订调用 this.delSub(),该方法通过 splice 从 programList 中删除指定索引的节目,然后关闭弹窗。

三个弹窗面板的"取消"按钮都使用暖米黄背景+棕色文字,而"确认"按钮的颜色根据操作性质不同——新建用声波橙(积极操作)、编辑用原木棕(中性操作)、退订用红色(危险操作),通过按钮颜色传达操作语义,是优秀的视觉设计实践。

十八、技术特性对比表格

技术维度 传统字幕方案 本应用 AI 字幕方案 技术优势说明
字幕语言方向 固定源语言+目标语言 sourceLanguage + targetLanguage 双字段配置 支持 zh/en 源语言与 zh/en/zh-en 目标语言自由组合
源语言选择 无或硬编码 sourceLanguage 字段取值 ‘zh’ | ‘en’ HarmonyOS 6.1.1 新增,AI 引擎按源语言切换识别模型
目标语言选择 无或硬编码 targetLanguage 字段取值 ‘zh’ | ‘en’ | ‘zh-en’ 支持中英双语字幕模式,原文译文同屏对照
字体大小 固定或全局缩放 fontSize 字段 AICaptionFontSize 四档枚举 SMALL/NORMAL/BIG/LARGE 四档可选,满足不同视力场景
字体颜色 固定白色或系统主题 fontColor 字段 ResourceColor 自定义 五预设色(经典白/暖阳黄/薄荷绿/云朵蓝/樱花粉)可选
字幕组件集成 自行实现文字渲染 AICaptionComponent 声明式组件 isShown/controller/options 三参数配置,框架管理渲染
音频流写入 无标准接口 AudioData + writeAudio 方法 支持写入 PCM 音频块,端侧 AI 实时识别转字幕
服务状态反馈 无或手动查询 onPrepared + onError 双回调 就绪状态和异常信息自动推送,UI 实时响应
语言联动逻辑 switchSourceLang 联动目标语言 中文源锁定 zh 目标,英文源默认 zh-en 双语
配置实时预览 options 实时代码预览卡 深色代码块实时展示 AICaptionOptions 结构与取值
场景化预设 五条字幕场景推荐一键套用 英文精听/科技速览/中文纪要/双语学习/通勤无耳机
呼吸动画驱动 CSS 动画或属性动画 setInterval 每秒翻转 breath 布尔 统一驱动头部图标+频道闪烁+直播红点+柱状图波动
四 Tab 布局 统一列表或网格 四 Tab 布局完全差异化 电台宫格大卡/订阅清单柱状图/字幕五区块/我的渐变大卡
数据可视化 系统组件或静态 Flex 柱状图 + 渐变柱 + 呼吸波动 纯 ArkUI 组件实现图表,无需 Canvas 依赖
弹窗系统 系统弹窗 Stack 叠加遮罩 + 三态自定义面板 新建/编辑/退订统一管理,遮罩点击关闭
状态管理 手动刷新 @State + @Observed 自动响应 二十余状态变量自动驱动 UI 重渲染
主题色系 冷色或默认 暖米白+声波橙+原木棕浅色暖色系 暖色调契合播客电台的内容调性

十九、总结

本应用"声波FM·播客电台平台"以 HarmonyOS ArkUI 声明式开发范式为基础,围绕播客电台这一内容消费行业场景,构建了一个集频道浏览、节目订阅、AI 字幕增强、个人管理于一体的完整应用。通过四个布局完全差异化的 Tab 页面——电台频道宫格与热门大卡、订阅清单与月度柱状图、AI 字幕五区块特性页、个人会员渐变大卡与功能清单——覆盖了播客用户从"发现内容"到"管理订阅"再到"字幕增强"最后到"个人中心"的完整使用旅程。

在 AI 字幕能力方面,本应用充分展示了 HarmonyOS 6.1.1 Speech Kit 的 AICaptionComponent 四大新增字段特性。sourceLanguage 字段支持 ‘zh’ 和 ‘en’ 两种源语言选择,决定 AI 引擎的语音识别模型;targetLanguage 字段支持 ‘zh’、‘en’ 和 ‘zh-en’ 三种目标语言,决定字幕输出和翻译方向;fontSize 字段通过 AICaptionFontSize 枚举提供 SMALL、NORMAL、BIG、LARGE 四档字号选择;fontColor 字段通过 ResourceColor 类型支持任意颜色自定义。这四大字段的组合,使得播客字幕从"中文转中文"的单一模式升级为"英文播客转中英双语""中文播客转纯文字纪要"等多场景化方案。通过 buildCaptionOptions 方法将四大字段组装为 AICaptionOptions 对象实时传入 AICaptionComponent,用户每次切换语言或外观设置后字幕效果立即更新,无需重新初始化服务。

在语言联动设计方面,switchSourceLang 方法体现了对语言组合合理性的精确把控。中文源时目标语言锁定为中文——因为中文转中文不存在翻译方向,AI 引擎仅做语音识别不做翻译;英文源时目标语言默认设为中英双语——这是英文播客最常见的字幕方案,原文译文同时展示,用户可在中文、英文、中英双语之间自由切换。这种联动设计避免了"中文源+英文目标"等无效语言组合的出现,在 UI 层面通过条件渲染——中文源显示锁定提示行、英文源显示三选项按钮组——确保用户始终看到合理且有效的操作选项。

在交互设计方面,AI 字幕 Tab 的五个区块形成了一条完整的"预览-配置-验证-应用"交互链条。预览卡直接渲染 AICaptionComponent 并提供开启/隐藏和音频写入控制;语言设置卡和外观设置卡提供四大字段的交互配置入口;代码预览卡以深色代码块形式实时展示当前 AICaptionOptions 的完整结构和取值,让配置变化可视化;场景推荐列表将常见使用场景预设为快捷方案,用户点击即可一键套用语言组合。五个区块通过 @State 状态变量全局联动——任一配置变化后所有区块同步更新,体现了 ArkUI 声明式状态管理的响应能力。

在视觉设计方面,本应用遵循了"暖色系浅色主题"“四 Tab 差异化布局”“呼吸动画全局贯穿"三大设计原则。暖米白背景+声波橙强调色+原木棕辅助色的暖色系色板,契合了"深夜谈天”“情感疗愈"等播客内容的温暖调性,与冷色调科技感形成差异化。四个 Tab 页面的布局结构完全不同——电台是 Flex 宫格+渐变大卡、订阅是清单行+柱状图、AI 字幕是五区块垂直排列、我的是渐变大卡+功能清单行——避免了同质化设计。呼吸动画通过每秒翻转的 breath 布尔状态,统一驱动头部 Banner 图标透明度脉动、频道宫格图标闪烁、直播红点明暗、柱状图柱高 ±5% 波动,让整个应用拥有"活体感”。

在工程架构方面,代码遵循了"接口定义-常量声明-辅助函数-数据模型-组件主体"的分层组织模式。ColorPalette 接口集中管理 15 个颜色字段,COLORS 常量提供浅色暖色系色板实例;TabMeta、LangOption、SizeOption 三个接口定义数据结构;catColor、sizeName、langName、colorName 四个辅助函数提供转换工具;@Observed 装饰的 ChannelItem、ProgramItem、CaptionScene、UserStat 四个数据模型类配合 Mock 数据数组模拟数据层;组件主体通过 20 余个 @State 变量、9 个核心方法、12 个 @Builder 函数构建完整 UI。这种架构使代码可读性高、维护成本低、扩展性强——未来接入真实音频流数据源时,只需替换 Mock 数据和 feedDemoAudio 方法即可。

在弹窗系统方面,采用 Stack 叠加遮罩+自定义面板的模式,统一管理新建订阅分组、编辑订阅、退订确认三个弹窗。modalOverlay 作为公共遮罩组件提供半透明背景和点击关闭能力,三个面板 Builder 分别构建不同内容的弹窗。三种操作的"确认"按钮使用不同颜色——新建用声波橙(积极)、编辑用原木棕(中性)、退订用红色(危险)——通过颜色传达操作语义。退订弹窗的确认文案既说明后果(不再接收更新提醒)又安抚顾虑(离线节目保留),体现了对用户体验的细致考量。

总而言之,本应用是一个将 HarmonyOS 系统能力(Speech Kit 的 AICaptionComponent)与行业业务场景(播客电台)深度融合的典范之作。它不仅展示了鸿蒙原生应用在 AI 语音字幕领域的技术深度——四大新增字段的完整配置、语言组合的联动逻辑、实时代码预览的创新展示——更通过精心设计的暖色主题、差异化布局和呼吸动画,让"播客电台"这一音频内容形态拥有了温暖、活跃、可交互的视觉表达,将"听播客"升级为"看播客"的多模态内容消费体验。

附录:DevEco Studio 创建新项目与查看 SDK 版本

本章节演示如何使用 DevEco Studio 创建一个 HarmonyOS 新项目,并查看当前 IDE 已安装的 SDK 版本,适合作为其他技术博文的补充操作指南。


一、创建新项目

1.1 进入欢迎界面

启动 DevEco Studio 后,首先看到的是欢迎界面。左侧导航栏默认选中 “项目”,右侧提供三个主要入口:

  • 新建项目:从头创建新项目
  • 打开项目:打开本地已有项目
  • 克隆仓库:从 Git 等版本控制拉取代码

点击 “新建项目” 按钮,进入项目创建向导。

在这里插入图片描述

1.2 选择项目模板

在弹出的"新建项目"对话框中,左侧分类标签提供了两种项目类型:

类型 说明
应用(Application) 开发标准的 HarmonyOS 应用,具备完整的 Ability 生命周期
元服务(Atomic Service) 开发轻量级的原子化服务,无需安装即可使用

选择 “应用” 标签后,右侧展示多种模板。对于大多数场景,推荐选择 “Empty Ability” —— 这是一个最基础的入门模板,仅包含 Hello World 功能,适合从零开始构建应用。

在这里插入图片描述

1.3 配置项目信息

点击 “下一步” 后,进入项目配置界面,需要填写以下核心参数:

配置项 示例值 说明
项目名称(Project name) rollboat 应用的项目名称,建议使用英文命名
包名(Bundle name) com.rollboat.myapplication 应用唯一标识,采用反向域名格式
保存路径(Save location) D:\CodeFactory\rollboat 项目本地存储路径,避免使用中文和空格
兼容 SDK(Compatible SDK) 6.1.1(24) 目标 HarmonyOS API 版本,点击"查看参考"可了解各版本差异
模块名称(Module name) entry 主模块名称,默认 entry 为应用入口模块
设备类型(Device types) ☑ Phone 勾选目标设备:Phone / Tablet / 2in1 / Car / Wearable / TV

右侧预览区会实时展示当前模板的默认效果 —— 一个居中显示的 “Hello World” 文本。

在这里插入图片描述

1.4 完成创建

确认配置无误后,点击右下角 “完成” 按钮,IDE 将自动执行以下操作:

  1. 生成项目骨架(Stage 模型目录结构)
  2. 执行 ohpm install 安装依赖
  3. 运行 Hvigor 构建初始化(Build Init

构建日志中显示 “退出代码为 0” 表示项目初始化成功。

在这里插入图片描述

1.5 项目结构概览

创建完成后,左侧项目面板展示的是标准的 Stage 模型 目录结构:

rollboat/
├── .hvigor/                   # Hvigor 构建工具缓存
├── .idea/                     # IDE 配置文件
├── AppScope/                  # 应用级全局配置
│   └── app.json5
├── entry/                     # 主模块(入口模块)
│   ├── src/main/ets/
│   │   ├── entryability/      # Ability 生命周期管理
│   │   │   └── EntryAbility.ets
│   │   └── pages/             # UI 页面
│   │       └── Index.ets      # 首页(默认 Hello World)
│   ├── src/main/resources/    # 资源文件
│   ├── module.json5           # 模块配置
│   └── build-profile.json5    # 构建配置
├── oh_modules/                # OHPM 依赖包
├── build-profile.json5        # 工程构建配置
├── hvigorfile.ts              # Hvigor 构建脚本
└── oh-package.json5           # 包管理配置

核心文件 Index.ets 的默认代码如下,采用 ArkTS 声明式 UI 语法:

@Entry
@Component
struct Index {
  @State message: string = 'Hello World';

  build() {
    RelativeContainer() {
      Text(this.message)
        .id('HelloWorld')
        .fontSize($r('app.float.page_text_font_size'))
        .fontWeight(FontWeight.Bold)
        .alignRules({
          center: { anchor: '__container__', align: VerticalAlign.Center },
          middle: { anchor: '__container__', align: HorizontalAlign.Center }
        })
        .onClick(() => {
          this.message = 'Welcome';
        })
    }
    .height('100%')
    .width('100%')
  }
}
关键语法 作用
@Entry 标记为页面入口,可用于路由跳转
@Component 声明为自定义组件
@State 状态变量,数据变更时自动触发 UI 刷新
RelativeContainer 相对布局容器,替代传统线性布局
.onClick() 点击事件,此处点击后文本变为 “Welcome”

打开右侧 Previewer(预览器),选择 Phone 设备,即可实时预览 Hello World 效果,无需连接真机或启动模拟器。

在这里插入图片描述


二、查看 SDK 版本

2.1 查看 HarmonyOS SDK

DevEco Studio 安装时已内置 HarmonyOS SDK,无需单独下载。通过以下路径查看:

文件 → 设置 → HarmonyOS SDK(或快捷键 Ctrl + Alt + S 搜索 “HarmonyOS SDK”)

在设置面板中,可以看到当前已安装的 SDK 版本信息:

名称 阶段 状态
HarmonyOS 6.1.1 Release ✅ 已安装

界面顶部提示:“HarmonyOS SDK 已经包含在 IDE,无需单独安装”,省去了手动配置 SDK 的繁琐步骤。

在这里插入图片描述

2.2 查看 ArkUI-X SDK(跨平台扩展)

如果项目需要将 ArkUI 框架扩展到多个 OS 平台(Android / iOS / OpenHarmony),还需要配置 ArkUI-X SDK。路径如下:

文件 → 设置 → 语言和框架 → ArkUI-X

在这里可以查看已安装和可选的 ArkUI-X SDK 版本:

版本 SDK 版本号 阶段 状态
API Version 24 6.1.1.100 Release ✅ 已安装
API Version 23 6.1.0.28 Beta1 未安装
API Version 22 6.0.2.112 Release 未安装

安装路径示例:D:\DevTools\ArkUI-X\sdk

说明:ArkUI-X 允许开发者使用一套 ArkTS 主代码,同时构建多平台应用。如果仅开发 HarmonyOS 原生应用,无需额外安装 ArkUI-X SDK。

在这里插入图片描述


三、小结

步骤 操作 关键点
创建项目 欢迎页 → 新建项目 → 选择 Empty Ability 模板 → 配置项目信息 → 完成 使用 Stage 模型 + ArkTS 语言
查看 SDK 设置 → HarmonyOS SDK SDK 已内置,无需手动安装
跨平台扩展 设置 → ArkUI-X 根据需要安装对应 API 版本

至此,DevEco Studio 的项目创建与 SDK 环境确认全部完成,可以开始 HarmonyOS 应用的功能开发。


本文基于 DevEco Studio 6.1.1 Release 版本编写,不同版本界面可能存在细微差异。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐