烈焰红与活力粉的深色短视频美学:HarmonyOS 6.1.1 Speech Kit AICaptionComponent 四大新增字段的场景化落地实战

一、技术前言

在这里插入图片描述

在移动端短视频行业经历数年高速增长之后,社区类产品已经从单纯的内容消费平台进化为集内容分发、创作者生态、智能化辅助于一体的综合体验场。HarmonyOS 作为华为自研的分布式操作系统,其 ArkUI 框架提供了一套声明式 UI 开发范式,通过 @Entry@Component@State@Builder@Observed 等装饰器,让开发者以接近自然描述的方式声明界面结构与状态驱动关系。框架本身负责高效的差分渲染、组件树的虚拟 DOM 比对以及状态到视图的自动同步,开发者只需关注"状态是什么、界面长什么样",而无需手写命令式的 DOM 操作。这种范式极大地降低了复杂界面的维护成本,也让多 Tab 多场景的复杂业务页面能够以单文件、单组件的形式被完整承载。

在这里插入图片描述
ArkTS 是 HarmonyOS 应用开发的首选语言,它在 TypeScript 的基础上做了进一步的静态类型强化。在 ArkTS 中,所有变量、参数、返回值都必须有明确的类型标注,interface 被广泛用于定义数据结构契约,@Observed 装饰器让自定义类的实例具备深度观察能力,一旦内部字段被修改,所有引用了该实例的 @ObjectLink 或直接绑定的组件都会自动刷新。这种类型安全与响应式的结合,使得编译期就能捕获大量潜在错误,同时运行时又能保持视图与数据的精准联动。在本文分析的短视频社区应用中,四个数据模型类全部使用 @Observed 标注,状态变量统一用 @State 管理,构建函数用 @Builder 封装,整体代码结构清晰、职责分明。

在这里插入图片描述
HarmonyOS 6.1.1 版本中,Speech Kit 迎来了一项重要的能力升级——AI 字幕组件 AICaptionComponent 新增了四大可配置字段。在以往的版本中,AI 字幕组件主要提供基于音频流的实时语音识别与字幕渲染能力,开发者通过 AICaptionControllerwriteAudio 方法持续写入 PCM 音频数据块,组件内部完成识别后将字幕浮层渲染在组件区域内。而 6.1.1 版本在此基础上新增了 sourceLanguage(源语言,取值 'zh''en')、targetLanguage(目标语言,取值 'zh''en''zh-en' 中英双语)、fontSize(字体大小,使用 AICaptionFontSize 枚举的 SMALL/NORMAL/BIG/LARGE 四档)以及 fontColor(字体颜色,类型为 ResourceColor)四个字段。这意味着开发者不仅能控制字幕的开关,还能精确指定语音的源语言与翻译目标语言,并对字幕的视觉外观进行细粒度定制。

在这里插入图片描述
这四大新增字段的实际意义非常重大。在短视频社区场景中,用户经常会刷到海外创作者的英文 Vlog、外语教程或双语学习内容。过去,AI 字幕往往只能做单一语言的识别,用户无法在观看英文视频时同时获得中文字幕翻译,也无法根据环境(如公共场合没戴耳机)切换字幕的显示语言。sourceLanguagetargetLanguage 的组合解决了这一痛点:英文源可以翻译为中文、保持英文原字幕,或开启中英双语对照模式;而中文源则锁定中文输出(因为中文源不存在翻译方向)。同时,fontSize 四档枚举让不同视力和不同距离的观看场景都能找到合适的字幕尺寸,fontColor 则通过 ResourceColor 类型支持任意 HEX 色值,本应用预置了经典白、暖阳黄、薄荷绿、云朵蓝、樱花粉五种颜色,覆盖了深夜观看、强光环境、个性化审美等多种需求。

在这里插入图片描述
从业务场景来看,本文分析的"光影刷刷·短视频社区平台"定位为内容消费与创作者生态并重的社区产品。应用设计了四个差异化 Tab:推荐页以横滑精选合集大卡搭配双列视频卡片瀑布,呈现沉浸式的内容发现体验;创作者页以四列头像墙展示热门 UP 主,并配以月度播放量柱状图反映创作者生态的成长曲线;AI 字幕页是整个应用的技术核心,完整展示了 Speech Kit 6.1.1 新特性的五大区块——组件实时预览、语言设置、外观设置、options 实时代码预览、字幕场景推荐列表;我的页则以渐变 VIP 大卡搭配功能清单行,承载用户的会员状态与个人数据。整体设计采用深色墨黑(#14100F)打底,搭配烈焰红(#FF5A45)作为主强调色、活力粉(#FF7BA9)作为次强调色,营造出夜店霓虹般的视觉氛围,非常契合短视频"夜刷"这一核心使用场景。

在这里插入图片描述
在交互层面,应用引入了一套"呼吸动画"机制:通过 setInterval 每秒翻转一个布尔值 breath,该状态被绑定到精选合集封面 emoji 的透明度、柱状图柱高、封面图标的脉动等多个视觉元素上,形成全局统一的节奏感。弹窗系统采用 modalOverlay 全屏遮罩加 panelAdd/panelEdit/panelDel 三套面板的组合方式,通过条件渲染按需挂载,避免了传统路由跳转的上下文丢失问题。所有表单操作(新建合集、编辑合集、删除合集)都通过 @State 表单字段与回调函数闭环完成,数据变更后通过数组引用替换触发瀑布流刷新。

下面,我们将从代码的第一行开始,逐段、逐块地深入分析这个短视频社区平台的完整技术实现。


二、整体架构流程图

在深入代码之前,先通过一张 Mermaid 流程图总览整个应用的架构层次与模块间的依赖关系。这张图从入口组件出发,自上而下覆盖状态管理层、数据层、核心业务方法、四大 Tab 内容区域、底部导航以及弹窗系统,帮助读者建立全局认知后再进入逐段代码精读。

弹窗系统

底部导航

内容区域 Scroll

头部区域

核心业务方法

数据层

状态管理层

入口组件

Page1112
@Entry @Component

currentTab: number
当前激活 Tab 索引

breath: boolean
呼吸动画开关

cateIdx: number
频道 chips 选中

addModal / editModal / delModal
三类弹窗开关

videoList / creatorList
sceneList / statList
四组业务数据

formName / formTag
editName / editTag
表单字段

srcLang / tgtLang
captionSize / captionColor
AI 字幕四大状态

captionShown / captionReady
captionFed / captionErrMsg
字幕运行态

ColorPalette 色彩体系
15 个颜色常量

4 个 Observed 数据模型
VideoItem / CreatorItem
CaptionScene / UserStat

4 组 Mock 数据数组
VIDEO_LIST / CREATOR_LIST
SCENE_LIST / STAT_LIST

Speech Kit 常量
SRC_LANGS / TGT_LANGS_EN
SIZE_OPTIONS / CAPTION_FONT_COLORS

4 个工具函数
catColor / sizeName
langName / colorName

buildCaptionOptions
组装 AICaptionOptions

switchSourceLang
源语言联动目标语言

feedDemoAudio
写入 PCM 演示音频

saveVideo / updateVideo
delVideo
合集增删改

headerMain
渐变 Banner + 搜索条 + 频道 chips

tabRecommend
推荐 Tab

tabCreator
创作者 Tab

tabCaption
AI 字幕 Tab

tabMine
我的 Tab

chartCard
月度柱状图

tabBar
单排 4 Tab

modalOverlay
全屏遮罩

panelAdd
新建合集

panelEdit
编辑合集

panelDel
删除确认

从这张架构图可以清晰看到,整个应用以 Page1112 为唯一入口,状态变量分三大类:导航与动画态(currentTabbreathcateIdx)、弹窗与表单态(三类 Modal 开关与四个表单字段)、AI 字幕态(语言/外观四字段加运行态四字段)。数据层通过 @Observed 模型与 Mock 数组解耦,工具函数负责枚举值到展示名的转换。四大 Tab 各自独立封装为 @Builder 函数,弹窗系统以遮罩加面板的叠加结构按需挂载。下面我们逐段进入代码精读。


三、色彩体系设计

3.1 ColorPalette 接口定义

/** 主题色板接口:集中声明页面所有颜色字段(墨黑+烈焰红+活力粉深色系) */
interface ColorPalette {
  bg: string;
  card: string;
  chip: string;
  title: string;
  sub: string;
  text3: string;
  red: string;
  redD: string;
  pink: string;
  blue: string;
  green: string;
  gold: string;
  line: string;
  tabOn: string;
  mask: string;
}

这段代码定义了一个名为 ColorPalette 的接口,它是整个应用色彩体系的契约层。在 ArkTS 中,interface 用于声明一组字段的结构形状,任何实现该接口的对象都必须包含所有声明的字段且类型匹配。这里将页面用到的全部 15 个颜色字段集中声明在一个接口中,体现了"色彩集中管理"的设计理念——所有颜色不再散落在各组件的内联样式中,而是统一收口到一个类型化常量里。

接口字段的设计层次分明,可以归纳为四组:第一组是基底色(bg 页面背景、card 卡片底色、chip 胶囊/次级底色),构成深色界面的三层灰度;第二组是文字色阶(title 主标题、sub 副文字、text3 三级弱化文字),形成标题到说明的视觉递减;第三组是语义强调色(red 烈焰红主色、redD 烈焰红深色、pink 活力粉、blue 蓝、green 绿、gold 金),分别承担不同状态和分类的标识;第四组是辅助色(line 分割线、tabOn Tab 选中色、mask 弹窗遮罩色)。这种分层让开发者一眼就能找到所需颜色的语义归属。

将颜色抽象为接口的另一个工程价值在于可维护性。当未来需要切换主题(比如新增一个浅色主题或节日主题),只需再声明一个实现 ColorPalette 接口的常量对象,在组件入口处替换引用即可,所有 @Builder 函数无需任何改动,因为它们引用的都是 COLORS.xxx 而非硬编码色值。这是典型的依赖倒置——组件依赖抽象(接口字段名)而非具体实现(色值)。

3.2 COLORS 深色主题常量

/** 深色主题色板常量(光影刷刷 · 墨黑 + 烈焰红 + 活力粉) */
const COLORS: ColorPalette = {
  bg: '#14100F',
  card: '#221B18',
  chip: '#31241E',
  title: '#FFF3E8',
  sub: '#D0A896',
  text3: '#8F7062',
  red: '#FF5A45',
  redD: '#C93A2B',
  pink: '#FF7BA9',
  blue: '#6BA8FF',
  green: '#6ED491',
  gold: '#FFD36E',
  line: '#3A2C24',
  tabOn: '#FF5A45',
  mask: 'rgba(10,6,4,0.68)'
};

这是 ColorPalette 接口的具体实现常量 COLORS,它装载了"光影刷刷"的完整深色主题色板。我们从基底色开始解读:bg#14100F,这是一种偏暖的墨黑色——纯黑 #000000 在 OLED 屏幕上虽然省电但过于刺眼,而 #14100F 在 RGB 三通道中加入少量红与绿,让黑色带有一丝暖意,更契合"深夜刷视频"的沉浸氛围。card#221B18,比背景略亮一档,用于卡片容器与背景做层次区分;chip#31241E,再亮一档,用于胶囊标签和次级背景,形成三层递进的深色阶梯。

文字色阶采用暖白系而非冷白:title#FFF3E8,是一种偏暖的奶白色,在深色背景上对比度高且不刺眼;sub#D0A896,是一种暖灰粉,用于副标题与数值文本;text3#8F7062,进一步弱化,用于说明性和辅助性文字。这套暖色阶文字与暖墨黑背景形成和谐统一的色温关系,避免了冷白文字配暖黑背景的割裂感。

强调色方面,red 烈焰红 #FF5A45 是全局主强调色,用于按钮、选中态、品牌渐变的高光端;redD 烈焰红深色 #C93A2B 用于渐变的暗端和深色背景上的强调按钮;pink 活力粉 #FF7BA9 是次级强调色,用于 Tab 标签、次级按钮和点缀元素,与烈焰红形成红粉呼应的女性化色彩语言。此外,blue/green/gold 分别为科技、萌宠、美食等频道提供分类色标识,line 分割线色 #3A2C24 比卡片底略深以形成内嵌分隔,mask 遮罩色使用 rgba(10,6,4,0.68) 半透明黑,68% 的不透明度既压暗了底层内容又保留了一丝透出感,让弹窗不显得过于生硬。


四、常量定义与数据准备

4.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: '我的' }
];

这段定义了底部导航的元数据结构。TabMeta 接口声明了两个字段:icon(emoji 图标字符串)和 label(中文标签文本)。TAB_LIST 数组按从左到右的顺序列出了四个 Tab:推荐、创作者、AI 字幕、我的。值得注意的是,这里使用 emoji 作为图标而非图片资源,这是一种极轻量化的图标方案——emoji 是系统级字体字符,无需打包任何图片资源,跨设备显示一致,且自带色彩,省去了对选中态和未选中态分别准备两套图标的成本。

四个 Tab 的排列逻辑遵循了短视频社区产品的通行范式:推荐是内容消费的主入口,放在最左侧;创作者是内容生产者的展示窗口,紧随其后;AI 字幕是本应用的技术差异化亮点,置于第三位,与头部搜索条右侧的"🗣"入口形成呼应;我的页是个人中心,按惯例放在最右。底部导航通过 ForEach 遍历 TAB_LIST 渲染,选中态由 currentTab 索引驱动,无需为每个 Tab 单独编写条件分支。

4.2 频道分类标签与精选合集

/** 头部横滑频道 chips 文案 */
const CATE_TAGS: string[] = ['热门', '科技', '美食', '萌宠', '旅行', '游戏', '二次元', '纪实'];

/** 精选合集条目接口(推荐 Tab 横滑大卡内展示) */
interface CollectItem {
  cover: string;   // 合集封面 emoji
  name: string;    // 合集名
  meta: string;    // 热度/简介文案
}

/** 精选合集 Mock 数据(4 条,横滑大卡) */
const HOT_COLLECTIONS: CollectItem[] = [
  { cover: '🔥', name: '今日热榜 TOP50', meta: '1.2 亿次播放 · 实时更新' },
  { cover: '🍜', name: '深夜食堂治愈瞬间', meta: '4860 万人追更 · 已更 36 集' },
  { cover: '🤖', name: '三分钟硬核科技', meta: '3200 万人追更 · 已更 52 集' },
  { cover: '🏔', name: '此生必去的川西线', meta: '2180 万人追更 · 已更 24 集' }
];

CATE_TAGS 是头部横滑频道 chips 的文案列表,包含 8 个分类标签。这些标签对应了 catColor 工具函数中的分类映射逻辑——科技对应粉色、美食对应金色、萌宠对应绿色、旅行对应蓝色,其余归为烈焰红,形成了一套"频道→颜色"的语义编码体系,让用户在浏览时能通过颜色快速识别内容类型。

CollectItem 接口定义了精选合集的数据结构,每条数据包含封面 emoji、合集名和元信息文案三个字段。HOT_COLLECTIONS 预置了 4 条 Mock 数据,覆盖热榜、美食、科技、旅行四个品类,每条数据都带有播放量、追更人数和已更集数等运营数据文案,用于在推荐 Tab 顶部的横滑大卡中展示。这些数据虽然是 Mock 值,但其结构和文案格式与真实运营数据完全一致,便于未来对接后端接口时直接替换。

4.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: '中英双语' }
];

/** 字号选项接口(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'];

这一组常量是整个 AI 字幕功能页的数据基石,它们直接对应了 Speech Kit 6.1.1 新增的四大字段。LangOption 接口定义了语言选项的结构——code 字段存储语言码(如 'zh''en''zh-en'),name 字段存储面向用户的展示名(如"中文"、“英文”、“中英双语”)。这种"码+名"的双字段设计是一种典型的国际化数据模式,内部逻辑使用 code 进行判断和传参,界面展示使用 name 让用户可读。

SRC_LANGS 列出了源语言的两种取值——中文和英文,这对应了 AICaptionOptions.sourceLanguage 字段允许的 'zh''en' 两个值。TGT_LANGS_EN 则列出了英文源时的三种目标语言选项:翻译为中文、保持英文原字幕、或开启中英双语对照。这里特别注释了"中文源时目标语言锁定 'zh'"——这是因为当源语言是中文时,不存在翻译方向,目标语言只能是中文本身。这一联动逻辑在组件的 switchSourceLang 方法中被精确实现。

SizeOption 接口和 SIZE_OPTIONS 数组对应了 AICaptionOptions.fontSize 字段。fontSize 的类型是 AICaptionFontSize 枚举,该枚举有 SMALLNORMALBIGLARGE 四个值,分别对应小号、标准、大号、超大四档字号。这里将枚举值与中文展示名配对存入数组,便于在 UI 中通过 ForEach 渲染选项按钮,点击后将枚举值赋给 @State captionSize

CAPTION_FONT_COLORS 是字幕字体颜色的五预设数组,对应 AICaptionOptions.fontColor 字段。fontColor 的类型是 ResourceColor,它接受 HEX 色值字符串。这里预置了经典白 #FFFFFF(深夜通用)、暖阳黄 #FFE9B0(暖色调护眼)、薄荷绿 #9CE8B5(清新高对比)、云朵蓝 #9CD0FF(冷色清晰)、樱花粉 #FFB3C1(女性化点缀)五种颜色,覆盖了不同审美与环境光的需求。用户点击圆形色块后,对应色值会赋给 @State captionColor,最终通过 buildCaptionOptions 注入到 AICaptionComponent 的 options 中实时生效。

4.4 月度播放量图表数据

/** 月度播放量柱状图月份索引 */
const MONTH_IDX: number[] = [0, 1, 2, 3, 4, 5];
/** 月度柱状图月份名称 */
const MONTH_LABELS: string[] = ['3月', '4月', '5月', '6月', '7月', '8月'];
/** 月度播放量数值(万次) */
const MONTH_PLAYS: number[] = [420, 512, 468, 590, 645, 703];
/** 月度柱状图最大值(万次) */
const MONTH_MAX: number = 760;

这组常量为创作者 Tab 的月度播放量柱状图提供数据支撑。MONTH_IDX 是 0 到 5 的索引数组,用于 ForEach 遍历;MONTH_LABELS 是六个月的中文标签;MONTH_PLAYS 是对应的播放量数值(单位:万次),呈现从 3 月的 420 万到 8 月的 703 万的稳步上升趋势;MONTH_MAX 是柱状图的纵轴最大刻度 760 万,用于计算每根柱子的相对高度比例。

这里将索引、标签、数值分成三个平行数组而非一个对象数组,是一种轻量化的数据组织方式。在柱状图的 ForEach 中,通过 MONTH_IDX 遍历,分别用 MONTH_PLAYS[i]MONTH_LABELS[i] 取值,代码简洁。MONTH_MAX 作为常量独立声明,是因为它不参与 ForEach 遍历,只在计算柱高时作为分母使用。数据呈现的上升趋势(420→512→468→590→645→703)配合呼吸动画的 ±5% 波动,让柱状图既有数据真实感又有动态活力。


五、辅助函数

5.1 频道颜色映射 catColor

/** 频道分类颜色映射:科技粉 / 美食金 / 萌宠绿 / 旅行蓝 / 其余烈焰红 */
function catColor(c: string): string {
  if (c === '科技') { return COLORS.pink; }
  if (c === '美食') { return COLORS.gold; }
  if (c === '萌宠') { return COLORS.green; }
  if (c === '旅行') { return COLORS.blue; }
  return COLORS.red;
}

catColor 函数实现了频道分类到颜色值的映射。它接收一个分类名称字符串,返回对应的主题色。这是一个纯函数——输入相同则输出相同,不依赖任何外部可变状态,只读取常量 COLORS。映射规则是:科技对应活力粉、美食对应金色、萌宠对应绿色、旅行对应蓝色,其余所有分类(热门、游戏、二次元、纪实等)都归为烈焰红。

这种"分类→颜色"的语义编码让用户在浏览创作者头像墙时,能通过领域胶囊的颜色快速识别创作者的内容类型。粉色的科技标签在深色背景上尤为醒目,金色的美食标签带有温暖食欲感,绿色的萌宠标签传递生命力,蓝色的旅行标签呼应天空与远方。函数实现采用连续的 if 判断而非 switch 或字典查找,在分类数量较少时这种写法可读性最佳,且 ArkTS 编译器能对连续相等判断做较好的优化。

5.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 '中英双语';
}

这两个函数分别负责将枚举值和语言码转换为面向用户的展示名。sizeName 接收 AICaptionFontSize 枚举值,返回其英文常量名字符串——注意这里返回的是 'SMALL''BIG''LARGE''NORMAL' 这些枚举的英文名而非中文"小号"等,因为该函数专用于 AI 字幕 Tab 中的"options 实时代码预览卡",那里需要展示的是代码层面的枚举名,让开发者能直观看到当前 fontSize 字段的实际传值。langName 则将语言码转换为中文名,用于"当前语言组合摘要"行的文案展示。

两个函数的最后一个分支都使用了 return 作为兜底——sizeName 的兜底是 'NORMAL',因为 NORMAL 是默认字号,如果传入了未预期的枚举值,返回默认值是安全的降级策略;langName 的兜底是"中英双语",因为除 'zh''en' 外,唯一合法的语言码就是 'zh-en'。这种兜底式写法比 switch-default 更紧凑,在选项数量少时非常实用。

5.3 字幕颜色转名 colorName

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

colorName 函数将字幕颜色预设的 HEX 色值转换为中文名。它通过比较传入色值与 CAPTION_FONT_COLORS 数组的各下标元素来确定颜色名称。由于 CAPTION_FONT_COLORS 的顺序是固定的(经典白、暖阳黄、薄荷绿、云朵蓝、樱花粉),函数按此顺序逐一比对,命中则返回对应中文名,最后兜底返回"樱花粉"。

这个函数的设计亮点在于它不硬编码色值字符串,而是引用 CAPTION_FONT_COLORS 数组元素做比较。这意味着如果未来调整了某个预设色的 HEX 值(比如把经典白从 #FFFFFF 调整为 #FFFAF0),只需修改 CAPTION_FONT_COLORS 数组一处,colorName 函数的比对逻辑自动跟随更新,不会出现"色值改了但函数里还写着旧色值"的不一致 bug。这是一种避免重复真理(DRY)的良好实践。


六、数据模型层

6.1 VideoItem 视频条目模型

/** 视频条目(推荐 Tab 双列视频卡片瀑布) */
@Observed export class VideoItem {
  title: string;   // 视频标题
  cover: string;   // emoji 封面
  up: string;      // UP主名
  likes: string;   // 点赞文本(如"12.8万")
  durs: string;    // 时长(如"03:24")
  cat: string;     // 频道分类

  constructor(title: string, cover: string, up: string, likes: string, durs: string, cat: string) {
    this.title = title;
    this.cover = cover;
    this.up = up;
    this.likes = likes;
    this.durs = durs;
    this.cat = cat;
  }
}

VideoItem 是推荐 Tab 视频瀑布流的数据模型,使用 @Observed 装饰器标注。@Observed 的作用是让类的实例成为可观察对象——当实例的某个字段被赋值修改时,所有通过 @ObjectLink 或直接绑定引用该实例的组件都会自动触发局部刷新。这意味着如果用户编辑了某条视频的标题,只有该视频对应的卡片会重新渲染,而非整个瀑布流全部刷新,极大提升了渲染性能。

模型包含六个字段:title 视频标题、cover emoji 封面、up UP 主名、likes 点赞文本(如"12.8万")、durs 时长文本(如"03:24")、cat 频道分类。值得注意的是 likesdurs 都用字符串而非数字存储,这是因为它们在界面上直接展示为带单位的文本,用字符串避免了数字到文本的格式化转换,简化了渲染逻辑。cat 字段存储分类名,会被传入 catColor 函数获取对应颜色,用于卡片角标的着色。

构造函数采用传统的参数列表赋值方式,六个参数按顺序传入并赋给对应字段。虽然 ArkTS 也支持对象字面量初始化,但在需要类型约束和构造逻辑的场合,显式构造函数更加安全可靠。export 关键字让该类可以被外部模块引用,便于未来拆分为独立的模型文件。

6.2 CreatorItem 与 CaptionScene 模型

/** 创作者条目(创作者 Tab 四列头像墙) */
@Observed export class CreatorItem {
  name: string;    // 创作者名
  avatar: string;  // emoji 头像
  fans: string;    // 粉丝文本
  videos: string;  // 作品数文本
  tag: string;     // 领域(如"科技")

  constructor(name: string, avatar: string, fans: string, videos: string, tag: string) {
    this.name = name;
    this.avatar = avatar;
    this.fans = fans;
    this.videos = videos;
    this.tag = tag;
  }
}

/** 字幕场景条目(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;
  }
}

CreatorItem 是创作者头像墙的数据模型,同样使用 @Observed 标注。它包含五个字段:name 创作者名、avatar emoji 头像、fans 粉丝数文本、videos 作品数文本、tag 领域标签。tag 字段会传入 catColor 函数获取领域颜色,用于创作者卡片中领域胶囊的着色,与视频卡片的分类角标形成统一的色彩编码体系。

CaptionScene 是 AI 字幕 Tab 场景推荐列表的数据模型,它的设计尤为精巧。每条场景数据包含场景名、场景说明、推荐源语言和推荐目标语言四个字段。当用户点击某条场景时,组件会调用 switchSourceLang(item.src) 联动设置源语言,同时将 item.tgt 赋给 tgtLang,一键套用该场景的语言组合。这种"场景化预设"的设计让用户无需理解 sourceLanguagetargetLanguage 的技术含义,只需选择"海外达人视频汉化"、"双语学习频道"等场景,系统自动完成语言配置。

预置的 5 条场景数据覆盖了短视频社区的核心字幕需求:海外达人视频汉化(en→zh)、双语学习频道(en→zh-en)、中文口播速记(zh→zh)、英语听力特训(en→en)、直播回放速览(zh→zh)。这些场景与 Mock 数据中的视频内容形成了呼应——比如"硬核科技说"的量子计算视频适合"英语听力特训"场景,"巷口小食堂"的葱油拌面视频适合"中文口播速记"场景。

6.3 UserStat 功能清单模型

/** 我的页功能清单条目 */
@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;
  }
}

UserStat 是我的页功能清单的数据模型,包含四个字段:icon emoji 图标、label 功能名、value 状态文本、arrow 是否显示右箭头。arrow 字段是一个布尔值,用于控制列表行右侧是否显示"›"箭头——大多数功能行都会显示箭头表示可点击进入二级页面,但某些纯信息展示行(如果未来加入)可以设为 false 隐藏箭头。

预置的 8 条数据覆盖了用户中心的核心功能:我的作品、我的喜欢、观看时长、我的关注、收藏合集、AI 字幕偏好、光影 VIP 特权、播放与缓存设置。其中"AI 字幕偏好"行的 value 文本"源 zh · 目标 zh"直接反映了当前字幕语言设置,让用户在个人中心也能快速查看自己的字幕配置,形成与 AI 字幕 Tab 的状态联动。

6.4 Mock 数据数组

/** 视频瀑布 Mock 数据(8 条) */
const VIDEO_LIST: Array<VideoItem> = [
  new VideoItem('深夜食堂治愈瞬间:一碗葱油拌面', '🍜', '巷口小食堂', '12.8万', '03:24', '美食'),
  new VideoItem('三分钟看懂量子计算到底是啥', '🧪', '硬核科技说', '45.2万', '05:10', '科技'),
  new VideoItem('布偶猫的一百种睡姿', '🐱', '毛球研究所', '88.6万', '01:47', '萌宠'),
  new VideoItem('川西自驾七日全攻略', '🏔', '在路上旅行', '23.4万', '08:32', '旅行'),
  new VideoItem('街头投篮压哨绝杀集锦', '🏀', '野球场阿凯', '67.9万', '02:58', '游戏'),
  new VideoItem('新手也能画好的动漫眼睛', '🎨', '画笔小柔', '31.5万', '06:05', '二次元'),
  new VideoItem('老巷理发店的三十个年头', '💈', '人间记录仪', '54.1万', '04:19', '纪实'),
  new VideoItem('宿舍党快手低脂餐教程', '🥗', '轻食小食堂', '19.7万', '02:36', '美食')
];

VIDEO_LIST 是推荐 Tab 视频瀑布的 8 条 Mock 数据,每条数据通过 new VideoItem(...) 构造实例。数据内容覆盖了美食、科技、萌宠、旅行、游戏、二次元、纪实七个分类,与头部频道 chips 的分类标签完全对应,验证了 catColor 颜色映射的全面覆盖性。每条数据的 UP 主名(如"巷口小食堂"、“硬核科技说”)也与创作者 Tab 的 CREATOR_LIST 数据形成了交叉引用——视频瀑布中"巷口小食堂"发布的葱油拌面视频,对应的正是创作者头像墙中"巷口小食堂"这位 UP 主,这种数据一致性增强了应用的真实感。

点赞数和时长的文本格式遵循了短视频行业的通用规范:点赞数用"万"为单位(如"12.8万"、“88.6万”),时长用"分:秒"格式(如"03:24"、“08:32”)。这些文本直接在界面上展示,无需任何格式化处理,体现了模型字段类型与展示需求的精准匹配。数据按点赞数从低到高大致排列(12.8万→45.2万→88.6万→23.4万→67.9万→31.5万→54.1万→19.7万),虽非严格排序但呈现出内容多样性,避免瀑布流显得单调。


七、组件主体结构

7.1 @State 状态变量声明

@Entry
@Component
struct Page1112 {
  /** 当前选中 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 videoList: Array<VideoItem> = VIDEO_LIST;
  /** 创作者头像墙数据 */
  @State creatorList: Array<CreatorItem> = CREATOR_LIST;
  /** 字幕场景列表数据 */
  @State sceneList: Array<CaptionScene> = SCENE_LIST;
  /** 我的页功能清单数据 */
  @State statList: Array<UserStat> = STAT_LIST;
  /** 新建表单:合集名称 */
  @State formName: string = '';
  /** 新建表单:频道分类 */
  @State formTag: string = '';
  /** 编辑表单:合集名称 */
  @State editName: string = '';
  /** 编辑表单:频道分类 */
  @State editTag: string = '';

这是组件 Page1112 的状态变量声明部分。@Entry 装饰器标记该组件为页面入口,可被路由系统直接加载;@Component 声明这是一个自定义组件。@State 装饰的变量是响应式状态——当其值发生变化时,引用该变量的所有 UI 片段会自动重新渲染。

状态变量可以分为四组来理解。第一组是导航与动画状态:currentTab 控制当前显示哪个 Tab,breath 是呼吸动画的布尔翻转开关,timer 是定时器句柄,cateIdx 是频道 chips 的选中索引。第二组是弹窗状态:addModaleditModaldelModal 三个布尔值分别控制三类弹窗的显示,editIdxdelIdx 记录当前操作的视频索引。第三组是业务数据:四个 @State 数组分别绑定到四个 Tab 的列表渲染,初始值来自 Mock 常量。第四组是表单字段:formName/formTag 用于新建合集表单,editName/editTag 用于编辑合集表单。

值得注意的是 timer 虽然用 @State 标注,但它存储的是定时器句柄(一个数字 ID),并非用于驱动 UI 渲染。将其声明为 @State 是因为 ArkUI 组件没有提供普通的实例字段语法,所有需要在组件实例上持久的变量都需要通过装饰器声明。在实际使用中,timer 的变化不会触发任何 UI 刷新,因为没有任何 UI 绑定引用了它。

7.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),因为它不需要驱动 UI 渲染,只用于调用 writeAudio 方法向字幕组件写入音频流。控制器是 Speech Kit 的桥梁——开发者不直接操作 AICaptionComponent 的内部状态,而是通过控制器实例注入音频数据。

captionShown 是字幕的显示开关,它会双向绑定到 AICaptionComponentisShown 参数,点击"开启字幕/隐藏字幕"按钮翻转该值即可控制字幕浮层的显隐。srcLangtgtLang 分别对应 6.1.1 新增的 sourceLanguagetargetLanguage,初始值都是 'zh'(中文源→中文目标)。captionSize 对应 fontSize,初始值为 AICaptionFontSize.NORMAL(标准字号)。captionColor 对应 fontColor,初始值取自 CAPTION_FONT_COLORS[0] 即经典白 #FFFFFF

captionReadycaptionErrMsgcaptionFed 是三个运行态状态:captionReadyonPrepared 回调置 true,表示字幕服务已就绪,界面上的状态标签从"初始化中"变为"已就绪";captionErrMsgonError 回调写入错误信息,非空时在预览卡底部显示错误提示;captionFed 是已写入音频块的计数,每次调用 feedDemoAudio 成功后自增,在"写入演示音频"按钮旁以"×N"格式展示。这三个状态让字幕服务的运行过程对用户完全透明可见。

7.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 字幕功能的枢纽方法,它将四个 @State 状态变量(srcLangtgtLangcaptionSizecaptionColor)组装成一个 AICaptionOptions 对象,供 AICaptionComponentoptions 参数消费。这个方法在组件的 build 中被调用,每次 build 触发时都会重新组装 options,确保字幕组件始终使用最新的语言和外观设置。

AICaptionOptions 对象包含七个字段:initialOpacity 设置为 1(字幕初始不透明度为满),四个 6.1.1 新增字段分别绑定到对应的 @State 变量,以及两个回调函数 onPreparedonErroronPrepared 在字幕服务初始化完成、可以接收音频流时被调用,将 captionReadytrue 并清空错误信息;onError 在字幕服务发生异常时被调用,参数是 BusinessError 类型(来自 @kit.BasicServicesKit),包含 code 错误码和 message 错误信息,回调内将其拼接为可读文案写入 captionErrMsg

这种"状态→options→组件"的单向数据流是 ArkUI 响应式范式的典型应用:用户在 UI 上点击语言/外观选项 → @State 变量更新 → build 重新执行 → buildCaptionOptions 重新组装 options → AICaptionComponent 接收新 options → 字幕外观实时变化。整个链路自动完成,无需开发者手动调用刷新方法。

7.4 switchSourceLang 语言联动

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

switchSourceLang 方法处理源语言切换时的目标语言联动逻辑。当用户选择源语言为中文时,目标语言被锁定为 'zh'——因为中文源不存在翻译方向,字幕就是中文语音的实时文字转写,目标语言只能是中文本身。当用户选择源语言为英文时,目标语言默认设为 'zh-en'(中英双语),用户随后可以在目标语言选项中进一步切换为纯中文或纯英文。

这一联动逻辑体现了对 Speech Kit 能力边界的精确理解。sourceLanguage 只支持 'zh''en' 两种值,targetLanguage 支持 'zh''en''zh-en' 三种值,但并非所有组合都合法——中文源时目标语言不能是英文或中英双语(因为 Speech Kit 不支持中文到英文的翻译),只有英文源时才支持三种目标语言。该方法在 UI 层面通过条件渲染配合:中文源时显示锁定提示行,英文源时显示三个可选按钮。点击字幕场景推荐列表时也会调用此方法,一键套用场景的源语言和目标语言组合。

7.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 方法生成一段合成的 PCM 音频数据并写入字幕控制器,用于在没有真实麦克风输入的情况下演示字幕组件的工作流程。方法首先创建一个 640 字节的 Uint8Array 缓冲区,然后通过一个 for 循环以步长 2(每两个字节为一个 16-bit 采样点)填充数据。每个采样点的值通过正弦函数计算:Math.sin(2 * Math.PI * 440 * t) * 6000,其中 440 是标准音 A4 的频率,t 是当前采样点的时间戳(秒)。计算出的振幅值被拆分为低字节和高字节,分别存入 block[i]block[i+1],构成小端序的 16-bit PCM 数据。

这段数据的参数对应 16kHz 采样率、16-bit 位深、单声道的 PCM 格式,640 字节正好是 320 个采样点,按 16000 采样/秒计算约为 20 毫秒的音频。写入时将 Uint8Array 包装为 AudioData 对象({ data: block }),然后调用 this.captionController.writeAudio(audioData) 注入字幕组件。整个写入过程包裹在 try-catch 中,如果控制器未就绪或参数不合法,异常会被捕获并写入 captionErrMsg。成功写入后 captionFed 自增,界面上"写入演示音频"按钮旁的计数器随之更新。这种"合成音频→写入→计数反馈"的演示闭环让开发者能直观验证字幕组件的音频输入通路是否正常工作。

7.6 视频增删改方法与生命周期

  /** 打开编辑弹窗(回填当前视频标题与频道分类) */
  openEditVideo(idx: number) {
    this.editIdx = idx;
    this.editName = this.videoList[idx].title;
    this.editTag = this.videoList[idx].cat;
    this.editModal = true;
  }

  /** 保存新建合集(空字段用默认值兜底,推入视频瀑布顶部) */
  saveVideo() {
    const name = this.formName === '' ? '未命名合集' : this.formName;
    const tag = this.formTag === '' ? '热门' : this.formTag;
    this.videoList.unshift(new VideoItem(name, '🎬', '我的创作号', '0', '00:15', tag));
    this.formName = '';
    this.formTag = '';
    this.addModal = false;
  }

  /** 保存编辑合集(整体刷新数组引用以刷新视频瀑布) */
  updateVideo() {
    if (this.editIdx >= 0 && this.editIdx < this.videoList.length) {
      if (this.editName !== '') {
        this.videoList[this.editIdx].title = this.editName;
      }
      if (this.editTag !== '') {
        this.videoList[this.editIdx].cat = this.editTag;
      }
      this.videoList = this.videoList.slice();
    }
    this.editModal = false;
  }

  /** 删除视频(确认弹窗回调) */
  delVideo() {
    if (this.delIdx >= 0 && this.delIdx < this.videoList.length) {
      this.videoList.splice(this.delIdx, 1);
    }
    this.delModal = false;
  }

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

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

这一组方法涵盖了视频合集的增删改完整操作和组件生命周期管理。openEditVideo 在打开编辑弹窗前,将当前视频的标题和分类回填到 editNameeditTag 表单字段,确保编辑弹窗打开时输入框已显示当前值——这是"编辑"与"新建"的核心区别,编辑必须预填已有数据。

saveVideo 处理新建合集的保存:对空字段做默认值兜底(名称为空则用"未命名合集",分类为空则用"热门"),然后通过 unshift 将新视频插入数组头部,使其出现在瀑布流最顶端。保存后清空表单字段并关闭弹窗,为下次新建做好准备。

updateVideo 处理编辑保存:先做索引边界检查,然后分别更新标题和分类字段(空值不覆盖),最后通过 this.videoList = this.videoList.slice() 创建数组副本并重新赋值。这一步至关重要——@Observed 类的内部字段修改虽然能触发 @ObjectLink 的局部刷新,但对于直接用 @State 绑定数组的 ForEach 来说,需要数组引用本身发生变化才能触发列表重新遍历,slice() 正是为此而生。delVideo 使用 splice 删除指定索引的元素,splice 会原地修改数组并改变其长度,足以触发 ForEach 刷新。

aboutToAppearaboutToDisappear 是组件的生命周期回调。aboutToAppear 在组件创建后、build 执行前被调用,这里启动一个每 1000 毫秒翻转 breath 布尔值的定时器,驱动全局呼吸动画。aboutToDisappear 在组件销毁前被调用,清理定时器避免内存泄漏。这种"成对出现"的资源获取与释放是良好工程实践的体现。

7.7 build 主构建方法

  /** 页面主构建:Stack 包裹主内容与三层弹窗 */
  build() {
    Stack() {
      Column() {
        this.headerMain()
        Divider().strokeWidth(1).color(COLORS.line)
        Scroll() {
          Column() {
            if (this.currentTab === 0) {
              this.tabRecommend()
            } else if (this.currentTab === 1) {
              this.tabCreator()
            } 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() 头部区域、一条分割线、Scroll 可滚动内容区、tabBar() 底部导航。Scroll 使用 layoutWeight(1) 占据头部和底部之间的全部剩余空间,scrollBar(BarState.Off) 隐藏滚动条保持视觉洁净。内容区内通过 if-else if-else 条件分支根据 currentTab 渲染对应的 Tab 构建函数——这种条件渲染比 Tabs 容器更轻量,因为非当前 Tab 的内容不会被实例化,节省内存。

三个弹窗面板通过 if (this.xxxModal) 条件渲染按需挂载。每个面板接收一个 onClose 回调函数(箭头函数将对应 Modal 开关置 false),当用户点击遮罩或取消按钮时调用该回调关闭弹窗。条件渲染意味着弹窗关闭后其组件树会被完全销毁,下次打开时重新创建,避免了多个弹窗同时存在的状态混乱。整个 build 的最外层 Stack 设置了 backgroundColor(COLORS.bg),将墨黑背景铺满全屏。


八、头部区域详解

  /** 头部:顶部渐变 Banner(品牌slogan+今日热门数据)+ 搜索条 + 横滑频道 chips */
  @Builder
  headerMain() {
    Column({ space: 12 }) {
      // 渐变 Banner:品牌 slogan + 今日热门数据
      Column({ space: 10 }) {
        Row() {
          Column({ space: 5 }) {
            Text('光影刷刷 · 记录每一种热爱').fontSize(16).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Text('今日热门 · 深夜食堂治愈瞬间正在冲榜').fontSize(9).fontColor(COLORS.title).opacity(0.72)
              .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.redD)
          .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
        }
        .width('100%')

        Row({ space: 8 }) {
          Text('🔥 热搜 1.2 亿播放').fontSize(9).fontColor(COLORS.title).opacity(0.9)
            .padding({ left: 8, right: 8, top: 3, bottom: 3 }).backgroundColor(COLORS.redD).borderRadius(8)
          Text('🆕 新增投稿 8.6 万条').fontSize(9).fontColor(COLORS.title).opacity(0.9)
            .padding({ left: 8, right: 3, top: 3, bottom: 3 }).backgroundColor(COLORS.redD).borderRadius(8)
          Text('⭐ VIP 免广告畅刷').fontSize(9).fontColor(COLORS.redD)
            .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.redD, 0], [COLORS.red, 1]] })

头部区域是用户进入应用后首先看到的视觉区块,由渐变 Banner、搜索条和横滑频道 chips 三部分组成。渐变 Banner 使用 linearGradient 设置 135 度对角渐变,从左上的 redD(深烈焰红 #C93A2B)过渡到右下的 red(烈焰红 #FF5A45),营造出火焰般的视觉张力。Banner 内部左侧是品牌 slogan"光影刷刷 · 记录每一种热爱"和今日热门动态文案,右侧是一个 44x44 圆形图标块,其中的"🎬"emoji 透明度随 breath 状态在 1 和 0.6 之间翻转,形成呼吸般的脉动效果。

Banner 底部是三个数据胶囊标签:热搜播放量、新增投稿数、VIP 特权。前两个标签使用 redD 背景 + 暖白文字,与 Banner 渐变融为一体;VIP 标签使用金色背景 + 深红文字,形成对比色突出会员特权。三个标签的文案都带有 emoji 前缀(🔥🆕⭐),用最少的字符传递最丰富的信息。标签的 paddingborderRadius 参数精调了胶囊的视觉比例,8 的圆角让方块柔和而不失锐利。

      // 搜索条(右侧字幕入口跳转 AI 字幕 Tab)
      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)

      // 横滑频道 chips
      Scroll() {
        Row({ space: 8 }) {
          ForEach(CATE_TAGS, (tg: string, idx: number) => {
            Text(tg).fontSize(11)
              .fontColor(this.cateIdx === idx ? COLORS.pink : 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 容器,左侧是放大镜 emoji,中间是占位提示文本"搜索视频 / 创作者 / 合集"(用 text3 弱化色 + layoutWeight(1) 占满中间空间 + maxLines(1) + 省略号),右侧是"🗣"字幕入口。点击字幕 emoji 会将 currentTab 设为 2,直接跳转到 AI 字幕 Tab——这是一个巧妙的产品设计,将语音字幕入口与搜索框并置,暗示"看不懂视频?用 AI 字幕"的使用场景。搜索条使用 card 底色 + 20 的圆角,形成胶囊状的搜索框外观。

横滑频道 chips 使用 Scroll 横向滚动容器包裹一个 RowForEach 遍历 CATE_TAGS 渲染 8 个分类标签。每个标签的选中态由 cateIdx === idx 判断:选中时文字用活力粉 pink、背景用 chip 深色底;未选中时文字用 sub 暖灰、背景用 card 卡片底。点击标签更新 cateIdx,选中态实时切换。scrollable(ScrollDirection.Horizontal) 启用横向滚动,scrollBar(BarState.Off) 隐藏滚动条。整个头部区域外层使用从 chipbg 的 180 度垂直渐变,让头部与主体内容之间形成柔和的色彩过渡。


九、推荐 Tab 详解

9.1 横滑精选合集大卡

  /** 推荐 Tab:横滑精选合集大卡 + 双列视频卡片瀑布 */
  @Builder
  tabRecommend() {
    Column({ space: 12 }) {
      // 1. 横滑精选合集大卡(烈焰红渐变 + 横向滚动)
      Column({ space: 10 }) {
        Row() {
          Text('🔥 精选合集').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
          Column().layoutWeight(1)
          Text('左右滑动查看').fontSize(9).fontColor(COLORS.text3)
        }
        .width('100%')

        Scroll() {
          Row({ space: 10 }) {
            ForEach(HOT_COLLECTIONS, (c: CollectItem) => {
              Column({ space: 8 }) {
                Text(c.cover).fontSize(32).opacity(this.breath ? 1 : 0.7)
                Text(c.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
                  .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
                Text(c.meta).fontSize(8).fontColor(COLORS.title).opacity(0.7)
                  .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
              }
              .width(150).padding(12).borderRadius(12)
              .linearGradient({ angle: 145, colors: [[COLORS.redD, 0], [COLORS.red, 1]] })
              .alignItems(HorizontalAlign.Start)
            }, (c: CollectItem) => c.name)
          }
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .width('100%')
      }
      .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)

推荐 Tab 的第一区块是横滑精选合集大卡。外层是一个 card 底色的卡片容器,内部包含标题行和横向滚动的合集卡片列表。标题行左侧是"🔥 精选合集"加粗标题,中间用空的 Column().layoutWeight(1) 占位推右,右侧是"左右滑动查看"的提示文案——这种"标题-占位-提示"的三段式行布局在整个应用中被反复使用,是统一的行级布局模式。

合集卡片列表使用 Scroll 横向滚动,ForEach 遍历 HOT_COLLECTIONS 渲染 4 张大卡。每张卡片固定宽度 150 像素,使用 145 度对角渐变从 redDred,与头部 Banner 的渐变方向一致形成视觉延续。卡片内部从上到下是:封面 emoji(字号 32,透明度随 breath 在 1 和 0.7 之间翻转)、合集名(加粗,单行省略)、热度文案(小字号,单行省略)。alignItems(HorizontalAlign.Start) 让卡片内文本左对齐,符合从左到右的阅读习惯。

9.2 双列视频卡片瀑布

      // 2. 视频瀑布标题行 + 新建入口
      Row() {
        Text('🎬 视频瀑布').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Column().layoutWeight(1)
        Text(this.videoList.length.toString() + ' 个视频').fontSize(9).fontColor(COLORS.text3)
        Text('+ 新建').fontSize(9).fontColor(COLORS.pink)
          .padding({ left: 9, right: 9, top: 4, bottom: 4 })
          .backgroundColor(COLORS.chip).borderRadius(8)
          .onClick(() => {
            this.addModal = true;
          })
      }
      .width('100%')

      // 3. 双列视频卡片瀑布(Flex 换行布局)
      Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
        ForEach(this.videoList, (item: VideoItem, idx: number) => {
          Column({ space: 8 }) {
            // 封面渐变块(emoji 封面 + 分类角标 + 时长角标)
            Column() {
              Row() {
                Text(item.cat).fontSize(8).fontColor(COLORS.pink)
                  .padding({ left: 5, right: 5, top: 1, bottom: 1 })
                  .backgroundColor(COLORS.redD).borderRadius(5)
                Column().layoutWeight(1)
                Text(item.durs).fontSize(8).fontColor(COLORS.title).opacity(0.85)
              }
              .width('100%').margin({ top: 6, left: 6, right: 6 })

              Column() {
                Text(item.cover).fontSize(30).opacity(this.breath ? 1 : 0.8)
              }
              .layoutWeight(1)
              .justifyContent(FlexAlign.Center)

              Row() {
                Text('▶ ' + item.up).fontSize(8).fontColor(COLORS.title).opacity(0.75)
                  .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
              }
              .width('100%').margin({ bottom: 8, left: 6, right: 6 })
            }
            .width('100%').height(92).borderRadius(10)
            .linearGradient({ angle: 145, colors: [[COLORS.redD, 0], [COLORS.red, 1]] })

            // 视频标题 + 点赞
            Text(item.title).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
              .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
            Text('❤ ' + item.likes + ' · ' + item.cat).fontSize(9).fontColor(COLORS.sub)
              .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })

            // 编辑 / 删除迷你操作行
            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.openEditVideo(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('48%').padding(10).backgroundColor(COLORS.card).borderRadius(12)
        }, (item: VideoItem) => item.title + item.cat)
      }
      .width('100%')
    }
    .width('100%')
  }

视频瀑布标题行除了标题外,还包含动态视频数量(this.videoList.length.toString() + ' 个视频',随增删操作实时更新)和"+ 新建"入口按钮。点击新建按钮将 addModaltrue,触发新建合集弹窗的条件渲染。

双列瀑布使用 Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) 实现换行布局。FlexWrap.Wrap 允许子元素在一行放不下时自动换行,SpaceBetween 让同行元素两端对齐、中间留等距间隙。每个视频卡片宽度为 '48%',两列加间隙正好填满行宽。ForEach 的第三个参数(键值生成器)是 item.title + item.cat,用标题加分类的组合作为列表项的唯一键,确保列表更新时 ArkUI 能正确识别哪些项发生了变化。

每张视频卡片由三部分组成:封面渐变块、标题与点赞行、编辑删除操作行。封面块高 92 像素,内部从上到下是分类角标行(左侧分类胶囊用 cat 取色后的粉色文字配 redD 底,右侧时长文案)、居中的 emoji 封面(透明度随 breath 翻转)、底部 UP 主名行。封面块整体使用烈焰红渐变,与精选合集大卡保持一致的视觉语言。标题行用加粗暖白色,点赞行用暖灰副色并附带心形 emoji。操作行的"编辑"按钮调用 openEditVideo(idx) 打开编辑弹窗,"删除"按钮设置 delIdx 后打开删除确认弹窗,两个按钮用 chip 底色 + 圆角形成迷你胶囊按钮。


十、创作者 Tab 详解

10.1 四列头像墙

  /** 创作者 Tab:四列头像墙 + 月度播放量柱状图 */
  @Builder
  tabCreator() {
    Column({ space: 12 }) {
      // 头像墙标题行
      Row() {
        Text('🎥 热门创作者').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Column().layoutWeight(1)
        Text('每周三更新').fontSize(9).fontColor(COLORS.text3)
      }
      .width('100%')

      // 四列头像墙(Flex 换行,每行 4 个创作者卡片)
      Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
        ForEach(this.creatorList, (item: CreatorItem) => {
          Column({ space: 6 }) {
            // 圆形 emoji 头像(烈焰红描边)
            Column() {
              Text(item.avatar).fontSize(24)
            }
            .width(50).height(50).borderRadius(25).backgroundColor(COLORS.chip)
            .border({ width: 2, color: COLORS.red })
            .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)

            // 创作者名 + 领域胶囊
            Column({ space: 4 }) {
              Text(item.name).fontSize(10).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
                .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
              Text(item.tag).fontSize(8).fontColor(catColor(item.tag))
                .padding({ left: 6, right: 6, top: 1, bottom: 1 })
                .backgroundColor(COLORS.chip).borderRadius(7)
            }
            .alignItems(HorizontalAlign.Center)

            // 粉丝数 + 作品数
            Text(item.fans + ' 粉丝').fontSize(8).fontColor(COLORS.sub)
              .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
            Text(item.videos).fontSize(8).fontColor(COLORS.text3)
              .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
          }
          .width('24%').padding({ top: 10, bottom: 10 }).backgroundColor(COLORS.card).borderRadius(12)
          .alignItems(HorizontalAlign.Center)
        }, (item: CreatorItem) => item.name)
      }
      .width('100%')

      // 月度播放量柱状图(呼吸 ±5% 波动)
      this.chartCard()
    }
    .width('100%')
  }

创作者 Tab 的核心是四列头像墙,使用 Flex 换行布局,每个创作者卡片宽度为 '24%',四张一行正好填满。每张卡片居中排列以下元素:50x50 的圆形 emoji 头像(chip 底色 + 2 像素烈焰红描边,圆形通过 borderRadius(25) 即宽高一半实现)、创作者名(加粗暖白)、领域胶囊(文字颜色由 catColor(item.tag) 动态取色,底色 chip)、粉丝数(暖灰副色)、作品数(三级弱化色)。所有文本都用 maxLines(1) + 省略号限制单行,避免长名撑破卡片。

头像墙的设计亮点在于"烈焰红描边"——2 像素的红色边框让每个头像都带有一圈火焰般的光环,在深色背景上形成醒目的视觉焦点。领域胶囊的颜色编码(科技粉、美食金、萌宠绿、旅行蓝、纪实红)让用户能一眼识别创作者的内容方向,这种色彩语义与视频瀑布中的分类角标保持完全一致,形成了全局统一的分类视觉语言。

头像墙下方通过 this.chartCard() 调用通用的柱状图卡片构建函数,展示月度播放量数据。将图表抽取为独立的 @Builder 函数而不是内联在 tabCreator 中,既保持了 tabCreator 的简洁性,又让图表逻辑可复用——如果未来其他 Tab 也需要展示柱状图,可以直接调用同一个 chartCard


十一、AI 字幕 Tab 详解

AI 字幕 Tab 是整个应用的技术核心,完整展示了 Speech Kit 6.1.1 的四大新增字段。该 Tab 分为五个区块,下面逐一深入分析。

11.1 特性简介条

  /** 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.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.redD, 0], [COLORS.red, 1]] })

Tab 顶部是一条烈焰红渐变的特性简介条,左侧是"🗣"图标,右侧是两行文字:加粗的"Speech Kit · 场景化语音服务"标题和"HarmonyOS 6.1.1:AI字幕支持源语言 / 目标语言 / 字体颜色 / 字体大小"的副标题。这条简介条以最简洁的方式向用户传达了本 Tab 的技术定位——这是 HarmonyOS 6.1.1 版本的 Speech Kit 新特性展示页。渐变背景与头部 Banner 一致,形成从顶部到内容区的视觉串联。

11.2 组件实时预览卡

      // ===== 区块 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.green : 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.title).fontWeight(FontWeight.Bold)
            .layoutWeight(1).textAlign(TextAlign.Center)
            .padding({ top: 9, bottom: 9 })
            .backgroundColor(this.captionShown ? COLORS.redD : COLORS.red)
            .borderRadius(10)
            .onClick(() => {
              this.captionShown = !this.captionShown;
            })

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

        // 错误信息行(onError 回调触发显示)
        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)

区块 1 是 AICaptionComponent 的实时预览卡。标题行右侧有一个状态标签:captionReadytrue 时显示绿色"已就绪",为 false 时显示金色"初始化中"且透明度随 breath 翻转形成闪烁等待效果。这个状态标签让用户能直观感知字幕服务的初始化进度。

AICaptionComponent 是 Speech Kit 的核心组件,接收三个参数:isShown(布尔值,绑定到 this.captionShown,控制字幕浮层显隐)、controllerAICaptionController 实例,用于写入音频流)、optionsAICaptionOptions 对象,由 buildCaptionOptions() 方法实时组装)。组件宽 100%、高 110 像素,带 1 像素分割线色边框。当用户在后续区块中调整语言或外观设置时,@State 变量变化触发 build 重新执行,buildCaptionOptions() 重新组装 options,AICaptionComponent 接收新 options 后字幕外观实时变化——这就是"实时预览"的含义。

控制按钮行有两个按钮:左侧的"开启字幕/隐藏字幕"按钮根据 captionShown 切换文案和背景色(开启时 red 亮红,隐藏时 redD 深红),点击翻转 captionShown;右侧的"写入演示音频"按钮调用 feedDemoAudio(),旁边显示"×N"计数器(N 为 captionFed 值)。如果 onError 回调写入了错误信息(captionErrMsg 非空),底部会显示红色错误提示行。这一区块构建了"开关字幕→写入音频→查看状态→捕获错误"的完整交互闭环。

11.3 语言设置卡

      // ===== 区块 2:语言设置卡(sourceLanguage / targetLanguage) =====
      Column({ space: 10 }) {
        Text('🌐 语言设置').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)

        // 源语言标题行('zh' | 'en' 二选一)
        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.title : 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.red : COLORS.chip)
              .borderRadius(10)
              .onClick(() => {
                this.switchSourceLang(l.code);
              })
          }, (l: LangOption) => l.code)
        }
        .width('100%')

区块 2 是语言设置卡,对应 sourceLanguagetargetLanguage 两个新增字段。源语言部分通过 ForEach 遍历 SRC_LANGS 渲染两个选项按钮(中文、英文),选中态由 this.srcLang === l.code 判断:选中时文字暖白加粗、背景烈焰红;未选中时文字暖灰常规、背景 chip 深色。点击按钮调用 switchSourceLang(l.code) 切换源语言,该方法会联动设置目标语言的默认值。

源语言标题行的右侧标注了"取值 ‘zh’ | ‘en’",这是面向开发者的技术提示——让阅读代码的开发者知道 sourceLanguage 字段只接受这两个值。这种在 UI 中同时呈现用户视角(中文/英文按钮)和开发者视角(取值范围标注)的设计,是技术展示型页面的独特风格,既服务终端用户也服务阅读源码的工程师。

        // 目标语言标题行(中文源锁定 zh;英文源可选 zh / en / zh-en)
        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.title : 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.red : 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.pink)
          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)

目标语言部分使用条件渲染实现了联动逻辑:当 srcLang === 'zh'(中文源)时,显示一个锁定提示行,带锁图标和"中文源锁定中文:无翻译方向,targetLanguage 固定为 zh"的说明文案,告知用户此时目标语言不可选;当 srcLang === 'en'(英文源)时,通过 ForEach 遍历 TGT_LANGS_EN 渲染三个选项按钮(中文、英文、中英双语),点击直接将 l.code 赋给 tgtLang

目标语言标题行的右侧标注会根据源语言动态变化:中文源时显示"中文源已锁定",英文源时显示"取值 ‘zh’ | ‘en’ | ‘zh-en’"。这种动态标注让开发者能直观看到当前源语言下目标语言的合法取值范围。最后,卡片底部有一个语言组合摘要行,用粉色小圆点 + "当前组合:源 中文 → 目标 中英双语"的文案,通过 langName 函数将语言码转为中文名,让用户对自己的语言配置一目了然。

11.4 外观设置卡

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

        // 字体大小标题行(AICaptionFontSize 四档)
        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.title : 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.red : COLORS.chip)
              .borderRadius(10)
              .onClick(() => {
                this.captionSize = s.size;
              })
          }, (s: SizeOption) => s.name)
        }
        .width('100%')

区块 3 是外观设置卡,对应 fontSizefontColor 两个新增字段。字体大小部分通过 ForEach 遍历 SIZE_OPTIONS 渲染四档选项(小号、标准、大号、超大),选中态判断逻辑与语言选项一致。点击按钮将 s.sizeAICaptionFontSize 枚举值)赋给 this.captionSize,触发 buildCaptionOptions 重新组装 options,字幕字体大小实时变化。标题行右侧标注"AICaptionFontSize",告知开发者该字段的类型是枚举而非任意数字。

        // 字体颜色标题行(ResourceColor 五预设)
        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.pink : 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)

字体颜色部分通过 ForEach 遍历 CAPTION_FONT_COLORS 渲染五个圆形色块(经典白、暖阳黄、薄荷绿、云朵蓝、樱花粉),每个色块用 Circle 组件绘制,26x26 像素,填充对应色值。选中态通过 border 描边颜色体现:选中时描边活力粉 pink,未选中时描边 line 分割线色。点击色块将色值赋给 this.captionColor,字幕颜色实时变化。

色块行使用 justifyContent(FlexAlign.SpaceBetween) 让五个色块均匀分布。底部有一个当前选中颜色说明行,左侧小圆点显示当前色,中间是 colorName 函数返回的中文名加 HEX 色值(如"经典白 #FFFFFF"),右侧标注"作用于字幕原文与译文"——这提醒用户 fontColor 同时影响原文和译文的语言色彩,而非仅作用于其中之一。标题行右侧标注"ResourceColor",告知开发者该字段的类型是 ResourceColor 而非普通字符串,它可以接受 HEX 色值、资源引用等多种形式。

11.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.pink)
        }
        .width('100%')

        // 深色代码块(monospace,键名烈焰红高亮)
        Column({ space: 5 }) {
          Text('AICaptionOptions = {').fontSize(9).fontColor(COLORS.text3).fontFamily('monospace')
          Row({ space: 4 }) {
            Text('● sourceLanguage:').fontSize(9).fontColor(COLORS.red).fontFamily('monospace')
            Text("'" + this.srcLang + "'").fontSize(9).fontColor(COLORS.pink).fontFamily('monospace')
          }
          .width('100%')
          Row({ space: 4 }) {
            Text('● targetLanguage:').fontSize(9).fontColor(COLORS.red).fontFamily('monospace')
            Text("'" + this.tgtLang + "'").fontSize(9).fontColor(COLORS.pink).fontFamily('monospace')
          }
          .width('100%')
          Row({ space: 4 }) {
            Text('● fontSize:').fontSize(9).fontColor(COLORS.red).fontFamily('monospace')
            Text(sizeName(this.captionSize)).fontSize(9).fontColor(COLORS.gold).fontFamily('monospace')
          }
          .width('100%')
          Row({ space: 4 }) {
            Text('● fontColor:').fontSize(9).fontColor(COLORS.red).fontFamily('monospace')
            Text("'" + this.captionColor + "'").fontSize(9)
              .fontColor(this.captionColor).fontFamily('monospace')
          }
          .width('100%')
          Text('}').fontSize(9).fontColor(COLORS.text3).fontFamily('monospace')
        }
        .width('100%').padding(12).backgroundColor(COLORS.bg).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)

区块 4 是 options 实时代码预览卡,这是整个 AI 字幕 Tab 最具技术展示特色的设计。卡片内部是一个墨黑背景(COLORS.bg)的代码块,使用 fontFamily('monospace') 等宽字体,模拟代码编辑器的外观。代码块逐行展示了 AICaptionOptions 对象的结构:开头的 AICaptionOptions = {、四个字段行、结尾的 }

每个字段行都用 Row 容器分为两部分:键名(如 ● sourceLanguage:)用烈焰红 red 高亮,值(如 'zh')根据字段类型用不同颜色——语言码用活力粉 pink、字号枚举用金色 gold、颜色值则直接用 this.captionColor 自身的色值渲染(即文字颜色就是它所表示的颜色,形成"所见即所得"的自证效果)。所有值都随用户在区块 2 和区块 3 的选择实时更新,标题行右侧的"随设置联动"粉色标签强调了这一特性。代码块底部的"★ 6.1.1 新增字段:sourceLanguage / targetLanguage / fontSize / fontColor"提示行,明确标注了这四个字段是 6.1.1 版本的新增能力。这种"代码即界面"的设计让开发者能直观理解 AICaptionOptions 的数据结构,同时看到当前的实际传值。

11.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.red : (idx % 3 === 1 ? COLORS.pink : 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.pink)
            }
            .layoutWeight(1).alignItems(HorizontalAlign.Start)

            Text('套用 ›').fontSize(9).fontColor(COLORS.red)
          }
          .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%')
  }

区块 5 是字幕场景推荐列表,它将抽象的语言配置转化为用户可理解的场景预设。ForEach 遍历 sceneList 渲染 5 条场景行,每行左侧有一个 4 像素宽的色条,颜色按索引取模 3 在红、粉、金三色间轮换,形成视觉节奏感。色条右侧是三行文字:场景名(加粗暖白)、场景说明(暖灰)、语言组合(粉色"源 en → 目标 zh"格式)。最右侧是"套用 ›"引导箭头。

点击场景行会触发两个操作:先调用 switchSourceLang(item.src) 联动设置源语言和目标语言默认值,再将 item.tgt 赋给 tgtLang 覆盖默认值——这样就能精确套用该场景的语言组合。例如点击"海外达人视频汉化"场景(src=‘en’, tgt=‘zh’),switchSourceLang('en')srcLang 设为 ‘en’ 并将 tgtLang 默认设为 ‘zh-en’,随后 this.tgtLang = 'zh' 覆盖为 ‘zh’,最终组合是英文源→中文目标。套用后,区块 1 的预览组件、区块 2 的语言按钮、区块 4 的代码预览都会实时联动更新,体现了 ArkUI 响应式状态驱动的强大威力。


十二、我的 Tab 详解

  /** 我的 Tab:用户信息+VIP渐变大卡 + 功能清单行 */
  @Builder
  tabMine() {
    Column({ space: 12 }) {
      // 1. 用户信息 + 光影 VIP 渐变大卡(烈焰红 → 活力粉)
      Column({ space: 12 }) {
        Row({ space: 12 }) {
          Column() {
            Text('🎬').fontSize(26)
          }
          .width(54).height(54).borderRadius(27).backgroundColor(COLORS.redD)
          .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)

          Column({ space: 4 }) {
            Row({ space: 6 }) {
              Text('追光少年阿澄').fontSize(16).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
              Text('光影 VIP').fontSize(8).fontColor(COLORS.redD)
                .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                .backgroundColor(COLORS.gold).borderRadius(7)
            }
            Text('光影号:guangying_0825 · 已连续打卡 96 天')
              .fontSize(9).fontColor(COLORS.title).opacity(0.72)
              .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Start)
        }
        .width('100%')

        // 三格观看数据
        Row({ space: 10 }) {
          Column({ space: 3 }) {
            Text('386').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Text('喜欢视频').fontSize(8).fontColor(COLORS.title).opacity(0.7)
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Center)

          Column({ space: 3 }) {
            Text('24').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Text('收藏合集').fontSize(8).fontColor(COLORS.title).opacity(0.7)
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Center)

          Column({ space: 3 }) {
            Text('1.2万').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Text('累计观看').fontSize(8).fontColor(COLORS.title).opacity(0.7)
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Center)
        }
        .width('100%').margin({ top: 2 })
      }
      .width('100%').padding(16).borderRadius(14)
      .linearGradient({ angle: 135, colors: [[COLORS.redD, 0], [COLORS.red, 1]] })

我的 Tab 顶部是一张用户信息 + VIP 渐变大卡。卡片使用与头部 Banner 相同的 135 度烈焰红渐变,形成视觉呼应。卡片左侧是 54x54 的圆形头像(redD 深红底色 + 🎬 emoji),右侧是用户名行("追光少年阿澄"加粗 + "光影 VIP"金色胶囊标签)和光影号 + 打卡天数文案。

卡片下半部分是三格观看数据:喜欢视频 386、收藏合集 24、累计观看 1.2 万。三格用 Row + layoutWeight(1) 等分排列,每格上方是加粗数值、下方是 70% 透明度的说明文字。这些数据与"我的"页功能清单中的部分条目形成交叉验证——“我的喜欢"行的 value 是"386 个”,与这里的"386"一致;“收藏合集"行的 value 是"24 个合集”,与这里的"24"一致,增强了数据的真实感。

      // 2. 功能清单行(UserStat)
      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 渲染 8 行功能条目。每行从左到右是:emoji 图标(字号 16)、功能名(暖白色 + layoutWeight(1) 占满中间 + 单行省略)、状态/数值文本(暖灰副色 + 单行省略)、右箭头"›"(三级弱化色,仅当 item.arrowtrue 时显示)。每行用 card 底色 + 10 的圆角形成独立的条目卡,行与行之间通过外层 Column 的 12 像素 space 间隔。

8 条功能数据覆盖了用户中心的完整需求:我的作品、我的喜欢、观看时长、我的关注、收藏合集、AI 字幕偏好、光影 VIP 特权、播放与缓存设置。其中"AI 字幕偏好"行的 value"源 zh · 目标 zh"直接反映了 AI 字幕 Tab 的当前语言设置——如果用户在 AI 字幕 Tab 切换了语言组合,回到我的页时这里的文本也会相应变化(前提是 statList 数据同步更新),形成了跨 Tab 的状态一致性。


十三、图表卡:月度柱状图

  /** 图表卡:月度播放量柱状图(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%')

      // 渐变柱状图(Column + ForEach 实现)
      Row({ space: 8 }) {
        ForEach(MONTH_IDX, (i: number) => {
          Column({ space: 5 }) {
            Text(MONTH_PLAYS[i].toString()).fontSize(8)
              .fontColor(this.breath ? COLORS.pink : COLORS.sub)
            Column().width(18)
              .height(Math.max(20, MONTH_PLAYS[i] / MONTH_MAX * 110 * (this.breath ? 1.05 : 0.95)))
              .borderRadius(5)
              .linearGradient({ angle: 180, colors: [[COLORS.red, 0], [COLORS.redD, 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 月累计 3338 万次播放').fontSize(8).fontColor(COLORS.sub)
        Column().layoutWeight(1)
        Text('环比 +9.0%').fontSize(8).fontColor(COLORS.green)
      }
      .width('100%')
    }
    .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
  }

chartCard 是一个通用的柱状图卡片构建函数,在创作者 Tab 中展示月度播放量数据。柱状图完全用 ArkUI 的原生组件 ColumnForEach 实现,没有引入任何第三方图表库——每根柱子就是一个设置了渐变背景和固定宽度的 Column,高度通过 Math.max(20, MONTH_PLAYS[i] / MONTH_MAX * 110 * (this.breath ? 1.05 : 0.95)) 计算。

柱高的计算公式值得仔细拆解:MONTH_PLAYS[i] / MONTH_MAX 是当前月数值占最大值的比例(0 到 1 之间),乘以 110 是将该比例映射到 0-110 像素的视觉高度范围,再乘以 (this.breath ? 1.05 : 0.95) 实现呼吸动画的 ±5% 波动(breathtrue 时柱子高 5%,为 false 时矮 5%),最后用 Math.max(20, ...) 确保最小柱高不低于 20 像素,避免数值较小的柱子完全不可见。Row 容器设置 alignItems(VerticalAlign.Bottom) 让所有柱子底部对齐,height(150) 给定图表区域的固定高度。

每根柱子的渐变使用 180 度垂直方向,从顶部的 red 亮红过渡到底部的 redD 深红,形成火焰般的视觉质感。柱子上方的数值文字颜色也随 breath 翻转(true 时粉色,false 时暖灰),与柱高的波动形成双重呼吸反馈。柱子下方是月份标签。图表底部汇总行显示"近 6 月累计 3338 万次播放"和"环比 +9.0%"(绿色表示增长),让用户在视觉之外获得数据总结。这种纯组件化的图表实现方式既控制了包体积,又保证了绘制的灵活性和性能——ForEach 只渲染 6 根柱子,状态变化时只需更新柱高属性,渲染开销极低。


十四、底部 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 渲染 4 个 Tab,每个 Tab 用 layoutWeight(1) 等分宽度。选中态由 this.currentTab === idx 判断,通过三个维度体现:图标字号(选中 20,未选中 17,放大效果)、图标透明度(选中 1,未选中 0.65)、标签文字颜色(选中 tabOn 烈焰红,未选中 text3 三级弱化色)和字重(选中加粗,未选中常规)。点击 Tab 将 idx 赋给 currentTab,触发 build 重新执行,内容区的条件分支切换到对应 Tab 的构建函数。

底部栏整体使用 card 底色,顶部带 1 像素的 line 色分割线(通过 border({ width: { top: 1 }, color: COLORS.line }) 只设置上边框),与内容区形成分隔。这种"只设置单边边框"的技巧在 ArkUI 中很实用——borderwidth 参数支持对象形式 { top: 1 },只给指定方向设置边框宽度,避免四周边框带来的视觉冗余。Tab 栏固定在 Column 主内容的最后位置,不随 Scroll 滚动,确保导航始终可见。


十五、弹窗系统

15.1 全屏遮罩 modalOverlay

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

modalOverlay 是弹窗系统的通用遮罩层,接收一个 onClose 回调函数作为参数。它是一个 Stack 容器,内部只有一个铺满全屏的 Column,背景色为 COLORS.maskrgba(10,6,4,0.68) 半透明黑)。alignContent(Alignment.Center) 让后续叠加的面板内容居中显示。点击遮罩任意区域都会调用 onClose() 关闭弹窗,这是弹窗的标准交互——点击遮罩区等同于点击取消按钮。

遮罩之所以用 Stack 而非直接用 Column,是因为弹窗面板会通过 Stack 的层叠能力叠加在遮罩之上。在 panelAdd 等面板构建函数中,遮罩先声明(底层),面板内容后声明(上层),二者共同构成完整的弹窗。onClick 绑定在 Stack 上而非内层 Column,确保点击事件能覆盖整个遮罩区域。

15.2 新建合集 panelAdd

  /** 新建合集弹窗面板(名称 + 分类输入) */
  @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.formTag, placeholder: '如:美食 / 科技 / 萌宠' })
            .fontSize(11).fontColor(COLORS.title)
            .backgroundColor(COLORS.chip).borderRadius(8)
            .onChange((value: string) => {
              this.formTag = 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.title).fontWeight(FontWeight.Bold)
            .layoutWeight(1).textAlign(TextAlign.Center)
            .padding({ top: 9, bottom: 9 }).backgroundColor(COLORS.red).borderRadius(9)
            .onClick(() => {
              this.saveVideo();
            })
        }
        .width('100%')
      }
      .width('78%').padding(16).backgroundColor(COLORS.card).borderRadius(14)
    }
    .width('100%')
    .height('100%')
    .alignContent(Alignment.Center)
  }

panelAdd 是新建合集的弹窗面板,采用 Stack 层叠遮罩和面板内容。面板是一个 78% 宽度的 Columncard 底色 + 14 的圆角。面板内含标题"新建合集"、两个输入字段(合集名称和频道分类,都用 TextInput 组件 + chip 底色 + 8 圆角)、取消和创建两个按钮。TextInputtext 参数绑定到 @State formName/formTagonChange 回调将输入值回写到状态变量,形成双向数据流。点击"创建"按钮调用 saveVideo(),该方法将新视频 unshiftvideoList 头部后关闭弹窗;点击"取消"按钮直接调用 onClose() 关闭。

15.3 编辑合集 panelEdit

  /** 编辑合集弹窗面板(回填名称 + 分类) */
  @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.editTag, placeholder: '频道分类' })
            .fontSize(11).fontColor(COLORS.title)
            .backgroundColor(COLORS.chip).borderRadius(8)
            .onChange((value: string) => {
              this.editTag = 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.title).fontWeight(FontWeight.Bold)
            .layoutWeight(1).textAlign(TextAlign.Center)
            .padding({ top: 9, bottom: 9 }).backgroundColor(COLORS.pink).borderRadius(9)
            .onClick(() => {
              this.updateVideo();
            })
        }
        .width('100%')
      }
      .width('78%').padding(16).backgroundColor(COLORS.card).borderRadius(14)
    }
    .width('100%')
    .height('100%')
    .alignContent(Alignment.Center)
  }

panelEdit 的结构与 panelAdd 几乎一致,区别在于:标题改为"编辑合集"、TextInput 绑定的是 editName/editTag 而非 formName/formTag、确认按钮文案改为"保存修改"且背景色用活力粉 pink(区别于新建的烈焰红 red)。打开编辑弹窗前,openEditVideo(idx) 方法已将当前视频的标题和分类回填到 editNameeditTag,因此弹窗打开时输入框已显示当前值。点击"保存修改"调用 updateVideo(),该方法更新 videoList[editIdx] 的字段后通过 slice() 刷新数组引用触发瀑布流重渲染。

15.4 删除确认 panelDel

  /** 删除合集确认弹窗面板 */
  @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.title).fontWeight(FontWeight.Bold)
            .layoutWeight(1).textAlign(TextAlign.Center)
            .padding({ top: 9, bottom: 9 }).backgroundColor(COLORS.red).borderRadius(9)
            .onClick(() => {
              this.delVideo();
            })
        }
        .width('100%')
      }
      .width('78%').padding(16).backgroundColor(COLORS.card).borderRadius(14)
    }
    .width('100%')
    .height('100%')
    .alignContent(Alignment.Center)
  }
}

panelDel 是删除确认弹窗,没有输入框,只有标题、警示文案和取消/确认删除两个按钮。警示文案"确认将该视频从合集中移除吗?移除后本地的点赞与缓存将一并清理,且不可恢复。"用两行省略号截断,向用户传达删除的不可逆性。点击"确认删除"调用 delVideo(),该方法通过 splice 删除 videoList[delIdx] 后关闭弹窗。三个弹窗面板的视觉结构高度一致(78% 宽度、card 底色、14 圆角、统一间距),形成统一的弹窗设计语言,区别仅在于标题文案、按钮文案和按钮强调色的差异——新建用烈焰红、编辑用活力粉、删除用烈焰红,通过颜色微妙区分操作类型。


十六、功能模块技术特性对比表

对比维度推荐 Tab创作者 TabAI 字幕 Tab我的 Tab
布局方式横滑大卡 + 双列 Flex 瀑布四列 Flex 头像墙 + 柱状图五区块纵向堆叠渐变大卡 + 功能清单行
数据模型VideoItem(6 字段)CreatorItem(5 字段)CaptionScene(4 字段)UserStat(4 字段)
Mock 数据量8 条视频8 位创作者5 个场景8 条功能
核心交互新建/编辑/删除合集无操作(展示型)语言/外观设置 + 音频写入无操作(展示型)
动画效果封面 emoji 呼吸透明度头像墙静态 + 柱状图呼吸状态标签呼吸闪烁VIP 大卡静态
弹窗联动panelAdd / panelEdit / panelDel
强调色烈焰红渐变封面烈焰红头像描边烈焰红渐变简介条 + 选项按钮烈焰红渐变 VIP 大卡
特殊组件Flex 换行 + Scroll 横滑Flex 换行 + Circle 描边AICaptionComponent + Circle 色块linearGradient + ForEach 行
状态驱动videoList + 表单字段creatorList(静态)srcLang/tgtLang/captionSize/captionColorstatList(静态)
技术亮点条件渲染弹窗 + 数组引用刷新分类颜色编码 + 纯组件柱状图Speech Kit 6.1.1 四大新增字段数据交叉验证
对比维度modalOverlay 遮罩panelAdd 新建panelEdit 编辑panelDel 删除
触发方式被三面板内部调用点击"+ 新建"按钮点击卡片"编辑"按钮点击卡片"删除"按钮
输入字段名称 + 分类(2 个 TextInput)名称 + 分类(2 个 TextInput)
回填机制空字段默认值兜底openEditVideo 预填当前值
确认按钮创建(烈焰红底)保存修改(活力粉底)确认删除(烈焰红底)
数据操作unshift 到数组头部更新字段 + slice 刷新splice 删除
宽度100% 全屏78% 居中78% 居中78% 居中

十七、总结与展望

本文从一个完整的 HarmonyOS ArkUI 短视频社区应用出发,逐段分析了从色彩体系、常量定义、工具函数、数据模型到组件主体、四大 Tab、图表卡、底部导航和弹窗系统的全部代码。这个应用以"光影刷刷"为品牌名,采用墨黑打底、烈焰红与活力粉双强调色的深色主题,精准契合了短视频"夜刷"的核心使用场景。在视觉层面,通过 linearGradient 渐变、emoji 图标、Circle 圆形色块、呼吸动画等手段,用纯 ArkUI 原生组件构建出了丰富的视觉效果,无需任何图片资源和第三方图表库,包体积和渲染性能都得到了最优控制。

在技术层面,本应用最核心的价值在于完整展示了 HarmonyOS 6.1.1 Speech Kit 的 AICaptionComponent 四大新增字段——sourceLanguagetargetLanguagefontSizefontColor。通过 buildCaptionOptions 方法的枢纽式组装,用户在 AI 字幕 Tab 的语言设置卡和外观设置卡中所做的每一次选择,都会实时反映到 AICaptionComponent 的 options 中,字幕的源语言、目标语言、字体大小和字体颜色随之即时变化。switchSourceLang 方法精确实现了中文源锁定中文、英文源支持三种目标语言的联动逻辑,feedDemoAudio 方法通过合成 440Hz 正弦波 PCM 数据演示了音频写入通路。options 实时代码预览卡更是将 AICaptionOptions 的数据结构以"代码即界面"的方式呈现,让开发者能直观看到当前四个字段的实际传值。

在架构层面,应用采用了"状态→options→组件"的单向数据流和"条件渲染 Tab + 条件渲染弹窗"的双条件渲染策略。@State 变量分为导航动画态、弹窗表单态、AI 字幕态三大组,各司其职;@Observed 数据模型让列表项的局部刷新成为可能;@Builder 函数将四大 Tab、头部、底部、图表、弹窗各自封装为独立的构建单元,职责清晰。呼吸动画通过一个每秒翻转的 breath 布尔值,联动了 Banner 图标、合集封面、视频封面、柱状图柱高、字幕状态标签等多个视觉元素,以最小的状态开销实现了全局统一的节奏感。弹窗系统通过 modalOverlay 遮罩 + 三套面板的组合,实现了新建、编辑、删除的完整 CRUD 闭环,数组引用替换(slice())确保了列表刷新的可靠性。

展望未来,这个短视频社区应用还有诸多可扩展的方向。在内容层,可以将 Mock 数据替换为真实后端接口,引入 @ohos.net.http 进行网络请求,配合 @StorageLink 实现数据持久化。在 AI 字幕层,可以将 feedDemoAudio 的合成音频替换为真实的 @kit.AudioKit 麦克风音频流采集,或对接视频播放器的音轨输出,实现真正的实时字幕转写和翻译。在交互层,可以引入 @kit.AnimationKit 的曲线动画替代简单的 setInterval 翻转,让呼吸动画更加平滑自然;可以为视频瀑布引入下拉刷新和上拉加载更多;可以为双列卡片加入 @Transition 的进出场动画。在主题层,可以基于 ColorPalette 接口扩展浅色主题和节日主题,通过运行时切换 COLORS 引用实现主题动态切换。这些扩展都能在现有架构基础上低耦合地接入,得益于 ArkUI 响应式范式和组件化设计所带来的良好可维护性。

最终,这个应用证明了一件事:HarmonyOS ArkUI 框架配合 Speech Kit 的系统能力,完全能够以单文件、单组件的形式承载一个包含四大差异化 Tab、完整 CRUD 弹窗、AI 字幕实时预览、纯组件柱状图的复杂社区应用。声明式 UI 范式让开发者从命令式 DOM 操作的繁琐中解放出来,@State/@Observed/@Builder 的组合让状态与视图的关系清晰可追溯,而 Speech Kit 6.1.1 的四大新增字段则为短视频社区的字幕体验打开了新的可能性——源语言与目标语言的自由组合让海外内容不再有语言门槛,字体大小与颜色的细粒度控制让字幕真正服务于每一个不同需求的用户。这正是 HarmonyOS 生态在行业场景化落地中的一个生动缩影。

附录: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、测试、元服务和应用上架分发等。

更多推荐