HarmonyOS 6.1.1 三特性叠加实战:新中式茶饮空间 ArkTS 全栈架构深度解析——从 Speech Kit AI 字幕、Canvas 双图绘制到 Tabs 嵌套滚动的端到端技术拆解
一、技术前置概述:HarmonyOS 开发栈与 ArkUI 声明式范式全链路解析
1.1 HarmonyOS 鸿蒙生态技术底座
HarmonyOS(鸿蒙操作系统)是华为面向万物互联时代推出的分布式操作系统,其核心设计理念是"一次开发,多端部署"。在 HarmonyOS 6.1.1 版本中,系统进一步完善了 ArkUI 声明式 UI 框架的能力边界,尤其是在组件嵌套滚动、AI 语音能力集成和 Canvas 自定义绘制三个方向上进行了显著的 API 扩展与能力增强。HarmonyOS 的技术栈涵盖了从底层内核(鸿蒙微内核)、系统服务层(系统服务、多模输入、分布式软总线)、应用框架层(ArkUI、ArkTS、Ability 框架)到应用层(原子化服务、元服务、应用)的全栈体系。

在应用框架层,ArkUI 是 HarmonyOS 的统一 UI 开发框架,它支持两种开发范式:声明式范式(ArkTS + ArkUI 组件)和类 Web 范式(JS + CSS + HTML)。当前主流推荐的是声明式范式,它使用 ArkTS 语言(TypeScript 的超集),通过 @Entry、@Component、@Builder、@State、@Observed 等装饰器来组织 UI 组件树和状态管理。ArkTS 在 TypeScript 基础上增加了类型安全约束和状态管理装饰器,使得 UI 与状态的绑定更加紧密和可预测。

1.2 ArkUI 声明式 UI 范式核心机制
ArkUI 声明式范式的核心思想是"UI 是状态的函数映射"——开发者声明状态变量和 UI 结构,当状态变化时,框架自动驱动 UI 更新。这一机制依赖于以下几个关键装饰器:

@Entry:标记组件为页面入口组件,每个页面有且仅有一个@Entry组件。@Component:声明一个自定义组件,组件内可包含build()方法构建 UI 树。@State:组件内状态变量,变化时触发该组件的build()重新执行。它是组件级的可观察状态,使用@State修饰的变量在赋值时会被框架拦截,触发依赖该变量的 UI 片段重新渲染。@Observed:标记类为可观察类,配合@ObjectLink或数组操作可以实现深层对象的属性级观察。当一个类被@Observed修饰后,其实例在参与 UI 渲染时,其属性变化可以被框架追踪到。@Builder:声明 UI 构建函数,类似于其他框架中的"渲染函数"或"模板函数",用于将复杂的 UI 片段封装为可复用的构建单元。@Link:父子组件间的双向绑定,父组件传@State引用给子组件的@Link,任一方变化同步到另一方。
1.3 Speech Kit 与 AI 字幕能力
HarmonyOS 的 Speech Kit(语音能力套件)提供了语音识别、语音合成和 AI 字幕三大核心能力。在 HarmonyOS 6.1.1 版本中,AI 字幕组件 AICaptionComponent 新增了四个关键配置字段:
sourceLanguage:字幕源语言,标识输入音频的语言类型,支持'zh'(中文)和'en'(英文)。targetLanguage:字幕目标语言,标识输出字幕的语言,中文源时仅支持'zh',英文源时支持'zh'、'en'、'zh-en'(中英双语)。fontSize:字幕字体大小,使用AICaptionFontSize枚举,提供SMALL、NORMAL、BIG、LARGE四档可选值。fontColor:字幕字体颜色,类型为ResourceColor,支持十六进制色值字符串。
AICaptionComponent 组件通过 AICaptionController 控制器进行生命周期管理,控制器提供 writeAudio() 方法用于向字幕引擎推送音频数据块。音频数据需要以 AudioData 格式封装,其中 data 字段为 Uint8Array 类型的 PCM 音频数据。典型参数为 16kHz 采样率、16bit 位深、单声道,每个数据块约 640 字节(约 20ms 音频)。组件还提供 onPrepared 和 onError 两个回调钩子,分别在字幕引擎就绪和出错时触发。

1.4 Canvas 自定义绘制能力
ArkUI 提供了 Canvas 组件用于自定义图形绘制,其 API 与 Web 端的 Canvas 2D Context 高度相似。开发者通过 CanvasRenderingContext2D 上下文对象进行绘制操作,支持路径(beginPath、moveTo、lineTo、arc)、填充(fill)、描边(stroke)、渐变(createLinearGradient)、文本(fillText)等全部 2D 绘制原语。
Canvas 组件通过 .onReady() 回调获取绘制上下文就绪信号,在回调中执行首次绘制。后续需要重绘时,直接调用上下文方法即可(方案 B 无闪烁重绘),无需重新创建 Canvas 实例。RenderingContextSettings 配置项中的 antialias 字段控制是否开启抗锯齿,对于圆形和弧线绘制尤为重要。

1.5 Tabs 嵌套滚动机制(API 24 新特性)
在 HarmonyOS 6.1.1(API 24)中,Tabs 组件新增了 .nestedScroll() 属性,用于处理 Tabs 组件在嵌套场景下的滚动事件分发。该属性接收 TabsNestedScrollMode 枚举参数,提供两种模式:
SELF_ONLY(仅内层):内层 Tabs 只响应自身的滚动手势,滑到边缘后不会联动外层 Tabs。这是默认行为。SELF_FIRST(先内后外):内层 Tabs 优先响应自身滚动,当内层内容滑到边缘后,继续滑动的手势会"接力"传递到外层 Tabs,触发外层页签切换。这就是所谓的"嵌套滚动接力"机制。
这一机制的实现依赖于手势分发系统的优先级管理。当内层 Tabs 的可滚动内容到达边界时,框架检测到滚动手势无法被内层消费,便将剩余的滚动位移转发给外层 Tabs 的手势处理器,从而实现无缝衔接的嵌套滚动体验。

1.6 涉及的 HarmonyOS 套件全景
本文所分析的代码涉及以下 HarmonyOS 技术套件:
@kit.SpeechKit:语音能力套件,提供AICaptionComponent、AudioData、AICaptionOptions、AICaptionController、AICaptionFontSize等类型。@kit.BasicServicesKit:基础服务套件,提供AsyncCallback、BusinessError等通用异步和错误处理类型。- ArkUI 内置组件:
Stack、Column、Row、Scroll、Swiper、Flex、Tabs/TabContent、List/ListItem、Canvas、Text、Button、TextInput、Divider、Circle等。 - ArkUI 通用属性:
linearGradient(线性渐变背景)、borderRadius、padding、margin、backgroundColor、fontColor、fontSize、fontWeight等。 - ArkUI 通用事件:
onClick、onChange、onReady等。 - 全局枚举:
TabsNestedScrollMode、BarMode、BarPosition、EdgeEffect、ScrollDirection、FlexWrap、FlexAlign、FontWeight、TextOverflow、InputType、Alignment、VerticalAlign、HorizontalAlign等——这些枚举由 ArkUI 框架全局声明,使用时无需 import。
二、应用全景与架构总览
2.1 应用定位与业务全景
本应用名为"茶境·新中式茶饮空间",定位为茶室预订与茶文化社区的移动端门户。应用以"浅色·月白宣+松烟墨+竹青"为主题色系,灵感来源于中国传统水墨画与茶道美学,将月白宣纸的温润底色、松烟墨的深沉主色与竹青绿的鲜活点缀融合为一个完整的视觉语言体系。
应用包含 7 个 Tab 页面,每个 Tab 拥有完全不同的布局风格:
- 茶单:茶类横滚 chips 筛选 + 双列茶品卡 + 今日推荐横滑大卡
- 频道:茶类×场景双层 Tabs 嵌套滚动(特性 C 宿主)
- 日志:SwipeLog 翻页事件时间轴(内外层双色徽标)
- 数据:Canvas 上座率进度环 + 近 12 日销量折线 + 包间状态清单
- 字幕:AI 字幕五区块配置面板(特性 A 宿主)
- 雅集:活动列表 + 茶室平面状态网格 + 三弹窗系统
- 我的:茶友渐变大卡 + 茶仓清单 + 月度消费柱状图
2.2 整体架构流程图
以下是应用的整体架构层次图:
┌─────────────────────────────────────────────────────────────────────┐
│ 茶境 · 新中式茶饮空间 │
│ (@Entry @Component) │
├─────────────────────────────────────────────────────────────────────┤
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ 根构建 build() │ │
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
│ │ │ Stack (全屏容器 + 弹窗层) │ │ │
│ │ │ ┌──────────────────────────────────────────────────┐ │ │ │
│ │ │ │ Column (主界面列) │ │ │ │
│ │ │ │ ┌────────────────────────────────────────────┐ │ │ │ │
│ │ │ │ │ headerMain() 头部区域 │ │ │ │ │
│ │ │ │ │ (应用名+副标题+三特性胶囊+呼吸圆点) │ │ │ │ │
│ │ │ │ └────────────────────────────────────────────┘ │ │ │ │
│ │ │ │ ┌────────────────────────────────────────────┐ │ │ │ │
│ │ │ │ │ Divider 分割线 │ │ │ │ │
│ │ │ │ └────────────────────────────────────────────┘ │ │ │ │
│ │ │ │ ┌────────────────────────────────────────────┐ │ │ │ │
│ │ │ │ │ 内容区 (Scroll 或 tabChannel 独占) │ │ │ │ │
│ │ │ │ │ ┌──────┬──────┬──────┬──────┬──────┬─────┐ │ │ │ │ │
│ │ │ │ │ │茶单 │频道 │日志 │数据 │字幕 │雅集 │ │ │ │ │ │
│ │ │ │ │ │Tab0 │Tab1 │Tab2 │Tab3 │Tab4 │Tab5 │ │ │ │ │ │
│ │ │ │ │ └──────┴──────┴──────┴──────┴──────┴─────┘ │ │ │ │ │
│ │ │ │ │ │我的 Tab6 │ │ │ │ │
│ │ │ │ └────────────────────────────────────────────┘ │ │ │ │
│ │ │ │ ┌────────────────────────────────────────────┐ │ │ │ │
│ │ │ │ │ tabBar() 底部导航栏 (7 Tab 单排) │ │ │ │ │
│ │ │ │ └────────────────────────────────────────────┘ │ │ │ │
│ │ │ └──────────────────────────────────────────────────┘ │ │ │
│ │ │ ┌──────────────────────────────────────────────────┐ │ │ │
│ │ │ │ 弹窗层:panelAdd / panelEdit / panelDel │ │ │ │
│ │ │ └──────────────────────────────────────────────────┘ │ │ │
│ │ └─────────────────────────────────────────────────────────┘ │ │
└─────────────────────────────────────────────────────────────────────┘
2.3 三特性组合关系图
┌─────────────────────────────────────────────────────────────────┐
│ 三特性同文件叠加架构 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 特性 A:Speech Kit AI 字幕 (API 24) │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ AICaptionComponent (isShown + controller + options) │ │
│ │ ├─ sourceLanguage: 'zh' | 'en' │ │
│ │ ├─ targetLanguage: 'zh' | 'en' | 'zh-en' │ │
│ │ ├─ fontSize: AICaptionFontSize (四档枚举) │ │
│ │ ├─ fontColor: ResourceColor (五色预设) │ │
│ │ ├─ onPrepared 回调 → captionReady = true │ │
│ │ ├─ onError 回调 → captionErrMsg 捕获 │ │
│ │ └─ writeAudio(640B PCM 块) → captionFed 计数 │ │
│ └───────────────────────────────────────────────────────────┘ │
│ ↕ breath 联动 │
│ 特性 B:Canvas 绘制 │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ drawRing() 上座率进度环 │ │
│ │ ├─ 背景环 (chip 色) │ │
│ │ ├─ 进度弧 (bamboo 色, breath 控制弧长) │ │
│ │ └─ 中心大字 + 副标签 │ │
│ │ drawLine() 销量折线 │ │
│ │ ├─ 网格 (4 条横线) │ │
│ │ ├─ 渐变填充区域 │ │
│ │ ├─ 折线主线 + 数据点 (breath 脉动半径) │ │
│ │ └─ 峰值标注 + 横轴标签 │ │
│ └───────────────────────────────────────────────────────────┘ │
│ ↕ breath 联动 │
│ 特性 C:Tabs 嵌套滚动 (API 24) │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ 外层 Tabs (4 茶类频道, BarMode.Fixed) │ │
│ │ └─ 内层 Tabs (5 场景页签, BarMode.Scrollable) │ │
│ │ └─ .nestedScroll(nestedMode) │ │
│ │ ├─ SELF_ONLY: 仅内层滚动 │ │
│ │ └─ SELF_FIRST: 先内后外, 边缘接力 │ │
│ │ └─ 每次翻页 → SwipeLog 时间轴记录 │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
2.4 弹窗系统架构图
┌─────────────────────────────────────────────────────────────────┐
│ 弹窗系统架构 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 触发源 弹窗 结果 │
│ ┌──────────┐ │ │
│ │ 推荐卡 │──→ openAdd() ┌─────────────┐ │ │
│ │ 拼杯按钮 │ │ panelAdd │──confirm→ │ 置顶 │
│ │ 包间清单 │──→ openAdd() │ 发起拼桌 │ Add() │ 新活动 │
│ │ 雅集入口 │ │ (表单3项) │ │ │
│ └──────────┘ └─────────────┘ │ │
│ V │
│ ┌──────────┐ ┌─────────────┐ │
│ │ 活动列表 │──→ openEdit() │ panelEdit │──save──→ 整对象 │
│ │ 改时间 │ │ 修改时间 │ Edit() 替换刷新 │
│ └──────────┘ └─────────────┘ │
│ V │
│ ┌──────────┐ ┌─────────────┐ │
│ │ 活动列表 │──→ openDel() │ panelDel │──conf──→ 列表 │
│ │ 取消报名 │ │ 取消确认 │ Del() 移除释放 │
│ └──────────┘ └─────────────┘ │
│ │
│ 全局遮罩: modalOverlay (半透明黑色, 点击关闭) │
│ │
└─────────────────────────────────────────────────────────────────┘
2.5 呼吸动画联动机制图
┌─────────────────────────────────────────────────────────────────┐
│ 呼吸定时器 (1s interval) 联动机制 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ aboutToAppear() ──→ setInterval(1000ms) │
│ │ │
│ ├─→ breath = !breath (布尔翻转) │
│ │ │ │
│ │ ├─→ drawRing() 进度弧弧长 0.88↔1.0 波动 │
│ │ │ │
│ │ ├─→ drawLine() 数据点半径 2.5↔3.5 脉动 │
│ │ │ │
│ │ └─→ barHeight() 柱高 ±6% 波动 │
│ │ (奇偶柱交替) │
│ │ │
│ └─→ UI 自动刷新 (headerMain 圆点 opacity, │
│ tabMine 渐变卡圆点 opacity) │
│ │
│ aboutToDisappear() ──→ clearInterval(timer) │
│ │
└─────────────────────────────────────────────────────────────────┘
三、逐段代码深度解析
3.1 文件头注释与 import 声明
/**
* =====================================================================
* 茶境 · 新中式茶饮空间(茶室预订与茶文化社区)浅色·月白宣+松烟墨+竹青主题
* 头部样式:应用名 + Tab 联动副标题 + 三特性状态胶囊 + 呼吸圆点
* 布局风格:7 个 tab 每个布局完全不同
* ...
* =====================================================================
*/
import { AICaptionComponent, AudioData, AICaptionOptions, AICaptionController, AICaptionFontSize } from '@kit.SpeechKit';
import { AsyncCallback, BusinessError } from '@kit.BasicServicesKit';
文件头部以多行注释的形式完整描述了应用的全貌:主题色方案(月白宣 + 松烟墨 + 竹青)、头部样式结构(应用名 + Tab 联动副标题 + 三特性状态胶囊 + 呼吸圆点)、7 个 Tab 的布局差异、底部导航配置、三特性组合的技术定义以及弹窗系统的构成。
import 部分是整个文件唯一允许的 import 区块,从 @kit.SpeechKit 导入了 Speech Kit 的全部所需类型:AICaptionComponent(AI 字幕组件)、AudioData(音频数据接口)、AICaptionOptions(字幕配置选项)、AICaptionController(字幕控制器)和 AICaptionFontSize(字号枚举)。从 @kit.BasicServicesKit 导入了 AsyncCallback 和 BusinessError,用于异步操作和错误处理。这种"双 import"策略保证了文件的自包含性,所有非框架内置类型都集中在两行 import 中完成声明。
3.2 颜色系统:ColorPalette 接口与 COLORS 常量
interface ColorPalette {
bg: string; // 页面背景(月白宣)
card: string; // 卡片底色(纯白)
chip: string; // 胶囊/输入底色(米灰)
title: string; // 主文字(松烟墨黑)
sub: string; // 次级文字(灰褐)
text3: string; // 弱文字(浅灰褐)
ink: string; // 主题墨绿(松烟墨)
inkD: string; // 深主题墨绿(按压态)
bamboo: string; // 竹青绿(强调)
gold: string; // 强调金(价位)
red: string; // 警示红(取消/满座)
blue: string; // 辅助蓝(信息)
line: string; // 分割线
tabOn: string; // 底部 tab 激活色
mask: string; // 弹窗遮罩
}
const COLORS: ColorPalette = {
bg: '#F6F4EF',
card: '#FFFFFF',
chip: '#ECE8DE',
title: '#2C2A25',
sub: '#6B675C',
text3: '#9B968A',
ink: '#3E4A3D',
inkD: '#2C362D',
bamboo: '#6B8E5A',
gold: '#B8935A',
red: '#A8524A',
blue: '#4E7BB0',
line: '#E3DFD3',
tabOn: '#3E4A3D',
mask: 'rgba(0,0,0,0.5)'
};
颜色系统是整个应用的视觉基础设施。这里采用了"接口定义 + 常量实例化"的设计模式:先通过 interface ColorPalette 定义一个包含 16 个颜色字段的结构化类型,确保每个颜色槽位都有明确的语义命名;再通过 const COLORS 实例化一个全局色板常量。
这种设计的优势在于:第一,类型安全——任何拼写错误或遗漏字段在编译期即被捕获;第二,语义明确——COLORS.bamboo 比 '#6B8E5A' 的可读性高出数个量级;第三,零硬编码——所有 Builder 函数内一律通过 COLORS.xxx 引用颜色,杜绝了散落的魔法色值。注释中明确指出"Builder 内零硬编码色值,一律 COLORS.xxx 引用",这是一条贯穿全文件的设计约束。
色值的选择体现了新中式茶饮的美学定位:bg: '#F6F4EF' 是月白宣纸的温润米白底色,ink: '#3E4A3D' 是松烟墨的深沉墨绿主色,bamboo: '#6B8E5A' 是竹青的鲜活点缀绿,gold: '#B8935A' 用于价位强调,red: '#A8524A' 用于警示状态。整体色调偏暖偏沉,符合茶道"静、雅、和"的精神内核。
3.3 Tab 元信息定义
interface TabMeta {
icon: string; // emoji 图标
label: string; // 中文标签
}
const TAB_LIST: TabMeta[] = [
{ icon: '🍵', label: '茶单' },
{ icon: '🗂️', label: '频道' },
{ icon: '📖', label: '日志' },
{ icon: '📊', label: '数据' },
{ icon: '🎙️', label: '字幕' },
{ icon: '🏮', label: '雅集' },
{ icon: '👤', label: '我的' }
];
底部导航栏的 7 个 Tab 配置以常量数组形式集中声明。TabMeta 接口定义了每个 Tab 的两个属性:icon(emoji 图标)和 label(中文标签)。使用 emoji 作为图标无需引入图片资源,且在不同设备上具有一致的渲染效果(依赖系统字体支持)。7 个 Tab 的语义覆盖了茶饮空间的全部核心业务流:茶单(浏览选购)→ 频道(内容分发)→ 日志(行为追踪)→ 数据(经营分析)→ 字幕(语音增强)→ 雅集(活动管理)→ 我的(个人中心)。
3.4 常量数据段:茶类、频道与茶品卡
const TEA_CATS: string[] = ['绿茶', '红茶', '乌龙', '白茶', '普洱'];
interface ChannelItem {
name: string;
icon: string;
}
const OUTER_CHANNELS: ChannelItem[] = [
{ name: '绿茶', icon: '🌿' },
{ name: '红茶', icon: '🍂' },
{ name: '乌龙', icon: '🍃' },
{ name: '普洱', icon: '🫖' }
];
const INNER_TABS: string[] = ['独饮', '对饮', '雅集', '外带', '礼盒'];
这段常量定义了茶单 Tab 的分类 chips(TEA_CATS,5 个茶类)和频道 Tab 的双层 Tabs 结构数据。外层频道 OUTER_CHANNELS 使用 ChannelItem 接口封装频道名和 emoji 图标,固定 4 个茶类频道,对应 BarMode.Fixed 单排平铺。内层场景子页签 INNER_TABS 为 5 个场景名称字符串数组,对应 BarMode.Scrollable 可滚动页签。内外层的组合形成了 4×5=20 个场景组合,每个组合下生成 8 条栏目卡片,总计 160 条内容——这一数据量足以让内层 List 内容超出屏幕高度,从而为 nestedScroll 的"滑到边缘"行为提供了可感知的前提条件。
interface TeaSeed {
cat: string;
name: string;
origin: string;
price: number;
flavor: string;
}
const TEA_SEED: TeaSeed[] = [
{ cat: '绿茶', name: '明前龙井·头采', origin: '杭州狮峰', price: 38, flavor: '豆香鲜爽' },
{ cat: '绿茶', name: '恩施玉露·蒸青', origin: '湖北恩施', price: 32, flavor: '海苔清香' },
{ cat: '红茶', name: '正山小种·烟熏', origin: '武夷桐木关', price: 42, flavor: '桂圆松烟' },
// ... 共 8 条
];
茶品卡数据使用 TeaSeed 接口建模,包含 5 个字段:茶类、茶品名、产地、单杯价和风味标签。8 条 Mock 数据全部使用真实茶品语义——明前龙井、恩施玉露、正山小种、凤凰单丛、武夷大红袍、福鼎白牡丹、勐海熟普等——不仅提供了数据量的充实度,也展现了茶文化的专业深度。每条数据都标注了精确的产地信息(杭州狮峰、武夷桐木关、潮州乌岽等),增强了应用的专业可信度。
3.5 今日推荐与内层栏目素材
const RECO_CARDS: string[] = ['春日抹茶拿铁套餐', '围炉煮茶双人席', '冷萃茉莉·夏日限定'];
const RECO_NOTES: string[] = [
'头采龙井打底的抹茶拿铁,配茶点九宫格,两人同行立减 12 元。',
'炭炉围坐双人席:白牡丹煮饮配烤柿饼栗子,含 90 分钟包间使用。',
'茉莉银毫冷萃 48 小时,夏末限定供应,买二赠一支持外带。'
];
今日推荐横滑大卡使用两个平行数组 RECO_CARDS 和 RECO_NOTES 存储卡名和描述文案,通过索引一一对应。这种平行数组设计在数据量小且结构简单时是合理的——避免了为 3 张卡定义接口和实例化的开销。文案本身融入了茶饮行业的专业术语(头采龙井、白牡丹煮饮、冷萃 48 小时),并附带了明确的优惠信息(立减 12 元、90 分钟包间、买二赠一),兼顾了内容丰富度和商业转化导向。
const INNER_TITLES: string[] = [
'茶品上新', '茶会预告', '冲泡技法', '茶器鉴赏',
'产地直供', '节气茶单', '会员权益', '联名周边'
];
const INNER_NOTES: string[] = [
'谷雨前头采龙井到店,本周五起全渠道同步开售,会员可提前预订',
'本周六申时茶会开放报名,主讲武夷岩茶的三坑两涧山场差异',
// ... 共 8 句
];
内层栏目标题和注解同样是平行数组,8 个标题覆盖了茶行业的 8 大内容维度(上新、预告、技法、鉴赏、直供、节气、权益、周边),8 句注解则提供了与每个标题对应的茶文化深度文案。这些文案将在 innerMockData() 函数中被组装到 InnerCard 对象的 desc 字段中,形成"频道图标 + 频道名 + 场景名 + 序号 + 行业注解"的完整描述串。
3.6 Canvas 数据源与包间/活动/茶仓数据
const RING_RATE: number = 0.72;
interface LinePoint {
day: string;
cups: number;
}
const LINE_DATA: LinePoint[] = [
{ day: '9/24', cups: 86 }, { day: '9/25', cups: 102 }, { day: '9/26', cups: 78 },
// ... 共 12 个数据点
];
Canvas 绘制的数据源以常量形式声明。RING_RATE 是门店今日上座率(72%),作为 drawRing() 进度弧的弧长参数。LINE_DATA 是近 12 日茶饮销量折线数据,使用 LinePoint 接口建模,包含日期简写和售出杯数。12 个数据点的时间跨度从 9/24 到 10/05,覆盖了国庆假期前后的销售波动——数据中 10/02 的 168 杯为峰值,这一峰值会在 drawLine() 中被自动定位并标注。
interface RoomRow {
name: string;
state: string;
seats: number;
}
const ROOM_ROWS: RoomRow[] = [
{ name: '听雨轩', state: '空闲', seats: 4 },
{ name: '煮雪堂', state: '使用中', seats: 6 },
// ... 共 6 间
];
包间数据 ROOM_ROWS 使用 RoomRow 接口建模,6 间茶室全部采用诗意化的中文命名(听雨轩、煮雪堂、松风阁、竹影间、兰亭雅座、半盏亭),状态分为空闲、使用中、已预订三种。空闲状态的包间会显示"拼桌"按钮,点击后触发 openAdd() 弹窗,预填包间名。这一设计将数据 Tab 的包间清单与雅集 Tab 的拼桌功能有机串联了起来。
interface EventRow {
name: string;
time: string;
quota: string;
state: string;
}
const EVENT_ROWS: EventRow[] = [
{ name: '径山茶会·秋', time: '本周六 14:00', quota: '12/20 席', state: '报名中' },
// ... 共 6 场
];
雅集活动数据 EVENT_ROWS 包含 6 场活动,覆盖茶会、体验课、品鉴局、围炉煮茶、启蒙班等多种形态。状态字段区分报名中、已满员、进行中三种,对应不同的颜色映射。quota 字段以"已报名/总名额 席"的格式展示名额进度。
interface CellarRow {
name: string;
type: string;
detail: string;
}
const CELLAR_ROWS: CellarRow[] = [
{ name: '明前龙井·头采', type: '绿', detail: '50g 罐 · 余 32g' },
// ... 共 6 件
];
const MONTH_DATA: number[] = [412, 530, 386, 618, 742, 565];
const MONTH_NAME: string[] = ['4月', '5月', '6月', '7月', '8月', '9月'];
const BAR_MAX: number = 800;
茶仓清单 CELLAR_ROWS 记录了用户个人存储的 6 件茶品,包含分类徽标(绿/乌/白/普/红)和规格存量说明。月度消费数据 MONTH_DATA 是近 6 个月的消费金额数组,配合 MONTH_NAME 横轴标签和 BAR_MAX 满刻度基准,驱动"我的"Tab 的柱状图渲染。
3.7 AI 字幕语言与字号配置常量
interface LangOption {
code: string;
name: string;
}
const SRC_LANGS: LangOption[] = [
{ code: 'zh', name: '中文' },
{ code: 'en', name: '英文' }
];
const TGT_LANGS_EN: LangOption[] = [
{ code: 'zh', name: '中文' },
{ code: 'en', name: '英文' },
{ code: 'zh-en', name: '中英双语' }
];
语言配置常量分为源语言 SRC_LANGS(2 选项)和英文源目标语言 TGT_LANGS_EN(3 选项)。中文源时目标语言锁定 'zh' 无选项可选——这是因为 AI 字幕引擎在中文源时不支持翻译到其他语言,试图设置其他值会导致初始化失败。英文源时目标语言支持中文、英文和中英双语三种选择。这种差异化的选项设计直接映射了底层 Speech Kit 的能力边界。
interface SizeOption {
size: AICaptionFontSize;
name: string;
}
const SIZE_OPTIONS: SizeOption[] = [
{ size: AICaptionFontSize.SMALL, name: '小号' },
{ size: AICaptionFontSize.NORMAL, name: '标准' },
{ size: AICaptionFontSize.BIG, name: '大号' },
{ size: AICaptionFontSize.LARGE, name: '超大' }
];
const CAPTION_FONT_COLORS: string[] = ['#FFFFFF', '#FFE9B0', '#9CE8B5', '#9CD0FF', '#FFB3C1'];
字号四档 SIZE_OPTIONS 使用 AICaptionFontSize 枚举(而非 number),这是 Speech Kit 的类型安全设计——开发者不能随意传入任意数字作为字号值,必须从枚举四档中选择。颜色五预设 CAPTION_FONT_COLORS 提供了白、暖黄、薄荷绿、天蓝、樱粉五种字幕颜色选项,覆盖了不同场景下的可读性需求(暗背景用白/黄,亮背景用绿/蓝/粉)。
3.8 数据模型:@Observed 类定义
@Observed
export class InnerCard {
id: string;
tag: string;
title: string;
desc: string;
constructor(id: string, tag: string, title: string, desc: string) {
this.id = id;
this.tag = tag;
this.title = title;
this.desc = desc;
}
}
InnerCard 是内层栏目卡片的数据模型,被 @Observed 修饰为可观察类。4 个属性分别承载唯一键、场景标签、标题和描述。innerMockData() 函数负责批量生成实例——根据传入的频道和场景名,循环 8 次创建卡片,标题从 INNER_TITLES 取材,描述组装为"频道图标+频道名+场景名+序号+行业注解"的完整串。id 字段的格式为"频道名-场景名-序号",保证了全局唯一性,可作为 ForEach 的键生成器参数。
@Observed
export class SwipeLog {
layer: string;
tabName: string;
fromIdx: number;
toIdx: number;
mode: string;
time: string;
constructor(layer: string, tabName: string, fromIdx: number, toIdx: number, mode: string) {
this.layer = layer;
this.tabName = tabName;
this.fromIdx = fromIdx;
this.toIdx = toIdx;
this.mode = mode;
const d = new Date();
this.time = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`;
}
}
SwipeLog 是翻页日志的数据模型,记录每次内外层 Tabs 翻页的完整上下文:层级(外层频道/内层场景)、翻到的页签名、起始索引、目标索引、事发时嵌套模式和精确时间戳。时间戳在构造函数内自动生成,使用 padStart(2, '0') 保证时分秒两位补零格式。这个类是日志 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;
}
}
const SCENE_LIST: CaptionScene[] = [
new CaptionScene('茶艺师冲泡讲解', '中文茶艺口述同步转写,超大字号适合隔台观看', 'zh', 'zh'),
new CaptionScene('英文茶文化沙龙', '海外茶友线上分享,整体译成中文字幕上屏', 'en', 'zh'),
// ... 共 5 条
];
CaptionScene 是字幕场景推荐卡的数据模型,每条场景预设了推荐的源/目标语言组合。5 条场景全部采用茶文化语义化描述:茶艺师冲泡讲解(中文源→中文目标,超大字号)、英文茶文化沙龙(英文→中文)、日语茶道课跟学(英文→中英双语)、粤语茶楼拼桌会(中文→中文)、围炉夜话双语场(英文→中英双语)。点击场景卡时调用 applyScene() 一键应用推荐的语言组合。
@Observed
export class EventItem {
name: string;
time: string;
quota: string;
state: string;
constructor(name: string, time: string, quota: string, state: string) {
this.name = name;
this.time = time;
this.quota = quota;
this.state = state;
}
}
const EVENT_SEED: EventItem[] = EVENT_ROWS.map((row: EventRow) =>
new EventItem(row.name, row.time, row.quota, row.state));
EventItem 是雅集活动的数据模型,从 EVENT_ROWS 常量通过 map 转换生成初始种子数据。@Observed 修饰使得该类的实例在数组操作(unshift、splice)时能够触发 UI 刷新——这是雅集 Tab 列表动态增删活动的状态管理基础。
3.9 辅助函数群
function modeLabel(mode: TabsNestedScrollMode): string {
return mode === TabsNestedScrollMode.SELF_FIRST
? 'SELF_FIRST·先内后外' : 'SELF_ONLY·仅内层';
}
function modeShort(mode: TabsNestedScrollMode): string {
return mode === TabsNestedScrollMode.SELF_FIRST ? '先内后外' : '仅内层';
}
modeLabel 和 modeShort 两个函数负责将 TabsNestedScrollMode 枚举值翻译为中文文案,分别用于完整说明和简短标签。TabsNestedScrollMode 是 ArkUI 全局枚举,无需 import 即可使用。这两个函数的存在使得 UI 层不必硬编码枚举文案,且当枚举语义变化时只需修改一处。
function langName(code: string): string {
if (code === 'zh') {
return '中文';
}
if (code === 'zh-en') {
return '中英双语';
}
return '英文';
}
langName 函数将语言码('zh'、'en'、'zh-en')翻译为展示名,用于字幕场景卡中"源语言 → 目标语言"的文案展示。
function roomStateColor(state: string): string {
if (state === '空闲') {
return COLORS.bamboo;
}
if (state === '使用中') {
return COLORS.ink;
}
return COLORS.gold;
}
function eventStateColor(state: string): string {
if (state === '报名中') {
return COLORS.bamboo;
}
if (state === '已满员') {
return COLORS.red;
}
return COLORS.blue;
}
roomStateColor 和 eventStateColor 两个函数实现了状态到颜色的映射逻辑。包间状态:空闲=竹青绿、使用中=松烟墨、已预订=强调金;活动状态:报名中=竹青绿、已满员=警示红、进行中=辅助蓝。这种"语义到色彩"的映射函数将状态配色规则集中管理,避免了在 UI 代码中散落的三元表达式判断。
function nowTime(): string {
const d = new Date();
const h = d.getHours().toString().padStart(2, '0');
const m = d.getMinutes().toString().padStart(2, '0');
return `${h}:${m}`;
}
nowTime 函数返回当前时间的 hh:mm 格式字符串,用于弹窗表单的默认时段值预填。
3.10 页面结构体声明与通用成员
@Entry
@Component
struct Page1297 {
@State currentTab: number = 0;
@State breath: boolean = false;
private timer: number = -1;
Page1297 是整个应用的入口组件。@Entry 标记其为页面入口,@Component 声明其为自定义组件。三个通用成员变量:currentTab(当前 Tab 索引,驱动内容区切换)、breath(呼吸动画布尔开关,每秒翻转一次,联动 Canvas 重绘和柱状图波动)、timer(呼吸定时器句柄,初始值 -1 表示未启动)。
// --- 茶单业务状态 ---
@State teaCat: string = '全部';
@State teaList: TeaSeed[] = TEA_SEED;
@State recoIdx: number = 0;
茶单业务状态三个变量:teaCat 是当前选中的茶类 chips(初始’全部’,即不筛选),teaList 是茶品卡数据源(直接引用 TEA_SEED 常量),recoIdx 是今日推荐 Swiper 的当前索引。
3.11 特性 A 状态成员
// --- 特性 A 状态(Speech Kit AI 字幕) ---
private captionController: AICaptionController = new AICaptionController();
@State captionShown: boolean = false;
@State srcLang: string = 'zh';
@State tgtLang: string = 'zh';
@State captionSize: AICaptionFontSize = AICaptionFontSize.NORMAL;
@State captionColor: string = CAPTION_FONT_COLORS[0];
@State captionReady: boolean = false;
@State captionErrMsg: string = '';
@State captionFed: number = 0;
@State sceneList: CaptionScene[] = SCENE_LIST;
特性 A 的状态成员是 AI 字幕功能的完整状态集。captionController 是 AICaptionController 的私有实例(非 @State,因为控制器本身不参与 UI 渲染),用于管理字幕组件的生命周期和音频写入。captionShown 是字幕显示开关,通过 @Link 双向绑定到 AICaptionComponent 的 isShown 参数——当该值变化时,字幕组件自动显示或隐藏。srcLang 和 tgtLang 分别是源语言和目标语言,对应 6.1.1 新增的 sourceLanguage 和 targetLanguage 字段。captionSize 是字号枚举,captionColor 是字体颜色。captionReady 在 onPrepared 回调时置 true,captionErrMsg 在 onError 回调时捕获错误信息,captionFed 记录已写入的音频块数。
3.12 特性 C 状态成员
// --- 特性 C 状态(Tabs 嵌套滚动) ---
@State nestedMode: TabsNestedScrollMode = TabsNestedScrollMode.SELF_FIRST;
@State outerIndex: number = 0;
@State innerIndex: number = 0;
@State swipeLogs: SwipeLog[] = [];
特性 C 的状态成员包含:nestedMode(嵌套滚动模式,默认 SELF_FIRST 先内后外)、outerIndex(外层频道当前索引)、innerIndex(内层场景当前索引)和 swipeLogs(翻页日志数组,unshift 置顶,封顶 40 条)。这四个状态变量共同构成了嵌套滚动的状态管理闭环:模式控制滚动行为,索引跟踪当前位置,日志记录历史轨迹。
3.13 弹窗系统与 Canvas 成员
// --- 雅集业务状态 ---
@State eventList: EventItem[] = EVENT_SEED;
// --- 弹窗系统成员 ---
@State addModal: boolean = false;
@State editModal: boolean = false;
@State delModal: boolean = false;
@State formRoom: string = '';
@State formSeats: string = '';
@State formTime: string = '';
@State editTarget: EventItem | null = null;
@State delTarget: EventItem | null = null;
// --- Canvas 绘制成员 ---
private ringCtx: CanvasRenderingContext2D =
new CanvasRenderingContext2D(new RenderingContextSettings(true));
private lineCtx: CanvasRenderingContext2D =
new CanvasRenderingContext2D(new RenderingContextSettings(true));
弹窗系统成员包含三个弹窗开关(addModal/editModal/delModal)、三个表单字段(formRoom/formSeats/formTime)和两个操作目标(editTarget/delTarget,使用联合类型 EventItem | null 表示可能无目标)。Canvas 绘制成员是两个 CanvasRenderingContext2D 私有实例,RenderingContextSettings(true) 开启抗锯齿。这两个上下文分别驱动上座率进度环和销量折线图的绘制。
3.14 生命周期函数
aboutToAppear() {
this.timer = setInterval(() => {
this.breath = !this.breath;
this.drawRing();
this.drawLine();
}, 1000);
}
aboutToDisappear() {
if (this.timer !== -1) {
clearInterval(this.timer);
this.timer = -1;
}
}
aboutToAppear 是组件即将出现时的生命周期回调,在此启动 1 秒间隔的呼吸定时器。定时器回调中依次执行三步:翻转 breath 布尔值、调用 drawRing() 重绘进度环、调用 drawLine() 重绘折线。这种"方案 B 无闪烁重绘"策略直接复用已有的 Canvas 上下文,无需重建 Canvas 组件,避免了画面闪烁。aboutToDisappear 在组件即将消失时清理定时器,将 timer 重置为 -1 防止重复清理。
3.15 根构建 build() 方法
build() {
Stack({ alignContent: Alignment.Center }) {
Column() {
this.headerMain()
Divider().strokeWidth(1).color(COLORS.line)
if (this.currentTab === 1) {
this.tabChannel()
} else {
Scroll() {
Column({ space: 12 }) {
if (this.currentTab === 0) {
this.tabMenu()
} else if (this.currentTab === 2) {
this.tabLog()
} else if (this.currentTab === 3) {
this.tabData()
} else if (this.currentTab === 4) {
this.tabCaption()
} else if (this.currentTab === 5) {
this.tabEvent()
} else {
this.tabMine()
}
}.width('100%').padding({ left: 14, right: 14, top: 12, bottom: 16 })
}.layoutWeight(1).width('100%')
.scrollBar(BarState.Off).edgeEffect(EdgeEffect.Spring)
}
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 作为最外层容器(居中对齐),内部嵌套 Column 承载主界面。主界面结构为:头部 headerMain() → 分割线 → 内容区 → 底部导航 tabBar()。
内容区的设计体现了对嵌套滚动的特殊处理:当 currentTab === 1(频道 Tab)时,直接调用 tabChannel() 而不包裹在 Scroll 中——因为频道 Tab 内部已有双层 Tabs 自带滚动能力,外层再加 Scroll 会导致滚动冲突。其余 6 个 Tab 的内容统一包裹在 Scroll 容器中,设置 scrollBar(BarState.Off) 隐藏滚动条、edgeEffect(EdgeEffect.Spring) 启用弹性边缘效果。
弹窗层使用三个 if 条件分支挂在 Stack 的第二层,通过 @State 布尔值控制显示/隐藏。每个弹窗接收一个 onClose 回调函数,在弹窗内部取消按钮或遮罩点击时调用,将对应的 @State 置为 false。
3.16 特性 A:AI 字幕配置方法
buildCaptionOptions(): AICaptionOptions {
const opts: AICaptionOptions = {
initialOpacity: 1,
sourceLanguage: this.srcLang,
targetLanguage: this.tgtLang,
fontSize: this.captionSize,
fontColor: this.captionColor,
onPrepared: () => {
this.captionReady = true;
this.captionErrMsg = '';
},
onError: (error: BusinessError) => {
this.captionErrMsg = '字幕服务异常 ' + error.code + ':' + error.message;
}
};
return opts;
}
buildCaptionOptions() 是 AI 字幕配置的核心组装方法,将组件状态变量实时映射为 AICaptionOptions 对象。注释中用 ★ 标记了 6.1.1 新增的四个字段:sourceLanguage、targetLanguage、fontSize、fontColor。onPrepared 回调在字幕引擎就绪时将 captionReady 置 true 并清空错误信息,onError 回调捕获 BusinessError 并组装错误文案。这个方法在每次 AICaptionComponent 渲染时被调用(作为 options 参数),因此状态变量的任何变化都会实时反映到字幕配置中。
switchSourceLang(code: string) {
this.srcLang = code;
if (code === 'zh') {
this.tgtLang = 'zh';
} else {
this.tgtLang = 'zh-en';
}
}
switchSourceLang() 处理源语言切换时的目标语言联动逻辑。中文源时目标语言锁定 'zh'(取值范围仅 ['zh'],选其他值会导致初始化失败),英文源时默认切换到中英双语 'zh-en',用户可再手动选择 zh 或 en。这一联动逻辑直接映射了 Speech Kit 的能力约束。
feedAudioStream() {
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 = '音频写入失败';
}
}
feedAudioStream() 生成 640 字节的 PCM 音频块并写入字幕引擎。音频参数为 16kHz 采样率、16bit 位深、单声道——640 字节 ÷ 2 字节/样本 = 320 个样本 ÷ 16000 Hz = 0.02 秒 = 20ms。数据内容为 440Hz 正弦波(A4 音高),振幅 6000(16bit 有符号数范围 -32768~32767 的约 18%)。生成时以 2 字节为单位(低字节在前,小端序),通过 v & 0xFF 取低 8 位、(v >> 8) & 0xFF 取高 8 位。写入时封装为 AudioData 对象调用 writeAudio(),成功后递增 captionFed 计数器,失败时捕获异常并设置错误信息。
applyScene(scene: CaptionScene) {
this.switchSourceLang(scene.src);
this.tgtLang = scene.tgt;
}
captionSizeName(): string {
if (this.captionSize === AICaptionFontSize.SMALL) return 'SMALL';
if (this.captionSize === AICaptionFontSize.BIG) return 'BIG';
if (this.captionSize === AICaptionFontSize.LARGE) return 'LARGE';
return 'NORMAL';
}
applyScene() 在字幕场景卡点击时调用,先通过 switchSourceLang() 联动源语言(确保中文源时目标语言锁定),再覆盖目标语言为场景推荐值。captionSizeName() 将字号枚举值转为展示名字符串,用于 options 代码预览卡的实时镜像显示。
3.17 过滤与统计辅助方法
filteredTeas(): TeaSeed[] {
if (this.teaCat === '全部') {
return this.teaList;
}
return this.teaList.filter((t: TeaSeed) => t.cat === this.teaCat);
}
outerCount(): number {
return this.swipeLogs.filter((l: SwipeLog) => l.layer === '外层频道').length;
}
innerCount(): number {
return this.swipeLogs.filter((l: SwipeLog) => l.layer === '内层场景').length;
}
filteredTeas() 根据当前选中的茶类 chips 过滤茶品卡列表——‘全部’ 返回全量,否则按 cat 字段筛选。outerCount() 和 innerCount() 分别统计翻页日志中外层频道和内层场景的翻页次数,用于日志 Tab 的统计卡展示。这三个方法都是"计算属性"模式的实现——不维护独立状态,而是在调用时实时从数据源推导结果。
3.18 特性 B:Canvas 绘制方法
drawRing() {
const ctx = this.ringCtx;
ctx.antialias = true;
const w = ctx.width;
if (w <= 0) { return; }
const cx = w / 2;
const cy = w / 2;
const r = w * 0.34;
const lw = Math.round(w * 0.082);
const rate = RING_RATE;
const wave = this.breath ? 1 : 0.88;
drawRing() 绘制门店今日上座率进度环。方法开头先设置 antialias = true(切 Tab 后 Canvas 重建时状态可能丢失,需同步),获取上下文宽度 w,若 w <= 0 则直接返回(Canvas 尚未就绪)。圆心坐标取 (w/2, w/2),半径 r = w * 0.34(约占 Canvas 宽度的三分之一),线宽 lw = w * 0.082。wave 变量是呼吸动画的弧长系数——breath 为 true 时取 1.0(完整弧长),为 false 时取 0.88(弧长微缩 12%),形成进度弧"呼吸"的视觉效果。
// 背景环
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.strokeStyle = COLORS.chip;
ctx.lineWidth = lw;
ctx.lineCap = 'round';
ctx.stroke();
// 进度弧
ctx.beginPath();
ctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * rate * wave);
ctx.strokeStyle = COLORS.bamboo;
ctx.lineWidth = lw;
ctx.lineCap = 'round';
ctx.stroke();
绘制分两步:背景环绘制完整的 360 度圆弧(0 到 Math.PI * 2),使用米灰色 COLORS.chip;进度弧从 12 点方向(-Math.PI / 2)起画,弧长为 Math.PI * 2 * rate * wave(72% 上座率 × 呼吸系数),使用竹青色 COLORS.bamboo。lineCap = 'round' 使弧线两端为圆头,视觉更柔和。
ctx.fillStyle = COLORS.title;
ctx.font = `bold ${Math.round(w * 0.14)}px sans-serif`;
ctx.textAlign = 'center';
ctx.fillText(`${Math.round(rate * 100)}%`, cx, cy + w * 0.02);
ctx.fillStyle = COLORS.text3;
ctx.font = `${Math.max(9, Math.round(w * 0.062))}px sans-serif`;
ctx.fillText('今日上座率', cx, cy + w * 0.15);
}
中心区域绘制两层文本:主文字"72%"使用松烟墨色、加粗、字号约为 Canvas 宽度的 14%;副标签"今日上座率"使用浅灰褐色、字号约 6.2%(最小 9px 防止过小)。textAlign = 'center' 配合圆心 x 坐标实现水平居中。
drawLine() {
const ctx = this.lineCtx;
ctx.antialias = true;
const w = ctx.width;
const h = ctx.height;
if (w <= 0 || h <= 0) { return; }
const padX = Math.round(w * 0.09);
const padY = Math.round(h * 0.16);
const max = 180;
const stepX = (w - padX * 2) / (LINE_DATA.length - 1);
const pointR = this.breath ? 3.5 : 2.5;
drawLine() 绘制近 12 日茶饮销量折线图。布局参数:水平内边距 padX = 9%、垂直内边距 padY = 16%、纵轴满刻度 180 杯。stepX 计算相邻数据点的水平间距,pointR 是数据点半径——呼吸时 3.5px,非呼吸时 2.5px,形成脉动效果。
// 背景网格
ctx.strokeStyle = COLORS.line;
ctx.lineWidth = 1;
for (let i = 0; i <= 3; i++) {
const y = padY + (h - padY * 2) * i / 3;
ctx.beginPath();
ctx.moveTo(padX, y);
ctx.lineTo(w - padX, y);
ctx.stroke();
}
背景网格绘制 4 条等距横线,将绘图区域分为 3 等分,使用分割线色 COLORS.line。
// 渐变填充区域
const grad = ctx.createLinearGradient(0, padY, 0, h - padY);
grad.addColorStop(0, COLORS.bamboo);
grad.addColorStop(1, 'rgba(107,142,90,0.05)');
ctx.beginPath();
ctx.moveTo(padX, h - padY);
for (let i = 0; i < LINE_DATA.length; i++) {
ctx.lineTo(padX + i * stepX,
h - padY - (LINE_DATA[i].cups / max) * (h - padY * 2));
}
ctx.lineTo(w - padX, h - padY);
ctx.closePath();
ctx.fillStyle = grad;
ctx.fill();
渐变填充区域从折线下方延伸到底部,使用 createLinearGradient 创建从竹青色(顶部不透明)到近透明竹青色(底部 5% 透明度)的垂直渐变。路径从左下角开始,依次 lineTo 每个数据点的位置(y 坐标 = 底部内边距 - (杯数/满刻度) × 可用高度),最后连回右下角闭合。
// 折线主线
ctx.beginPath();
for (let i = 0; i < LINE_DATA.length; i++) {
const x = padX + i * stepX;
const y = h - padY - (LINE_DATA[i].cups / max) * (h - padY * 2);
if (i === 0) { ctx.moveTo(x, y); }
else { ctx.lineTo(x, y); }
}
ctx.strokeStyle = COLORS.bamboo;
ctx.lineWidth = 2;
ctx.stroke();
// 数据点
for (let i = 0; i < LINE_DATA.length; i++) {
const x = padX + i * stepX;
const y = h - padY - (LINE_DATA[i].cups / max) * (h - padY * 2);
ctx.beginPath();
ctx.arc(x, y, pointR, 0, Math.PI * 2);
ctx.fillStyle = COLORS.card;
ctx.fill();
ctx.strokeStyle = COLORS.bamboo;
ctx.lineWidth = 1.5;
ctx.stroke();
}
折线主线从第一个点 moveTo 开始,后续点 lineTo 连接,使用竹青色 2px 线宽描边。数据点为白心竹青边圆环——先以白色 COLORS.card 填充圆,再以竹青色描边——形成"空心圆点"的视觉效果,半径随呼吸脉动。
// 峰值标注
let peakIdx = 0;
for (let i = 1; i < LINE_DATA.length; i++) {
if (LINE_DATA[i].cups > LINE_DATA[peakIdx].cups) {
peakIdx = i;
}
}
const px = padX + peakIdx * stepX;
const py = h - padY - (LINE_DATA[peakIdx].cups / max) * (h - padY * 2);
ctx.fillStyle = COLORS.gold;
ctx.font = `${Math.max(9, Math.round(w * 0.026))}px sans-serif`;
ctx.textAlign = 'center';
ctx.fillText(`峰值 ${LINE_DATA[peakIdx].cups} 杯`, px, py - 10);
// 横轴标签
ctx.fillStyle = COLORS.text3;
for (let i = 0; i < LINE_DATA.length; i += 2) {
ctx.fillText(LINE_DATA[i].day, padX + i * stepX, h - 6);
}
}
峰值标注自动遍历数据寻找最大杯数的索引(168 杯,10/02),在该点上方 10px 处以强调金色绘制"峰值 168 杯"文本。横轴日期标签每隔一个点(i += 2)绘制一次,避免 12 个标签拥挤重叠。
3.19 弹窗操作方法群
openAdd(room: string) {
this.formRoom = room;
this.formSeats = '';
this.formTime = nowTime();
this.addModal = true;
}
confirmAdd() {
if (this.formRoom === '' || this.formSeats === '') { return; }
this.eventList.unshift(new EventItem(
`拼桌·${this.formRoom}`, this.formTime === '' ? nowTime() : this.formTime,
`${this.formSeats} 人席`, '报名中'));
this.addModal = false;
}
openAdd() 打开发起拼桌弹窗,预填意向包间名(可从包间清单带入)、清空人数、设置默认时段为当前时间。confirmAdd() 校验包间名和人数非空后,以"拼桌·包间名"为活动名、当前时段为时间、拼桌人数为名额说明、状态为"报名中",创建 EventItem 并 unshift 置顶到雅集列表,最后关闭弹窗。
openEdit(item: EventItem) {
this.editTarget = item;
this.formTime = item.time;
this.editModal = true;
}
saveEdit() {
const target = this.editTarget;
if (target === null || this.formTime === '') { return; }
const idx = this.eventList.indexOf(target);
if (idx >= 0) {
this.eventList.splice(idx, 1,
new EventItem(target.name, this.formTime, target.quota, target.state));
}
this.editModal = false;
}
openEdit() 打开修改时间弹窗,保存编辑目标引用并预填当前时间。saveEdit() 通过 indexOf 定位目标在数组中的索引,使用 splice 进行整对象替换——创建一个新的 EventItem 实例替换原对象,新对象的时间为表单输入值,其他属性保持不变。这种"整对象替换"策略是触发 @Observed 数组 UI 刷新的必要手段——直接修改对象属性不会触发 ForEach 重新渲染。
openDel(item: EventItem) {
this.delTarget = item;
this.delModal = true;
}
confirmDel() {
const target = this.delTarget;
if (target === null) { this.delModal = false; return; }
const idx = this.eventList.indexOf(target);
if (idx >= 0) {
this.eventList.splice(idx, 1);
}
this.delModal = false;
}
openDel() 打开取消报名确认弹窗,仅保存目标引用(无表单字段)。confirmDel() 定位并 splice 移除目标活动,释放名额。
3.20 头部 Builder:headerMain()
@Builder
headerMain() {
Column({ space: 10 }) {
Row() {
Column({ space: 4 }) {
Text('茶境 · 新中式茶饮空间').fontSize(19).fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text(this.currentTab === 0 ? '今日茶单 · 明前龙井头采到店'
: this.currentTab === 1 ? '茶类频道 · 双层嵌套滚动'
: this.currentTab === 2 ? '翻页日志 · 内外层时间轴'
: this.currentTab === 3 ? '门店数据 · 今日上座率 72%'
: this.currentTab === 4 ? 'AI 字幕 · 四新字段配置'
: this.currentTab === 5 ? '雅集活动 · 径山茶会报名中'
: '我的茶境 · 茶仓与消费').fontSize(11).fontColor(COLORS.sub)
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
Circle({ width: 10, height: 10 }).fill(COLORS.bamboo)
.opacity(this.breath ? 1 : 0.4)
}.width('100%')
头部区域分上下两部分。上半部分是左侧应用名+副标题与右侧呼吸圆点的水平布局。副标题通过 7 重三元表达式根据 currentTab 索引动态切换文案,实现 Tab 联动副标题效果——每个 Tab 都有对应的语义化副标题。呼吸圆点使用竹青色 10px 圆形,opacity 随 breath 在 1.0 和 0.4 之间切换,形成 1 秒一次的心跳脉动,提示应用正在实时刷新数据。
Row({ space: 8 }) {
Row({ space: 4 }) {
Circle({ width: 6, height: 6 })
.fill(this.captionReady ? COLORS.bamboo : COLORS.gold)
Text(this.captionReady ? '字幕已就绪' : '字幕初始化').fontSize(9).fontColor(COLORS.sub)
}.padding({ left: 8, right: 8, top: 5, bottom: 5 })
.borderRadius(10).backgroundColor(COLORS.chip)
Row({ space: 4 }) {
Circle({ width: 6, height: 6 }).fill(COLORS.blue)
Text('Canvas 双图').fontSize(9).fontColor(COLORS.sub)
}.padding({ left: 8, right: 8, top: 5, bottom: 5 })
.borderRadius(10).backgroundColor(COLORS.chip)
Row({ space: 4 }) {
Circle({ width: 6, height: 6 }).fill(COLORS.gold)
Text(`嵌套 ${modeShort(this.nestedMode)}`).fontSize(9).fontColor(COLORS.sub)
}.padding({ left: 8, right: 8, top: 5, bottom: 5 })
.borderRadius(10).backgroundColor(COLORS.chip).layoutWeight(1)
}.width('100%')
}.padding({ left: 16, right: 16, top: 12, bottom: 12 }).width('100%')
.linearGradient({ angle: 160, colors: [[COLORS.chip, 0], [COLORS.bg, 1]] })
}
下半部分是三特性状态胶囊水平排列。第一个胶囊反映 AI 字幕就绪状态:圆点颜色和文案随 captionReady 变化(就绪=竹青/初始化=强调金)。第二个胶囊标识 Canvas 双图能力,圆点固定蓝色。第三个胶囊显示当前嵌套模式简文案,通过 modeShort(this.nestedMode) 实时反映模式切换。整个头部区域使用 linearGradient 设置 160 度线性渐变背景(从米灰到月白宣),营造温润的视觉质感。
3.21 底部导航 Builder:tabBar()
@Builder
tabBar() {
Row() {
ForEach(TAB_LIST, (tab: TabMeta, index: number) => {
Column({ space: 3 }) {
Text(tab.icon).fontSize(15)
Text(tab.label).fontSize(8)
.fontColor(this.currentTab === index ? COLORS.tabOn : COLORS.text3)
}.justifyContent(FlexAlign.Center).layoutWeight(1)
.padding({ top: 7, bottom: 7 })
.onClick(() => {
this.currentTab = index;
})
}, (tab: TabMeta) => tab.label)
}.width('100%').backgroundColor(COLORS.card)
}
底部导航栏使用 ForEach 遍历 TAB_LIST 渲染 7 个 Tab 按钮。每个 Tab 是一个垂直排列的 Column(图标+文字),layoutWeight(1) 使 7 个 Tab 等分宽度。选中状态的文字颜色为 COLORS.tabOn(松烟墨),非选中为 COLORS.text3(浅灰褐)。点击时更新 currentTab 索引触发内容区切换。ForEach 的键生成器使用 tab.label,保证了 Tab 列表的稳定性。
3.22 Tab0 茶单 Builder:tabMenu()
@Builder
tabMenu() {
Column({ space: 12 }) {
Scroll() {
Row({ space: 8 }) {
Text('全部').fontSize(11).fontWeight(FontWeight.Bold)
.fontColor(this.teaCat === '全部' ? COLORS.card : COLORS.sub)
.padding({ left: 14, right: 14, top: 6, bottom: 6 }).borderRadius(14)
.backgroundColor(this.teaCat === '全部' ? COLORS.ink : COLORS.card)
.onClick(() => { this.teaCat = '全部'; })
ForEach(TEA_CATS, (cat: string) => {
Text(cat).fontSize(11).fontWeight(FontWeight.Bold)
.fontColor(this.teaCat === cat ? COLORS.card : COLORS.sub)
.padding({ left: 14, right: 14, top: 6, bottom: 6 }).borderRadius(14)
.backgroundColor(this.teaCat === cat ? COLORS.ink : COLORS.card)
.onClick(() => { this.teaCat = cat; })
}, (cat: string) => cat)
}.padding({ left: 2, right: 2 })
}.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).width('100%')
茶单 Tab 的第一部分是茶类横滚 chips 筛选条。"全部"按钮和 5 个茶类 chips(绿茶、红茶、乌龙、白茶、普洱)并排排列在横向 Scroll 容器中。每个 chip 的选中态为松烟墨背景+白色文字,非选中态为白色背景+灰色文字。点击更新 teaCat 状态变量,触发下方双列茶品卡的过滤刷新。横向滚动 + 隐藏滚动条的设计使 chips 在窄屏设备上也能完整展示。
Column({ space: 8 }) {
Swiper() {
ForEach(RECO_CARDS, (name: string, idx: number) => {
Column({ space: 10 }) {
Text(`今日推荐 ${idx + 1}`).fontSize(10).fontColor(COLORS.chip)
Text(name).fontSize(17).fontWeight(FontWeight.Bold).fontColor(COLORS.card)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text(RECO_NOTES[idx]).fontSize(10).fontColor(COLORS.chip).maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row() {
Text('¥68 起').fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.gold)
Column().layoutWeight(1)
Text('拼桌').fontSize(10).fontColor(COLORS.card)
.padding({ left: 12, right: 12, top: 5, bottom: 5 }).borderRadius(12)
.backgroundColor(COLORS.bamboo)
.onClick(() => { this.openAdd(''); })
}.width('100%')
}.width('100%').padding(16).borderRadius(14).alignItems(HorizontalAlign.Start)
.linearGradient({ angle: 150, colors: [[COLORS.inkD, 0], [COLORS.ink, 1]] })
}, (name: string, idx: number) => `reco-${idx}-${name}`)
}
.index(this.recoIdx)
.onChange((index: number) => { this.recoIdx = index; })
.height(158).indicator(false)
第二部分是今日推荐 Swiper 横滑大卡。3 张推荐卡使用松烟墨渐变背景(从深墨绿 inkD 到标准墨绿 ink),卡内依次展示推荐序号、套餐名(maxLines(1) + 省略号溢出)、描述文案(maxLines(2))、价位(强调金色)和拼桌按钮(竹青色)。Swiper 的 index 绑定到 recoIdx 状态变量实现双向同步,indicator(false) 关闭默认指示点,改用下方的自定义指示点。
Row({ space: 5 }) {
ForEach(RECO_CARDS, (name: string, idx: number) => {
if (this.recoIdx === idx) {
Column().width(16).height(4).borderRadius(2).backgroundColor(COLORS.ink)
} else {
Column().width(4).height(4).borderRadius(2).backgroundColor(COLORS.chip)
}
}, (name: string, idx: number) => `dot-${idx}`)
}
}.width('100%')
自定义指示点根据 recoIdx 区分当前页和其余页——当前页为 16px 宽的加宽胶囊(松烟墨色),其余页为 4px 宽的小圆点(米灰色),形成清晰的当前位置标识。
Row() {
Text('茶品单').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.title).layoutWeight(1)
Text(`${this.filteredTeas().length} 款在售`).fontSize(10).fontColor(COLORS.text3)
}.width('100%')
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(this.filteredTeas(), (tea: TeaSeed, idx: number) => {
Column({ space: 8 }) {
Row() {
Text(tea.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
.layoutWeight(1).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text(tea.cat).fontSize(9).fontColor(COLORS.bamboo)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(6).backgroundColor(COLORS.chip)
}.width('100%')
Text(`${tea.origin} · ${tea.flavor}`).fontSize(10).fontColor(COLORS.sub)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }).width('100%')
Row() {
Text(`¥${tea.price}/杯`).fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.gold).layoutWeight(1)
Text('拼杯').fontSize(9).fontColor(COLORS.blue)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(8).backgroundColor(COLORS.chip)
.onClick(() => { this.openAdd(''); })
}.width('100%')
}.width('49%').padding(12).backgroundColor(COLORS.card).borderRadius(12)
.margin({ bottom: 10 })
}, (tea: TeaSeed, idx: number) => `${tea.name}-${idx}`)
}.width('100%')
}.width('100%')
}
双列茶品卡使用 Flex 换行布局(FlexWrap.Wrap + SpaceBetween),每个卡片宽度 49%,形成双列排列。卡片内展示茶品名(省略号截断)、茶类徽标(竹青色小标签)、产地+风味、单价(强调金色)和拼杯按钮。数据源是 filteredTeas() 方法的返回值——当茶类 chips 切换时,teaCat 状态变化触发 filteredTeas() 重新计算,ForEach 自动刷新卡片列表。
3.23 Tab1 频道 Builder:tabChannel() 与 innerTabs()
@Builder
tabChannel() {
Column({ space: 10 }) {
Row({ space: 8 }) {
Text(`嵌套模式:${modeLabel(this.nestedMode)}`).fontSize(11).fontColor(COLORS.sub)
.layoutWeight(1).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
ForEach([TabsNestedScrollMode.SELF_ONLY, TabsNestedScrollMode.SELF_FIRST],
(m: TabsNestedScrollMode) => {
Text(modeShort(m)).fontSize(10)
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(12)
.fontColor(this.nestedMode === m ? COLORS.card : COLORS.text3)
.backgroundColor(this.nestedMode === m ? COLORS.ink : COLORS.card)
.onClick(() => { this.nestedMode = m; })
}, (m: TabsNestedScrollMode) => `mode_${m}`)
}.width('100%')
频道 Tab 是特性 C 的宿主区域。顶部首先是嵌套模式切换 chips——两个按钮分别对应 SELF_ONLY 和 SELF_FIRST 模式,点击切换 nestedMode 状态变量。模式说明文案通过 modeLabel() 展示完整模式名。
Row({ space: 6 }) {
Circle({ width: 6, height: 6 }).fill(COLORS.ink)
Text(`外层 ${OUTER_CHANNELS[this.outerIndex].name}(第 ${this.outerIndex + 1}/4 个)`)
.fontSize(10).fontColor(COLORS.sub)
Column().layoutWeight(1)
Circle({ width: 6, height: 6 }).fill(COLORS.bamboo)
Text(`内层 ${INNER_TABS[this.innerIndex]}(第 ${this.innerIndex + 1}/5 页)`)
.fontSize(10).fontColor(COLORS.sub)
}.width('100%')
双层位置说明行使用双圆点徽标(外层松烟墨/内层竹青)实时展示当前内外层页签的位置索引,帮助用户理解嵌套滚动的层级关系。
Column({ space: 4 }) {
Text('⌨️ Tabs 6.1.1 嵌套滚动').fontSize(11).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text('Tabs() { ... }').fontSize(10).fontColor(COLORS.sub).fontFamily('monospace')
Text(' .nestedScroll(TabsNestedScrollMode.SELF_FIRST)').fontSize(10)
.fontColor(COLORS.bamboo).fontFamily('monospace')
Text('SELF_ONLY 默认仅自身 · SELF_FIRST 到边缘联动外层').fontSize(9)
.fontColor(COLORS.text3)
}.width('100%').padding(10).borderRadius(10).backgroundColor(COLORS.card)
代码预览卡以等宽字体展示 .nestedScroll() 的调用镜像,帮助开发者理解新特性的 API 形态。
Tabs({ barPosition: BarPosition.Start }) {
ForEach(OUTER_CHANNELS, (ch: ChannelItem) => {
TabContent() {
this.innerTabs(ch)
}.tabBar(`${ch.icon} ${ch.name}`)
}, (ch: ChannelItem) => ch.name)
}
.barMode(BarMode.Fixed)
.onChange((index: number) => {
this.swipeLogs.unshift(new SwipeLog('外层频道', OUTER_CHANNELS[index].name,
this.outerIndex, index, modeLabel(this.nestedMode)));
this.outerIndex = index;
if (this.swipeLogs.length > 40) {
this.swipeLogs.pop();
}
})
.layoutWeight(1).width('100%')
外层 Tabs 是 4 个茶类频道的容器,使用 BarMode.Fixed 固定页签单排平铺。onChange 回调在外层翻页时触发——无论是用户直接点击页签,还是内层滚动到边缘后"接力"触发。回调中创建 SwipeLog 实例记录翻页信息,unshift 置顶到日志数组,数组超过 40 条时 pop 移除最旧的一条。
@Builder
innerTabs(channel: ChannelItem) {
Tabs({ barPosition: BarPosition.Start }) {
ForEach(INNER_TABS, (name: string) => {
TabContent() {
List({ space: 10 }) {
ForEach(innerMockData(channel, name), (item: InnerCard) => {
ListItem() {
Column({ space: 6 }) {
Row() {
Text(`${channel.icon} ${channel.name}·${name}`).fontSize(13)
.fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column().layoutWeight(1)
Text(item.tag).fontSize(10).fontColor(COLORS.gold)
}.width('100%')
Text(item.title).fontSize(12).fontColor(COLORS.sub).maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(item.desc).fontSize(11).fontColor(COLORS.text3).maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 8 }) {
Text(`${channel.name}频道`).fontSize(9).fontColor(COLORS.ink)
.padding({ left: 5, right: 5, top: 1, bottom: 1 }).borderRadius(4)
.backgroundColor(COLORS.chip)
Text(`${name}场景`).fontSize(9).fontColor(COLORS.bamboo)
.padding({ left: 5, right: 5, top: 1, bottom: 1 }).borderRadius(4)
.backgroundColor(COLORS.chip)
}.width('100%')
}.width('100%').padding(12).borderRadius(10).backgroundColor(COLORS.card)
}
}, (item: InnerCard) => item.id)
}.width('100%').height('100%').scrollBar(BarState.Off)
}.tabBar(name)
}, (name: string) => name)
}
.barMode(BarMode.Scrollable)
.onChange((index: number) => {
this.swipeLogs.unshift(new SwipeLog('内层场景', INNER_TABS[index],
this.innerIndex, index, modeLabel(this.nestedMode)));
this.innerIndex = index;
if (this.swipeLogs.length > 40) {
this.swipeLogs.pop();
}
})
.nestedScroll(this.nestedMode)
.layoutWeight(1).width('100%')
}
innerTabs() 是内层场景 Tabs 的 Builder,接收外层频道对象作为参数。内层 Tabs 使用 BarMode.Scrollable 可滚动页签,5 个场景页签各包含一个 8 条 InnerCard 的 List。每张卡片展示频道+场景标题、栏目标题、行业注解描述和频道/场景双徽标。onChange 回调在内层翻页时记录日志。最关键的一行是 .nestedScroll(this.nestedMode)——这是整个特性 C 的挂载点,将嵌套滚动模式绑定到内层 Tabs。当模式为 SELF_FIRST 时,内层 List 滑到底部边缘后继续上滑手势会"接力"触发外层 Tabs 翻页;当模式为 SELF_ONLY 时,内层只响应自身滚动,到边缘即停。
3.24 Tab2 日志 Builder:tabLog()
@Builder
tabLog() {
Column({ space: 12 }) {
Column({ space: 10 }) {
Row() {
Text('📖 翻页事件时间轴').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text(`共 ${this.swipeLogs.length} 条`).fontSize(10).fontColor(COLORS.text3)
}.width('100%')
Row({ space: 8 }) {
Row({ space: 4 }) {
Circle({ width: 6, height: 6 }).fill(COLORS.ink)
Text(`外层频道 ${this.outerCount()} 次`).fontSize(10).fontColor(COLORS.sub)
}.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.borderRadius(10).backgroundColor(COLORS.chip)
Row({ space: 4 }) {
Circle({ width: 6, height: 6 }).fill(COLORS.bamboo)
Text(`内层场景 ${this.innerCount()} 次`).fontSize(10).fontColor(COLORS.sub)
}.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.borderRadius(10).backgroundColor(COLORS.chip)
Column().layoutWeight(1)
Text('清空').fontSize(10).fontColor(COLORS.red)
.padding({ left: 12, right: 12, top: 5, bottom: 5 }).borderRadius(9)
.backgroundColor(COLORS.chip).onClick(() => { this.clearLogs(); })
}.width('100%')
}.width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.card)
日志 Tab 的统计卡展示总记录数、外层频道翻页次数和内层场景翻页次数。三个统计数字分别由 swipeLogs.length、outerCount() 和 innerCount() 实时计算。"清空"按钮使用警示红色文字,点击调用 clearLogs() 清空日志数组。
if (this.swipeLogs.length === 0) {
Column({ space: 8 }) {
Text('📭').fontSize(28)
Text('暂无翻页记录:去「频道」Tab 横滑内外层页签试试').fontSize(11).fontColor(COLORS.text3)
}.width('100%').padding({ top: 28, bottom: 28 }).borderRadius(12).backgroundColor(COLORS.card)
} else {
Column() {
ForEach(this.swipeLogs, (log: SwipeLog) => {
Row() {
Column({ space: 4 }) {
Text(log.layer === '外层频道' ? '外' : '内').fontSize(12).fontWeight(FontWeight.Bold)
.fontColor(COLORS.card)
.width(26).height(26).textAlign(TextAlign.Center).borderRadius(13)
.backgroundColor(log.layer === '外层频道' ? COLORS.ink : COLORS.bamboo)
Text(log.time).fontSize(8).fontColor(COLORS.text3)
}.width(52).height('100%').justifyContent(FlexAlign.Center)
Column({ space: 4 }) {
Circle({ width: 8, height: 8 }).fill(log.layer === '外层频道' ? COLORS.ink : COLORS.bamboo)
Column().layoutWeight(1).width(2).backgroundColor(COLORS.line)
}.width(20).height('100%').alignItems(HorizontalAlign.Center).padding({ top: 10 })
Column({ space: 6 }) {
Row({ space: 8 }) {
Text(log.tabName).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text(`${log.fromIdx} → ${log.toIdx}`).fontSize(9).fontColor(COLORS.sub).fontFamily('monospace')
}.width('100%')
Text(log.layer + ' · ' + log.mode).fontSize(9).fontColor(COLORS.text3)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
}.layoutWeight(1).height('100%').justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Start)
}.width('100%').height(72).margin({ bottom: 6 }).alignItems(VerticalAlign.Top)
}, (log: SwipeLog, idx: number) => `${log.time}_${log.tabName}_${idx}`)
}.width('100%')
}
}.width('100%')
}
时间轴列表的每行固定高度 72px,分三列:层级徽标列(“外”/"内"圆形徽标+时刻)、竖线列(圆点+填充行高的竖线)、内容列(翻到的页签名+索引变化+层级模式信息)。徽标颜色根据层级区分——外层松烟墨、内层竹青——形成双色时间轴的视觉识别系统。空列表时显示空状态卡,引导用户去频道 Tab 操作触发翻页。
3.25 Tab3 数据 Builder:tabData()
@Builder
tabData() {
Column({ space: 12 }) {
Column({ space: 8 }) {
Row() {
Text('今日上座率').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.title).layoutWeight(1)
Text('大厅 + 包间 · 实时').fontSize(10).fontColor(COLORS.text3)
}.width('100%')
Row({ space: 14 }) {
Canvas(this.ringCtx).width(168).height(168)
.onReady(() => { this.drawRing(); })
Column({ space: 8 }) {
Text('在店 118 人').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text('空余 46 席').fontSize(12).fontColor(COLORS.sub)
Text('申时 15~17 点上座最高,围炉席需提前半天预订;雨夜散台常有空位。')
.fontSize(10).fontColor(COLORS.text3)
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
}.width('100%').alignItems(VerticalAlign.Center)
}.width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
数据 Tab 的第一部分是上座率进度环卡片。Canvas 组件尺寸 168×168 像素,.onReady() 回调中调用 drawRing() 执行首次绘制。Canvas 右侧是文字信息列:在店人数、空余席数和经营说明文案。Canvas 与文字列通过 Row 水平排列,alignItems(VerticalAlign.Center) 垂直居中。
Column({ space: 8 }) {
Row() {
Text('近 12 日茶饮销量').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.title).layoutWeight(1)
Text('9/24 ~ 10/05 · 杯').fontSize(10).fontColor(COLORS.text3)
}.width('100%')
Canvas(this.lineCtx).width('100%').height(180)
.onReady(() => { this.drawLine(); })
}.width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
第二部分是近 12 日销量折线图卡片。Canvas 宽度 100%、高度 180 像素,.onReady() 调用 drawLine() 首次绘制。呼吸定时器每秒重绘时,进度弧弧长和数据点半径会波动,形成"活"的数据可视化效果。
Column({ space: 10 }) {
Row() {
Text('🚪 包间状态').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column().layoutWeight(1)
Text(`${ROOM_ROWS.length} 间`).fontSize(10).fontColor(COLORS.text3)
}.width('100%')
ForEach(ROOM_ROWS, (room: RoomRow, idx: number) => {
Row({ space: 10 }) {
Column({ space: 3 }) {
Text(room.name).fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text(`${room.seats} 人间`).fontSize(9).fontColor(COLORS.text3)
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text(room.state).fontSize(10).fontColor(roomStateColor(room.state))
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(8).backgroundColor(COLORS.chip)
if (room.state === '空闲') {
Text('拼桌').fontSize(10).fontColor(COLORS.card)
.padding({ left: 10, right: 10, top: 4, bottom: 4 }).borderRadius(9)
.backgroundColor(COLORS.bamboo)
.onClick(() => { this.openAdd(room.name); })
}
}.width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.card)
}, (room: RoomRow, idx: number) => `room-${idx}-${room.name}`)
}.width('100%')
}.width('100%')
}
第三部分是包间状态清单。6 间包间以列表形式展示,每行包含包间名、容纳人数、状态标签(颜色由 roomStateColor() 映射)。空闲状态的包间额外显示"拼桌"按钮,点击调用 openAdd(room.name) 打开拼桌弹窗并预填包间名——这一设计将数据 Tab 与雅集 Tab 的拼桌功能打通。
3.26 Tab4 字幕 Builder:tabCaption()
@Builder
tabCaption() {
Column({ space: 12 }) {
// ① AICaptionComponent 实时预览卡
Column({ space: 10 }) {
Row() {
Text('🗣 AI 字幕实时预览').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text(this.captionReady ? '已就绪' : '初始化中').fontSize(10)
.fontColor(this.captionReady ? COLORS.bamboo : COLORS.text3)
}.width('100%')
AICaptionComponent({
isShown: this.captionShown,
controller: this.captionController,
options: this.buildCaptionOptions()
})
.width('100%').height(110).borderRadius(10)
if (this.captionErrMsg !== '') {
Text(this.captionErrMsg).fontSize(10).fontColor(COLORS.red).width('100%')
.maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
}
Row({ space: 10 }) {
Button(this.captionShown ? '隐藏字幕' : '开启字幕')
.fontSize(12).height(32).layoutWeight(1)
.backgroundColor(COLORS.ink).fontColor(COLORS.card).borderRadius(10)
.onClick(() => { this.captionShown = !this.captionShown; })
Button(`写入演示音频(${this.captionFed})`)
.fontSize(12).height(32).layoutWeight(1)
.backgroundColor(COLORS.chip).fontColor(COLORS.sub).borderRadius(10)
.onClick(() => { this.feedAudioStream(); })
}.width('100%')
}.width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.card)
字幕 Tab 的第一区块是 AI 字幕实时预览卡。AICaptionComponent 接收三个参数:isShown(显示状态,@Link 双向绑定到 captionShown)、controller(字幕控制器实例)和 options(通过 buildCaptionOptions() 实时组装的配置对象)。两个按钮分别控制字幕显隐和写入演示音频。captionFed 计数器实时显示已写入的音频块数,让用户感知到音频正在被送入字幕引擎。
// ② 语言设置卡
Column({ space: 10 }) {
Row() {
Text('🌐 语言设置(联动)').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('sourceLanguage → targetLanguage').fontSize(8).fontColor(COLORS.text3)
}.width('100%')
Row({ space: 8 }) {
Text('源语言').fontSize(11).fontColor(COLORS.text3).width(48)
ForEach(SRC_LANGS, (opt: LangOption) => {
Text(opt.name).fontSize(11).fontWeight(FontWeight.Bold)
.fontColor(this.srcLang === opt.code ? COLORS.card : COLORS.sub)
.padding({ left: 12, right: 12, top: 5, bottom: 5 }).borderRadius(14)
.backgroundColor(this.srcLang === opt.code ? COLORS.ink : COLORS.chip)
.onClick(() => { this.switchSourceLang(opt.code); })
}, (opt: LangOption) => `src-${opt.code}`)
}.width('100%')
第二区块是语言设置卡。源语言选择器展示中文/英文两个选项,点击调用 switchSourceLang() 联动目标语言。当源语言为中文时,目标语言区域展示"中文(锁定)“和说明文案"中文源仅支持目标 zh”;当源语言为英文时,展示三个可选目标语言 chips(中文/英文/中英双语)。
// ③ 外观设置卡
Column({ space: 12 }) {
Row() {
Text('🎨 外观设置').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('fontSize · fontColor').fontSize(9).fontColor(COLORS.text3)
}.width('100%')
Row({ space: 8 }) {
ForEach(SIZE_OPTIONS, (opt: SizeOption) => {
Column({ space: 4 }) {
Text(opt.size === AICaptionFontSize.LARGE ? '大A' : ...)
.fontSize(...)
.fontColor(this.captionSize === opt.size ? COLORS.ink : COLORS.sub)
Text(opt.name).fontSize(9)
.fontColor(this.captionSize === opt.size ? COLORS.ink : COLORS.text3)
}.layoutWeight(1).padding({ top: 8, bottom: 8 }).borderRadius(10)
.backgroundColor(this.captionSize === opt.size ? COLORS.chip : COLORS.card)
.border({
width: this.captionSize === opt.size ? 1 : 0,
color: this.captionSize === opt.size ? COLORS.ink : COLORS.line
})
.onClick(() => { this.captionSize = opt.size; })
}, (opt: SizeOption) => opt.name)
}.width('100%')
Divider().strokeWidth(1).color(COLORS.line)
Row({ space: 10 }) {
ForEach(CAPTION_FONT_COLORS, (c: string, idx: number) => {
Column({ space: 5 }) {
Column().width(30).height(30).borderRadius(15).backgroundColor(c)
.border({
width: this.captionColor === c ? 2 : 1,
color: this.captionColor === c ? COLORS.ink : COLORS.line
})
Text(this.captionColor === c ? '使用中' : `#${idx + 1}`).fontSize(8)
.fontColor(this.captionColor === c ? COLORS.ink : COLORS.text3)
}.layoutWeight(1).onClick(() => { this.captionColor = c; })
}, (c: string, idx: number) => `color-${idx}-${c}`)
}.width('100%')
}.width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.card)
第三区块是外观设置卡。字号四档选择器使用 ForEach 渲染 SIZE_OPTIONS,每个档位展示不同字号大小的"A"字预览和档位名称。选中态有 COLORS.chip 背景+COLORS.ink 边框,非选中态无背景无边框。颜色五色块选择器渲染 CAPTION_FONT_COLORS 的 5 个预设色,每个色块为 30×30 圆形,选中态有加粗边框并显示"使用中"文案。
// ④ options 代码预览卡
Column({ space: 8 }) {
Row() {
Text('🧾 options 代码预览').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('buildCaptionOptions()').fontSize(9).fontColor(COLORS.text3)
}.width('100%')
Column({ space: 4 }) {
Text('AICaptionOptions {').fontSize(10).fontColor(COLORS.card).fontFamily('monospace')
Text(' initialOpacity: 1,').fontSize(10).fontColor(COLORS.chip).fontFamily('monospace')
Text(` sourceLanguage: '${this.srcLang}', // ★ 6.1.1 新增`)
.fontSize(10).fontColor(COLORS.gold).fontFamily('monospace')
Text(` targetLanguage: '${this.tgtLang}', // ★ 6.1.1 新增`)
.fontSize(10).fontColor(COLORS.gold).fontFamily('monospace')
Text(` fontSize: AICaptionFontSize.${this.captionSizeName()}, // ★ 6.1.1 新增`)
.fontSize(10).fontColor(COLORS.gold).fontFamily('monospace')
Text(` fontColor: '${this.captionColor}', // ★ 6.1.1 新增`)
.fontSize(10).fontColor(COLORS.gold).fontFamily('monospace')
// ...
}.width('100%').padding(10).borderRadius(10).backgroundColor(COLORS.ink)
}.width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.card)
第四区块是 options 代码预览卡,以松烟墨色背景+等宽字体实时镜像 buildCaptionOptions() 的组装结果。四个新增字段用强调金色高亮并标注"★ 6.1.1 新增"注释,让开发者一目了然地看到配置结构。代码预览的值会随用户的语言/字号/颜色选择实时更新,形成"所见即所得"的配置体验。
// ⑤ 字幕场景列表
Column({ space: 8 }) {
Row() {
Text('💡 茶境字幕场景').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('点击应用').fontSize(9).fontColor(COLORS.text3)
}.width('100%')
ForEach(this.sceneList, (sc: CaptionScene, idx: number) => {
Row({ space: 10 }) {
Text('🎙️').fontSize(14)
Column({ space: 3 }) {
Text(sc.scene).fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text(sc.desc).fontSize(10).fontColor(COLORS.text3)
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text(langName(sc.src) + '→' + langName(sc.tgt)).fontSize(9)
.fontColor(this.srcLang === sc.src && this.tgtLang === sc.tgt ? COLORS.gold : COLORS.text3)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(10).backgroundColor(COLORS.chip)
}.width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.card)
.onClick(() => { this.applyScene(sc); })
}, (sc: CaptionScene, idx: number) => `scene-${idx}-${sc.scene}`)
}.width('100%')
}.width('100%')
}
第五区块是字幕场景推荐列表。5 条场景卡展示场景名、说明文案和推荐语言组合。当前语言组合匹配的场景会高亮显示推荐语言标签(强调金色)。点击场景卡调用 applyScene() 一键应用推荐的语言组合,省去用户手动选择源语言和目标语言的步骤。
3.27 Tab5 雅集 Builder:tabEvent()
@Builder
tabEvent() {
Column({ space: 12 }) {
Row({ space: 10 }) {
Column({ space: 3 }) {
Text('雅集与拼桌').fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text('拼桌确认后将以新活动置顶到下方列表').fontSize(9).fontColor(COLORS.text3)
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Button('发起拼桌').height(34).fontSize(12).borderRadius(10)
.fontColor(COLORS.card).backgroundColor(COLORS.ink)
.onClick(() => { this.openAdd(''); })
}.width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.card)
雅集 Tab 的第一部分是发起拼桌入口卡。标题+说明文案在左,"发起拼桌"按钮在右,点击调用 openAdd('') 打开拼桌弹窗(空包间名预填)。
Column({ space: 8 }) {
Row() {
Text('🏮 雅集活动').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column().layoutWeight(1)
Text(`${this.eventList.length} 场`).fontSize(10).fontColor(COLORS.text3)
}.width('100%')
ForEach(this.eventList, (ev: EventItem, idx: number) => {
Column({ space: 8 }) {
Row({ space: 8 }) {
Text(ev.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
.layoutWeight(1).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text(ev.state).fontSize(9).fontColor(eventStateColor(ev.state))
.padding({ left: 7, right: 7, top: 2, bottom: 2 })
.borderRadius(7).backgroundColor(COLORS.chip)
}.width('100%')
Row({ space: 8 }) {
Text(`🕐 ${ev.time}`).fontSize(10).fontColor(COLORS.sub)
Text(`👥 ${ev.quota}`).fontSize(10).fontColor(COLORS.sub)
Column().layoutWeight(1)
Text('改时间').fontSize(9).fontColor(COLORS.blue)
.padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(8)
.backgroundColor(COLORS.chip)
.onClick(() => { this.openEdit(ev); })
Text('取消报名').fontSize(9).fontColor(COLORS.red)
.padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(8)
.backgroundColor(COLORS.chip)
.onClick(() => { this.openDel(ev); })
}.width('100%')
}.width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.card)
}, (ev: EventItem, idx: number) => `ev-${idx}-${ev.name}-${ev.state}`)
}.width('100%')
第二部分是雅集活动列表。每张活动卡展示活动名(省略号截断)、状态标签(颜色由 eventStateColor() 映射)、时间、名额和两个操作按钮——“改时间”(蓝色,调用 openEdit())和"取消报名"(红色,调用 openDel())。这两个操作直接关联弹窗系统的弹窗二和弹窗三。
Column({ space: 8 }) {
Row() {
Text('🗺️ 茶室平面').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column().layoutWeight(1)
Text('空闲竹青 / 使用中墨 / 已预订金').fontSize(9).fontColor(COLORS.text3)
}.width('100%')
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(ROOM_ROWS, (room: RoomRow, idx: number) => {
Column({ space: 6 }) {
Text(room.name).fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text(`${room.seats} 人间`).fontSize(9).fontColor(COLORS.sub)
Text(room.state).fontSize(9).fontColor(COLORS.card)
.padding({ left: 8, right: 8, top: 2, bottom: 2 }).borderRadius(7)
.backgroundColor(roomStateColor(room.state))
}.width('32%').padding({ top: 12, bottom: 12 }).borderRadius(12)
.backgroundColor(COLORS.card)
.border({ width: 1, color: roomStateColor(room.state) })
.margin({ bottom: 10 })
}, (room: RoomRow, idx: number) => `plan-${idx}-${room.name}`)
}.width('100%')
}.width('100%')
}.width('100%')
}
第三部分是茶室平面状态网格。6 间包间以 3 列网格排列(每项宽度 32%,Flex 换行布局),每项的边框颜色由 roomStateColor() 映射——空闲=竹青、使用中=松烟墨、已预订=强调金——形成视觉化的平面状态图。这一设计让用户在雅集 Tab 中也能直观看到包间状态,与数据 Tab 的包间清单形成互补。
3.28 Tab6 我的 Builder:tabMine()
@Builder
tabMine() {
Column({ space: 12 }) {
Column({ space: 12 }) {
Row({ space: 10 }) {
Text('🍵').fontSize(30)
Column({ space: 3 }) {
Text('茶境常客 · 松烟会员').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.card)
Text('入驻 365 天 · 新中式茶饮空间年卡').fontSize(10).fontColor(COLORS.chip)
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Circle({ width: 8, height: 8 }).fill(COLORS.gold).opacity(this.breath ? 1 : 0.3)
}.width('100%')
Row() {
Column({ space: 3 }) {
Text('42').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.gold)
Text('品鉴茶款').fontSize(9).fontColor(COLORS.chip)
}.layoutWeight(1)
// ... 4 个指标列
}.width('100%')
}.width('100%').padding(16).borderRadius(16)
.linearGradient({
angle: 160,
colors: [[COLORS.inkD, 0], [COLORS.ink, 0.55], [COLORS.card, 1]]
})
我的 Tab 的第一部分是茶友渐变大卡。整卡使用三段线性渐变背景(深墨绿→标准墨绿→纯白),从深到浅过渡,营造从茶汤到宣纸的渐变意象。卡内展示用户等级(松烟会员)、入驻天数和四指标(品鉴茶款 42、雅集参与 18、茶友徽章 9、茶仓存量 6)。右上角金色圆点随呼吸脉动。
Column({ space: 8 }) {
Row() {
Text('🏺 我的茶仓').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column().layoutWeight(1)
Text(`${CELLAR_ROWS.length} 件`).fontSize(10).fontColor(COLORS.text3)
}.width('100%')
ForEach(CELLAR_ROWS, (row: CellarRow, idx: number) => {
Row({ space: 10 }) {
Text(row.type).fontSize(10).fontWeight(FontWeight.Bold).fontColor(COLORS.bamboo)
.width(22).height(22).textAlign(TextAlign.Center).borderRadius(11)
.backgroundColor(COLORS.chip)
Column({ space: 3 }) {
Text(row.name).fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text(row.detail).fontSize(9).fontColor(COLORS.text3)
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text('去选购').fontSize(9).fontColor(COLORS.blue)
.padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(8)
.backgroundColor(COLORS.chip)
.onClick(() => { this.currentTab = 0; })
}.width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.card)
}, (row: CellarRow, idx: number) => `cellar-${idx}-${row.name}`)
}.width('100%')
第二部分是茶仓清单。每行展示分类圆徽(绿/乌/白/普/红)、茶品名、规格存量说明和"去选购"按钮。点击"去选购"直接将 currentTab 设为 0,跳转到茶单 Tab——实现 Tab 间的跨页跳转。
Column({ space: 10 }) {
Row() {
Text('📊 月度消费').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column().layoutWeight(1)
Text('近 6 个月 · 元').fontSize(10).fontColor(COLORS.text3)
}.width('100%')
Row({ space: 10 }) {
ForEach(MONTH_DATA, (val: number, idx: number) => {
Column({ space: 5 }) {
Column().width('100%').height(this.barHeight(idx))
.borderRadius(5).backgroundColor(idx % 2 === 0 ? COLORS.ink : COLORS.bamboo)
Text(MONTH_NAME[idx]).fontSize(9).fontColor(COLORS.text3)
}.layoutWeight(1)
}, (val: number, idx: number) => `bar-${idx}`)
}.width('100%').alignItems(VerticalAlign.Bottom).height(130)
Text('柱高随呼吸动画微幅波动(barHeight 内 ±6%),满刻度 800 元')
.fontSize(9).fontColor(COLORS.text3).width('100%')
}.width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.card)
}.width('100%')
}
第三部分是月度消费柱状图。使用 Column + ForEach 的传统方式绘制——每个柱子是一个 Column 组件,高度由 barHeight(idx) 方法计算。barHeight() 方法内根据 breath 状态对奇偶柱交替施加 ±6% 的波动系数,形成柱高随呼吸微幅波动的效果。柱子颜色奇偶交替(松烟墨/竹青),满刻度 800 元。
3.29 弹窗 Builder:modalOverlay / panelAdd / panelEdit / panelDel
@Builder
modalOverlay(onClose: () => void) {
Column().width('100%').height('100%').backgroundColor(COLORS.mask)
.onClick(() => { onClose(); })
}
全局遮罩层是一个全屏半透明黑色 Column(rgba(0,0,0,0.5)),点击触发 onClose 回调关闭弹窗。所有弹窗都复用这个 Builder 作为底层遮罩。
@Builder
panelAdd(onClose: () => void) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(onClose)
Column({ space: 12 }) {
Text('发起拼桌').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title).width('100%')
Text('确认后以「拼桌·包间名」置顶到雅集列表,状态为报名中')
.fontSize(10).fontColor(COLORS.text3).width('100%')
TextInput({ text: this.formRoom, placeholder: '意向包间,如:听雨轩' })
.height(38).fontSize(12).fontColor(COLORS.title)
.placeholderColor(COLORS.text3).backgroundColor(COLORS.chip).borderRadius(8)
.onChange((value: string) => { this.formRoom = value; })
TextInput({ text: this.formSeats, placeholder: '拼桌人数,如:3' })
.height(38).fontSize(12).fontColor(COLORS.title)
.placeholderColor(COLORS.text3).backgroundColor(COLORS.chip).borderRadius(8)
.type(InputType.Number)
.onChange((value: string) => { this.formSeats = value; })
TextInput({ text: this.formTime, placeholder: '到店时段,如:19:30' })
.height(38).fontSize(12).fontColor(COLORS.title)
.placeholderColor(COLORS.text3).backgroundColor(COLORS.chip).borderRadius(8)
.onChange((value: string) => { this.formTime = value; })
Row({ space: 10 }) {
Button('取消').height(38).fontSize(12).borderRadius(10)
.fontColor(COLORS.sub).backgroundColor(COLORS.chip).layoutWeight(1)
.onClick(() => { onClose(); })
Button('发起').height(38).fontSize(12).borderRadius(10)
.fontColor(COLORS.card).backgroundColor(COLORS.ink).layoutWeight(1)
.onClick(() => { this.confirmAdd(); })
}.width('100%')
}.width('86%').padding(18).borderRadius(14).backgroundColor(COLORS.card)
}.width('100%').height('100%')
}
拼桌弹窗使用 Stack 叠加遮罩层和表单卡片。表单包含三个 TextInput(意向包间、拼桌人数、到店时段),人数输入框设置 InputType.Number 限制数字输入。底部"取消"和"发起"按钮分别调用 onClose() 和 confirmAdd()。
@Builder
panelEdit(onClose: () => void) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(onClose)
Column({ space: 12 }) {
Text('修改雅集时间').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title).width('100%')
Text(this.editTargetLabel()).fontSize(10).fontColor(COLORS.sub).width('100%')
TextInput({ text: this.formTime, placeholder: '新时间,如:10/12 15:00' })
.height(38).fontSize(12).fontColor(COLORS.title)
.placeholderColor(COLORS.text3).backgroundColor(COLORS.chip).borderRadius(8)
.onChange((value: string) => { this.formTime = value; })
Row({ space: 10 }) {
Button('取消').height(38).fontSize(12).borderRadius(10)
.fontColor(COLORS.sub).backgroundColor(COLORS.chip).layoutWeight(1)
.onClick(() => { onClose(); })
Button('保存').height(38).fontSize(12).borderRadius(10)
.fontColor(COLORS.card).backgroundColor(COLORS.bamboo).layoutWeight(1)
.onClick(() => { this.saveEdit(); })
}.width('100%')
}.width('86%').padding(18).borderRadius(14).backgroundColor(COLORS.card)
}.width('100%').height('100%')
}
修改时间弹窗结构类似,但只有一个时间输入框。副标题通过 editTargetLabel() 展示目标活动名和名额。"保存"按钮使用竹青色背景,调用 saveEdit() 执行整对象替换。
@Builder
panelDel(onClose: () => void) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(onClose)
Column({ space: 12 }) {
Text('取消报名').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.title).width('100%')
Text(`确认取消「${this.delTargetLabel()}」的报名?取消后名额将立即释放给候补茶友,本场不可再恢复。`)
.fontSize(11).fontColor(COLORS.sub).width('100%')
Row({ space: 10 }) {
Button('再想想').height(38).fontSize(12).borderRadius(10)
.fontColor(COLORS.sub).backgroundColor(COLORS.chip).layoutWeight(1)
.onClick(() => { onClose(); })
Button('确认取消').height(38).fontSize(12).borderRadius(10)
.fontColor(COLORS.card).backgroundColor(COLORS.red).layoutWeight(1)
.onClick(() => { this.confirmDel(); })
}.width('100%')
}.width('86%').padding(18).borderRadius(14).backgroundColor(COLORS.card)
}.width('100%').height('100%')
}
}
取消报名弹窗无输入框,仅有确认文案和两个按钮。"再想想"按钮(取消操作)使用灰色,“确认取消"按钮使用警示红色,调用 confirmDel() 执行列表移除。弹窗文案明确告知"名额将立即释放给候补茶友,本场不可再恢复”,引导用户审慎操作。
四、技术对比表
| 对比维度 | 特性 A:Speech Kit AI 字幕 | 特性 B:Canvas 双图绘制 | 特性 C:Tabs 嵌套滚动 |
|---|---|---|---|
| API 版本 | API 24(HarmonyOS 6.1.1) | ArkUI 基础能力(全版本) | API 24(HarmonyOS 6.1.1) |
| 技术套件 | @kit.SpeechKit | ArkUI Canvas 组件 | ArkUI Tabs 组件 |
| 核心类型 | AICaptionComponent / AICaptionController / AICaptionOptions | CanvasRenderingContext2D / RenderingContextSettings | Tabs / TabContent / TabsNestedScrollMode |
| 关键新增字段 | sourceLanguage / targetLanguage / fontSize / fontColor | — | .nestedScroll(TabsNestedScrollMode) |
| 状态管理 | @State captionShown (@Link 双向绑定) + 6 个 @State 配置变量 | 2 个 private CanvasRenderingContext2D + @State breath 联动 | @State nestedMode + outerIndex + innerIndex + swipeLogs |
| 交互模式 | 语言选择 chips + 字号色块选择 + 场景一键应用 + 音频写入按钮 | onReady 首次绘制 + setInterval 定时重绘 | 双层 Tabs 嵌套 + 模式切换 chips + 翻页日志记录 |
| 数据流向 | 状态变量 → buildCaptionOptions() → AICaptionComponent | breath 翻转 → drawRing()/drawLine() 直接调 Canvas API | 用户翻页 → onChange 回调 → SwipeLog unshift → 日志 Tab 展示 |
| 动画机制 | 无独立动画(配置变化即时生效) | 呼吸定时器 1s 联动弧长/点半径/柱高波动 | 滚动手势接力分发(SELF_FIRST 模式) |
| 视觉效果 | 字幕组件显隐 + 语言/字号/颜色配置 | 进度弧呼吸脉动 + 数据点半径脉动 + 柱高波动 | 内层滑到边缘后联动外层翻页 |
| 错误处理 | onError 回调 → captionErrMsg 展示 | Canvas 未就绪时 w<=0 直接 return | 日志封顶 40 条 pop 淘汰 |
| Tab 归属 | Tab4 字幕 | Tab3 数据 + Tab6 我的(柱状图) | Tab1 频道 + Tab2 日志(记录) |
| 数据模型 | CaptionScene (@Observed) | LinePoint / RING_RATE 常量 | InnerCard / SwipeLog (@Observed) |
| 设计约束 | 中文源目标语言锁定 ‘zh’ | 零闪烁重绘(方案 B) | 频道 Tab 独占内容区不进 Scroll |
五、深度总结
5.1 三特性叠加的架构价值
本应用最核心的技术价值在于"三特性同文件叠加"——Speech Kit AI 字幕、Canvas 自定义绘制和 Tabs 嵌套滚动三大 HarmonyOS 6.1.1 新特性在同一个 @Entry @Component 结构体内共存共演。这种叠加不是简单的功能堆砌,而是通过"呼吸动画"这一统一的节奏源实现了三特性的有机联动:呼吸定时器每秒翻转 breath 布尔值,同时驱动 Canvas 进度弧的弧长波动、折线数据点半径脉动和柱状图柱高微幅波动——一个状态变量串联了两个 Canvas 绘制方法和一个柱高计算方法,形成了高度内聚的动画体系。
5.2 颜色系统的工程化设计
颜色系统的"接口定义+常量实例化"模式是本应用工程化程度的典型体现。16 个语义化颜色槽位覆盖了背景、卡片、胶囊、文字三级(主/次/弱)、主题墨绿及深色变体、竹青强调色、金色价位色、红色警示色、蓝色辅助色、分割线、Tab 激活色和遮罩色——全部通过 COLORS.xxx 引用,零硬编码色值。这种设计不仅保证了视觉一致性,更重要的是在主题切换场景下只需修改一处 COLORS 常量即可全局生效,极大降低了维护成本。
5.3 状态管理的分层策略
应用的状态管理采用了清晰的分层策略:通用状态(currentTab/breath/timer)在顶层统一管理;特性 A 状态集中在"Speech Kit AI 字幕"注释块下,包含控制器实例和 6 个配置变量;特性 C 状态集中在"Tabs 嵌套滚动"注释块下,包含模式、索引和日志数组;雅集业务状态和弹窗系统状态各自独立。这种分层使得每个功能模块的状态边界清晰,新增或修改功能时不会产生状态耦合。
5.4 弹窗系统的回调驱动设计
三个弹窗(拼桌/改时间/取消报名)全部采用"回调驱动"设计:每个弹窗 Builder 接收一个 onClose: () => void 回调参数,弹窗内部的取消按钮和遮罩点击统一调用该回调。这种设计使得弹窗的关闭逻辑由调用方决定(通常是将对应的 @State 布尔值置 false),而弹窗自身只需关注表单交互逻辑。全局遮罩 modalOverlay 作为公共 Builder 被三个弹窗复用,保证了遮罩视觉的一致性。
5.5 嵌套滚动的行为验证机制
特性 C 的设计不仅实现了嵌套滚动的功能,更重要的是构建了一套完整的行为验证机制:每次内外层翻页都会创建 SwipeLog 实例记录层级、页签名、索引变化和事发时嵌套模式,unshift 置顶到日志数组(封顶 40 条)。这些日志在 Tab2 以双色时间轴形式展示,内外层分别用松烟墨和竹青圆徽标识,让开发者可以直观验证 SELF_FIRST 模式下"内层滑到边缘→外层翻页"的接力行为是否正确触发。这种"功能+验证"一体化的设计思路在技术探索阶段尤为重要。
5.6 AI 字幕配置的约束映射
特性 A 对 Speech Kit 的能力约束进行了精确的映射:中文源时目标语言锁定 'zh'(选其他值会导致初始化失败),英文源时支持中文、英文和中英双语三种目标语言。这种约束通过 switchSourceLang() 方法的联动逻辑和 UI 层的条件渲染(中文源时展示"锁定"文案,英文源时展示可选 chips)双重保障,确保用户不会配置出无效的语言组合。buildCaptionOptions() 方法在每次 AICaptionComponent 渲染时被调用,将状态变量实时组装为配置对象,实现了配置变化的即时生效。
5.7 Canvas 无闪烁重绘的工程实践
特性 B 的 Canvas 绘制采用了"方案 B 无闪烁重绘"策略:在 aboutToAppear 中启动定时器,直接调用 drawRing() 和 drawLine() 重绘,而非销毁重建 Canvas 组件。drawRing() 方法开头设置 ctx.antialias = true 是因为切 Tab 后 Canvas 重建可能丢失抗锯齿状态,需要同步恢复。drawLine() 在绘制前检查 w <= 0 || h <= 0 提前返回,避免了 Canvas 未就绪时的无效绘制。这些工程细节体现了对 Canvas 生命周期的深入理解。
5.8 数据模型的 @Observed 策略
四个数据模型类(InnerCard/SwipeLog/CaptionScene/EventItem)全部使用 @Observed 修饰。@Observed 的核心价值在于:当被修饰的类实例参与 ForEach 渲染时,数组操作(unshift/splice/pop)能够触发列表的增删动画和重新渲染。雅集活动列表的 confirmAdd() 使用 unshift 置顶新活动,saveEdit() 使用 splice 整对象替换编辑目标,confirmDel() 使用 splice 移除取消目标——这些操作的 UI 刷新效果都依赖于 @Observed 的观察机制。
5.9 茶文化语义化数据的深度
应用的数据设计全部采用真实茶文化语义:茶品覆盖龙井、玉露、正山小种、滇红、凤凰单丛、大红袍、白牡丹、熟普等经典品种;产地精确到产区(杭州狮峰、武夷桐木关、潮州乌岽、福建福鼎、云南勐海);雅集活动包含径山茶会、宋代点茶、岩茶品鉴、围炉煮茶、少儿茶礼等真实业态;字幕场景融入茶艺师冲泡讲解、英文茶文化沙龙、日语茶道课等跨文化场景。这种语义化深度不仅提升了应用的沉浸感和可信度,也使得技术演示不再是干瘪的"测试数据",而是具有业务生命力的完整产品原型。
5.10 零 import 全局枚举的工程约束
文件头部注释明确声明"两行 import(本文件唯一允许的 import)",所有 ArkUI 全局枚举(TabsNestedScrollMode、BarMode、BarPosition、EdgeEffect、ScrollDirection、FlexWrap、FlexAlign、FontWeight、TextOverflow、InputType、Alignment、VerticalAlign、HorizontalAlign 等)均无需 import 直接使用。这种约束保证了文件的自包含性,也体现了 ArkUI 框架全局枚举的设计哲学——基础枚举是框架内置的,开发者不应为使用它们付出 import 成本。
5.11 总结与展望
本应用以"茶境·新中式茶饮空间"为业务载体,以"浅色·月白宣+松烟墨+竹青"为视觉语言,以"三特性同文件叠加"为技术焦点,完整展现了 HarmonyOS 6.1.1 在 AI 语音能力集成、自定义图形绘制和嵌套滚动交互三个方向上的最新能力。7 个 Tab 页面各具特色的布局设计、3 个弹窗的回调驱动架构、呼吸动画的三特性联动机制、@Observed 数据模型的数组操作刷新策略以及颜色系统的工程化设计,共同构成了一个兼具技术深度和业务厚度的 HarmonyOS 应用原型。
未来在真实业务落地时,Mock 数据可替换为网络请求获取的真实数据,呼吸定时器可改为 WebSocket 长连接驱动的实时数据推送,AI 字幕的演示音频可替换为麦克风实时采集的 PCM 流,Canvas 绘制可引入更丰富的图表类型(如热力图、雷达图),嵌套滚动可扩展为三层以上的多级嵌套。这些扩展方向都建立在当前代码已有的架构基础之上,体现了代码设计的良好扩展性。
从行业视角来看,新中式茶饮空间是"国潮+数字化"的典型场景。HarmonyOS 的三特性叠加为这一场景提供了端到端的技术支撑:AI 字幕赋能跨语言茶文化交流,Canvas 数据可视化赋能门店经营分析,Tabs 嵌套滚动赋能多维度内容分发。这种"技术赋能业务"的路径正是鸿蒙生态在垂直行业落地的缩影,也为其他垂直场景的 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 将自动执行以下操作:
- 生成项目骨架(Stage 模型目录结构)
- 执行
ohpm install安装依赖 - 运行 Hvigor 构建初始化(
Build Init)
构建日志中显示 “退出代码为 0” 表示项目初始化成功。

1.5 项目结构概览
创建完成后,左侧项目面板展示的是标准的 Stage 模型 目录结构:
rollboat/
├── .hvigor/ # Hvigor 构建工具缓存
├── .idea/ # IDE 配置文件
├── AppScope/ # 应用级全局配置
│ └── app.json5
├── entry/ # 主模块(入口模块)
│ ├── src/main/ets/
│ │ ├── entryability/ # Ability 生命周期管理
│ │ │ └── EntryAbility.ets
│ │ └── pages/ # UI 页面
│ │ └── Index.ets # 首页(默认 Hello World)
│ ├── src/main/resources/ # 资源文件
│ ├── module.json5 # 模块配置
│ └── build-profile.json5 # 构建配置
├── oh_modules/ # OHPM 依赖包
├── build-profile.json5 # 工程构建配置
├── hvigorfile.ts # Hvigor 构建脚本
└── oh-package.json5 # 包管理配置
核心文件 Index.ets 的默认代码如下,采用 ArkTS 声明式 UI 语法:
@Entry
@Component
struct Index {
@State message: string = 'Hello World';
build() {
RelativeContainer() {
Text(this.message)
.id('HelloWorld')
.fontSize($r('app.float.page_text_font_size'))
.fontWeight(FontWeight.Bold)
.alignRules({
center: { anchor: '__container__', align: VerticalAlign.Center },
middle: { anchor: '__container__', align: HorizontalAlign.Center }
})
.onClick(() => {
this.message = 'Welcome';
})
}
.height('100%')
.width('100%')
}
}
| 关键语法 | 作用 |
|---|---|
@Entry | 标记为页面入口,可用于路由跳转 |
@Component | 声明为自定义组件 |
@State | 状态变量,数据变更时自动触发 UI 刷新 |
RelativeContainer | 相对布局容器,替代传统线性布局 |
.onClick() | 点击事件,此处点击后文本变为 “Welcome” |
打开右侧 Previewer(预览器),选择 Phone 设备,即可实时预览 Hello World 效果,无需连接真机或启动模拟器。

二、查看 SDK 版本
2.1 查看 HarmonyOS SDK
DevEco Studio 安装时已内置 HarmonyOS SDK,无需单独下载。通过以下路径查看:
文件 → 设置 → HarmonyOS SDK(或快捷键
Ctrl + Alt + S搜索 “HarmonyOS SDK”)
在设置面板中,可以看到当前已安装的 SDK 版本信息:
| 名称 | 阶段 | 状态 |
|---|---|---|
| HarmonyOS 6.1.1 | Release | ✅ 已安装 |
界面顶部提示:“HarmonyOS SDK 已经包含在 IDE,无需单独安装”,省去了手动配置 SDK 的繁琐步骤。

2.2 查看 ArkUI-X SDK(跨平台扩展)
如果项目需要将 ArkUI 框架扩展到多个 OS 平台(Android / iOS / OpenHarmony),还需要配置 ArkUI-X SDK。路径如下:
文件 → 设置 → 语言和框架 → ArkUI-X
在这里可以查看已安装和可选的 ArkUI-X SDK 版本:
| 版本 | SDK 版本号 | 阶段 | 状态 |
|---|---|---|---|
| API Version 24 | 6.1.1.100 | Release | ✅ 已安装 |
| API Version 23 | 6.1.0.28 | Beta1 | 未安装 |
| API Version 22 | 6.0.2.112 | Release | 未安装 |
安装路径示例:D:\DevTools\ArkUI-X\sdk
说明:ArkUI-X 允许开发者使用一套 ArkTS 主代码,同时构建多平台应用。如果仅开发 HarmonyOS 原生应用,无需额外安装 ArkUI-X SDK。

三、小结
| 步骤 | 操作 | 关键点 |
|---|---|---|
| 创建项目 | 欢迎页 → 新建项目 → 选择 Empty Ability 模板 → 配置项目信息 → 完成 | 使用 Stage 模型 + ArkTS 语言 |
| 查看 SDK | 设置 → HarmonyOS SDK | SDK 已内置,无需手动安装 |
| 跨平台扩展 | 设置 → ArkUI-X | 根据需要安装对应 API 版本 |
至此,DevEco Studio 的项目创建与 SDK 环境确认全部完成,可以开始 HarmonyOS 应用的功能开发。
本文基于 DevEco Studio 6.1.1 Release 版本编写,不同版本界面可能存在细微差异。
更多推荐


所有评论(0)