一、技术前言:HarmonyOS ArkUI 与 Speech Kit AICaptionComponent 的技术全景

在这里插入图片描述
HarmonyOS 的 ArkUI 框架是华为为全场景多设备应用开发打造的声明式 UI 框架,其核心语言 ArkTS 在 TypeScript 的基础上扩展了 @Entry@Component@State@Builder@Observed 等装饰器语法,使开发者能够以接近自然语言的方式描述界面结构与状态依赖关系。与传统的命令式 UI 编程不同,ArkUI 采用状态驱动渲染的范式——当 @State 修饰的变量发生变化时,框架会自动 diff 出受影响的 UI 节点并执行最小化重渲染,开发者无需手动操作 DOM 或调用 setTextsetColor 等方法。这种范式极大地简化了状态管理的心智负担,尤其适合需要频繁交互、多状态联动的复杂应用场景。

在这里插入图片描述
在 HarmonyOS 6.1.1 版本中,Speech Kit(语音服务套件)迎来了一次重要更新——AICaptionComponent(AI 字幕组件)新增了四个关键配置字段:sourceLanguagetargetLanguagefontSizefontColor。这四个字段的加入使得 AI 字幕从一个功能固定的系统级组件,进化为一个可深度定制的场景化语音交互组件。sourceLanguage 接受 'zh''en' 两种取值,用于指定输入音频的源语言;targetLanguage 接受 'zh''en''zh-en' 三种取值,分别对应中文翻译、英文翻译和中英双语显示;fontSize 使用 AICaptionFontSize 枚举,提供 SMALLNORMALBIGLARGE 四档字号选择;fontColor 则接受 ResourceColor 类型,允许开发者自定义字幕的字体颜色。这四个字段的组合,使得 AI 字幕组件能够灵活适配听力训练、口语陪练、影视跟读、沉浸式磨耳朵等多种语言学习细分场景。

在这里插入图片描述
语言学习应用作为一个高度依赖音频交互的场景,天然需要实时语音转文字能力来辅助学习者。传统的语言学习应用往往只能提供预录制的文本字幕,缺乏实时性和互动性。而 HarmonyOS Speech Kit 的 AICaptionComponent 通过 AICaptionControllerwriteAudio 方法接收实时 PCM 音频流,经云端 AI 引擎进行语音识别和翻译后,将字幕实时渲染在组件区域内——这一流程实现了从"听不懂"到"看得见"的即时跨越。更重要的是,双语字幕(zh-en)模式可以同时展示原文和译文,这对于语言学习者理解语音内容、对比翻译差异、练习跟读发音具有不可替代的价值。

在这里插入图片描述
本篇博文将深入分析一个名为"语伴陪练"的语言学习应用,该应用完整集成了 HarmonyOS Speech Kit 的 AICaptionComponent 组件,并充分利用了 6.1.1 版本新增的四大配置字段。应用采用四个 Tab 页面架构(课程/口语/AI字幕/我的),每个 Tab 拥有完全不同的布局风格和功能侧重点,形成了一个从课程管理到口语陪练、从 AI 字幕体验到用户中心的完整学习闭环。应用整体采用浅色清新白(#F4FAF8)+ 学习青绿(#2FA98C)的色彩主题,传达出清新、专注、可持续学习的设计理念。

在这里插入图片描述
在设计理念上,该应用遵循了"场景驱动配置"的原则——AI 字幕的四大新字段并非孤立地暴露给用户,而是与具体的语言学习场景深度绑定。应用预置了五种字幕场景(口语陪练对话、听力真题训练、影视片段跟读、中文文化讲解、沉浸式磨耳朵),每个场景都预设了对应的源语言和目标语言组合,用户点击即可一键套用。这种设计将技术能力的复杂度封装在场景背后,让非技术用户也能便捷地使用 AI 字幕的高级配置,体现了优秀的产品思维。

在这里插入图片描述
此外,应用在数据层面使用了 @Observed 装饰器修饰数据模型类(CourseItem、CoachItem、ReviewItem、CaptionScene、UserStat),配合 @State 实现了数据的响应式驱动。弹窗系统采用 modalOverlay 全屏遮罩 + 面板分离的设计模式,将创建计划、编辑计划、删除确认三种交互分别封装为独立的 @Builder 函数,既保证了代码的可维护性,又实现了弹窗 UI 的统一风格。下面,我们将从整体架构、代码逐段分析、关键技术点等维度展开详细的技术剖析。

二、应用整体架构流程图

应用采用单页面多 Tab 的架构模式,以 Stack 容器为根布局,内嵌 Column 承载主内容,底部固定 4 Tab 导航栏。以下流程图展示了应用的整体架构层次与数据流向。

0

1

2

3

Stack 根容器

Column 主内容列

panelAdd 创建计划弹窗

panelEdit 编辑计划弹窗

panelDel 删除确认弹窗

headerMain 头部区

Scroll 可滚动内容区

tabBar 底部导航栏

渐变 Banner 问候语

搜索条

横滑语言/级别 Chips

currentTab 判断

tabCourse 课程页

tabSpeak 口语页

tabCaption AI字幕页

tabMine 我的页

课程分类入口

在学课程进度清单

chartCard 月度柱状图

外教/语伴横滑大卡

学员反馈评论卡

AI字幕实时预览

语言设置 sourceLanguage/targetLanguage

外观设置 fontSize/fontColor

options 代码预览

字幕场景推荐列表

用户渐变大卡

功能清单行

modalOverlay 全屏遮罩

modalOverlay 全屏遮罩

modalOverlay 全屏遮罩

AICaptionController.writeAudio

Speech Kit AI引擎

AICaptionComponent 字幕渲染

从架构流程图可以看出,应用的核心数据流围绕 currentTab 状态变量展开:底部 Tab 的点击事件修改 currentTabScroll 区域内的条件渲染根据 currentTab 的值决定显示哪个 Tab 的内容。AI 字幕 Tab(currentTab === 2)是整个应用的技术核心,它通过 AICaptionController 将音频数据写入 Speech Kit 引擎,引擎返回的字幕内容实时渲染在 AICaptionComponent 组件区域内,同时四大配置字段(sourceLanguage/targetLanguage/fontSize/fontColor)的变更会通过 buildCaptionOptions() 方法实时组装并传入组件,实现字幕的即时样式更新。

三、模块导入与 Speech Kit 核心类型引入

3.1 模块导入语句

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

应用的第一行代码就从 @kit.SpeechKit 模块中导入了五个核心类型,这五者共同构成了 AI 字幕功能的类型基石。AICaptionComponent 是一个系统级 UI 组件,它负责在界面上渲染字幕区域,接收用户的 isShown 状态(控制显示/隐藏)、controller 控制器实例和 options 配置对象三个参数。AudioData 是音频数据的接口类型,其 data 字段为 Uint8Array,用于承载 PCM 格式的原始音频字节流。AICaptionOptions 是字幕配置的接口类型,包含了 6.1.1 版本新增的四大字段以及回调函数。AICaptionController 是字幕控制器类,其实例通过 writeAudio 方法向引擎推送音频数据。AICaptionFontSize 是字号枚举,定义了 SMALL/NORMAL/BIG/LARGE 四档。

第二行从 @kit.BasicServicesKit 导入了 BusinessError 类型,这是 HarmonyOS 的标准业务错误类型,包含 code(错误码)和 message(错误描述)两个字段。在 AICaptionOptionsonError 回调中,该类型用于接收 Speech Kit 引擎返回的异常信息,使应用能够对字幕服务异常进行优雅处理和用户提示。

3.2 导入设计意图分析

将这两个模块的导入放在文件最顶部,体现了 ArkTS 的模块化依赖管理规范。@kit.SpeechKit@kit.BasicServicesKit 都是 HarmonyOS 的系统能力 Kit(能力套件),通过 import { ... } from '@kit.xxx' 语法引入,而非 npm 包的路径式引入。这种 Kit 级别的导入方式确保了应用能够调用系统底层的语音 AI 能力,而不需要引入额外的第三方库。

值得注意的是,导入语句中五个类型全部来自同一个模块,这表明它们在运行时共享同一个 Speech Kit 服务实例。AICaptionController 作为控制器是音频数据写入的入口,AICaptionComponent 作为视图组件是字幕渲染的出口,二者通过同一个组件实例的 controller 参数建立关联,形成了一条"数据写入 -> 引擎处理 -> 视图渲染"的完整链路。

四、颜色系统设计

4.1 ColorPalette 接口定义

/** 主题色板接口:集中声明页面所有颜色字段(清新白+学习青绿浅色系) */
interface ColorPalette {
  bg: string;
  card: string;
  chip: string;
  title: string;
  sub: string;
  text3: string;
  teal: string;
  tealD: string;
  blue: string;
  red: string;
  gold: string;
  line: string;
  tabOn: string;
  mask: string;
}

应用首先定义了一个 ColorPalette 接口,将页面中可能用到的所有颜色字段集中声明。这是一种优秀的颜色管理实践——通过接口约束,确保所有颜色字段都有明确的语义命名,而非在代码各处散落硬编码的十六进制色值。接口中包含了 13 个字段,涵盖了背景色(bg)、卡片色(card)、标签色(chip)、标题色(title)、副文本色(sub)、三级文本色(text3)、主题青绿色(teal)及其深色变体(tealD)、辅助蓝色(blue)、警示红色(red)、强调金色(gold)、分割线色(line)、Tab 选中色(tabOn)和遮罩色(mask)。

这种语义化的命名方式使得后续的维护和主题切换变得极为便捷。如果需要支持深色主题,只需新建一个 ColorPalette 的实现并替换 COLORS 常量,所有引用 COLORS.xxx 的代码将自动适配新主题,无需逐行查找和替换颜色值。

4.2 COLORS 主题色板常量

/** 浅色主题色板常量(语伴陪练 · 清新白 + 学习青绿) */
const COLORS: ColorPalette = {
  bg: '#F4FAF8',
  card: '#FFFFFF',
  chip: '#E6F2ED',
  title: '#1F332C',
  sub: '#5E7A70',
  text3: '#93AAA0',
  teal: '#2FA98C',
  tealD: '#1F856D',
  blue: '#4D8FD6',
  red: '#E06A5A',
  gold: '#D9A441',
  line: '#DCEAE4',
  tabOn: '#2FA98C',
  mask: 'rgba(31,51,44,0.5)'
};

COLORS 常量实现了 ColorPalette 接口,定义了应用"清新白 + 学习青绿"主题的全部色值。主背景色 #F4FAF8 是一种极浅的青绿色调白,比纯白 #FFFFFF 更柔和,长时间观看不易产生视觉疲劳。主题色 #2FA98C 是一种中等饱和度的青绿色,介于绿色和青色之间,给人以自然、成长、专注的心理暗示,非常契合语言学习应用的调性。深色变体 #1F856D 用于渐变的起始端和需要更高对比度的场景(如按钮按下态、渐变 Banner 底端)。

文本色阶从 #1F332C(深绿黑色标题)到 #5E7A70(灰绿副文本)再到 #93AAA0(浅灰三级文本)形成了三级灰度体系,保证了信息层级的清晰可读性。辅助色方面,#4D8FD6 蓝色用于信息标签和链接,#E06A5A 红色用于删除和错误提示,#D9A441 金色用于评分和会员强调。遮罩色 rgba(31,51,44,0.5) 使用了半透明的深绿黑色,在弹窗出现时覆盖底层内容,既保证了视觉焦点集中,又不完全遮蔽背景信息。

五、常量定义与数据预设

5.1 Tab 导航与语言标签常量

/** Tab 元数据接口:底部导航图标 + 标签 */
interface TabMeta {
  icon: string;
  label: string;
}

/** 底部导航 Tab 常量列表(4 Tab 单排) */
const TAB_LIST: TabMeta[] = [
  { icon: '📖', label: '课程' },
  { icon: '🗨', label: '口语' },
  { icon: '🗣', label: 'AI字幕' },
  { icon: '👤', label: '我的' }
];

/** 头部横滑语言/级别 chips 文案 */
const LANG_TAGS: string[] = ['推荐', '英语', '日语', '法语', '韩语', 'A1 入门', 'B1 中级', 'C1 高级'];

TabMeta 接口定义了底部导航 Tab 的数据结构,包含 icon(图标 emoji)和 label(标签文本)两个字段。TAB_LIST 常量数组定义了四个 Tab 的元数据,分别对应课程、口语、AI 字幕、我的四个功能模块。使用 emoji 作为 Tab 图标是一种轻量化的设计选择——无需引入图标资源文件,且 emoji 在不同设备上的显示一致性较好。四个 Tab 的排列顺序遵循了用户的学习路径:先选课程 -> 再练口语 -> 借助 AI 字幕 -> 管理个人中心。

LANG_TAGS 数组定义了头部横滑语言/级别 chips 的文案,包含语言类别(推荐、英语、日语、法语、韩语)和 CEFR 级别(A1 入门、B1 中级、C1 高级)两个维度。CEFR(欧洲语言共同参考框架)是国际通用的语言能力分级标准,将其引入应用体现了语言学习产品的专业性。这些 chips 通过横滑 Scroll 容器呈现,选中态使用 COLORS.chip 背景和 COLORS.teal 文字高亮,未选中态使用白色背景和 COLORS.sub 文字。

5.2 课程分类入口数据

/** 课程分类入口接口(课程 Tab 图标清单) */
interface CateEntry {
  icon: string;  // 分类图标
  name: string;  // 分类名
  hot: string;   // 角标文案
}

/** 课程分类入口 Mock 数据(6 条) */
const CATE_ENTRY: CateEntry[] = [
  { icon: '📖', name: '每日一句', hot: '晨读' },
  { icon: '🎯', name: '口语闯关', hot: '热门' },
  { icon: '✏', name: '语法诊所', hot: '提分' },
  { icon: '🎧', name: '听力精讲', hot: '真题' },
  { icon: '🔤', name: '单词打卡', hot: '晨读' },
  { icon: '🗣', name: '发音矫正', hot: '外教' }
];

CateEntry 接口定义了课程分类入口的数据结构,包含图标、名称和角标三个字段。六条 Mock 数据覆盖了语言学习的主要细分场景:每日一句适合碎片化学习,口语闯关提供游戏化练习,语法诊所聚焦规则讲解,听力精讲针对考试训练,单词打卡建立学习习惯,发音矫正提供外教服务。每条数据的 hot 字段作为角标文案,使用不同的文案(晨读、热门、提分、真题、外教)来传递该分类的特点和推荐理由,帮助用户快速做出选择。

这组数据通过横滑 Scroll + ForEach 渲染为图标清单,每个入口包含一个 48x48 的圆形图标背景、分类名和角标文案。选中态的视觉区分通过 cateIdx 状态变量实现,但课程分类入口本身不支持选中态(点击会跳转到对应分类的课程列表页),因此在代码中没有选中态逻辑。

5.3 Speech Kit 字幕语言与样式常量

/** 源语言选项(取值范围:['zh','en']) */
interface LangOption {
  code: string;    // 语言码
  name: string;    // 展示名
}

/** 源语言选项列表 */
const SRC_LANGS: LangOption[] = [
  { code: 'zh', name: '中文' },
  { code: 'en', name: '英文' }
];

/** 英文源时的目标语言选项(中文源时目标语言锁定 'zh') */
const TGT_LANGS_EN: LangOption[] = [
  { code: 'zh', name: '中文' },
  { code: 'en', name: '英文' },
  { code: 'zh-en', name: '中英双语' }
];

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

值得注意的是,源语言和目标语言的选项是分离的——这是因为 sourceLanguage'zh'(中文源)时,targetLanguage 只能锁定为 'zh'(中文),不存在翻译方向。这种设计逻辑在代码的 switchSourceLang 方法中得到了体现:当用户选择中文源时,目标语言自动锁定为 'zh' 并禁用目标语言选择器;当用户选择英文源时,目标语言默认设为 'zh-en'(中英双语)并开放选择器。这种联动逻辑避免了无效的语言组合,提升了用户体验的合理性。

5.4 字号与字体颜色预设

/** 字号选项接口(AICaptionFontSize 枚举四档) */
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: '超大' }
];

/** 字幕字体颜色预设(fontColor 为 ResourceColor) */
const CAPTION_FONT_COLORS: string[] = ['#FFFFFF', '#FFE9B0', '#9CE8B5', '#9CD0FF', '#FFB3C1'];

SizeOption 接口将 AICaptionFontSize 枚举值与中文展示名配对,SIZE_OPTIONS 数组定义了四档字号选项。这种"枚举 + 展示名"的数据结构设计,使得 UI 层可以直接通过 ForEach 渲染选项列表,而无需在渲染逻辑中编写 if-else 判断枚举值。fontColor 字段接受 ResourceColor 类型,这里使用字符串色值数组 CAPTION_FONT_COLORS 预设了五种颜色:经典白、暖阳黄、薄荷绿、云朵蓝、樱花粉。

五种颜色预设的选择并非随意,而是考虑了字幕在不同背景下的可读性。经典白(#FFFFFF)适用于深色背景和视频场景;暖阳黄(#FFE9B0)在深色背景上具有暖色调视觉感受,适合长时间阅读;薄荷绿(#9CE8B5)与应用主题色青绿形成呼应,具有品牌一致性;云朵蓝(#9CD0FF)提供冷色调选择,适合冷静的学习场景;樱花粉(#FFB3C1)则提供了柔和的暖色选择。用户通过圆形色块按钮选择颜色,选中态使用 COLORS.teal 描边高亮,视觉反馈清晰直观。

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[] = [42, 55, 48, 63, 71, 80];
/** 月度柱状图最大值(小时) */
const MONTH_MAX: number = 90;

这组常量定义了月度学习时长柱状图的全部数据。MONTH_IDX 是月份索引数组,用于 ForEach 的遍历。MONTH_LABELSMONTH_HOURS 分别存储月份名称和学习时长(小时),两者通过相同的索引位置关联。MONTH_MAX 定义为 90 小时,作为柱状图的纵轴最大值,用于计算柱子的相对高度比例。

从数据趋势来看,42 -> 55 -> 48 -> 63 -> 71 -> 80 呈现整体上升态势(仅 5 月有小幅回落),体现了用户学习投入的持续增长。柱状图的渲染逻辑使用 MONTH_HOURS[i] / MONTH_MAX * 110 计算柱子高度(最大 110vp),并叠加 breath 呼吸动画的 ±5% 波动效果(this.breath ? 1.05 : 0.95),使图表呈现微妙的"呼吸"动态效果,增强了视觉活力。

六、辅助函数设计

6.1 级别与进度颜色映射函数

/** 级别徽章颜色映射:A 系入门蓝 / B 系中级青绿 / C 系高级金 / 其他弱化 */
function levelColor(level: string): string {
  if (level.indexOf('A') === 0) { return COLORS.blue; }
  if (level.indexOf('B') === 0) { return COLORS.blue; }
  if (level.indexOf('C') === 0) { return COLORS.gold; }
  return COLORS.text3;
}

/** 课程进度颜色映射:满额青绿 / 过半蓝 / 不足金 / 刚起步红 */
function progressColor(done: number): string {
  if (done >= 100) { return COLORS.teal; }
  if (done >= 60) { return COLORS.blue; }
  if (done >= 30) { return COLORS.gold; }
  return COLORS.red;
}

/** 外教评分颜色映射:4.9 及以上金 / 4.7 及以上青绿 / 其他蓝 */
function ratingColor(rating: string): string {
  const score: number = Number(rating);
  if (score >= 4.9) { return COLORS.gold; }
  if (score >= 4.7) { return COLORS.teal; }
  return COLORS.blue;
}

应用定义了三个颜色映射辅助函数,将业务数据映射为主题色板中的具体颜色值。levelColor 函数根据语言级别的前缀字母返回对应颜色:A 系(A1/A2 入门级)映射为蓝色,B 系(B1/B2 中级)映射为青绿色,C 系(C1/C2 高级)映射为金色,其他级别(如 N3 日语级别)映射为弱化灰绿色。这种映射使得用户在浏览课程列表时,能够通过颜色快速识别课程的难度层级。

progressColor 函数根据完成进度百分比返回四种颜色:100% 满额映射为青绿色(表示已完成的成就感),60% 以上映射为蓝色(表示进展顺利),30% 以上映射为金色(表示需要加把劲),不足 30% 映射为红色(表示刚起步需要关注)。这种渐进式的颜色反馈,使进度条不仅是数据可视化,更是学习激励的工具。

ratingColor 函数将外教评分映射为颜色:4.9 及以上映射为金色(顶级评价),4.7 及以上映射为青绿色(优秀评价),其他映射为蓝色(良好评价)。三个函数共同体现了"数据驱动的颜色系统"设计理念——颜色不再是静态的装饰,而是业务语义的视觉编码。

6.2 枚举与语言码转换函数

/** 字号枚举转展示名(options 代码预览用) */
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';
}

/** 语言码转展示名(如 'zh-en' → '中英双语') */
function langName(code: string): string {
  if (code === 'zh') { return '中文'; }
  if (code === 'en') { return '英文'; }
  return '中英双语';
}

/** 字幕颜色预设转中文名(按 CAPTION_FONT_COLORS 下标顺序) */
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 '樱花粉';
}

这三个辅助函数负责将技术化的枚举值和语言码转换为用户友好的展示名。sizeNameAICaptionFontSize 枚举值转换为对应的大写英文名,用于 AI 字幕 Tab 的"options 实时代码预览"区域,使预览代码看起来更接近实际开发中编写的配置对象。langName 将语言码(zh/en/zh-en)转换为中文名,用于语言设置卡底部的"当前组合"文本展示。colorName 将十六进制颜色值转换为诗意化的中文名(经典白、暖阳黄、薄荷绿、云朵蓝、樱花粉),用于外观设置卡的当前颜色展示。

这三个函数虽然在逻辑上简单,但体现了产品思维中"技术透明化"的原则——将技术内部的语言码、枚举值、颜色值翻译为用户可理解的自然语言,降低了用户对 AI 字幕配置的认知门槛。特别是 colorName 函数使用了富有诗意的中文名,而非"白色""黄色"等直白描述,增强了应用的人文气质。

七、数据模型定义

7.1 课程数据模型 CourseItem

/** 在学课程条目(课程 Tab 进度条清单) */
@Observed export class CourseItem {
  icon: string;     // 课程图标
  name: string;     //课程名(如"日常口语 500 句")
  level: string;    // 级别(如"B1 中级")
  lessons: string;  // 课时数文本(如"120 课时")
  done: number;     // 完成进度 0~100

  constructor(icon: string, name: string, level: string, lessons: string, done: number) {
    this.icon = icon;
    this.name = name;
    this.level = level;
    this.lessons = lessons;
    this.done = done;
  }
}

/** 在学课程 Mock 数据(8 条) */
const COURSE_LIST: Array<CourseItem> = [
  new CourseItem('📖', '日常口语 500 句', 'B1 中级', '120 课时', 68),
  new CourseItem('🎧', '雅思听力真题精讲', 'B2 中高级', '96 课时', 42),
  new CourseItem('💼', '商务英语邮件写作', 'B1 中级', '64 课时', 55),
  new CourseItem('🗾', '日语 N3 语法通关', 'N3 进阶', '88 课时', 12),
  new CourseItem('🗣', '发音矫正训练营', 'A2 初级', '40 课时', 90),
  new CourseItem('🥐', '法语旅行生存口语', 'A1 入门', '36 课时', 100),
  new CourseItem('✍', '托福独立写作强化', 'C1 高级', '72 课时', 25),
  new CourseItem('📘', '韩语入门四十音', 'A1 入门', '24 课时', 75)
];

CourseItem 类使用了 @Observed 装饰器修饰,这是 ArkUI 的响应式数据模型装饰器。@Observed 会为类的每个属性生成代理,当属性值被修改时,框架会自动通知所有引用该实例的 @State 变量并触发重渲染。这意味着当用户通过编辑弹窗修改课程名称或级别后,课程列表的 UI 会自动刷新,无需手动调用刷新方法。

CourseItem 类包含五个字段:icon(课程图标 emoji)、name(课程名)、level(语言级别)、lessons(课时数文本)、done(完成进度 0-100)。构造函数接收所有字段参数并赋值。八条 Mock 数据覆盖了英语、日语、法语、韩语四种语言,级别从 A1 入门到 C1 高级,进度从 12% 到 100%,充分展示了各种数据状态下的 UI 表现力。特别注意到 done 为 100 的"法语旅行生存口语"会显示"已完成"文本,而其他课程显示"进行中",这体现了进度数据的语义化处理。

7.2 外教与学员反馈数据模型

/** 外教/语伴条目(口语 Tab 横滑人物大卡) */
@Observed export class CoachItem {
  name: string;    // 外教名
  avatar: string;  // emoji 头像
  country: string; // 国籍(如"加拿大")
  rating: string;  // 评分文本(如"4.9")
  tag: string;     // 擅长方向(如"商务英语")

  constructor(name: string, avatar: string, country: string, rating: string, tag: string) {
    this.name = name;
    this.avatar = avatar;
    this.country = country;
    this.rating = rating;
    this.tag = tag;
  }
}

/** 学员反馈条目(口语 Tab 评论卡列表) */
@Observed export class ReviewItem {
  user: string;     // 学员昵称
  avatar: string;   // emoji 头像
  content: string;  // 反馈内容
  score: string;    // 星级文本(如"5.0")

  constructor(user: string, avatar: string, content: string, score: string) {
    this.user = user;
    this.avatar = avatar;
    this.content = content;
    this.score = score;
  }
}

CoachItem 类定义了外教/语伴的数据模型,包含姓名、头像、国籍、评分和擅长方向五个字段。六条 Mock 数据涵盖了来自加拿大、英国、日本、美国、法国、韩国六国的外教,评分从 4.6 到 4.9,擅长方向涵盖商务英语、雅思口语、日语会话、托福听力、法语启蒙、韩语发音,体现了多语种、多方向的陪练能力。外教卡片的头部区域使用渐变背景(tealD -> teal),头像 emoji 的透明度随 breath 状态产生呼吸效果,评分使用 ratingColor 函数映射颜色。

ReviewItem 类定义了学员反馈的数据模型,包含昵称、头像、反馈内容和星级评分四个字段。六条 Mock 数据使用了动物 emoji 作为学员头像(狐狸、考拉、老虎、兔子、企鹅、狮子),反馈内容涉及纠音细致、双语字幕、雅思提分、碎片学习、场景对话、课后复习等具体学习体验点,评分从 4.7 到 5.0。这些真实感强的反馈文案增强了应用的说服力和可信度。

7.3 字幕场景与用户功能数据模型

/** 字幕场景条目(AI字幕 Tab 场景推荐列表) */
@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 = tgt;
  }
}

/** 字幕场景 Mock 数据(5 条:语言学习行业相关) */
const SCENE_LIST: Array<CaptionScene> = [
  new CaptionScene('口语陪练对话', '与外教对话双语字幕对照', 'en', 'zh-en'),
  new CaptionScene('听力真题训练', '考试听力原文实时展示', 'en', 'en'),
  new CaptionScene('影视片段跟读', '台词原文译文对照练发音', 'en', 'zh'),
  new CaptionScene('中文文化讲解', '中文讲解内容速记', 'zh', 'zh'),
  new CaptionScene('沉浸式磨耳朵', '只开目标语言字幕浸泡', 'en', 'en')
];

CaptionScene 类定义了字幕场景推荐的数据模型,包含场景名、场景说明、推荐源语言和推荐目标语言四个字段。五条 Mock 数据覆盖了语言学习的五种典型场景,每条数据都预设了对应的 sourceLanguagetargetLanguage 组合,用户点击"套用"按钮即可一键应用该语言组合到 AI 字幕组件。

这五条场景数据的设计体现了对语言学习不同阶段的深度理解。口语陪练对话场景使用中英双语(zh-en),方便学习者对照原文和译文;听力真题训练场景使用英文源英文目标(en/en),让学习者专注于原文理解;影视片段跟读场景使用英文源中文目标(en/zh),方便理解剧情的同时练习发音;中文文化讲解场景使用中文源中文目标(zh/zh),用于速记中文讲解内容;沉浸式磨耳朵场景同样使用英文源英文目标,但意图是让学习者浸泡在目标语言中培养语感。

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;
  }
}

/** 我的页功能清单 Mock 数据(8 条) */
const STAT_LIST: Array<UserStat> = [
  new UserStat('📅', '学习日历', '连续打卡 128 天', true),
  new UserStat('🔤', '生词本', '342 词待复习', true),
  new UserStat('🏅', '成就徽章', '已解锁 26 枚', true),
  new UserStat('⏰', '每日提醒', '21:30 提醒学习', true),
  new UserStat('🗣', 'AI 字幕偏好', '源 en · 目标 zh-en', true),
  new UserStat('⬇', '离线课件', '12 门课已缓存', true),
  new UserStat('👑', '会员中心', '2026-11-20 到期', true),
  new UserStat('⚙', '学习设置', '每天 25 分钟', true)
];

UserStat 类定义了"我的"页面功能清单的数据模型,包含图标、功能名、状态值和是否显示右箭头四个字段。八条 Mock 数据涵盖了学习日历、生词本、成就徽章、每日提醒、AI 字幕偏好、离线课件、会员中心和学习设置八项功能。每条数据的 value 字段展示了该功能的状态摘要(如"连续打卡 128 天"、“342 词待复习”),用户无需进入二级页面即可了解关键信息。

特别注意到"AI 字幕偏好"这一条目,其状态值为"源 en · 目标 zh-en",直接映射了 AI 字幕 Tab 中的语言设置。这体现了应用各 Tab 之间的数据联动性——用户在 AI 字幕 Tab 修改语言设置后,"我的"页面的 AI 字幕偏好摘要也会相应更新(在真实应用中需要通过 AppStorage 或全局状态管理实现跨 Tab 数据共享)。

八、主组件状态与 AI 字幕核心逻辑

8.1 组件声明与状态变量定义

/** 1117 语伴陪练 · 语言学习主页面 */
@Entry
@Component
struct Page1117 {
  /** 当前选中 Tab 索引 */
  @State currentTab: number = 0;
  /** 呼吸动画开关(每秒翻转,联动柱状图与头像) */
  @State breath: boolean = false;
  /** 呼吸动画定时器句柄 */
  @State timer: number = -1;
  /** 头部语言/级别 chips 选中索引 */
  @State cateIdx: number = 0;
  /** 创建学习计划弹窗开关 */
  @State addModal: boolean = false;
  /** 编辑学习计划弹窗开关 */
  @State editModal: boolean = false;
  /** 删除课程确认弹窗开关 */
  @State delModal: boolean = false;
  /** 当前编辑的课程索引 */
  @State editIdx: number = 0;
  /** 当前删除的课程索引 */
  @State delIdx: number = 0;
  /** 在学课程清单数据 */
  @State courseList: Array<CourseItem> = COURSE_LIST;
  /** 外教/语伴列表数据 */
  @State coachList: Array<CoachItem> = COACH_LIST;
  /** 学员反馈列表数据 */
  @State reviewList: Array<ReviewItem> = REVIEW_LIST;
  /** 字幕场景列表数据 */
  @State sceneList: Array<CaptionScene> = SCENE_LIST;
  /** 我的页功能清单数据 */
  @State statList: Array<UserStat> = STAT_LIST;
  /** 新建表单:学习计划名称 */
  @State formName: string = '';
  /** 新建表单:学习目标级别 */
  @State formGoal: string = '';
  /** 编辑表单:学习计划名称 */
  @State editName: string = '';
  /** 编辑表单:学习目标级别 */
  @State editGoal: string = '';

主组件 Page1117 使用 @Entry@Component 装饰器声明,表明它是应用的入口组件。组件内部定义了大量 @State 状态变量,可分为四组:Tab 导航状态(currentTab)、呼吸动画状态(breath、timer)、弹窗状态(addModal、editModal、delModal、editIdx、delIdx)、数据列表状态(courseList、coachList、reviewList、sceneList、statList)和表单状态(formName、formGoal、editName、editGoal)。

@State 装饰器是 ArkUI 响应式状态管理的核心。被 @State 修饰的变量在赋值时,框架会自动检测值的变化并触发依赖于该变量的 UI 节点重渲染。例如,当 currentTab 从 0 变为 2 时,Scroll 区域内的条件渲染分支会从 tabCourse() 切换到 tabCaption(),底部 Tab 栏的高亮状态也会相应变化。这种自动化的状态-视图同步机制,使得开发者只需关注状态变更,而无需手动操作视图更新。

breath 状态变量是一个布尔型翻转开关,配合 timer 定时器句柄,实现了每秒一次的呼吸动画效果。breath 的值被多处引用:头部 Banner 的图书图标透明度、口语 Tab 外教头像的透明度、柱状图的柱子高度和文本颜色、AI 字幕 Tab 就绪状态的透明度等。这种"一个状态驱动多处动画"的设计,使整个应用呈现统一的呼吸节奏感。

8.2 AI 字幕状态字段

  // --- AI 字幕状态(6.1.1 特性:源语言/目标语言/字体大小/字体颜色) ---
  /** 字幕控制器(writeAudio 写入音频流) */
  private captionController: AICaptionController = new AICaptionController();
  /** 字幕显示状态(@Link 双向绑定到 AICaptionComponent.isShown) */
  @State captionShown: boolean = false;
  /** sourceLanguage:字幕源语言 */
  @State srcLang: string = 'zh';
  /** targetLanguage:字幕目标语言 */
  @State tgtLang: string = 'zh';
  /** fontSize:字体大小(AICaptionFontSize 枚举) */
  @State captionSize: AICaptionFontSize = AICaptionFontSize.NORMAL;
  /** fontColor:字体颜色(默认经典白,取自预设数组) */
  @State captionColor: string = CAPTION_FONT_COLORS[0];
  /** onPrepared 回调置 true(字幕服务就绪) */
  @State captionReady: boolean = false;
  /** onError 错误信息 */
  @State captionErrMsg: string = '';
  /** 已写入音频块计数 */
  @State captionFed: number = 0;

这一段代码定义了 AI 字幕功能的全部状态字段,是整个应用的技术核心。captionControllerAICaptionController 的实例,使用 private 修饰(非 @State),因为控制器实例本身不需要变化,变化的是通过控制器写入的音频数据和返回的字幕内容。控制器的 writeAudio 方法接收 AudioData 类型的参数,将 PCM 音频字节流传入 Speech Kit 引擎。

captionShown 是字幕显示/隐藏的布尔状态,通过 AICaptionComponentisShown 参数实现双向绑定——当 captionShowntrue 时字幕组件显示,为 false 时隐藏。srcLangtgtLangcaptionSizecaptionColor 四个状态分别对应 6.1.1 版本新增的四大配置字段,它们的初始值分别为 'zh''zh'NORMAL'#FFFFFF'(经典白),对应中文源中文目标的默认配置。

captionReady 状态由 onPrepared 回调置 true,表示字幕服务已就绪可以接收音频数据。captionErrMsg 状态由 onError 回调赋值,存储错误信息供 UI 展示。captionFed 状态记录已写入的音频块计数,用于 UI 上的"写入演示音频 xN"计数器展示,让用户直观感知音频数据正在被持续推送。

8.3 buildCaptionOptions 方法:组装字幕配置

  /** 组装 AICaptionOptions:体现 6.1.1 新增的源语言/目标语言/字体大小/字体颜色 */
  buildCaptionOptions(): AICaptionOptions {
    const opts: AICaptionOptions = {
      initialOpacity: 1,
      sourceLanguage: this.srcLang,        // ★ 6.1.1:字幕源语言('zh' | 'en')
      targetLanguage: this.tgtLang,        // ★ 6.1.1:字幕目标语言('zh' | 'en' | 'zh-en')
      fontSize: this.captionSize,          // ★ 6.1.1:字体大小(AICaptionFontSize 枚举)
      fontColor: this.captionColor,        // ★ 6.1.1:字体颜色(ResourceColor)
      onPrepared: () => {
        this.captionReady = true;
        this.captionErrMsg = '';
      },
      onError: (error: BusinessError) => {
        this.captionErrMsg = '字幕服务异常 ' + error.code + ':' + error.message;
      }
    };
    return opts;
  }

buildCaptionOptions 方法是 AI 字幕配置的核心组装器,每次调用时都会读取当前的 srcLangtgtLangcaptionSizecaptionColor 四个状态变量,组装为一个全新的 AICaptionOptions 对象返回。该方法在 AICaptionComponent 组件的 options 参数中被调用,意味着每当任意一个配置状态发生变化时,buildCaptionOptions() 都会被重新执行,生成包含新配置的 options 对象传入组件,实现字幕样式的实时更新。

配置对象中,initialOpacity 设为 1 表示字幕初始不透明度为 100%。四个标注了 ★ 6.1.1 的字段正是本次 HarmonyOS Speech Kit 更新的核心新增字段。onPrepared 回调在字幕服务初始化完成后被触发,将 captionReady 置为 true 并清空错误信息;onError 回调在字幕服务发生异常时被触发,将错误码和错误描述拼接为可读的错误信息存入 captionErrMsg

这种"方法返回配置对象"而非"静态配置常量"的设计,确保了配置的动态性——用户在 UI 上修改语言、字号或颜色后,下一次组件渲染时就会传入更新后的配置。这是 ArkUI 响应式范式在 AI 字幕配置上的典型应用。

8.4 switchSourceLang 方法:源语言联动逻辑

  /** 切换源语言时联动目标语言(中文源时目标语言仅支持 'zh') */
  switchSourceLang(code: string) {
    this.srcLang = code;
    if (code === 'zh') {
      this.tgtLang = 'zh';        // 中文源:无翻译方向,锁定中文
    } else {
      this.tgtLang = 'zh-en';     // 英文源:默认双语,可再选 zh/en
    }
  }

switchSourceLang 方法处理源语言切换时的目标语言联动逻辑。当用户选择中文源(code === 'zh')时,目标语言自动锁定为 'zh'——因为中文源时不存在翻译方向,字幕直接以中文显示。当用户选择英文源(code === 'en')时,目标语言默认设为 'zh-en'(中英双语),用户可以在目标语言选择器中进一步切换为 'zh'(中文翻译)或 'en'(英文原文)。

这种联动逻辑的设计避免了无效的语言组合(如中文源翻译为英文),保证了 AI 字幕配置的语义合理性。在 UI 层,当 srcLang === 'zh' 时,目标语言选择器区域会显示一个锁定提示(“中文源锁定中文:无翻译方向,targetLanguage 固定为 zh”),而非可点击的语言选项,使用户明确理解为何目标语言不可选。这种"状态联动 + UI 反馈"的双重保障,确保了用户操作的流畅性和配置的正确性。

8.5 feedDemoAudio 方法:演示音频写入

  /** 演示写入音频流:生成 640 字节 PCM 块(16kHz/16bit/单声道 ≈ 20ms)调用 writeAudio */
  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 方法是一个演示性质的音频数据写入器。它生成一个 640 字节的 PCM 音频块,对应 16kHz 采样率、16 位位深、单声道的音频格式,时长约 20 毫秒。音频内容是一个 440Hz(标准音 A4)的正弦波,通过 Math.sin(2 * Math.PI * 440 * t) * 6000 生成振幅为 6000 的波形样本,然后将 16 位样本拆分为低字节和高字节存入 Uint8Array

在真实应用中,音频数据应来自麦克风实时采集的 PCM 流,而非合成的正弦波。但作为演示,正弦波足以验证 writeAudio 方法的调用链路是否通畅。每次调用 feedDemoAudio 后,captionFed 计数器自增 1,UI 上的"写入演示音频 xN"计数器会实时更新。try-catch 块捕获 writeAudio 可能抛出的异常(如控制器未初始化、音频格式不匹配等),将错误信息存入 captionErrMsg 供 UI 展示。

这种"演示音频 + 计数器"的设计,使得开发者和用户能够在没有真实麦克风音频源的情况下,验证 AI 字幕的数据写入链路是否正常工作。在实际部署中,开发者需要将 feedDemoAudio 替换为从 AudioCapturermic 模块获取的实时 PCM 数据写入逻辑。

8.6 课程管理方法群

  /** 打开编辑课程弹窗(回填当前课程名称与目标级别) */
  openEditCourse(idx: number) {
    this.editIdx = idx;
    this.editName = this.courseList[idx].name;
    this.editGoal = this.courseList[idx].level;
    this.editModal = true;
  }

  /** 保存新建学习计划(空字段用默认值兜底) */
  savePlan() {
    const name = this.formName === '' ? '未命名计划' : this.formName;
    const goal = this.formGoal === '' ? '自选级别' : this.formGoal;
    this.courseList.push(new CourseItem('📘', name, goal, '30 课时', 0));
    this.formName = '';
    this.formGoal = '';
    this.addModal = false;
  }

  /** 保存编辑课程(整体刷新数组引用以刷新进度清单) */
  updatePlan() {
    if (this.editIdx >= 0 && this.editIdx < this.courseList.length) {
      if (this.editName !== '') {
        this.courseList[this.editIdx].name = this.editName;
      }
      if (this.editGoal !== '') {
        this.courseList[this.editIdx].level = this.editGoal;
      }
      this.courseList = this.courseList.slice();
    }
    this.editModal = false;
  }

  /** 删除课程(确认弹窗回调) */
  delPlan() {
    if (this.delIdx >= 0 && this.delIdx < this.courseList.length) {
      this.courseList.splice(this.delIdx, 1);
    }
    this.delModal = false;
  }

这一组方法处理课程管理的增删改逻辑。openEditCourse 方法接收课程索引参数,将当前课程的名称和级别回填到编辑表单状态变量中,然后打开编辑弹窗。这种"先回填后打开"的模式,确保了编辑弹窗打开时表单中已有所选课程的原始数据,用户可以在原有基础上修改。

savePlan 方法处理新建学习计划的保存逻辑,对空字段使用默认值兜底(“未命名计划"和"自选级别”),保证数据完整性。新课程使用默认图标 ‘📘’、默认课时 ‘30 课时’、默认进度 0,被 pushcourseList 数组末尾。savePlan 在保存后清空表单状态变量并关闭弹窗,为下次创建做好准备。

updatePlan 方法使用了一个关键技巧——this.courseList = this.courseList.slice()。虽然直接修改数组元素的属性值会触发 @Observed 的属性级响应,但为了让 ForEach 的整体列表刷新(包括可能的排序、动画等),使用 slice() 创建数组的新引用副本,强制框架执行完整的列表重渲染。这种技巧在需要列表级刷新而非元素级刷新时非常实用。

delPlan 方法使用 splice 从数组中移除指定索引的课程项。splice 方法会直接修改原数组并触发响应式更新。删除前进行索引边界检查(this.delIdx >= 0 && this.delIdx < this.courseList.length),防止越界访问导致运行时错误。

8.7 生命周期方法

  /** 生命周期:启动呼吸动画定时器(每 1000ms 翻转 breath) */
  aboutToAppear() {
    this.timer = setInterval(() => {
      this.breath = !this.breath;
    }, 1000);
  }

  /** 生命周期:销毁时清理定时器 */
  aboutToDisappear() {
    clearInterval(this.timer);
  }

aboutToAppearaboutToDisappear 是 ArkUI 组件的两个关键生命周期回调。aboutToAppear 在组件创建后、build 方法执行前被调用,适合进行状态初始化和定时器启动。这里启动了一个 1000 毫秒间隔的 setInterval 定时器,每秒翻转 breath 布尔值,驱动全应用的呼吸动画效果。定时器句柄存入 this.timer 以备后续清理。

aboutToDisappear 在组件销毁前被调用,这里使用 clearInterval(this.timer) 清理定时器,防止内存泄漏。在 ArkUI 中,组件的销毁时机由框架管理(如页面切换、条件渲染移除等),开发者无法精确预测,因此在 aboutToDisappear 中清理资源是保证应用稳定性的最佳实践。如果遗漏定时器清理,即使组件已被销毁,定时器回调仍会继续执行,导致对已销毁组件的状态访问异常。

九、页面构建与布局结构

9.1 build 方法:主构建入口

  /** 页面主构建:Stack 包裹主内容与三层弹窗 */
  build() {
    Stack() {
      Column() {
        this.headerMain()
        Divider().strokeWidth(1).color(COLORS.line)
        Scroll() {
          Column() {
            if (this.currentTab === 0) {
              this.tabCourse()
            } else if (this.currentTab === 1) {
              this.tabSpeak()
            } 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 是一种层叠布局容器,其子元素按声明顺序从底层到顶层堆叠。这里 Stack 的第一层是 Column 主内容(头部 + 可滚动内容区 + 底部 Tab 栏),第二层到第四层是三个条件渲染的弹窗面板(创建、编辑、删除确认)。当弹窗状态变量为 true 时,对应的弹窗面板会渲染在 Stack 的上层,覆盖住底层的主内容。

Column 内部的结构从上到下依次为:headerMain() 头部区域、Divider 分割线、Scroll 可滚动内容区、tabBar() 底部导航栏。Scroll 使用 layoutWeight(1) 占据剩余空间,scrollBar(BarState.Off) 隐藏滚动条以保持视觉简洁。内容区内部使用 if-else 条件渲染,根据 currentTab 的值决定显示哪个 Tab 的内容——这是 ArkUI 条件渲染的基本模式,当条件不满足时,对应的 UI 节点会从组件树中移除。

三个弹窗面板都接收一个 onClose 回调函数作为参数,该回调将对应的弹窗状态变量置为 false,实现点击关闭按钮或遮罩区域时关闭弹窗。这种"状态驱动弹窗显示 + 回调驱动弹窗关闭"的模式,保证了弹窗的显示/隐藏完全由状态变量控制,符合 ArkUI 的响应式设计范式。

9.2 headerMain 方法:头部渐变 Banner

  /** 头部:顶部渐变 Banner(学习问候语+连续打卡天数)+ 搜索条 + 横滑语言/级别 chips */
  @Builder
  headerMain() {
    Column({ space: 12 }) {
      Column({ space: 10 }) {
        Row() {
          Column({ space: 5 }) {
            Text('早上好,坚持学习的你').fontSize(16).fontColor(COLORS.card).fontWeight(FontWeight.Bold)
            Text('连续打卡 128 天 · 已超过 92% 的学友').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.65)
          }
          .width(44).height(44).borderRadius(22).backgroundColor(COLORS.tealD)
          .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
        }
        .width('100%')

        Row({ space: 8 }) {
          Text('🔥 连续打卡 128 天').fontSize(9).fontColor(COLORS.card).opacity(0.94)
            .padding({ left: 8, right: 8, top: 3, bottom: 3 }).backgroundColor(COLORS.tealD).borderRadius(8)
          Text('🎯 在学课程 8 门').fontSize(9).fontColor(COLORS.card).opacity(0.94)
            .padding({ left: 8, right: 8, top: 3, bottom: 3 }).backgroundColor(COLORS.tealD).borderRadius(8)
          Text('⭐ 会员无限学').fontSize(9).fontColor(COLORS.tealD)
            .padding({ left: 8, right: 8, top: 3, bottom: 3 }).backgroundColor(COLORS.gold).borderRadius(8)
        }
        .width('100%')
      }
      .width('100%').padding(16).borderRadius(14)
      .linearGradient({ angle: 135, colors: [[COLORS.tealD, 0], [COLORS.teal, 1]] })

headerMain 是一个 @Builder 修饰的构建器函数,用于复用 UI 片段。头部区域由三层结构组成:最外层 Column 使用 linearGradient 设置了从 COLORS.chip(浅青绿)到 COLORS.bg(背景白)的垂直渐变背景;中间层 Column 是渐变 Banner 卡片,使用 135 度角的 COLORS.tealD -> COLORS.teal 对角渐变;Banner 内部包含问候语文本 + 打卡天数 + 图书图标(呼吸动画),以及三个状态标签(连续打卡、在学课程、会员无限学)。

问候语文本"早上好,坚持学习的你"使用了 fontWeight(FontWeight.Bold) 加粗和白色字体(COLORS.card),副文本"连续打卡 128 天 · 已超过 92% 的学友"使用了 0.78 透明度,形成主次分明的信息层级。图书图标在 44x44 的圆形容器中居中显示,透明度随 breath 状态在 1 和 0.65 之间翻转,产生微妙的呼吸闪烁效果。三个状态标签使用小圆角胶囊样式,前两个使用深青绿背景(tealD),第三个使用金色背景(gold),形成视觉差异化。

搜索条区域是一个白底圆角 Row,包含搜索图标、占位文本和一个麦克风图标。麦克风图标绑定了 onClick 事件,点击后切换到 AI 字幕 Tab(this.currentTab = 2),形成了从搜索到语音功能的快捷入口。语言/级别 chips 使用横滑 Scroll + ForEach 渲染,选中态通过 cateIdx 状态变量控制背景色和文字色的高亮切换。

十、课程 Tab 详细分析

10.1 课程分类入口区

  /** 课程 Tab:图标清单课程分类入口 + 进度条清单在学课程 + 月度学习时长柱状图 */
  @Builder
  tabCourse() {
    Column({ space: 12 }) {
      Column({ space: 12 }) {
        Row() {
          Text('📚 课程分类').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
          Column().layoutWeight(1)
          Text('6 大入口').fontSize(9).fontColor(COLORS.text3)
        }
        .width('100%')

        Scroll() {
          Row({ space: 14 }) {
            ForEach(CATE_ENTRY, (c: CateEntry) => {
              Column({ space: 6 }) {
                Column() {
                  Text(c.icon).fontSize(20)
                }
                .width(48).height(48).borderRadius(24).backgroundColor(COLORS.chip)
                .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)

                Text(c.name).fontSize(9).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
                Text(c.hot).fontSize(7).fontColor(COLORS.teal)
              }
              .width(58).alignItems(HorizontalAlign.Center)
            }, (c: CateEntry) => c.name)
          }
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .width('100%')
      }
      .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)

课程 Tab 的第一个区块是课程分类入口区,包裹在一个白色卡片(COLORS.card 背景 + 12 圆角)中。区块头部使用 Row + layoutWeight(1) 占位实现左右布局:左侧是"课程分类"标题,右侧是"6 大入口"摘要文本。这种"标题 + 空间占位 + 摘要"的三段式布局是应用中反复使用的布局模式,保证了区块头部的一致性视觉风格。

分类入口使用横滑 Scroll + Row + ForEach 渲染,每个入口是一个 58 宽的 Column,包含 48x48 圆形图标背景(COLORS.chip 浅青绿底)、分类名和角标文案。ForEach 的第三个参数是键值生成器函数 (c: CateEntry) => c.name,使用分类名作为唯一键,确保列表重渲染时正确复用和更新节点。横滑容器的 scrollable(ScrollDirection.Horizontal) 设置了横向滚动方向,scrollBar(BarState.Off) 隐藏了滚动条。

10.2 在学课程进度清单

      Row() {
        Text('📖 在学课程').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Column().layoutWeight(1)
        Text(this.courseList.length.toString() + ' 门课程').fontSize(9).fontColor(COLORS.text3)
        Text('+ 新建计划').fontSize(9).fontColor(COLORS.teal)
          .padding({ left: 9, right: 9, top: 4, bottom: 4 })
          .backgroundColor(COLORS.chip).borderRadius(8)
          .onClick(() => {
            this.addModal = true;
          })
      }
      .width('100%')

      ForEach(this.courseList, (item: CourseItem, idx: number) => {
        Column({ space: 9 }) {
          Row({ space: 10 }) {
            Column() {
              Text(item.icon).fontSize(18)
            }
            .width(40).height(40).borderRadius(20).backgroundColor(COLORS.chip)
            .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)

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

              Text(item.lessons + ' · 目标 ' + item.level).fontSize(9).fontColor(COLORS.sub)
                .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
            }
            .layoutWeight(1).alignItems(HorizontalAlign.Start)

            Column({ space: 2 }) {
              Text(item.done.toString() + '%').fontSize(13).fontColor(progressColor(item.done)).fontWeight(FontWeight.Bold)
              Text(item.done >= 100 ? '已完成' : '进行中').fontSize(8).fontColor(COLORS.text3)
            }
            .alignItems(HorizontalAlign.End)
          }
          .width('100%')

          Column() {
            Column().width(item.done.toString() + '%').height(6).borderRadius(3).backgroundColor(progressColor(item.done))
          }
          .width('100%').height(6).borderRadius(3).backgroundColor(COLORS.line)

          Row({ space: 6 }) {
            Text('编辑').fontSize(8).fontColor(COLORS.sub).layoutWeight(1).textAlign(TextAlign.Center)
              .padding({ top: 4, bottom: 4 }).backgroundColor(COLORS.chip).borderRadius(6)
              .onClick(() => {
                this.openEditCourse(idx);
              })
            Text('移除').fontSize(8).fontColor(COLORS.red).layoutWeight(1).textAlign(TextAlign.Center)
              .padding({ top: 4, bottom: 4 }).backgroundColor(COLORS.chip).borderRadius(6)
              .onClick(() => {
                this.delIdx = idx;
                this.delModal = true;
              })
          }
          .width('100%')
        }
        .width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(12)
      }, (item: CourseItem) => item.name + item.level)

在学课程清单是课程 Tab 的核心区块。头部行包含标题、课程数量摘要和"+ 新建计划"按钮,按钮点击后设置 addModal = true 打开创建计划弹窗。课程列表使用 ForEach 遍历 courseList 数组渲染,每个课程项是一个白色卡片,包含三行内容:课程信息行、进度条和操作按钮行。

课程信息行采用三列布局:左侧 40x40 圆形图标、中间课程名 + 级别标签 + 课时信息、右侧进度百分比 + 状态文本。课程名使用 maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) 确保单行显示并省略溢出文本。级别标签使用 levelColor 函数映射背景色,白色文字在彩色背景上形成清晰的级别标识。右侧进度百分比使用 progressColor 函数映射颜色,加粗大字号(13)使进度数字成为视觉焦点。

进度条使用嵌套 Column 结构实现:外层 Column 作为轨道(COLORS.line 浅色背景 + 6vp 高度 + 3vp 圆角),内层 Column 作为填充体,宽度使用 item.done.toString() + '%' 动态设置百分比,背景色同样使用 progressColor 函数映射。这种"轨道 + 填充"的进度条实现方式简洁高效,无需引入额外的进度条组件。

操作按钮行包含"编辑"和"移除"两个等宽按钮,使用 layoutWeight(1) 实现均分。"编辑"按钮点击后调用 openEditCourse(idx) 打开编辑弹窗,"移除"按钮点击后设置 delIdxdelModal = true 打开删除确认弹窗。ForEach 的键值生成器使用 item.name + item.level 组合键,确保即使课程名相同但级别不同时也能正确区分。

10.3 月度学习时长柱状图

  /** 图表卡:月度学习时长柱状图(6 个月,柱高随呼吸 ±5% 波动) */
  @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.teal : COLORS.sub)
            Column().width(18).borderRadius(5)
              .height(Math.max(20, MONTH_HOURS[i] / MONTH_MAX * 110 * (this.breath ? 1.05 : 0.95)))
              .linearGradient({ angle: 180, colors: [[COLORS.teal, 0], [COLORS.tealD, 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 月累计 359 小时').fontSize(8).fontColor(COLORS.sub)
        Column().layoutWeight(1)
        Text('环比 +12.7%').fontSize(8).fontColor(COLORS.teal)
      }
      .width('100%')
    }
    .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
  }

chartCard 构建器渲染了月度学习时长柱状图,这是一个纯 ArkUI 声明式实现的图表组件,未依赖任何第三方图表库。柱状图使用 Row + ForEach + Column 的嵌套结构实现:外层 Row 设置 alignItems(VerticalAlign.Bottom) 使所有柱子底部对齐,高度 150vp;每个柱子是一个 Column,从上到下包含数值文本、柱体和月份标签。

柱体的高度计算公式为 Math.max(20, MONTH_HOURS[i] / MONTH_MAX * 110 * (this.breath ? 1.05 : 0.95))。其中 MONTH_HOURS[i] / MONTH_MAX * 110 计算出柱子的基准高度(最大 110vp),Math.max(20, ...) 保证柱子最小高度不低于 20vp(防止极小值不可见),this.breath ? 1.05 : 0.95 叠加 ±5% 的呼吸波动。柱体使用 linearGradient 设置了从 COLORS.teal(顶部浅青绿)到 COLORS.tealD(底部深青绿)的垂直渐变,使柱子呈现从浅到深的视觉层次。

数值文本的颜色也随 breath 状态在 COLORS.tealCOLORS.sub 之间切换,与柱体高度波动同步,形成一致的呼吸节奏。底部摘要行展示了累计学习时长和环比增长率,使用青绿色强调正向增长趋势,激励用户持续学习。整个柱状图的实现充分体现了 ArkUI 声明式 UI 的灵活性——通过基础组件的组合和状态驱动,即可实现具有动态效果的图表可视化。

十一、口语 Tab 详细分析

11.1 外教横滑人物大卡

  /** 口语 Tab:外教/语伴横滑人物大卡 + 学员反馈评论卡列表 */
  @Builder
  tabSpeak() {
    Column({ space: 12 }) {
      Row() {
        Text('🗨 外教语伴').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Column().layoutWeight(1)
        Text('横滑查看 6 位').fontSize(9).fontColor(COLORS.text3)
      }
      .width('100%')

      Scroll() {
        Row({ space: 12 }) {
          ForEach(this.coachList, (item: CoachItem) => {
            Column() {
              Column() {
                Text(item.avatar).fontSize(36).opacity(this.breath ? 1 : 0.82)
              }
              .width('100%').height(86)
              .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
              .linearGradient({ angle: 145, colors: [[COLORS.tealD, 0], [COLORS.teal, 1]] })

              Column({ space: 8 }) {
                Row() {
                  Text(item.name).fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
                    .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }).layoutWeight(1)
                  Text('⭐ ' + item.rating).fontSize(10).fontColor(ratingColor(item.rating)).fontWeight(FontWeight.Bold)
                }
                .width('100%')

                Row({ space: 6 }) {
                  Text(item.country).fontSize(8).fontColor(COLORS.tealD)
                    .padding({ left: 5, right: 5, top: 1, bottom: 1 })
                    .backgroundColor(COLORS.chip).borderRadius(5)
                  Text(item.tag).fontSize(8).fontColor(COLORS.blue)
                    .padding({ left: 5, right: 5, top: 1, bottom: 1 })
                    .backgroundColor(COLORS.chip).borderRadius(5)
                  Text('立即约课').fontSize(11).fontColor(COLORS.card).fontWeight(FontWeight.Bold).width('100%').textAlign(TextAlign.Center)
                  .padding({ top: 7, bottom: 7 }).backgroundColor(COLORS.teal).borderRadius(9)
              }
              .width('100%').padding(10).alignItems(HorizontalAlign.Start)
            }
            .width(186).backgroundColor(COLORS.card).borderRadius(12)
            .clip(true)
          }, (item: CoachItem) => item.name)
        }
        .padding({ left: 2, right: 2 })
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .width('100%')

口语 Tab 的第一个区块是外教横滑人物大卡列表。每张卡片宽 186vp,使用 clip(true) 裁剪超出圆角区域的内容。卡片结构分为两部分:头部 86vp 高的渐变区域(tealD -> teal 145 度对角渐变),内含 36 字号的 emoji 头像,透明度随 breath 状态在 1 和 0.82 之间翻转产生呼吸效果;底部信息区包含外教名 + 评分行、国籍 + 擅长方向标签行和"立即约课"按钮。

评分使用 ratingColor 函数映射颜色并加粗显示,配合星号 emoji 形成直观的评分视觉。国籍和擅长方向标签使用小圆角胶囊样式,底色为 COLORS.chip(浅青绿),分别使用 tealDblue 文字色,形成颜色差异化。"立即约课"按钮使用满宽(width('100%'))青绿色底 + 白色加粗文字,视觉引导明确。

卡片列表使用横滑 Scroll + Row({ space: 12 }) 渲染,ForEach 的键值为外教名。这种横滑大卡的设计是电商和社交应用中常见的人物/商品展示模式,能够在有限的屏幕宽度内展示多个卡片,用户通过横滑浏览全部内容。

11.2 学员反馈评论卡

      Row() {
        Text('💬 学员反馈').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Column().layoutWeight(1)
        Text('好评率 98%').fontSize(9).fontColor(COLORS.gold)
      }
      .width('100%')

      ForEach(this.reviewList, (item: ReviewItem) => {
        Row({ space: 10 }) {
          Column() {
            Text(item.avatar).fontSize(18)
          }
          .width(38).height(38).borderRadius(19).backgroundColor(COLORS.chip)
          .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)

          Column({ space: 4 }) {
            Row() {
              Text(item.user).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
              Column().layoutWeight(1)
              Text('⭐ ' + item.score).fontSize(9).fontColor(COLORS.gold)
            }
            .width('100%')

            Text(item.content).fontSize(9).fontColor(COLORS.sub).maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Start)
        }
        .width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(12)
        .alignItems(VerticalAlign.Top)
      }, (item: ReviewItem) => item.user)
    }
    .width('100%')
  }

学员反馈区块使用纵向 ForEach 渲染评论卡列表,每张卡片是一个白色圆角 Row,左侧是 38x38 圆形头像,右侧是昵称 + 评分行和反馈内容文本。卡片使用 alignItems(VerticalAlign.Top) 设置顶部对齐,确保当反馈内容较长时头像固定在顶部位置。

反馈内容使用 maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis }) 限制为两行并省略溢出文本,保证每张卡片的高度不会因内容过长而差异过大。评分使用金色(COLORS.gold)显示,与头部"好评率 98%"的摘要形成呼应。整个评论卡列表的设计简洁但信息量充足,用户能够快速浏览多条反馈,建立对外教服务质量的信任感。

十二、AI 字幕 Tab 详细分析

12.1 特性介绍 Banner

  /** AI字幕 Tab:Speech Kit 6.1.1 特性页(预览/语言/外观/代码/场景 五区块) */
  @Builder
  tabCaption() {
    Column({ space: 12 }) {
      Row({ space: 8 }) {
        Text('🗣').fontSize(16)
        Column({ space: 2 }) {
          Text('Speech Kit · 场景化语音服务').fontSize(11)
            .fontColor(COLORS.card).fontWeight(FontWeight.Bold)
          Text('HarmonyOS 6.1.1:AI字幕支持源语言 / 目标语言 / 字体颜色 / 字体大小')
            .fontSize(8).fontColor(COLORS.card).opacity(0.85)
            .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
      }
      .width('100%').padding(10).borderRadius(10)
      .linearGradient({ angle: 135, colors: [[COLORS.tealD, 0], [COLORS.teal, 1]] })

AI 字幕 Tab 是整个应用的技术核心,顶部使用渐变 Banner 介绍 Speech Kit 的特性。Banner 采用与头部主 Banner 一致的 135 度对角渐变(tealD -> teal),内含语言图标、主标题"Speech Kit · 场景化语音服务"和副标题"HarmonyOS 6.1.1:AI字幕支持源语言 / 目标语言 / 字体颜色 / 字体大小"。副标题直接列出了四大新增字段的名称,使用户在进入 Tab 第一时间就了解到本次更新的技术要点。

副标题使用 opacity(0.85)maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }),确保在窄屏设备上文本不会溢出 Banner 边界。整个 Banner 的设计风格与应用头部保持一致,形成了统一的渐变 Banner 视觉语言。

12.2 区块一:AI 字幕实时预览

      // ===== 区块 1:组件实时预览卡 =====
      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.teal : COLORS.gold)
            .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%')

        // AI 字幕组件(isShown @Link 双向绑定,options 实时重建)
        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.tealD : COLORS.teal)
            .borderRadius(10)
            .onClick(() => {
              this.captionShown = !this.captionShown;
            })

          Row({ space: 5 }) {
            Text('写入演示音频').fontSize(12).fontColor(COLORS.teal)
            Text('×' + this.captionFed.toString()).fontSize(9).fontColor(COLORS.teal)
          }
          .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 的核心交互区。卡片头部展示就绪状态标签:当 captionReadytrue 时显示青绿色"已就绪",为 false 时显示金色"初始化中",且初始化中的标签透明度随 breath 状态闪烁,提示用户正在等待服务就绪。

AICaptionComponent 组件的三个参数体现了 AI 字幕的核心设计:isShown 通过 captionShown 状态实现双向绑定,控制字幕的显示/隐藏;controller 传入 captionController 实例,建立音频数据写入通道;options 调用 buildCaptionOptions() 方法实时组装配置对象,使四大字段(sourceLanguage/targetLanguage/fontSize/fontColor)的变更能够即时反映到字幕渲染中。组件区域固定高度 110vp,使用浅色边框圈定视觉范围。

组件下方是两个等宽操作按钮:左侧"开启/隐藏字幕"按钮切换 captionShown 状态,按钮文字根据当前状态动态切换(开启时显示"隐藏字幕"并使用深青绿底色,隐藏时显示"开启字幕"并使用标准青绿底色);右侧"写入演示音频"按钮调用 feedDemoAudio() 方法,并显示已写入次数(× + captionFed 计数)。当 captionErrMsg 不为空字符串时,底部条件渲染一个红色错误提示文本,使用 maxLines(2) 限制为两行并省略溢出。

12.3 区块二:语言设置卡

      // ===== 区块 2:语言设置卡(sourceLanguage / targetLanguage) =====
      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.teal : COLORS.chip)
              .borderRadius(10)
              .onClick(() => {
                this.switchSourceLang(l.code);
              })
          }, (l: LangOption) => l.code)
        }
        .width('100%')

        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.teal : COLORS.chip)
                .borderRadius(10)
                .onClick(() => {
                  this.tgtLang = l.code;
                })
            }, (l: LangOption) => l.code)
          }
          .width('100%')
        }

        Row({ space: 6 }) {
          Circle().width(6).height(6).fill(COLORS.blue)
          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)

区块二是语言设置卡,用于配置 sourceLanguagetargetLanguage 两个字段。卡片采用"字段名 + 取值范围说明 + 选项按钮组"的三段式结构,每个字段上方有一行说明文本,展示字段名(如"源语言 sourceLanguage")和取值范围(如"取值 ‘zh’ | ‘en’"),既面向普通用户展示功能名称,又面向开发者展示技术参数。

源语言选项使用 ForEach 渲染 SRC_LANGS 数组(中文、英文),选中态使用青绿底色 + 白色加粗文字,未选中态使用浅青绿底色 + 灰色文字。点击选项调用 switchSourceLang(l.code) 方法,该方法会联动设置目标语言(中文源锁定 zh,英文源默认 zh-en)。目标语言选项的渲染使用条件分支:当 srcLang === 'zh' 时显示锁定提示(“中文源锁定中文:无翻译方向,targetLanguage 固定为 zh”),当 srcLang === 'en' 时渲染 TGT_LANGS_EN 数组(中文、英文、中英双语)的三选项按钮组。

卡片底部展示当前语言组合摘要,使用蓝色小圆点 + 文本"当前组合:源 中文 → 目标 中英双语"的形式,langName 函数将语言码转换为中文名,使用户能够直观理解当前的配置含义。这种"技术参数 + 选项按钮 + 当前状态摘要"的三层信息结构,使得语言设置卡既具有技术文档的精确性,又具有消费级应用的易用性。

12.4 区块三:外观设置卡

      // ===== 区块 3:外观设置卡(fontSize / fontColor) =====
      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.teal : COLORS.chip)
              .borderRadius(10)
              .onClick(() => {
                this.captionSize = s.size;
              })
          }, (s: SizeOption) => s.name)
        }
        .width('100%')

        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.teal : 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)

区块三是外观设置卡,用于配置 fontSizefontColor 两个字段。字号选项使用 ForEach 渲染 SIZE_OPTIONS 数组(小号、标准、大号、超大),布局与语言设置卡的选择按钮组一致,选中态使用青绿底色 + 白色加粗文字。点击选项直接赋值 this.captionSize = s.size,触发 buildCaptionOptions() 重新组装配置并传入 AICaptionComponent,字幕字号即时更新。

字体颜色选项使用 ForEach 渲染 CAPTION_FONT_COLORS 数组的五种颜色,每个颜色渲染为一个 26x26 的圆形色块,使用 Circle().fill(c) 填充颜色。选中态使用青绿色 2vp 描边(COLORS.teal),未选中态使用浅色 2vp 描边(COLORS.line),视觉反馈清晰。颜色按钮组使用 justifyContent(FlexAlign.SpaceBetween) 使五个色块均匀分布。

卡片底部展示当前颜色信息:小圆点填充当前颜色 + colorName 函数返回的诗意中文名 + 十六进制色值,右侧标注"作用于字幕原文与译文",明确告知用户颜色设置的作用范围。这种"色块选择 + 当前状态展示 + 作用范围说明"的设计,使用户对外观设置有充分的认知和掌控感。

12.5 区块四:options 实时代码预览

      // ===== 区块 4:options 实时代码预览卡 =====
      Column({ space: 10 }) {
        Row() {
          Text('💻 AICaptionOptions 实时代码').fontSize(13)
            .fontColor(COLORS.title).fontWeight(FontWeight.Bold)
          Column().layoutWeight(1)
          Text('随设置联动').fontSize(8).fontColor(COLORS.teal)
        }
        .width('100%')

        Column({ space: 5 }) {
          Text('AICaptionOptions = {').fontSize(9).fontColor(COLORS.card).opacity(0.72).fontFamily('monospace')
          Row({ space: 4 }) {
            Text('● sourceLanguage:').fontSize(9).fontColor(COLORS.gold).fontFamily('monospace')
            Text("'" + this.srcLang + "'").fontSize(9).fontColor(COLORS.teal).fontFamily('monospace')
          }
          .width('100%')
          Row({ space: 4 }) {
            Text('● targetLanguage:').fontSize(9).fontColor(COLORS.gold).fontFamily('monospace')
            Text("'" + this.tgtLang + "'").fontSize(9).fontColor(COLORS.teal).fontFamily('monospace')
          }
          .width('100%')
          Row({ space: 4 }) {
            Text('● fontSize:').fontSize(9).fontColor(COLORS.gold).fontFamily('monospace')
            Text(sizeName(this.captionSize)).fontSize(9).fontColor(COLORS.card).fontFamily('monospace')
          }
          .width('100%')
          Row({ space: 4 }) {
            Text('● fontColor:').fontSize(9).fontColor(COLORS.gold).fontFamily('monospace')
            Text("'" + this.captionColor + "'").fontSize(9)
              .fontColor(this.captionColor).fontFamily('monospace')
          }
          .width('100%')
          Text('}').fontSize(9).fontColor(COLORS.card).opacity(0.72).fontFamily('monospace')
        }
        .width('100%').padding(12).backgroundColor(COLORS.mask).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)

区块四是 AICaptionOptions 的实时代码预览卡,以类似 IDE 代码编辑器的风格展示当前配置对象的内容。预览区使用 COLORS.mask(半透明深色)作为背景色,模拟代码编辑器的深色主题。代码内容使用 fontFamily('monospace') 等宽字体渲染,字段名使用金色(COLORS.gold),字符串值使用青绿色(COLORS.teal),枚举值使用白色(COLORS.card),颜色值使用其自身颜色渲染——这种语法高亮的配色方案使代码预览具有接近真实 IDE 的视觉效果。

预览代码的内容是动态的——每个字段的值直接绑定到对应的状态变量(this.srcLangthis.tgtLangthis.captionSizethis.captionColor),当用户在区块二和区块三修改设置时,预览代码会实时更新。sizeName 函数将 AICaptionFontSize 枚举值转换为对应的英文枚举名,使预览代码与实际开发中编写的配置对象保持一致。fontColor 的值使用其自身颜色渲染(fontColor(this.captionColor)),形成"颜色即代码"的自描述效果。

预览区底部标注"★ 6.1.1 新增字段:sourceLanguage / targetLanguage / fontSize / fontColor",明确告知用户这四个字段是本次 HarmonyOS Speech Kit 更新的核心新增内容。这个代码预览卡的设计体现了"技术可视化"的产品理念——将底层配置对象的实时状态以代码形式展示给用户,既满足了开发者的调试需求,也帮助普通用户理解 AI 字幕的配置原理。

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

      // ===== 区块 5:字幕场景推荐列表(左色条 + 点击套用语言组合) =====
      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.teal : (idx % 3 === 1 ? COLORS.blue : COLORS.gold))

            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.teal)
            }
            .layoutWeight(1).alignItems(HorizontalAlign.Start)

            Text('套用 ›').fontSize(9).fontColor(COLORS.teal)
          }
          .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 字幕的四大配置字段与具体的语言学习场景绑定。每条场景项使用 Row 布局,左侧是 4vp 宽的彩色色条(使用 idx % 3 循环映射 teal/blue/gold 三种颜色),中间是场景名 + 描述 + 语言组合信息,右侧是"套用"操作入口。

点击场景项调用 this.switchSourceLang(item.src) 设置源语言并联动目标语言,然后 this.tgtLang = item.tgt 覆盖目标语言为场景预设值。这种"一键套用"的设计将复杂的技术配置简化为场景选择,用户无需理解 sourceLanguagetargetLanguage 的取值含义,只需选择符合自己学习目标的场景即可自动配置。

场景项使用 COLORS.chip(浅青绿)作为背景色,与卡片的白色背景形成层次区分。语言组合信息使用青绿色文字展示(如"源 en → 目标 zh-en"),使用户在套用前就能确认该场景的语言配置。左侧色条的循环配色(teal/blue/gold)为列表增加了视觉节奏感,避免了五条同色项的单调感。

十三、我的 Tab 与底部导航

13.1 用户渐变大卡与功能清单

  /** 我的 Tab:用户信息+会员渐变大卡 + 功能清单行 */
  @Builder
  tabMine() {
    Column({ space: 12 }) {
      Column({ space: 12 }) {
        Row({ space: 12 }) {
          Column() {
            Text('🦉').fontSize(26)
          }
          .width(54).height(54).borderRadius(27).backgroundColor(COLORS.tealD)
          .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)

          Column({ space: 4 }) {
            Row({ space: 6 }) {
              Text('林小语').fontSize(16).fontColor(COLORS.card).fontWeight(FontWeight.Bold)
              Text('L6 达人').fontSize(8).fontColor(COLORS.tealD)
                .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                .backgroundColor(COLORS.gold).borderRadius(7)
            }
            Text('语伴 ID:yuban_1117 · 已连续打卡 128 天')
              .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('128').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('3420').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('86').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.tealD, 0], [COLORS.teal, 1]] })

我的 Tab 顶部是一个用户信息渐变大卡,使用与应用头部一致的 135 度对角渐变(tealD -> teal)。卡片上半部分展示用户头像(猫头鹰 emoji)+ 用户名 + 等级标签 + 语伴 ID 和打卡天数。头像使用 54x54 圆形容器,底色为深青绿(tealD)。用户名"林小语"使用 16 字号白色加粗,等级标签"L6 达人"使用金色底色小圆角胶囊。

卡片下半部分是三列统计数据:连续打卡 128 天、学过单词 3420 个、口语小时 86 小时。每列使用 layoutWeight(1) 均分宽度,数值使用 13 字号白色加粗,标签使用 8 字号白色 0.75 透明度。三列之间的视觉平衡和数值加粗使关键数据一目了然。

13.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,包含图标、功能名、状态值和右箭头四个元素。功能名使用 layoutWeight(1) 占据剩余空间,状态值使用灰色副文本色,右箭头通过 item.arrow 布尔值条件渲染。每行的设计遵循了 iOS Settings 列表的经典布局模式,用户能够在不进入二级页面的情况下获取每项功能的状态摘要。

13.3 底部导航 Tab 栏

  /** 底部导航:4 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 })
  }

底部导航栏使用 Row + ForEach 渲染四个 Tab,每个 Tab 使用 layoutWeight(1) 均分宽度。选中态的视觉差异通过三个维度实现:图标字号放大(20 vs 17)、图标不透明度提高(1 vs 0.65)、标签文字使用主题色加粗(COLORS.tabOn + FontWeight.Bold)。这种多维度的选中态差异,使当前 Tab 的高亮状态在视觉上非常醒目。

Tab 栏使用白色背景(COLORS.card),顶部使用 border({ width: { top: 1 }, color: COLORS.line }) 添加 1vp 的分割线,与内容区形成视觉分离。点击 Tab 调用 this.currentTab = idx 切换当前 Tab 索引,触发条件渲染分支切换和 Tab 高亮状态更新。

十四、弹窗系统详细分析

14.1 全屏遮罩构建器

  /** 弹窗全屏遮罩(点击遮罩关闭弹窗) */
  @Builder
  modalOverlay(onClose: () => void) {
    Stack() {
      Column().width('100%').height('100%').backgroundColor(COLORS.mask)
    }
    .width('100%')
    .height('100%')
    .alignContent(Alignment.Center)
    .onClick(() => onClose())
  }

modalOverlay 是弹窗系统的基础构建器,渲染一个全屏半透明遮罩层。遮罩使用 COLORS.maskrgba(31,51,44,0.5) 半透明深绿黑色)作为背景色,覆盖整个屏幕区域。Stack 容器使用 alignContent(Alignment.Center) 使后续叠加的弹窗面板内容居中显示。onClick 绑定 onClose 回调,使用户点击遮罩区域(弹窗外部)时能够关闭弹窗。

这种"遮罩 + 面板"分离的设计模式,使得三个弹窗(创建、编辑、删除)可以共享同一个遮罩层,保证了视觉一致性。同时,遮罩的点击关闭行为是弹窗 UX 的标准实践——用户可以通过点击弹窗外部的遮罩区域快速取消操作,无需精确点击"取消"按钮。

14.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: '如:日常口语 500 句' })
            .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.formGoal, placeholder: '如:B1 中级 / 每天 25 分钟' })
            .fontSize(11).fontColor(COLORS.title)
            .backgroundColor(COLORS.chip).borderRadius(8)
            .onChange((value: string) => {
              this.formGoal = 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.teal).borderRadius(9)
            .onClick(() => {
              this.savePlan();
            })
        }
        .width('100%')
      }
      .width('78%').padding(16).backgroundColor(COLORS.card).borderRadius(14)
    }
    .width('100%')
    .height('100%')
    .alignContent(Alignment.Center)
  }

创建学习计划弹窗使用 Stack 叠加遮罩层和面板层。面板使用 78% 宽度居中显示,白色背景(COLORS.card)+ 14 圆角。面板内容包含标题、计划名称输入框、学习目标输入框和操作按钮行。

两个 TextInput 组件分别绑定 formNameformGoal 状态变量,onChange 回调将输入值同步到状态。输入框使用 COLORS.chip(浅青绿)作为背景色,与面板白色背景形成层次区分。操作按钮行包含等宽的"取消"和"创建"按钮:取消按钮使用浅色底 + 灰色文字,调用 onClose 回调关闭弹窗;创建按钮使用青绿色底 + 白色加粗文字,调用 savePlan() 方法保存计划。

14.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.editGoal, placeholder: '学习目标' })
            .fontSize(11).fontColor(COLORS.title)
            .backgroundColor(COLORS.chip).borderRadius(8)
            .onChange((value: string) => {
              this.editGoal = 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.tealD).borderRadius(9)
            .onClick(() => {
              this.updatePlan();
            })
        }
        .width('100%')
      }
      .width('78%').padding(16).backgroundColor(COLORS.card).borderRadius(14)
    }
    .width('100%')
    .height('100%')
    .alignContent(Alignment.Center)
  }

  /** 删除课程确认弹窗面板 */
  @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.delPlan();
            })
        }
        .width('100%')
      }
      .width('78%').padding(16).backgroundColor(COLORS.card).borderRadius(14)
    }
    .width('100%')
    .height('100%')
    .alignContent(Alignment.Center)
  }

编辑弹窗与创建弹窗结构高度一致,区别在于:标题为"编辑学习计划",输入框绑定 editNameeditGoal 状态变量(由 openEditCourse 方法预先回填),确认按钮文案为"保存修改"并使用深青绿底色(COLORS.tealD,比创建按钮的 COLORS.teal 更深,暗示编辑操作的确认级别更高),点击调用 updatePlan() 方法保存修改。

删除确认弹窗结构更简单,没有输入框,只有标题、说明文本和操作按钮行。说明文本"确认将该课程移出学习计划吗?学习进度将保留,可随时重新加入继续学习。“使用两行省略,既明确了删除操作的后果,也安抚用户进度不会被丢失。确认按钮文案为"确认移除”,使用红色底色(COLORS.red),强烈的红色警示用户这是一个不可逆的删除操作,点击调用 delPlan() 方法执行删除。

三个弹窗的统一设计模式——Stack 叠加遮罩和面板、78% 宽度居中、白色卡片背景、14 圆角、等宽双按钮——保证了弹窗系统的视觉一致性。差异化的标题文案、输入框绑定状态、按钮文案和按钮颜色,使每个弹窗的功能用途清晰可辨。

十五、关键技术特性对比

技术维度传统语言学习应用本应用(集成 Speech Kit 6.1.1)
字幕来源预录制静态文本字幕AICaptionController.writeAudio 实时音频流识别
源语言配置不支持或固定单一语言sourceLanguage 字段支持 ‘zh’ / ‘en’ 动态切换
目标语言配置不支持翻译方向选择targetLanguage 字段支持 ‘zh’ / ‘en’ / ‘zh-en’ 三种模式
字体大小固定字号不可调fontSize 字段提供 SMALL/NORMAL/BIG/LARGE 四档选择
字体颜色固定白色字幕fontColor 字段接受 ResourceColor 自定义颜色
语言联动源语言切换时自动联动目标语言(中文源锁定 zh)
场景适配无场景概念预置五种学习场景一键套用语言组合
状态管理手动 DOM 操作@State + @Observed 响应式自动驱动
动画效果无或依赖动画库setInterval 呼吸动画驱动多处视觉同步波动
数据可视化依赖第三方图表库纯 ArkUI 声明式柱状图(渐变 + 呼吸波动)
弹窗系统系统弹窗或第三方组件modalOverlay 遮罩 + panel 面板自定义构建器
主题管理硬编码颜色散落ColorPalette 接口 + COLORS 常量集中管理
配置可视化实时代码预览卡展示 AICaptionOptions 当前状态

十六、总结

本文详细分析了一个基于 HarmonyOS ArkUI 框架开发的"语伴陪练"语言学习应用,该应用的核心技术亮点在于完整集成了 HarmonyOS 6.1.1 版本 Speech Kit 的 AICaptionComponent 组件,并充分利用了新增的四大配置字段(sourceLanguage、targetLanguage、fontSize、fontColor)实现场景化的 AI 字幕服务。

从技术架构层面来看,应用采用了单页面多 Tab 的架构模式,通过 currentTab 状态变量驱动条件渲染,实现了课程、口语、AI 字幕、我的四个功能模块的切换。每个 Tab 拥有完全不同的布局风格——课程 Tab 的图标清单 + 进度条 + 柱状图三段式布局、口语 Tab 的横滑人物大卡 + 评论卡列表、AI 字幕 Tab 的五区块特性页、我的 Tab 的渐变大卡 + 功能清单——这种布局多样性使每个 Tab 都有独特的视觉体验,避免了单一布局的审美疲劳。

从 AI 字幕技术层面来看,应用通过 AICaptionControllerwriteAudio 方法接收 PCM 音频流,配合 buildCaptionOptions 方法实时组装配置对象,实现了音频写入到字幕渲染的完整链路。四大新增字段的使用方式各具特色:sourceLanguagetargetLanguage 通过联动逻辑避免了无效语言组合;fontSize 使用 AICaptionFontSize 枚举提供四档选择;fontColor 接受 ResourceColor 类型支持自定义颜色。预置的五种字幕场景将技术配置封装为场景选择,降低了用户使用门槛。

从状态管理层面来看,应用大量使用 @State@Observed 装饰器实现响应式数据驱动。breath 状态变量通过 setInterval 定时翻转,驱动全应用的呼吸动画效果——头部图标透明度、外教头像透明度、柱状图柱体高度和数值颜色、AI 字幕就绪标签透明度等多处视觉元素同步波动,形成了统一的动态节奏感。courseList 等数据列表使用 @Observed 修饰的数据模型,配合 slice() 创建新引用的技巧实现列表级刷新。

从 UI 设计层面来看,应用采用了"清新白 + 学习青绿"的浅色主题,通过 ColorPalette 接口和 COLORS 常量实现了颜色的集中管理。三个辅助函数(levelColorprogressColorratingColor)将业务数据映射为颜色值,使颜色成为业务语义的视觉编码。纯 ArkUI 声明式实现的柱状图、modalOverlay 弹窗系统、实时代码预览卡等组件,充分展示了 ArkUI 框架在不依赖第三方库的情况下实现复杂 UI 的能力。

总的来说,这个应用展示了 HarmonyOS ArkUI 框架在语言学习场景下的完整技术实践——从系统级 AI 能力(Speech Kit)的集成,到响应式状态管理的应用,再到声明式 UI 组件的创新实现,为开发者提供了一个可参考的端到端技术范例。特别是 AI 字幕四大新增字段的场景化应用,为后续基于 Speech Kit 开发更多语音交互场景的应用奠定了技术基础。

附录: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.1Release✅ 已安装

界面顶部提示:“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 246.1.1.100Release✅ 已安装
API Version 236.1.0.28Beta1未安装
API Version 226.0.2.112Release未安装

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

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

在这里插入图片描述


三、小结

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

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


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

Logo

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

更多推荐