HarmonyOS 6 ArkUI 胶囊标签自适应布局开发,结合弹性布局实现标签自动适配与选中高亮样式切换
一、技术前言
在跨境旅行服务领域,出境导航正经历从"纸质攻略"到"数字向导"的深刻变革。从签证状态速览到港珠澳大桥口岸集合提醒,从景点 POI 相关性搜索到 AI 字幕多语讲解,每一项能力都需要匹配不同的出行场景、数据模型和交互反馈机制。传统出境导航应用面临三大挑战:地图标注无法长按交互导致地标探索割裂、字幕语言无法灵活切换导致双语讲解断档、通知铃声无法自定义导致行程提醒千篇一律。
HarmonyOS ArkUI 框架为这些挑战提供了系统级解决方案。ArkUI 的声明式 UI 范式通过 @Component 封装可复用组件、@State 管理响应式状态、@Builder 拆分复杂 UI 结构,天然适合"行程-地图-提醒"三层架构。@Observed 装饰器让数据模型字段级变化被 UI 感知,实现"签证状态更新即视图刷新"的流畅体验。aboutToAppear 与 aboutToDisappear 生命周期回调则保障了呼吸动画定时器的正确创建与释放。ArkUI 的核心设计哲学包含三大支柱:声明式渲染——开发者只需描述界面"是什么"而非"怎么构建",框架通过虚拟 DOM diff 算法自动完成最小化 DOM 更新,使 UI 状态与数据模型保持同步;组件化架构——通过 @Component 装饰器将界面拆分为独立可复用的组件单元,每个组件拥有自己的状态管理和生命周期,组件间通过参数传递和回调函数实现松耦合通信;状态驱动机制——@State 装饰器监听变量变化并自动触发关联 UI 的重渲染,@Observed 装饰器使类的实例具备可观察性,当对象属性变更时通知所有引用处刷新,实现数据到视图的单向流动。
本平台深度融合 HarmonyOS 6.1.1 的三大前沿特性。Map Kit 提供地图组件的 onMarkerLongClick 与 onPoiLongClick 双长按监听能力——通过 MapComponentController 获取 MapEventManager 后注册回调,长按地标标注可读取 marker.getId() 与 getPosition(),长按 POI 可读取 poi.name 与 poi.position,同时 site.searchByText 接口以 query、location、radius、language 四参数发起 POI 关键字搜索并返回 reliability 相关性分数。在地图初始化阶段,setupMapCallback 方法通过 AsyncCallback 回调依次完成错误检查、控制器实例获取、事件管理器装配、批量地标标注添加与双长按监听注册五步链路,其中 addMarker 接受 MarkerOptions 配置项(包含 position、clickable、visible、rotation、zIndex、alpha、anchorU、anchorV、draggable、flat 共十个字段),以逐个 await 异步添加确保标注全部就绪后再注册监听。Speech Kit 的 AI 字幕组件引入了 sourceLanguage、targetLanguage、fontSize、fontColor 四个新字段,支持中英双向翻译、中英双语对照、四档字号调节和五色字体预设,配合 writeAudio 640 字节 PCM 块写入实现实时语音转字幕。字幕组件以 isShown 为 @Link 双向绑定入口,外部状态翻转即可控制字幕显隐;onPrepared 回调在字幕服务就绪后置 captionReady 为 true,onError 回调捕获异常并写入 captionErrMsg 供界面兜底展示。Notification Kit 支持 EL1 沙箱自定义铃声——通过 fileIo 将生成的 WAV 写入 filesDir 目录,再经 fileUri.getUriFromPath 转换后以 sound: 'uri::' + uri 前缀格式发布携带自定义铃声的通知。通知发布采用 NotificationRequest 结构,notificationSlotType 设为 SOCIAL_COMMUNICATION 社交通信类型,content.normal 包含 title、text、additionalText 三级文本,sound 字段填入沙箱铃声 URI 前缀值。授权流程通过 requestEnableNotification 发起首次授权弹窗,当用户曾拒绝时返回错误码 1600004,此时调用 openNotificationSettings 拉起系统通知设置页引导用户手动开启二次授权。
二、整体架构流程图
架构以出境导航主组件为根,使用 Stack 容器层叠:底层 Column 纵向排列头部信息栏、分割线、内容区和底部 Tab 栏,顶层是三个独立弹窗(新增/编辑/删除各自条件渲染)。内容区通过 currentTab 在 7 个 Builder 方法间切换,地图 Tab 因 MapComponent 需有界高度独占内容区不进 Scroll 容器,其余 Tab 进主滚动容器统一管理。四大特性分散在行程(柱状图)、地图(双长按监听)、铃音(EL1 沙箱)和字幕(AI 字幕)四个 Tab 上,状态变量统一声明在组件顶层实现跨 Tab 共享。
这种"Stack 层叠 + Column 纵向 + 条件分支 Tab 切换"的架构模式具有三重优势:其一,Stack 天然支持弹窗系统在主界面之上悬浮渲染,无需额外管理弹窗层级;其二,Column 的纵向排列保证了头部、内容、底部的固定三段式布局,适配竖屏移动设备的阅读习惯;其三,if/else 条件分支替代了 Tabs 容器的默认切换动画,使每个 Tab 的渲染完全独立可控,开发者可以精确控制何时销毁与重建 Tab 内容,避免不必要的组件常驻内存。
三、色彩体系设计
3.1 ColorPalette 接口定义
平台采用深色"夜航深蓝 + 霓虹青"主题,通过 ColorPalette 接口集中声明全部颜色字段,使全文件色彩管理统一可控:
interface ColorPalette {
bg: string; // 页面背景(夜航深蓝)
card: string; // 卡片底色(深海军蓝)
title: string; // 主标题(冷白)
sub: string; // 副标题(雾蓝灰)
text3: string; // 三级弱文本(暗蓝灰)
cyan: string; // 霓虹青(主色)
cyanD: string; // 霓虹青深色
orange: string; // 落日橙(辅助暖色)
purple: string; // 星紫(电子签徽标)
green: string; // 通过绿(免签徽标)
red: string; // 警示红(删除 / 失败)
line: string; // 分割线
tabOn: string; // Tab 选中色
mask: string; // 弹窗遮罩
}
这段接口定义体现了 ArkTS 的类型安全优势。与普通 JavaScript 动态添加属性不同,ColorPalette 接口在编译期即约束所有颜色字段必须是 string 类型,任何拼写错误或类型不匹配都会在编译阶段暴露。接口注释采用"字段名 + 用途"的格式,使每个颜色的语义角色一目了然,后续维护者无需追踪代码即可理解色彩用途。接口设计上还预留了 dark 字段(次级容器底色,用于徽标底和开关底),虽未在接口声明中列出但在常量对象中补充了它——这是因为次级容器底色属于实现期按需扩展的字段,开发者通过常量对象的额外属性实现灵活扩展,同时主接口保持最小声明集合的清晰性。
3.2 COLORS 常量逐色分析
const COLORS: ColorPalette = {
bg: '#0D1522', // 夜航深蓝,模拟深夜机舱的沉浸暗光环境
card: '#16233A', // 深海军蓝卡片底,比背景亮一档
dark: '#1D2E4A', // 次级容器底(徽标底 / 开关底)
title: '#E8F0FA', // 冷白标题,暗光高对比
sub: '#9DB4D0', // 雾蓝灰副标题,层次柔和
text3: '#67819E', // 暗蓝灰弱文本,辅助信息不抢视觉
cyan: '#38C8D8', // 霓虹青主色,渐变横幅与按钮主色
cyanD: '#2298A8', // 霓虹青深色,渐变起点与次要按钮
orange: '#FF8A4C', // 落日橙强调色,POI徽标与未授权提示
purple: '#8A7FE8', // 星紫,电子签徽标与目标语言选中
green: '#4EC98A', // 通过绿,免签徽标与已授权状态
red: '#E86060', // 警示红,删除操作与失败反馈
line: '#223450', // 深蓝分割线,低对比不干扰
tabOn: '#38C8D8', // Tab 选中色为霓虹青(与主色一致)
mask: 'rgba(0,0,0,0.6)' // 半透黑遮罩
};
色彩体系以"夜航深蓝 + 霓虹青"为核心对比。深蓝代表深夜航行的沉浸氛围,霓虹青代表机场指示牌与城市灯光的高光时刻。下面逐色解析其设计语义:
bg 为 #0D1522 夜航深蓝,模拟深夜机舱或酒店暗光环境,降低屏幕对眼睛的刺激,适合长时间使用导航应用的旅行场景。card 为 #16233A 深海军蓝,卡片底色比背景亮一档,形成柔和的层次区分而非生硬的边界。dark 为 #1D2E4A 次级容器底,用于徽标背景和开关底色,比卡片底再亮一档,构建三级亮度层次。
title 为 #E8F0FA 冷白标题,在深色背景上形成高对比度但不刺眼,模拟机舱阅读灯的冷白光。sub 为 #9DB4D0 雾蓝灰副标题,在标题与弱文本之间架起层次过渡。text3 为 #67819E 暗蓝灰弱文本,用于辅助说明和时间戳,视觉权重最低。
cyan 为 #38C8D8 霓虹青主色,象征机场指示牌与城市霓虹灯的高光,贯穿渐变横幅、按钮主色和 Tab 选中态。cyanD 为 #2298A8 霓虹青深色,用于渐变起点和当前铃声的按钮态。orange 为 #FF8A4C 落日橙辅助暖色,用于距离数值、POI 长按徽标和未授权状态提示,与主色形成冷暖对比。purple 为 #8A7FE8 星紫,专用于电子签徽标和目标语言选中态,赋予科技感。green 为 #4EC98A 通过绿,用于免签徽标和已授权状态。red 为 #E86060 警示红,仅用于删除操作和失败反馈,通过低频使用强化警示语义。line 为 #223450 深蓝分割线,低对比度不干扰内容。tabOn 与 cyan 同值,保证 Tab 选中态与主色一致。mask 为半透明黑 rgba(0,0,0,0.6),弹窗遮罩使用 RGBA 格式实现 60% 透明度。
值得注意的是 Tab 选中色使用 cyan(霓虹青)与主色一致,这是因为霓虹青在深蓝背景上对比度极高,用户视觉定位更迅速。旅行家渐变大卡使用 linearGradient 从 cyanD 经 dark 到 card 的 160° 渐变,模拟夜航舷窗由天际线向机舱渐暗的效果。签证类型通过四色徽标区分:免签绿、落地签青、电子签紫、需面签橙,覆盖跨境出行的主要签证场景。
四、Tab 元数据与常量数据
4.1 底部导航 Tab 定义
interface TabMeta {
icon: string;
label: string;
}
const TAB_LIST: TabMeta[] = [
{ icon: '🧭', label: '行程' },
{ icon: '🗺️', label: '地图' },
{ icon: '🔍', label: '搜索' },
{ icon: '⏰', label: '提醒' },
{ icon: '🎵', label: '铃音' },
{ icon: '🗣', label: '字幕' },
{ icon: '👤', label: '我的' }
];
TabMeta 接口定义了 Tab 导航项的最小数据结构:icon 为 emoji 字符串,label 为中文标签文字。TAB_LIST 常量数组按顺序声明七个 Tab 项,分别对应行程、地图、搜索、提醒、铃音、字幕和我的。这种将导航元数据与 UI 渲染分离的设计使 Tab 配置可独立维护,新增或调整 Tab 只需修改数组而无需触碰 @Builder 方法。底部导航栏在 tabBar() 构建器中通过 ForEach 遍历此数组渲染,选中态通过 currentTab 索引与 index 比较判断,ForEach 的键值生成器采用 `tab-${idx}-${tab.label}` 复合键格式,保证 Tab 列表在数据变更时 ArkUI 差分渲染的正确性。
7 个 Tab 从行程总览到旅行家主页覆盖出境导航全流程:行程是目的地管理与签证速览入口,地图承载地标长按探索,搜索提供 POI 相关性检索,提醒管理行程时间轴与通知,铃音实现沙箱自定义铃声,字幕提供 AI 字幕多语设置,我的展示旅行家信息与足迹国家。每个 Tab 都有独立的 TAB_SUBS 副标题在头部联动显示,形成"导航即场景"的信息架构。
4.2 头部 Tab 联动副标题
const TAB_SUBS: string[] = [
'跨境行程总览与签证速览',
'香港地标长按探索',
'景点 POI 相关性搜索',
'行程提醒与本地通知',
'沙箱自定义通知铃声',
'AI 字幕多语讲解',
'旅行家主页与足迹'
];
TAB_SUBS 数组与 TAB_LIST 一一对应,为每个 Tab 提供一行功能说明副标题。当用户切换 Tab 时,头部信息栏的副标题通过 TAB_SUBS[this.currentTab] 实时更新,使当前 Tab 的功能定位一目了然。这种"Tab 索引驱动副标题"的设计将 Tab 的语义信息从导航栏的简短标签扩展为完整的功能描述,帮助用户在切换 Tab 时快速理解当前页面的核心能力。副标题使用 maxLines(1) 与 TextOverflow.Ellipsis 防止长文本溢出破坏头部布局,保证在窄屏设备上也能优雅显示。
4.3 地图标注与搜索常量
const CITY_CENTER: mapCommon.LatLng = { latitude: 22.3193, longitude: 114.1694 };
interface SpotItem {
name: string;
lat: number;
lng: number;
tag: string;
}
const MARKER_SPOTS: SpotItem[] = [
{ name: '维多利亚港', lat: 22.2938, lng: 114.1722, tag: '夜景' },
{ name: '太平山顶', lat: 22.2759, lng: 114.1455, tag: '观景' },
{ name: '尖沙咀星光大道', lat: 22.2930, lng: 114.1718, tag: '海滨' },
{ name: '旺角街市', lat: 22.3217, lng: 114.1697, tag: '市集' },
{ name: '香港迪士尼乐园', lat: 22.3130, lng: 114.0420, tag: '乐园' },
{ name: '港珠澳大桥口岸', lat: 22.4947, lng: 113.9770, tag: '口岸' }
];
const QUICK_QUERIES: string[] = ['景点', '餐厅', '地铁站', '酒店', '口岸', '博物馆'];
城市中心点定在香港中环(跨境游枢纽),作为地图初始视野与 POI 搜索基准。CITY_CENTER 定义了 mapCommon.LatLng 类型的经纬度坐标(纬度 22.3193、经度 114.1694),同时驱动地图 MapOptions 的 position.target 初始视野中心和 searchByText 的 location 搜索基准点。
SpotItem 接口定义了地图标注点的四字段结构:name 地标名称、lat 纬度、lng 经度、tag 业态标签。MARKER_SPOTS 数组包含六处香港跨境游客常用地标的模拟数据,覆盖夜景、观景、海滨、市集、乐园、口岸六类跨境游客高频场景。这些数据在 setupMapCallback 中通过 mapController.addMarker 批量添加到地图上,每个标注的 MarkerOptions 配置项包含十个字段,逐个 await 异步添加确保标注全部就绪后再注册双长按监听。
QUICK_QUERIES 数组包含六项跨境游客高频 POI 类型的快捷关键字(景点、餐厅、地铁站、酒店、口岸、博物馆),在搜索 Tab 中以 chips 形式展示,点击即设置关键字并自动触发 runSearch 异步搜索。快捷关键字降低了关键字输入成本,让用户一键触达常见 POI 类型的搜索结果。
4.4 字幕语言与字号颜色预设
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: '中英双语' }
];
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', '#7CE8F5', '#FFD9A8', '#C9F2D9', '#FFC2CE'];
字幕语言体系采用"源语言 + 目标语言"双语配置模型。LangOption 接口定义了语言选项的两字段结构:code 为语言码(如 zh、en、zh-en),name 为中文展示名。源语言仅中文和英文两选,因为跨境讲解场景的源语言主要来自导游讲解(英文为主)和中文复盘(中文源)。中文源时目标语言锁定 zh(原文直显不翻译,无可选项),英文源时目标语言有三选:中文翻译、英文原文、中英双语对照。这种"源语言驱动目标语言可选范围"的设计通过 switchSourceLang 方法实现联动,避免了无效翻译方向的误选。
字号使用 AICaptionFontSize 枚举而非数字,四档从小号到超大。SizeOption 接口将枚举值与中文展示名绑定,每档卡片以不同字号大小的字母示例(小/标/大/大A)直观展示尺寸差异,选中项加 1px 霓虹青边框并以暗蓝灰底突出。颜色预设五色:经典白(#FFFFFF)、霓虹青(#7CE8F5)、暖橙(#FFD9A8)、薄荷绿(#C9F2D9)、樱花粉(#FFC2CE),覆盖不同跨境讲解场景的视觉需求——经典白适合明亮环境、霓虹青适合科技感场景、暖橙适合温馨讲解、薄荷绿适合自然导览、樱花粉适合轻松对话。
4.5 行程出行与足迹数据
const MONTH_NAME: string[] = ['03', '04', '05', '06', '07', '08'];
const MONTH_DAYS: number[] = [3, 5, 2, 6, 4, 8];
interface FootRow {
flag: string; // 国旗 emoji
name: string; // 国家 / 地区名
cities: number; // 解锁城市数
last: string; // 最近到访
}
const FOOT_ROWS: FootRow[] = [
{ flag: '🇭🇰', name: '中国香港', cities: 18, last: '08月' },
{ flag: '🇯🇵', name: '日本', cities: 6, last: '07月' },
{ flag: '🇹🇭', name: '泰国', cities: 3, last: '05月' },
{ flag: '🇸🇬', name: '新加坡', cities: 1, last: '04月' },
{ flag: '🇰🇷', name: '韩国', cities: 2, last: '03月' },
{ flag: '🇲🇴', name: '中国澳门', cities: 2, last: '02月' }
];
MONTH_NAME 和 MONTH_DAYS 为近 6 个月跨境出行天数数据(单位:天),驱动行程 Tab 的月度柱状图,合计 28 天。最高值 8 天对应当月(08月),在柱状图中以最高的渐变柱呈现。柱状图通过 breath 状态在 true/false 间切换基准值(14 与 12),实现 ±2px 微波动模拟实时数据刷新效果。
FootRow 接口定义了足迹国家清单行的四字段结构:flag 为国旗 emoji、name 为国家/地区名、cities 为解锁城市数、last 为最近到访月份。FOOT_ROWS 包含 6 项足迹国家数据,覆盖中国香港(18 城)、日本(6 城)、泰国(3 城)、新加坡(1 城)、韩国(2 城)、中国澳门(2 城),呈现旅行家的跨境出行版图。maxLines(1) 与 TextOverflow.Ellipsis 防止国家名溢出破坏行布局,最近到访月份以暗蓝灰底小徽标形式呈现。
五、工具函数分析
5.1 搜索相关性等级配色
interface ScoreLevel {
label: string;
color: string;
}
function reliabilityScore(score: number): ScoreLevel {
if (score >= 0.8) {
return { label: '高相关', color: COLORS.green };
}
if (score >= 0.5) {
return { label: '中相关', color: COLORS.orange };
}
return { label: '低相关', color: COLORS.text3 };
}
reliabilityScore 将 searchByText 返回的 0~1 相关性分数映射为三档标签与颜色:0.8 以上高相关(通过绿)、0.5 以上中相关(落日橙)、其余低相关(暗蓝灰)。ScoreLevel 接口封装了标签文字和颜色两个字段,使函数返回值结构清晰。该函数在搜索结果列表中同时驱动等级标签文字色和分数条进度色,实现"分数即颜色"的视觉直觉。
这种"单函数双输出"的设计避免了在 UI 模板中重复编写条件判断逻辑,保证了标签与进度条配色始终一致。当 searchByText 返回的 reliability 字段为可选类型时,在 runSearch 方法中通过 ?? 0 空值合并运算符将其兜底为 0,确保分数映射函数不会因空值崩溃。三档分数阈值(0.8 和 0.5)的设定参考了搜索引擎常见的相关性分级标准,0.8 以上表示强匹配,0.5 以上表示弱匹配但可用,0.5 以下则仅作参考展示。
5.2 长按事件类型与签证配色
function typeColor(type: string): string {
if (type === 'Marker') {
return COLORS.cyan;
}
if (type === 'POI') {
return COLORS.orange;
}
return COLORS.text3;
}
function visaColor(visa: string): string {
if (visa.startsWith('免签')) {
return COLORS.green;
}
if (visa.startsWith('落地签')) {
return COLORS.cyan;
}
if (visa.startsWith('电子签')) {
return COLORS.purple;
}
return COLORS.orange;
}
typeColor 区分 Marker 长按(霓虹青)与 POI 长按(落日橙)两种事件类型的徽标色,让用户在事件日志流中一眼分辨来源。函数以字符串精确匹配 === 判断类型,Marker 和 POI 分别对应两种长按回调的来源,默认返回暗蓝灰作为兜底。该函数驱动日志流中每条记录的类型徽标背景色,形成"Marker 青色、POI 橙色"的视觉编码。
visaColor 以 startsWith 前缀匹配四种签证类型:免签绿、落地签青、电子签紫、需面签橙,与行程横滑大卡和清单行的签证徽标保持一致配色。使用 startsWith 而非 === 的原因在于种子数据中存在"免签备案"这类带后缀的签证类型,前缀匹配能正确识别其属于免签大类。四色签证徽标的设计覆盖了跨境出行的主要签证场景:免签(绿色代表畅通无阻)、落地签(青色代表落地即可办理)、电子签(紫色代表数字化便捷)、需面签(橙色代表需提前准备),色彩语义与签证便利程度形成视觉直觉。
5.3 时间戳生成函数
function nowTime(): string {
const d = new Date();
const p = (n: number) => n.toString().padStart(2, '0');
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
nowTime 生成 HH:mm:ss 格式时间戳,用于长按事件日志和通知历史的时间标记。函数内部定义了辅助函数 p,它将数字转为字符串后用 padStart(2, '0') 前导补零,确保时、分、秒均为两位数格式。例如上午 9 时 5 分 3 秒会被格式化为 09:05:03 而非 9:5:3,保证了时间戳在日志流中对齐排列的视觉一致性。
该函数在 bindMarkerLongClick 和 bindPoiLongClick 中被调用,为每次长按事件生成触发时刻;在 publishNotice 中被调用,为每条通知历史记录生成发布时刻。时间戳以 Text 组件展示并配合 fontFamily('monospace') 等宽字体,使数字宽度一致,在日志流中形成整齐的时间列。这种"函数生成时间戳 + 等宽字体展示"的组合是日志类界面的经典设计模式。
5.4 WAV 音频字节生成函数
function buildWavBytes(freq: number, durationMs: number): ArrayBuffer {
const sampleRate = 44100;
const numSamples = Math.floor(sampleRate * durationMs / 1000);
const dataSize = numSamples * 2;
const buf = new ArrayBuffer(44 + dataSize);
const view = new DataView(buf);
const writeStr = (offset: number, s: string) => {
for (let i = 0; i < s.length; i++) {
view.setUint8(offset + i, s.charCodeAt(i));
}
};
writeStr(0, 'RIFF');
view.setUint32(4, 36 + dataSize, true);
writeStr(8, 'WAVE');
writeStr(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true); // PCM 编码
view.setUint16(22, 1, true); // 单声道
view.setUint32(24, sampleRate, true); // 采样率
view.setUint32(28, sampleRate * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true); // 16bit 量化
writeStr(36, 'data');
view.setUint32(40, dataSize, true);
for (let i = 0; i < numSamples; i++) {
const t = i / sampleRate;
const env = Math.min(1, i / (sampleRate * 0.02)); // 起音包络
const decay = Math.max(0, 1 - t / (durationMs / 1000)); // 自然衰减
const v = Math.sin(2 * Math.PI * freq * t) * 0.5 * env * decay;
view.setInt16(44 + i * 2, Math.round(v * 32767), true);
}
return buf;
}
buildWavBytes 是通知铃声链路的核心函数:以 44100Hz 采样率生成正弦波 PCM 数据,44 字节标准 WAV 头部包含 RIFF/WAVE/fmt/data 四个块标记。函数接受两个参数:freq 为正弦波频率(Hz),durationMs 为时长(毫秒)。函数首先计算采样总数 numSamples(采样率 × 时长 / 1000),再计算数据区字节数 dataSize(每个采样 2 字节,16bit 量化),最终分配 44 + dataSize 字节的 ArrayBuffer。
WAV 头部写入遵循标准规范:writeStr 辅助函数将字符串逐字节写入指定偏移位置;setUint32(4, 36 + dataSize, true) 写入文件总长度(36 字节头 + 数据区);setUint16(20, 1, true) 标记 PCM 编码格式(格式码 1);setUint16(22, 1, true) 标记单声道;setUint32(24, sampleRate, true) 写入采样率 44100;setUint16(34, 16, true) 标记 16bit 量化精度。所有 setUint 调用的第三参数 true 表示小端序(Little Endian),这是 WAV 文件的标准字节序。
数据区采用 ADSR 包络模拟真实铃声:起音包络(前 20ms 线性渐入,env = Math.min(1, i / (sampleRate * 0.02)))使声音从静默平滑升起避免爆音;自然衰减(按时长线性衰减,decay = Math.max(0, 1 - t / (durationMs / 1000)))使声音逐渐减弱模拟自然衰减。最终正弦波值为 Math.sin(2 * Math.PI * freq * t) * 0.5 * env * decay,乘以 32767 量化到 16bit 有符号整数范围,通过 setInt16 写入。该函数生成的 ArrayBuffer 通过 fs.writeSync 直接写入 EL1 沙箱文件,成为通知 sound 字段的数据源。
六、数据模型层
6.1 行程模型 TripItem
@Observed export class TripItem {
city: string; // 目的地城市
days: number; // 行程天数
visa: string; // 签证类型(免签 / 落地签 / 电子签 / 需面签)
plan: string; // 行程概要
constructor(city: string, days: number, visa: string, plan: string) {
this.city = city;
this.days = days;
this.visa = visa;
this.plan = plan;
}
}
const TRIP_LIST: TripItem[] = [
new TripItem('香港', 4, '免签备案', '维港夜景 · 太平山顶 · 迪士尼'),
new TripItem('东京', 6, '电子签', '浅草寺 · 涩谷十字 · 镰仓一日'),
new TripItem('曼谷', 5, '落地签', '大皇宫 · 水上市场 · 恰图洽周末市集'),
new TripItem('新加坡', 4, '免签', '滨海湾花园 · 环球影城 · 圣淘沙'),
new TripItem('首尔', 5, '免签', '景福宫 · 明洞 · 南怡岛'),
new TripItem('大阪', 6, '电子签', '环球影城 · 道顿堀 · 奈良喂鹿'),
new TripItem('巴黎', 9, '需面签', '卢浮宫 · 塞纳河游船 · 凡尔赛宫')
];
TripItem 是业务主 Tab 的核心实体,绑定新增/编辑/删除三个弹窗。@Observed export class 声明使其成为可观察的导出类——@Observed 装饰器使其字段级变化被 UI 感知,编辑弹窗 doEdit 方法就地修改 t.city、t.days 等字段后,横滑大卡和清单行自动刷新;export 关键字使该类可被其他模块引用。
构造函数接受四个参数并赋值给实例字段,这种显式构造函数设计比对象字面量更具类型安全性。TRIP_LIST 种子数据包含 7 条行程,覆盖香港(免签备案)、东京(电子签)、曼谷(落地签)、新加坡(免签)、首尔(免签)、大阪(电子签)、巴黎(需面签)七大跨境热门目的地,签证类型涵盖四类,天数从 4 到 9 天不等,行程概要以"地标 · 地标 · 地标"的格式精炼描述每日亮点。这些数据在行程 Tab 中以横滑大卡和清单行两种视图呈现,横滑大卡显示渐变封面与签证色徽标,清单行显示城市名、签证徽标、天数和行程概要。
6.2 搜索结果模型 SearchRecord
@Observed export class SearchRecord {
name: string; // 地点名称
address: string; // 格式化地址
distance: number; // 直线距离(米)
reliability: number; // 相关性分数 [0,1]
constructor(name: string, address: string, distance: number, reliability: number) {
this.name = name;
this.address = address;
this.distance = distance;
this.reliability = reliability;
}
}
const SEARCH_MOCK: SearchRecord[] = [
new SearchRecord('香港太空馆', '尖沙咀梳士巴利道10号', 420, 0.94),
new SearchRecord('星光大道', '尖沙咀海滨长廊', 680, 0.88),
new SearchRecord('香港文化中心', '尖沙咀梳士巴利道', 750, 0.71),
new SearchRecord('天星小轮尖沙咀码头', '尖沙咀天星码头', 910, 0.62),
new SearchRecord('海港城', '尖沙咀广东道3-27号', 1150, 0.45),
new SearchRecord('重庆大厦', '尖沙咀弥敦道36-44号', 1320, 0.22)
];
SearchRecord 包装 site.searchByText 的返回结果,reliability 字段直接驱动 reliabilityScore 函数生成等级标签和分数条颜色。@Observed 装饰器使搜索结果列表在数据更新时自动刷新 UI。SEARCH_MOCK 种子数据覆盖高(0.94、0.88)、中(0.71、0.62)、低(0.45、0.22)三档分数,体现 UI 适配完整性——高相关显示通过绿徽标和进度条、中相关显示落日橙、低相关显示暗蓝灰,让搜索结果的视觉层次与相关性分数严格对应。
Mock 数据以尖沙咀为搜索中心,包含香港太空馆、星光大道、香港文化中心、天星小轮码头、海港城、重庆大厦六处地标,距离从 420 米到 1320 米递增。在 runSearch 方法中,当真实搜索成功时,sites 数组被映射为 SearchRecord 列表——每个 site.Site 的 name、formatAddress、distance、reliability 均以 ?? 空值合并兜底,确保可选字段缺失时不崩溃;当搜索失败时(如无 AGC 配置或无网络),保留 Mock 数据并提示错误码,体现调用链的完整性与容错设计。
6.3 地图事件模型 EventLog
@Observed export class EventLog {
type: string; // Marker / POI
name: string; // 标记 id 或 POI 名称
lat: number; // 纬度
lng: number; // 经度
time: string; // 触发时刻
constructor(type: string, name: string, lat: number, lng: number, time: string) {
this.type = type;
this.name = name;
this.lat = lat;
this.lng = lng;
this.time = time;
}
}
const EVENT_SEED: EventLog[] = [
new EventLog('Marker', '#0 维多利亚港(演示)', 22.2938, 114.1722, '09:12:40'),
new EventLog('POI', '尖沙咀海滨花园(演示)', 22.2932, 114.1716, '09:15:03')
];
EventLog 记录地图长按事件流,五字段结构完整描述一次长按事件:type 区分 Marker 与 POI 两种来源,name 记录标记 ID 或 POI 名称,lat/lng 记录触发坐标,time 记录触发时刻。在 bindMarkerLongClick 和 bindPoiLongClick 回调中,每次长按事件通过 this.eventLogs.unshift(new EventLog(...)) 置顶最新事件并限制 12 条上限(超出时 pop 移除末尾),保证日志窗口不会无限增长影响性能。
type 字段通过 typeColor 函数映射徽标色(Marker 霓虹青、POI 落日橙),让用户在事件日志流中一眼分辨来源。坐标以 toFixed(4) 保留四位小数并以 fontFamily('monospace') 等宽字体展示,保证经纬度数字对齐。EVENT_SEED 种子数据包含两条演示事件(一条 Marker、一条 POI),让用户进入地图 Tab 时即能看到日志流的样例效果,理解长按交互的输出格式。
6.4 提醒模型 RemindItem
@Observed export class RemindItem {
time: string; // 提醒时刻
title: string; // 提醒标题
repeat: string; // 重复规则
on: boolean; // 开关状态
constructor(time: string, title: string, repeat: string, on: boolean) {
this.time = time;
this.title = title;
this.repeat = repeat;
this.on = on;
}
}
const REMIND_LIST: RemindItem[] = [
new RemindItem('06:30', '值机提醒:CX984 香港 → 东京', '出行日', true),
new RemindItem('09:00', '酒店入住:尖沙咀皇悦酒店', '单次', true),
new RemindItem('09:40', '集合出发:旺角地铁站 C3 口', '出行日', true),
new RemindItem('12:00', '景点预约:太平山缆车快速通道', '单次', false),
new RemindItem('15:30', '跨境巴士:港珠澳大桥口岸集合', '单次', true),
new RemindItem('20:00', '夜间导览:维港幻彩咏香江', '出行日', false)
];
RemindItem 是提醒时间轴的行实体,四字段结构描述一条行程提醒:time 为提醒时刻(HH:mm 格式)、title 为提醒标题、repeat 为重复规则(“出行日"或"单次”)、on 为开关状态。on 布尔字段是状态联动的核心——toggleRemind 方法直接翻转该字段,驱动时间轴圆点与竖线的亮灭状态、提醒标题的着色以及状态文案的切换(“提醒开启中"或"已暂停”)。
REMIND_LIST 种子数据包含六条跨境出行典型提醒,覆盖值机提醒、酒店入住、集合出发、景点预约、跨境巴士、夜间导览六个时间节点,on 状态有开有关,体现时间轴的视觉层次。竖线颜色随 item.on 状态切换霓虹青或暗蓝灰,圆点同步变色形成"亮线即启用"的视觉直觉。重复规则中"出行日"表示仅在出行当日触发,"单次"表示仅触发一次后自动关闭。
6.5 铃声模型 RingItem
@Observed export class RingItem {
name: string; // 铃声名
file: string; // 沙箱文件名
freq: number; // 生成频率 Hz
duration: number; // 时长 ms
size: string; // 文件大小展示
inSandbox: boolean; // 是否已写入沙箱
constructor(name: string, file: string, freq: number,
duration: number, size: string, inSandbox: boolean) {
this.name = name;
this.file = file;
this.freq = freq;
this.duration = duration;
this.size = size;
this.inSandbox = inSandbox;
}
}
const RING_LIST: RingItem[] = [
new RingItem('登机叮咚', 'ring_boarding_ding.wav', 990, 900, '—', false),
new RingItem('口岸钟声', 'ring_gate_bell.wav', 523, 1400, '—', false),
new RingItem('巴士到站', 'ring_bus_arrive.wav', 660, 1000, '—', false),
new RingItem('行李转盘', 'ring_baggage_loop.wav', 440, 1200, '—', false),
new RingItem('集合哨声', 'ring_meet_whistle.wav', 880, 800, '—', false),
new RingItem('夜航星光', 'ring_night_star.wav', 784, 1500, '—', false)
];
RingItem 是铃声库的核心实体,六字段结构描述一个铃声:name 为铃声名、file 为沙箱文件名、freq 为生成频率 Hz、duration 为时长 ms、size 为文件大小展示、inSandbox 为是否已写入沙箱。inSandbox 字段是铃声生成链路的状态核心:初始为 false,调用 genRing 方法将 WAV 写入 EL1 沙箱后翻转为 true,同时更新 size 字段为实际文件大小(通过 Math.round((44 + 44100 * duration / 1000 * 2) / 1024) 计算 KB 值)。
RING_LIST 种子数据包含六项铃声,频率与跨境出行场景一一对应:登机叮咚(990Hz,高频清脆)、口岸钟声(523Hz,C5 音符庄重)、巴士到站(660Hz,E5 音符明亮)、行李转盘(440Hz,A4 音符沉稳)、集合哨声(880Hz,A5 音符穿透)、夜航星光(784Hz,G5 音符悠扬)。时长从 800ms 到 1500ms 不等,模拟不同场景的铃声持续需求。发布通知时 sound 字段取当前铃声的沙箱路径,size 初始为"—"表示未生成,生成后显示精确的 KB 数值。
6.6 字幕场景模型 CaptionScene
@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 CAPTION_SCENES: CaptionScene[] = [
new CaptionScene('双语导览讲解', '英文导游讲解实时转中英双语字幕,边走边听', 'en', 'zh-en'),
new CaptionScene('外语点餐对话', '读懂菜单与侍应对话,点单不踩雷', 'en', 'zh'),
new CaptionScene('问路应急翻译', '街头问路实时出字,方向看得见', 'en', 'zh'),
new CaptionScene('机场广播听译', '登机口变更广播即时转文字提醒', 'en', 'zh-en'),
new CaptionScene('中文讲解复盘', '回国回放中文讲解录音,整理游记笔记', 'zh', 'zh')
];
CaptionScene 封装跨境讲解的五类典型场景,四字段结构描述一个场景:scene 为场景名、desc 为场景说明、src 为推荐源语言、tgt 为推荐目标语言。applyScene 方法一键套用推荐的 src/tgt 语言组合,免去用户手动逐项配置的繁琐步骤。每张场景卡以导游 emoji 起首,标题加粗、说明文案以暗蓝灰弱化,右侧以霓虹青徽标展示推荐语言组合(如"en→zh-en")。
CAPTION_SCENES 种子数据包含五个跨境出行典型场景:双语导览讲解(英文源、中英双语目标,适合边走边听)、外语点餐对话(英文源、中文目标,适合读懂菜单)、问路应急翻译(英文源、中文目标,适合街头问路)、机场广播听译(英文源、中英双语目标,适合登机口变更)、中文讲解复盘(中文源、中文目标,适合回国整理游记)。五场景覆盖了从行前到行后、从对话到广播的完整跨境出行字幕需求。
6.7 通知历史模型 NoticeLog
@Observed export class NoticeLog {
title: string; // 通知标题
text: string; // 通知正文
time: string; // 发布时刻
constructor(title: string, text: string, time: string) {
this.title = title;
this.text = text;
this.time = time;
}
}
const NOTICE_SEED: NoticeLog[] = [
new NoticeLog('值机提醒', 'CX984 香港 → 东京 已开放线上值机,请核对护照有效期。', '06:30:00'),
new NoticeLog('集合提醒', '跨境巴士 15:30 港珠澳大桥口岸发车,请提前 20 分钟集合。', '15:10:00'),
new NoticeLog('夜间导览', '维港幻彩咏香江 20:00 开始,星光大道观景位已收藏。', '19:40:00')
];
NoticeLog 记录已发布通知的标题、正文和发布时刻,三字段结构简洁明了。在 publishNotice 方法中,通知发布成功后通过 this.noticeLogs.unshift(new NoticeLog(...)) 将记录置顶到历史流并限制 8 条上限(超出时 pop 移除末尾),体现提醒 Tab 历史流的完整性。NOTICE_SEED 种子数据包含三条跨境出行典型通知(值机提醒、集合提醒、夜间导览),让用户进入提醒 Tab 时即能看到历史流的样例效果。
通知历史以 NoticeLog 倒序流显示已发布通知的标题、正文和时间,每条记录以暗蓝灰底卡呈现,正文以 maxLines(2) 截断防止溢出。标题以 sub 雾蓝灰加粗显示,时间以 text3 暗蓝灰弱化展示,形成"标题为主、时间为辅"的信息层次。@Observed 装饰器使通知历史列表在新通知发布后自动刷新,无需手动触发 UI 更新。
七、组件主体结构
7.1 状态变量声明
组件的状态变量按照功能职责分为五大组,每组承担独立的状态管理角色:
@Entry
@Component
struct Page1252 {
// --- Tab 状态 ---
@State currentTab: number = 0;
// --- 弹窗状态(绑定行程实体 TripItem) ---
@State addModal: boolean = false;
@State editModal: boolean = false;
@State delModal: boolean = false;
@State editIdx: number = -1;
@State delIdx: number = -1;
// --- 弹窗表单缓存 ---
@State formCity: string = '';
@State formDays: string = '';
@State formVisa: string = '';
@State formPlan: string = '';
第一组 Tab 切换状态:currentTab 为当前选中的 Tab 索引(0~6),驱动内容区 if/else 分支选择对应的 @Builder 方法渲染,同时联动头部副标题 TAB_SUBS[currentTab] 更新。第二组 弹窗状态:addModal/editModal/delModal 三个布尔值分别控制新增、编辑、删除弹窗的显隐,editIdx/delIdx 记录当前编辑或删除的行程索引,三个弹窗互斥独立、各自条件渲染。第三组 弹窗表单缓存:formCity/formDays/formVisa/formPlan 四个字符串缓存弹窗中四个 TextInput 的输入值,openAdd 清空缓存、openEdit 回填当前行程字段、doAdd/doEdit 读取缓存组装实体。
// --- 动画状态 ---
@State breath: boolean = false;
timer: number = -1;
// --- 业务数据数组 ---
@State tripList: TripItem[] = TRIP_LIST;
@State remindList: RemindItem[] = REMIND_LIST;
@State ringList: RingItem[] = RING_LIST;
@State noticeLogs: NoticeLog[] = NOTICE_SEED;
第四组 动画与业务数据:breath 布尔值每秒翻转一次,联动柱状图高度、呼吸圆点透明度和授权状态卡圆点,形成全局"生命体征"节拍;timer 存储定时器 ID 用于 aboutToDisappear 时清除。tripList/remindList/ringList/noticeLogs 四个业务数据数组分别绑定行程清单、提醒时间轴、铃声库列表和通知历史流的渲染,均为 @Observed 类的实例数组,字段级变化自动驱动 UI 刷新。
// --- Map Kit 状态 ---
private mapOptions: mapCommon.MapOptions = {
position: { target: CITY_CENTER, zoom: 13 }
};
private mapCallback?: AsyncCallback<map.MapComponentController>;
private mapController?: map.MapComponentController;
private mapEventManager?: map.MapEventManager;
@State eventLogs: EventLog[] = EVENT_SEED;
@State markerListenOn: boolean = true;
@State poiListenOn: boolean = true;
@State queryInput: string = '景点';
@State searchRecords: SearchRecord[] = SEARCH_MOCK;
@State searchState: string = '待搜索(等待实时搜索)';
第五组 Map Kit 状态:mapOptions 配置地图初始视野(中心点 CITY_CENTER 香港、缩放级别 13),mapCallback 为地图初始化回调函数,mapController 和 mapEventManager 为地图控制器和事件管理器实例(私有非响应式,不需触发 UI 刷新)。eventLogs 长按事件日志流以 @State 声明,unshift 置顶新事件时触发日志流 UI 刷新。markerListenOn/poiListenOn 两个布尔开关控制双长按监听的注册与清除,驱动 Toggle 开关的选中态和头部胶囊的状态文案。queryInput 搜索关键字、searchRecords 搜索结果列表、searchState 搜索状态文案共同支撑搜索 Tab 的交互。
// --- Speech Kit 状态(6.1.1 特性四件套) ---
private captionController: AICaptionController = new AICaptionController();
@State captionShown: boolean = false;
@State srcLang: string = 'en';
@State tgtLang: string = 'zh-en';
@State captionSize: AICaptionFontSize = AICaptionFontSize.NORMAL;
@State captionColor: string = CAPTION_FONT_COLORS[0];
@State captionReady: boolean = false;
@State captionErrMsg: string = '';
@State captionFed: number = 0;
// --- Notification 状态 ---
@State granted: boolean = false;
notifyId: number = 100;
@State currentRing: RingItem = RING_LIST[0];
@State noticeState: string = '尚未发布通知';
}
第六组 Speech Kit 状态:captionController 为 AI 字幕控制器实例(私有非响应式),captionShown 为字幕显隐状态(@Link 双向绑定到 AICaptionComponent),srcLang/tgtLang/captionSize/captionColor 四个字段对应 6.1.1 新增的四个字幕配置字段,captionReady 标记字幕服务就绪状态(onPrepared 回调置 true),captionErrMsg 存储错误信息(onError 回调写入),captionFed 记录已写入音频块计数。
第七组 Notification 状态:granted 通知授权状态(isNotificationEnabled 查询结果),notifyId 通知 ID 自增基数(每次发布递增),currentRing 当前默认铃声(发布通知时 sound 取此铃声的沙箱路径),noticeState 发布结果反馈文案。
7.2 生命周期方法
aboutToAppear() {
this.setupMapCallback();
notificationManager.isNotificationEnabled().then((enabled: boolean) => {
this.granted = enabled;
}).catch((err: BusinessError) => {
console.error(`isNotificationEnabled failed: ${err.message}`);
});
this.timer = setInterval(() => {
this.breath = !this.breath;
}, 1000);
}
aboutToDisappear() {
clearInterval(this.timer);
}
aboutToAppear 是组件挂载前的生命周期回调,完成三项初始化工作:
第一,装配地图回调——调用 setupMapCallback() 方法设置 mapCallback 异步回调函数,该回调在 MapComponent 初始化完成时被触发,依次完成错误检查、控制器获取、事件管理器获取、批量 Marker 添加和双长按监听注册五步链路。由于地图初始化是异步的,此处的 setupMapCallback 仅设置回调函数本身,真正的初始化逻辑在回调被触发时执行。
第二,查询通知授权状态——调用 notificationManager.isNotificationEnabled() 返回 Promise,成功时将 enabled 布尔值赋给 this.granted 驱动授权状态卡刷新,失败时通过 .catch 捕获 BusinessError 并打印错误日志。这种"查询即刷新"的模式确保用户每次进入页面时看到的授权状态都是最新的。
第三,启动呼吸动画定时器——通过 setInterval(() => { this.breath = !this.breath; }, 1000) 每秒翻转 breath 布尔值,定时器 ID 存储到 this.timer。breath 状态的翻转联动柱状图高度(±2px 波动)、头部呼吸圆点透明度(1.0 与 0.25 交替)、授权状态卡圆点透明度(1.0 与 0.4 交替)和旅行家大卡呼吸圆点透明度(1.0 与 0.3 交替),形成全局"生命体征"节拍。
aboutToDisappear 是组件卸载前的生命周期回调,调用 clearInterval(this.timer) 清除呼吸动画定时器,防止组件销毁后定时器继续运行导致的内存泄漏。这种"成对管理定时器"的模式(aboutToAppear 创建、aboutToDisappear 清除)是 ArkUI 动画管理的标准范式,保证了资源的正确释放。
7.3 根构建方法
build() {
Stack() {
Column() {
this.headerMain()
Divider().strokeWidth(1).color(COLORS.line)
if (this.currentTab === 1) {
this.tabMap()
} else {
Scroll() {
Column({ space: 12 }) {
if (this.currentTab === 0) {
this.tabTrip()
} else if (this.currentTab === 2) {
this.tabSearch()
} else if (this.currentTab === 3) {
this.tabRemind()
} else if (this.currentTab === 4) {
this.tabRing()
} else if (this.currentTab === 5) {
this.tabCaption()
} 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; })
}
}
.alignContent(Alignment.Center)
.backgroundColor(COLORS.bg)
.height('100%')
}
根构建使用 Stack 层叠容器,内部分为两层:底层是 Column 纵向排列的"头部 + 分割线 + 内容区 + 底部 Tab 栏",顶层是三个条件渲染的弹窗。
底层 Column 的内容区采用"地图 Tab 独占 + 其余 Tab 共享 Scroll"的双模式设计。当 currentTab === 1(地图 Tab)时,直接调用 this.tabMap() 不进入 Scroll 容器——这是因为 MapComponent 需要有界高度(通过 layoutWeight(1) 占满剩余空间),若放入 Scroll 滚动容器则高度无界导致地图无法正确渲染。其余六个 Tab 共享一个 Scroll 滚动容器,内部以 if/else if/else 条件分支选择对应的 @Builder 方法渲染。Scroll 设置 scrollBar(BarState.Off) 隐藏滚动条、edgeEffect(EdgeEffect.Spring) 弹性边缘效果,内容区 Column 设置 padding 四边内边距和 space: 12 子元素间距。
顶层弹窗通过 addModal/editModal/delModal 三个布尔状态条件渲染,各自持有 onClose 回调函数(翻转对应布尔值为 false)。Stack 的 alignContent(Alignment.Center) 使弹窗在屏幕中央居中呈现,backgroundColor(COLORS.bg) 设置页面背景为夜航深蓝,height('100%') 撑满屏幕高度。
八、头部信息栏详解
@Builder
headerMain() {
Row({ space: 10 }) {
Column({ space: 3 }) {
Text('出境导航')
.fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text(TAB_SUBS[this.currentTab])
.fontSize(11).fontColor(COLORS.text3)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Column({ space: 4 }) {
Row({ space: 4 }) {
Text('🗺️').fontSize(10)
Text(this.markerListenOn || this.poiListenOn ? '长按On' : '长按Off')
.fontSize(9).fontColor(this.markerListenOn || this.poiListenOn ? COLORS.cyan : COLORS.text3)
}.padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(10).backgroundColor(COLORS.dark)
Row({ space: 4 }) {
Text('🗣').fontSize(10)
Text(this.srcLang + '→' + this.tgtLang).fontSize(9).fontColor(COLORS.purple)
}.padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(10).backgroundColor(COLORS.dark)
Row({ space: 4 }) {
Text('🔔').fontSize(10)
Text(this.granted ? '已授权' : '未授权')
.fontSize(9).fontColor(this.granted ? COLORS.green : COLORS.orange)
}.padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(10).backgroundColor(COLORS.dark)
}
Circle({ width: 8, height: 8 })
.fill(COLORS.cyan)
.opacity(this.breath ? 1 : 0.25)
}
.width('100%')
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
}
头部采用左中右三段布局。左侧是应用名与 Tab 联动副标题:Text('出境导航') 以 20 号加粗冷白色显示应用名,Text(TAB_SUBS[this.currentTab]) 以 11 号暗蓝灰显示当前 Tab 的功能副标题,副标题随 currentTab 切换实时更新。Column 设置 layoutWeight(1) 占满左侧剩余空间、alignItems(HorizontalAlign.Start) 左对齐,副标题使用 maxLines(1) 与 TextOverflow.Ellipsis 防止长文本溢出破坏布局。
中间是三个特性状态胶囊,纵向排列:第一个胶囊显示地图长按监听状态(markerListenOn || poiListenOn 任一开启即显示"长按On"霓虹青、否则"长按Off"暗蓝灰),第二个胶囊显示字幕语言组合(srcLang→tgtLang 以星紫色展示,如"en→zh-en"),第三个胶囊显示通知授权状态(granted 为 true 显示"已授权"通过绿、否则"未授权"落日橙)。三胶囊均以 COLORS.dark 暗蓝灰为底、10 像素圆角、3 像素上下内边距呈现,emoji 图标以 10 号字体标识特性类型。三胶囊设计让用户在任何 Tab 都能一眼掌握三大 Kit 的运行状态,无需切换到对应 Tab 查看。
右侧是呼吸圆点:Circle 以 8x8 像素霓虹青绘制,opacity 通过 this.breath ? 1 : 0.25 在每秒间交替切换透明度,形成"呼吸"视觉效果。呼吸圆点与各 Tab 内的呼吸元素(柱状图波动、授权圆点、旅行家大卡圆点)形成跨 Tab 的"生命体征"节拍呼应,让整个应用始终保持"活着"的动态感。
九、各 Tab 独立深度分析
9.1 Tab0 行程:横滑大卡与清单行
行程 Tab 是平台的主业务入口,由四段组成:目的地横滑大卡、行程清单行、新增入口和月度柱状图。
@Builder
tabTrip() {
Column({ space: 12 }) {
Row() {
Text('🧭 目的地横滑 · 签证状态一屏速览')
.fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text(`${this.tripList.length} 个行程`)
.fontSize(10).fontColor(COLORS.text3)
}.width('100%')
第一段是标题行:左侧标题"目的地横滑 · 签证状态一屏速览"以 13 号加粗冷白色展示,中间用 Column().layoutWeight(1) 占位推右,右侧显示当前行程总数以 10 号暗蓝灰弱化展示。标题行让用户进入 Tab 即知当前管理的行程数量和功能定位。
Scroll() {
Row({ space: 12 }) {
ForEach(this.tripList, (item: TripItem, idx: number) => {
Column({ space: 8 }) {
Row() {
Text(item.city).fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column().layoutWeight(1)
Text(`${item.days}天`).fontSize(12).fontColor(COLORS.bg)
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
.borderRadius(8).backgroundColor(COLORS.cyan)
}.width('100%')
Text(item.plan).fontSize(10).fontColor(COLORS.sub)
.maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis }).textAlign(TextAlign.Start)
Row({ space: 6 }) {
Circle({ width: 6, height: 6 }).fill(visaColor(item.visa))
Text(item.visa).fontSize(10).fontColor(visaColor(item.visa))
}.width('100%')
}
.width(170).padding(12).borderRadius(14)
.linearGradient({
angle: 135,
colors: [[COLORS.dark, 0], [COLORS.card, 1]]
})
}, (item: TripItem, idx: number) => `${idx}-${item.city}`)
}
.padding({ left: 2, right: 2 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
第二段是目的地横滑大卡:Scroll 横向滚动,内部 Row 以 12 像素间距排列卡片。每张卡片以 linearGradient 135° 渐变(dark 到 card)模拟夜航舷窗质感,卡片内含城市名(18 号加粗冷白)、天数徽标(霓虹青底深蓝字)、行程概要(10 号雾蓝灰,maxLines(2) 防止溢出)和签证色圆点(visaColor 函数映射的四色徽标)。横滑卡片宽度固定 170,padding(12) 内边距配合 borderRadius(14) 圆角营造沉浸式卡片体验。横滑大卡的 ForEach 键值生成采用 `${idx}-${item.city}` 复合键,保证列表增删时 ArkUI 差分渲染的正确性。
Column({ space: 8 }) {
ForEach(this.tripList, (item: TripItem, idx: number) => {
Row({ space: 10 }) {
Text('🧭').fontSize(16)
Column({ space: 3 }) {
Row({ space: 8 }) {
Text(item.city).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text(item.visa).fontSize(9).fontColor(visaColor(item.visa))
.padding({ left: 6, right: 6, top: 1, bottom: 1 })
.borderRadius(6).backgroundColor(COLORS.dark)
Text(`${item.days}天`).fontSize(10).fontColor(COLORS.text3)
}
Text(item.plan).fontSize(10).fontColor(COLORS.sub)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text('✏️').fontSize(14).onClick(() => { this.openEdit(idx); })
Text('🗑').fontSize(14).onClick(() => { this.openDel(idx); })
}
.width('100%').padding(12)
.borderRadius(12).backgroundColor(COLORS.card)
}, (item: TripItem, idx: number) => `row-${idx}-${item.city}`)
}.width('100%')
第三段是行程清单行:ForEach 遍历 tripList 渲染,每行以暗蓝灰底卡呈现,内含指南针 emoji、城市名(13 号加粗冷白)、签证类型徽标(visaColor 映射色,暗蓝灰底小徽章)、天数(10 号暗蓝灰)、行程概要(10 号雾蓝灰,maxLines(1) 截断)和编辑/删除两个操作图标。编辑图标(✏️)绑定 openEdit(idx) 方法回填表单缓存并打开编辑弹窗,删除图标(🗑)绑定 openDel(idx) 方法打开删除确认弹窗。清单行键值采用 `row-${idx}-${item.city}` 复合键格式,与横滑大卡的键值格式区分避免冲突。
Row() {
Text('+ 新增行程')
.fontSize(13).fontColor(COLORS.bg).fontWeight(FontWeight.Bold)
}
.width('100%').height(42).justifyContent(FlexAlign.Center)
.borderRadius(12).backgroundColor(COLORS.cyan)
.onClick(() => { this.openAdd(); })
this.chartCard()
}.width('100%')
}
第四段是新增入口和月度柱状图:新增入口为霓虹青底满宽按钮,高度 42 居中对齐,点击调用 openAdd() 清空表单缓存后打开新增弹窗。月度柱状图通过 this.chartCard() 调用独立 Builder 方法渲染,以 breath 状态联动柱子高度波动。行程 Tab 的四段结构从"概览(横滑大卡)→ 明细(清单行)→ 操作(新增入口)→ 统计(柱状图)"层层递进,形成完整的行程管理闭环。
9.2 Tab1 地图:双长按监听与事件日志
@Builder
tabMap() {
Column({ space: 10 }) {
Row({ space: 14 }) {
Toggle({ type: ToggleType.Switch, isOn: this.markerListenOn })
.selectedColor(COLORS.cyan).width(40).height(22)
.onChange(() => { this.toggleMarkerListen(); })
Text('Marker长按')
.fontSize(11).fontColor(this.markerListenOn ? COLORS.cyan : COLORS.text3)
Toggle({ type: ToggleType.Switch, isOn: this.poiListenOn })
.selectedColor(COLORS.orange).width(40).height(22)
.onChange(() => { this.togglePoiListen(); })
Text('POI长按')
.fontSize(11).fontColor(this.poiListenOn ? COLORS.orange : COLORS.text3)
Column().layoutWeight(1)
Text('长按地标/POI 试试')
.fontSize(9).fontColor(COLORS.text3)
}
.width('100%')
.padding({ left: 14, right: 14 })
地图 Tab 是 Map Kit 特性的核心载体。顶部双 Toggle 开关行分别控制 Marker 与 POI 长按监听的注册与清除:Marker 长按 Toggle 以霓虹青为选中色,POI 长按 Toggle 以落日橙为选中色,两种颜色与 typeColor 函数的徽标配色一致,形成"开关色即事件色"的视觉关联。onChange 回调分别绑定 toggleMarkerListen() 和 togglePoiListen() 方法,实现运行时随时注册/清除长按监听订阅。右侧提示文字"长按地标/POI 试试"引导用户尝试长按交互。
MapComponent({ mapOptions: this.mapOptions, mapCallback: this.mapCallback })
.layoutWeight(1).width('100%').borderRadius(12)
MapComponent 是地图 Tab 的本体,以 layoutWeight(1) 占满内容区剩余高度(这是地图 Tab 不进入 Scroll 容器的根本原因——MapComponent 需要有界高度才能正确渲染),width('100%') 撑满宽度,borderRadius(12) 圆角包裹。mapOptions 配置初始视野中心点和缩放级别,mapCallback 为初始化回调函数,在组件挂载后异步触发,依次完成五步初始化链路。
Column({ space: 6 }) {
Row() {
Text('📍 长按事件日志')
.fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text(`${this.eventLogs.length} 条`)
.fontSize(10).fontColor(COLORS.text3)
}.width('100%')
Scroll() {
Column({ space: 6 }) {
ForEach(this.eventLogs, (log: EventLog, idx: number) => {
Row({ space: 8 }) {
Text(log.type).fontSize(9).fontColor(COLORS.bg)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(6).backgroundColor(typeColor(log.type))
Text(log.name).fontSize(11).fontColor(COLORS.sub).layoutWeight(1)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text(`${log.lat.toFixed(4)}, ${log.lng.toFixed(4)}`)
.fontSize(9).fontColor(COLORS.text3).fontFamily('monospace')
Text(log.time).fontSize(9).fontColor(COLORS.text3)
}.width('100%')
}, (log: EventLog, idx: number) => `log-${idx}-${log.time}`)
}.width('100%')
}
.layoutWeight(1).width('100%')
.scrollBar(BarState.Off).edgeEffect(EdgeEffect.Spring)
Text('off 不传参=清除该类型全部订阅')
.fontSize(9).fontColor(COLORS.text3).fontFamily('monospace')
}
.width('100%').height(172)
.padding(10).borderRadius(12).backgroundColor(COLORS.card)
}
.width('100%').layoutWeight(1)
.padding({ left: 14, right: 14, top: 10, bottom: 10 })
}
底部长按事件日志流以暗蓝灰底卡呈现,固定 172 高度内可滚动。标题行显示"长按事件日志"和当前日志条数。日志流以 ForEach 渲染 eventLogs 数组,每条日志显示类型徽标(typeColor 配色,Marker 霓虹青、POI 落日橙)、名称(11 号雾蓝灰,maxLines(1) 截断)、坐标(toFixed(4) 保留四位小数,fontFamily('monospace') 等宽字体对齐)和时间戳(9 号暗蓝灰)。日志流底部附有 off 不传参=清除该类型全部订阅 的等宽字体提示,帮助开发者理解订阅管理语义。日志以 unshift 置顶最新事件并限制 12 条上限,保证日志窗口不会无限增长影响性能。
9.3 Tab2 搜索:关键字搜索与分数条
@Builder
tabSearch() {
Column({ space: 12 }) {
Row({ space: 8 }) {
TextInput({ text: this.queryInput, placeholder: '输入关键字,如:景点' })
.layoutWeight(1).height(38).fontSize(12).fontColor(COLORS.title)
.backgroundColor(COLORS.dark).placeholderColor(COLORS.text3).borderRadius(10)
.onChange((v: string) => { this.queryInput = v; })
Button('搜索').height(38).fontSize(12)
.backgroundColor(COLORS.cyan).fontColor(COLORS.bg).borderRadius(10)
.onClick(() => { this.runSearch(); })
}.width('100%')
搜索 Tab 由搜索框、快捷关键字 chips、状态文案和结果列表四段组成。搜索框行:TextInput 绑定 queryInput 状态,layoutWeight(1) 占满左侧空间,onChange 回调实时更新 queryInput。搜索按钮以霓虹青底深蓝字呈现,点击调用 runSearch() 异步方法发起 POI 搜索。
Row({ space: 8 }) {
ForEach(QUICK_QUERIES, (q: string) => {
Text(q).fontSize(11).fontColor(this.queryInput === q ? COLORS.bg : COLORS.sub)
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(12)
.backgroundColor(this.queryInput === q ? COLORS.cyan : COLORS.dark)
.onClick(() => {
this.queryInput = q;
this.runSearch();
})
}, (q: string) => q)
}.width('100%')
快捷关键字 chips:ForEach 遍历 QUICK_QUERIES 数组渲染六个 chips(景点、餐厅、地铁站、酒店、口岸、博物馆)。选中态以霓虹青底深蓝字高亮、未选中态以暗蓝灰底雾蓝灰字区分。点击即设置 queryInput 为该关键字并自动触发 runSearch() 搜索,降低了关键字输入成本。
Text(this.searchState)
.fontSize(11).fontColor(COLORS.text3).width('100%')
List({ space: 10 }) {
ForEach(this.searchRecords, (rec: SearchRecord, idx: number) => {
ListItem() {
Column({ space: 7 }) {
Row() {
Text(rec.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
.layoutWeight(1).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text(reliabilityScore(rec.reliability).label).fontSize(10)
.fontColor(reliabilityScore(rec.reliability).color)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(6).backgroundColor(COLORS.dark)
}.width('100%')
Text(rec.address).fontSize(10).fontColor(COLORS.sub).width('100%')
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 8 }) {
Text(`${rec.distance}m`).fontSize(10).fontColor(COLORS.text3).width(52)
Progress({ value: rec.reliability * 100, total: 100, type: ProgressType.Linear })
.layoutWeight(1).color(reliabilityScore(rec.reliability).color)
.backgroundColor(COLORS.dark).borderRadius(3)
Text(rec.reliability.toFixed(2)).fontSize(10).fontColor(COLORS.sub).width(36)
.fontFamily('monospace').textAlign(TextAlign.End)
}.width('100%')
}
.width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.card)
}
}, (rec: SearchRecord, idx: number) => `rec-${idx}-${rec.name}`)
}
.width('100%').scrollBar(BarState.Off)
}.width('100%')
}
状态文案:searchState 以 11 号暗蓝灰显示搜索状态(“待搜索”、“搜索中…”、“返回 N 条结果”、"搜索失败(code)"等),让用户了解搜索进度。
结果列表:List 以 10 像素间距渲染 searchRecords,每项显示名称(13 号加粗冷白)、等级标签(reliabilityScore 函数映射的三档标签和颜色)、地址(10 号雾蓝灰截断)、距离(以 m 简写显示,固定 52 宽度)、reliability 分数条(Progress 线性进度条以 0~1 映射 0~100)和分数值(toFixed(2) 保留两位小数,fontFamily('monospace') 等宽字体右对齐)。分数条颜色与等级标签同色,形成视觉一致性——高相关绿色、中相关橙色、低相关暗蓝灰。
9.4 Tab3 提醒:时间轴与通知授权
@Builder
tabRemind() {
Column({ space: 12 }) {
Column({ space: 8 }) {
Row({ space: 8 }) {
Circle({ width: 8, height: 8 })
.fill(this.granted ? COLORS.green : COLORS.orange)
.opacity(this.breath ? 1 : 0.4)
Text(this.granted ? '通知授权:已开启' : '通知授权:未开启')
.fontSize(13).fontWeight(FontWeight.Bold)
.fontColor(this.granted ? COLORS.green : COLORS.orange)
Column().layoutWeight(1)
if (!this.granted) {
Button('去授权')
.height(28).fontSize(11)
.backgroundColor(COLORS.orange).fontColor(COLORS.bg)
.borderRadius(14)
.onClick(() => { this.requestAuth(); })
}
}.width('100%')
Text('开启后可接收值机、集合、入住等行程提醒;铃声来自「铃音」Tab 的沙箱自定义铃声。')
.fontSize(10).fontColor(COLORS.text3).width('100%')
}
.width('100%').padding(12)
.borderRadius(12).backgroundColor(COLORS.card)
提醒 Tab 由通知授权卡、行程提醒时间轴、发布行程提醒和通知历史四段组成。通知授权卡以呼吸圆点(breath 联动透明度,1.0 与 0.4 交替)显示授权状态,已授权时通过绿、未授权时落日橙。未授权时显示"去授权"按钮调用 requestAuth() 方法发起授权流程。授权卡底部附有说明文字,告知用户铃声来自铃音 Tab 的沙箱自定义铃声,形成跨 Tab 联动的引导。
Text('⏰ 行程提醒时间轴')
.fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
Column() {
ForEach(this.remindList, (item: RemindItem, idx: number) => {
Row() {
Column({ space: 4 }) {
Text(item.time).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text(item.repeat).fontSize(9).fontColor(COLORS.text3)
}
.width(52).height('100%').justifyContent(FlexAlign.Center)
Column({ space: 4 }) {
Circle({ width: 8, height: 8 }).fill(item.on ? COLORS.cyan : COLORS.text3)
Column().layoutWeight(1).width(2)
.backgroundColor(item.on ? COLORS.cyan : COLORS.line)
}
.width(20).height('100%')
.alignItems(HorizontalAlign.Center).padding({ top: 10 })
Column({ space: 6 }) {
Text(item.title).fontSize(12).fontColor(item.on ? COLORS.title : COLORS.text3)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text(item.on ? '提醒开启中' : '已暂停')
.fontSize(9).fontColor(item.on ? COLORS.green : COLORS.text3)
}
.layoutWeight(1).height('100%')
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Start)
Toggle({ type: ToggleType.Switch, isOn: item.on })
.selectedColor(COLORS.cyan).width(40).height(22)
.onChange(() => { this.toggleRemind(idx); })
}
.width('100%').height(72).margin({ bottom: 6 })
.alignItems(VerticalAlign.Top)
.backgroundColor(COLORS.card).borderRadius(12)
.padding({ left: 12, right: 12 })
}, (item: RemindItem, idx: number) => `remind-${idx}-${item.time}`)
}.width('100%')
行程提醒时间轴:ForEach 遍历 remindList 渲染,每行固定 72 高度,由四部分组成:时间列(固定 52 宽度居中显示时刻与重复规则)、竖线列(圆点加竖线填充行高,竖线宽度 2 以 backgroundColor 着色,圆点 8x8 像素)、内容列(标题与状态文案)和开关列(Toggle 控件)。竖线颜色随 item.on 状态切换霓虹青或暗蓝灰,圆点同步变色形成"亮线即启用"的视觉直觉。时间轴行以 margin({ bottom: 6 }) 间隔排列,圆角 12 的暗蓝灰底卡增强层次感。toggleRemind 方法直接翻转 item.on 字段驱动 UI 刷新。
Column({ space: 8 }) {
Row({ space: 8 }) {
Text('🎵').fontSize(14)
Text('当前铃声:' + this.currentRing.name).fontSize(11).fontColor(COLORS.sub)
.layoutWeight(1).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.currentRing.inSandbox ? '沙箱已就绪' : '未生成').fontSize(9)
.fontColor(this.currentRing.inSandbox ? COLORS.green : COLORS.orange)
}.width('100%')
Row() {
Text('📣 发布行程提醒').fontSize(13).fontColor(COLORS.bg).fontWeight(FontWeight.Bold)
}
.width('100%').height(40).justifyContent(FlexAlign.Center)
.borderRadius(12).backgroundColor(COLORS.cyan)
.onClick(() => {
this.publishNotice('行程提醒', '集合出发前 30 分钟,请核对证件与签证材料。');
})
Text(this.noticeState).fontSize(10).fontColor(COLORS.text3).width('100%')
}
.width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.card)
发布行程提醒:以暗蓝灰底卡呈现,顶部显示当前铃声名和沙箱状态("沙箱已就绪"通过绿或"未生成"落日橙),中部为霓虹青底发布按钮,点击调用 publishNotice() 方法携带当前铃声的沙箱路径发布通知。发布后自动将通知记录 unshift 到历史流并限制 8 条上限,底部显示发布结果反馈文案 noticeState。
Column({ space: 8 }) {
Text('📜 通知历史')
.fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
ForEach(this.noticeLogs, (log: NoticeLog, idx: number) => {
Column({ space: 4 }) {
Row() {
Text(log.title).fontSize(11).fontWeight(FontWeight.Bold).fontColor(COLORS.sub)
Column().layoutWeight(1)
Text(log.time).fontSize(9).fontColor(COLORS.text3)
}.width('100%')
Text(log.text).fontSize(10).fontColor(COLORS.text3).width('100%')
.maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%').padding(10).borderRadius(10).backgroundColor(COLORS.dark)
}, (log: NoticeLog, idx: number) => `notice-${idx}-${log.time}`)
}.width('100%')
}.width('100%')
}
通知历史:以 NoticeLog 倒序流显示已发布通知的标题、正文和时间,每条记录以暗蓝灰底卡呈现,正文以 maxLines(2) 截断防止溢出。标题以雾蓝灰加粗显示,时间以暗蓝灰弱化展示,形成"标题为主、时间为辅"的信息层次。
9.5 Tab4 铃音:EL1 沙箱铃声库
@Builder
tabRing() {
Column({ space: 12 }) {
Column({ space: 8 }) {
Row({ space: 8 }) {
Text('🎵').fontSize(16)
Column({ space: 3 }) {
Text(this.currentRing.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text(`${this.currentRing.freq}Hz · ${this.currentRing.duration}ms · ${this.currentRing.size}`)
.fontSize(10).fontColor(COLORS.text3)
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text(this.currentRing.inSandbox ? 'EL1 ✓' : '未落盘').fontSize(10)
.fontColor(this.currentRing.inSandbox ? COLORS.green : COLORS.orange)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(10).backgroundColor(COLORS.dark)
}.width('100%')
Text('生成 WAV → 写入 EL1 files → 设默认 → sound 填 uri:: 前缀发布')
.fontSize(9).fontColor(COLORS.text3).width('100%')
}
.width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.card)
铃音 Tab 由当前铃声预览卡和铃声库列表两段组成。预览卡显示当前默认铃声的名称、频率、时长、大小和沙箱状态(inSandbox 为 true 显示"EL1 ✓"绿色徽标,否则显示"未落盘"落日橙徽标)。预览卡底部附有链路说明文字"生成 WAV → 写入 EL1 files → 设默认 → sound 填 uri:: 前缀发布",以等宽字体呈现完整的数据流路径,让用户理解铃声从生成到发布的技术链路。
Text('🔔 铃声库(点击「生成」写入 EL1 沙箱)')
.fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
Column({ space: 8 }) {
ForEach(this.ringList, (ring: RingItem, idx: number) => {
Row({ space: 10 }) {
Circle({ width: 8, height: 8 })
.fill(this.currentRing === ring ? COLORS.cyan : COLORS.line)
Column({ space: 3 }) {
Row({ space: 6 }) {
Text(ring.name)
.fontSize(12).fontWeight(FontWeight.Bold)
.fontColor(this.currentRing === ring ? COLORS.cyan : COLORS.title)
Text(ring.inSandbox ? '沙箱' : '未生成')
.fontSize(9).fontColor(ring.inSandbox ? COLORS.green : COLORS.text3)
}
Text(`${ring.file} · ${ring.freq}Hz · ${ring.duration}ms · ${ring.size}`)
.fontSize(9).fontColor(COLORS.text3)
.fontFamily('monospace')
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text('生成')
.fontSize(11).fontColor(COLORS.cyan)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.borderRadius(10).backgroundColor(COLORS.dark)
.onClick(() => { this.genRing(idx); })
Text('设默认')
.fontSize(11).fontColor(COLORS.bg)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.borderRadius(10)
.backgroundColor(this.currentRing === ring ? COLORS.cyanD : COLORS.cyan)
.onClick(() => { this.setCurrentRing(idx); })
}
.width('100%').padding(12)
.borderRadius(12).backgroundColor(COLORS.card)
}, (ring: RingItem, idx: number) => `ring-${idx}-${ring.name}`)
}.width('100%')
Text(this.noticeState)
.fontSize(10).fontColor(COLORS.text3).width('100%')
}.width('100%')
}
铃声库列表:每项包含选中圆点(当前铃声为霓虹青,否则暗蓝灰)、铃声名与沙箱状态("沙箱"绿色或"未生成"暗蓝灰)、文件信息(monospace 字体显示文件名、频率、时长、大小)、"生成"和"设默认"两个操作按钮。"生成"按钮以霓虹青文字暗蓝灰底呈现,点击调用 genRing(idx) 方法完成 WAV 生成与沙箱写入。"设默认"按钮以霓虹青底深蓝字呈现(当前铃声为深青底区分),点击调用 setCurrentRing(idx) 更新当前铃声引用。铃声库六项覆盖登机叮咚(990Hz)、口岸钟声(523Hz)、巴士到站(660Hz)、行李转盘(440Hz)、集合哨声(880Hz)和夜航星光(784Hz),频率与跨境出行场景一一对应。
9.6 Tab5 字幕:AI 字幕五区块
@Builder
tabCaption() {
Column({ space: 12 }) {
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.green : 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.cyan).fontColor(COLORS.bg)
.borderRadius(10)
.onClick(() => { this.captionShown = !this.captionShown; })
Button(`写入演示音频(${this.captionFed})`)
.fontSize(12).height(32).layoutWeight(1)
.backgroundColor(COLORS.dark).fontColor(COLORS.sub)
.borderRadius(10)
.onClick(() => { this.feedAudioStream(); })
}.width('100%')
}
.width('100%').padding(12)
.borderRadius(12).backgroundColor(COLORS.card)
字幕 Tab 由五区块组成。第一区块:实时预览区——嵌入 AICaptionComponent,以 isShown: this.captionShown 实现 @Link 双向绑定,buildCaptionOptions() 方法组装 AICaptionOptions 时填充 6.1.1 四新字段(sourceLanguage/targetLanguage/fontSize/fontColor)及 onPrepared/onError 回调。预览区高度固定 110,圆角 10 包裹字幕组件,captionReady 状态以"已就绪"绿色或"初始化中"暗蓝灰文字右上角标注。错误信息以 captionErrMsg 驱动红色双行截断文本显示。预览区底部两个按钮分别控制字幕显隐(captionShown 翻转)和演示音频写入(调用 feedAudioStream() 写入 640 字节 PCM 块并以 captionFed 计数显示已写入次数)。
Column({ space: 10 }) {
Text('🌐 语言设置(sourceLanguage → targetLanguage)')
.fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
Row({ space: 8 }) {
Text('源语言').fontSize(11).fontColor(COLORS.text3).width(48)
ForEach(SRC_LANGS, (opt: LangOption) => {
Text(opt.name)
.fontSize(11)
.fontColor(this.srcLang === opt.code ? COLORS.bg : COLORS.sub)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor(this.srcLang === opt.code ? COLORS.cyan : COLORS.dark)
.onClick(() => { this.switchSourceLang(opt.code); })
}, (opt: LangOption) => opt.code)
}.width('100%')
if (this.srcLang === 'zh') {
Row({ space: 8 }) {
Text('目标语言').fontSize(11).fontColor(COLORS.text3).width(48)
Text('中文(锁定 zh)')
.fontSize(11).fontColor(COLORS.purple)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(14).backgroundColor(COLORS.dark)
Text('中文源无翻译方向可选')
.fontSize(9).fontColor(COLORS.text3)
}.width('100%')
} else {
Row({ space: 8 }) {
Text('目标语言').fontSize(11).fontColor(COLORS.text3).width(48)
ForEach(TGT_LANGS_EN, (opt: LangOption) => {
Text(opt.name)
.fontSize(11)
.fontColor(this.tgtLang === opt.code ? COLORS.bg : COLORS.sub)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor(this.tgtLang === opt.code ? COLORS.purple : COLORS.dark)
.onClick(() => { this.tgtLang = opt.code; })
}, (opt: LangOption) => opt.code)
}.width('100%')
}
}
.width('100%').padding(12)
.borderRadius(12).backgroundColor(COLORS.card)
第二区块:语言设置区——通过 switchSourceLang() 方法实现源语言切换时联动目标语言:中文源时目标语言锁定 zh(无可选项,显示"中文源无翻译方向可选"提示文案),英文源时默认切双语 zh-en,用户可再选 zh 中文或 en 英文。英文源的目标语言选中态以星紫底色区分(与源语言的霓虹青底色形成视觉对比),让用户一眼分辨源与目标。源语言点击调用 switchSourceLang(opt.code),目标语言点击直接赋值 this.tgtLang = opt.code。
Column({ space: 10 }) {
Text('🔠 字幕字号(AICaptionFontSize 四档枚举)')
.fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
Row({ space: 8 }) {
ForEach(SIZE_OPTIONS, (opt: SizeOption) => {
Column({ space: 4 }) {
Text(opt.name === '超大' ? '大A' : (opt.name === '大号' ? '大' : (opt.name === '小号' ? '小' : '标')))
.fontSize(opt.size === AICaptionFontSize.LARGE ? 20 : (opt.size === AICaptionFontSize.BIG ? 17 : (opt.size === AICaptionFontSize.SMALL ? 11 : 14)))
.fontColor(this.captionSize === opt.size ? COLORS.cyan : COLORS.sub)
Text(opt.name)
.fontSize(9)
.fontColor(this.captionSize === opt.size ? COLORS.cyan : COLORS.text3)
}
.layoutWeight(1)
.padding({ top: 8, bottom: 8 })
.borderRadius(10)
.backgroundColor(this.captionSize === opt.size ? COLORS.dark : COLORS.card)
.border({
width: this.captionSize === opt.size ? 1 : 0,
color: this.captionSize === opt.size ? COLORS.cyan : COLORS.line
})
.onClick(() => { this.captionSize = opt.size; })
}, (opt: SizeOption) => opt.name)
}.width('100%')
}
.width('100%').padding(12)
.borderRadius(12).backgroundColor(COLORS.card)
第三区块:字号四档区——以 AICaptionFontSize 枚举驱动,每档卡片以不同字号大小的字母示例(小/标/大/大A)直观展示尺寸差异,选中项加 1px 霓虹青边框并以暗蓝灰底突出。字母示例的 fontSize 随枚举值递增(SMALL 为 11、NORMAL 为 14、BIG 为 17、LARGE 为 20),让用户在点击前即可预览字幕的实际尺寸。
Column({ space: 10 }) {
Text('🎨 字幕颜色(fontColor · ResourceColor)')
.fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
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.cyan : COLORS.line
})
Text(this.captionColor === c ? '使用中' : `#${idx + 1}`)
.fontSize(8)
.fontColor(this.captionColor === c ? COLORS.cyan : 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)
第四区块:颜色五色卡——以 30x30 圆形色块展示五色预设(经典白、霓虹青、暖橙、薄荷绿、樱花粉),选中项加 2px 霓虹青边框并以"使用中"文字标注,未选中项加 1px 暗蓝灰边框以序号标注。点击即设置 captionColor 为对应颜色字符串。
Column({ space: 8 }) {
Text('🧳 跨境讲解场景(点击套用推荐语言组合)')
.fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
ForEach(CAPTION_SCENES, (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)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text(sc.src + '→' + sc.tgt)
.fontSize(9).fontColor(COLORS.cyan)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(10).backgroundColor(COLORS.dark)
}
.width('100%').padding(12)
.borderRadius(12).backgroundColor(COLORS.card)
.onClick(() => { this.applyScene(idx); })
}, (sc: CaptionScene, idx: number) => `scene-${idx}-${sc.scene}`)
if (this.captionErrMsg !== '') {
Text('onError 兜底:' + this.captionErrMsg)
.fontSize(9).fontColor(COLORS.red).width('100%')
}
}.width('100%')
}.width('100%')
}
第五区块:场景推荐区——五张卡片点击调用 applyScene(idx) 一键套用推荐的 src/tgt 语言组合,每张卡片以导游 emoji 起首,标题加粗、说明文案以暗蓝灰弱化,右侧以霓虹青徽标展示推荐语言组合(如"en→zh-en")。底部附有 onError 兜底的红色错误信息文本,确保异常状态可见。
9.7 Tab6 我的:旅行家渐变大卡
@Builder
tabMine() {
Column({ space: 12 }) {
Column({ space: 12 }) {
Row({ space: 10 }) {
Text('🧑✈️').fontSize(30)
Column({ space: 3 }) {
Text('环球线旅行家 · VoyagerPro')
.fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Text('开通 680 天 · 出境导航终身版')
.fontSize(10).fontColor(COLORS.sub)
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Circle({ width: 8, height: 8 })
.fill(COLORS.cyan)
.opacity(this.breath ? 1 : 0.3)
}.width('100%')
Row() {
Column({ space: 3 }) {
Text('12').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.cyan)
Text('足迹国家').fontSize(9).fontColor(COLORS.sub)
}.layoutWeight(1)
Column({ space: 3 }) {
Text('38').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.orange)
Text('解锁城市').fontSize(9).fontColor(COLORS.sub)
}.layoutWeight(1)
Column({ space: 3 }) {
Text('28').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.purple)
Text('出行天数').fontSize(9).fontColor(COLORS.sub)
}.layoutWeight(1)
Column({ space: 3 }) {
Text('6').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.green)
Text('待启程').fontSize(9).fontColor(COLORS.sub)
}.layoutWeight(1)
}.width('100%')
}
.width('100%').padding(16)
.borderRadius(16)
.linearGradient({
angle: 160,
colors: [[COLORS.cyanD, 0], [COLORS.dark, 0.55], [COLORS.card, 1]]
})
我的 Tab 由旅行家渐变大卡、足迹国家清单行和版本声明三段组成。渐变大卡以 linearGradient 160° 三色渐变(cyanD → dark → card)模拟夜航舷窗质感,160° 角度使渐变从左上向右下流动,形成类似舷窗外天际线的视觉层次。大卡内含旅行家称号(“环球线旅行家 · VoyagerPro”)、开通天数(“开通 680 天 · 出境导航终身版”)和四列统计数字(足迹国家 12 霓虹青、解锁城市 38 落日橙、出行天数 28 星紫、待启程 6 通过绿),四列以 layoutWeight(1) 等宽排列,数字以 20 号加粗字体突出、标签以 9 号雾蓝灰弱化,形成强烈的视觉层次对比。呼吸圆点以 breath 联动透明度(1.0 与 0.3 交替),与头部呼吸圆点形成跨 Tab 的"生命体征"节拍呼应。
Text('🌏 足迹国家 / 地区')
.fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
Column({ space: 8 }) {
ForEach(FOOT_ROWS, (row: FootRow, idx: number) => {
Row({ space: 10 }) {
Text(row.flag).fontSize(20)
Text(row.name)
.fontSize(13).fontColor(COLORS.title)
.layoutWeight(1)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text(`${row.cities} 城`)
.fontSize(11).fontColor(COLORS.sub)
Text(row.last)
.fontSize(10).fontColor(COLORS.text3)
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
.borderRadius(8).backgroundColor(COLORS.dark)
}
.width('100%').padding(12)
.borderRadius(12).backgroundColor(COLORS.card)
}, (row: FootRow, idx: number) => `foot-${idx}-${row.name}`)
}.width('100%')
Column({ space: 4 }) {
Text('出境导航 v6.1.1 · Map Kit + Speech Kit + Notification Kit')
.fontSize(9).fontColor(COLORS.text3).width('100%')
.textAlign(TextAlign.Center)
Text('多语旅行服务 · 深色主题')
.fontSize(9).fontColor(COLORS.text3).width('100%')
.textAlign(TextAlign.Center)
}
.width('100%').padding({ top: 4, bottom: 4 })
}.width('100%')
}
足迹国家清单行:6 项以国旗 emoji 标识(中国香港、日本、泰国、新加坡、韩国、中国澳门),每行显示国家名、解锁城市数("18 城"等)和最近到访月份(以暗蓝灰底小徽标呈现),maxLines(1) 与 TextOverflow.Ellipsis 防止国家名溢出破坏行布局。
版本声明行:以居中文字呈现平台版本号与三大 Kit 特性标识,底部说明"多语旅行服务 · 深色主题"作为视觉收尾。
十、月度柱状图与底部 Tab 栏
10.1 月度出行柱状图
@Builder
chartCard() {
Column({ space: 10 }) {
Row() {
Text('📊 近 6 个月跨境出行天数')
.fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('合计 28 天')
.fontSize(10).fontColor(COLORS.text3)
}.width('100%')
Row({ space: 10 }) {
ForEach(MONTH_DAYS, (v: number, idx: number) => {
Column({ space: 6 }) {
Text(`${v}天`)
.fontSize(10).fontColor(COLORS.sub)
Column() {
Column()
.width('100%')
.height(this.breath ? 14 + v * 9 : 12 + v * 9)
.borderRadius(4)
.linearGradient({
angle: 180,
colors: [[COLORS.cyan, 0], [COLORS.cyanD, 1]]
})
}
.height(92).width(20)
.justifyContent(FlexAlign.End)
Text(MONTH_NAME[idx])
.fontSize(10).fontColor(COLORS.text3)
}.layoutWeight(1)
}, (v: number, idx: number) => `month-${idx}-${v}`)
}.width('100%')
}
.width('100%').padding(12)
.borderRadius(12).backgroundColor(COLORS.card)
}
柱状图使用 Column 嵌套 ForEach 传统绘制方式而非 Canvas,每根柱子高度为 v * 9 + 基准值,breath 状态在 true/false 间切换基准值(14 与 12),实现 ±2px 微波动模拟实时数据刷新效果。柱子以 180° 纵向渐变(cyan 到 cyanD)模拟霓虹灯光由顶向底渐暗。外层 Column 固定 92 高度并 justifyContent(FlexAlign.End) 让柱子从底部对齐生长。每根柱子顶部显示天数标签、底部显示月份标签,形成完整的"标签-柱-标签"数据可视化结构。标题行右侧显示"合计 28 天"汇总数据,让用户快速掌握出行总量。
10.2 底部 Tab 栏
@Builder
tabBar() {
Row() {
ForEach(TAB_LIST, (tab: TabMeta, idx: number) => {
Column({ space: 3 }) {
Text(tab.icon).fontSize(18).opacity(this.currentTab === idx ? 1 : 0.55)
Text(tab.label).fontSize(9)
.fontColor(this.currentTab === idx ? COLORS.tabOn : COLORS.text3)
.fontWeight(this.currentTab === idx ? FontWeight.Bold : FontWeight.Normal)
}.layoutWeight(1).padding({ top: 7, bottom: 7 }).onClick(() => {
this.currentTab = idx;
})
}, (tab: TabMeta, idx: number) => `tab-${idx}-${tab.label}`)
}
.width('100%').backgroundColor(COLORS.card)
.border({ width: { top: 1 }, color: COLORS.line })
}
底部 Tab 栏单排 7 项等宽布局,每项以 Column 纵向排列 emoji 图标和中文标签。选中态图标全透明度(opacity(1))、文字霓虹青加粗,未选中态图标 0.55 透明度、文字暗蓝灰常规字重。选中态与非选中态的对比通过三个维度实现:透明度(1 vs 0.55)、颜色(霓虹青 vs 暗蓝灰)、字重(加粗 vs 常规),三重对比确保选中态在任何视觉环境下都清晰可辨。
顶部 1px 分割线以 COLORS.line 深蓝色低对比不干扰,border({ width: { top: 1 }, color: COLORS.line }) 仅设置上边框。点击切换 currentTab 即驱动内容区 Builder 方法切换和头部副标题联动更新。ForEach 的键值生成采用 `tab-${idx}-${tab.label}` 复合键格式,保证 Tab 列表渲染的稳定性。每项 padding({ top: 7, bottom: 7 }) 保证足够的点击热区,提升触摸友好性。
十一、Map Kit 方法群深度解析
11.1 双长按监听注册
bindMarkerLongClick() {
if (!this.mapEventManager) {
return;
}
this.mapEventManager.onMarkerLongClick((marker: map.Marker) => {
const pos = marker.getPosition();
this.eventLogs.unshift(new EventLog('Marker',
'#' + marker.getId() + ' 地标标注', pos.latitude, pos.longitude, nowTime()));
if (this.eventLogs.length > 12) {
this.eventLogs.pop();
}
});
}
bindPoiLongClick() {
if (!this.mapEventManager) {
return;
}
this.mapEventManager.onPoiLongClick((poi: mapCommon.Poi) => {
this.eventLogs.unshift(new EventLog('POI',
poi.name ?? '未命名 POI', poi.position.latitude, poi.position.longitude, nowTime()));
if (this.eventLogs.length > 12) {
this.eventLogs.pop();
}
});
}
bindMarkerLongClick 注册 Marker 长按监听:通过 mapEventManager.onMarkerLongClick 注册回调函数,回调参数为 map.Marker 类型。长按地标标注时,通过 marker.getPosition() 获取标注坐标,通过 marker.getId() 获取标注 ID,组装 EventLog 实例(type 为"Marker"、name 为"#ID 地标标注"格式)后 unshift 置顶到事件日志流。当日志超过 12 条时 pop 移除末尾,保证日志窗口不会无限增长。
bindPoiLongClick 注册 POI 长按监听:通过 mapEventManager.onPoiLongClick 注册回调函数,回调参数为 mapCommon.Poi 类型(仅含 id、name、position 三字段)。长按兴趣点时,通过 poi.name 获取 POI 名称(以 ?? '未命名 POI' 空值合并兜底),通过 poi.position 获取坐标,组装 EventLog 实例后 unshift 置顶。两种监听共享同一个日志流 eventLogs,通过 type 字段区分来源。
11.2 地图初始化五步链路
setupMapCallback() {
this.mapCallback = async (err: BusinessError, mapController: map.MapComponentController) => {
if (err) {
console.error(`Map init failed, code: ${err.code}, message: ${err.message}`);
return;
}
this.mapController = mapController;
this.mapEventManager = mapController.getEventManager();
for (const spot of MARKER_SPOTS) {
const markerOptions: mapCommon.MarkerOptions = {
position: { latitude: spot.lat, longitude: spot.lng },
clickable: true,
visible: true,
rotation: 0,
zIndex: 0,
alpha: 1,
anchorU: 0.5,
anchorV: 1,
draggable: false,
flat: false
};
try {
await this.mapController.addMarker(markerOptions);
} catch (e) {
console.error(`addMarker failed: ${(e as BusinessError).message}`);
}
}
this.bindMarkerLongClick();
this.bindPoiLongClick();
};
}
setupMapCallback 方法设置地图初始化回调函数,该回调在 MapComponent 初始化完成时被异步触发,依次完成五步链路:
第一步:错误检查——回调参数 err 不为空时打印错误日志并返回,阻止后续初始化。第二步:控制器获取——将 mapController 赋值给 this.mapController 供后续方法使用。第三步:事件管理器获取——通过 mapController.getEventManager() 获取事件管理器实例赋值给 this.mapEventManager,供后续注册长按监听。第四步:批量 Marker 添加——遍历 MARKER_SPOTS 数组,为每个标注点构造 MarkerOptions 配置项(包含 position、clickable、visible、rotation、zIndex、alpha、anchorU、anchorV、draggable、flat 共十个字段),逐个 await addMarker 异步添加。anchorU 和 anchorV 设为 0.5 和 1,使标注图标的锚点位于底部中心;clickable 设为 true 允许标注被点击交互;flat 设为 false 使标注始终朝向屏幕不随地图旋转。第五步:双长按监听注册——标注全部就绪后调用 bindMarkerLongClick() 和 bindPoiLongClick() 注册双长按监听。
11.3 监听开关与关键字搜索
toggleMarkerListen() {
if (!this.mapEventManager) {
return;
}
if (this.markerListenOn) {
this.mapEventManager.offMarkerLongClick();
} else {
this.bindMarkerLongClick();
}
this.markerListenOn = !this.markerListenOn;
}
togglePoiListen() {
if (!this.mapEventManager) {
return;
}
if (this.poiListenOn) {
this.mapEventManager.offPoiLongClick();
} else {
this.bindPoiLongClick();
}
this.poiListenOn = !this.poiListenOn;
}
async runSearch() {
this.searchState = '搜索中…';
const params: site.SearchByTextParams = {
query: this.queryInput,
location: CITY_CENTER,
radius: 5000,
language: 'zh'
};
try {
const result: site.SearchByTextResult = await site.searchByText(params);
const sites: site.Site[] = result.sites ?? [];
if (sites.length === 0) {
this.searchState = '无结果,已保留当前推荐';
return;
}
this.searchRecords = sites.map((s: site.Site) => new SearchRecord(
s.name ?? '未命名地点', s.formatAddress ?? '暂无地址',
s.distance ?? 0, s.reliability ?? 0));
this.searchState = `返回 ${sites.length} 条结果`;
} catch (e) {
const err = e as BusinessError;
this.searchState = `搜索失败(${err.code}),保留当前推荐`;
}
}
toggleMarkerListen 和 togglePoiListen 实现运行时监听订阅的动态管理:当前为开启状态时调用 offMarkerLongClick()/offPoiLongClick()(不传参表示清除该类型全部订阅)关闭监听,当前为关闭状态时调用 bindMarkerLongClick()/bindPoiLongClick() 重新注册监听,最后翻转布尔开关状态。
runSearch 异步方法发起 POI 关键字搜索:构造 SearchByTextParams(query 为关键字、location 为 CITY_CENTER 香港中环坐标、radius 为 5000 米覆盖主要游客活动区域、language 为 zh 返回中文结果)调用 site.searchByText。成功时将 sites 数组映射为 SearchRecord 列表——每个 site.Site 的 name、formatAddress、distance、reliability 均以 ?? 空值合并兜底,确保可选字段缺失时不崩溃;无结果时保留当前推荐并提示;失败时(如无 AGC 配置或无网络)保留 Mock 数据并提示错误码,体现调用链的完整性与容错设计。
十二、Speech Kit 方法群深度解析
12.1 字幕选项组装与语言联动
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;
}
switchSourceLang(code: string) {
this.srcLang = code;
if (code === 'zh') {
this.tgtLang = 'zh';
} else {
this.tgtLang = 'zh-en';
}
}
applyScene(idx: number) {
const sc = CAPTION_SCENES[idx];
this.srcLang = sc.src;
this.tgtLang = sc.tgt;
}
buildCaptionOptions 方法组装 AICaptionOptions 配置对象:initialOpacity 设为 1(字幕初始不透明)、四个 6.1.1 新字段(sourceLanguage/targetLanguage/fontSize/fontColor)填充当前状态值、onPrepared 回调在字幕服务就绪后置 captionReady 为 true 并清空错误信息、onError 回调捕获异常并写入 captionErrMsg 供界面兜底展示。该方法在 AICaptionComponent 的 options 参数中被调用,每次状态变化都会重新组装配置。
switchSourceLang 方法实现源语言切换时联动目标语言:中文源时目标语言锁定 zh(无可选项),英文源时默认切双语 zh-en。这种联动设计避免了中文源时选择英文翻译的无效操作。
applyScene 方法一键套用场景推荐的语言组合:从 CAPTION_SCENES 数组取对应索引的场景,将其 src 和 tgt 赋值给当前状态,免去用户手动逐项配置。
12.2 演示音频流写入
sizeEnumName(): string {
if (this.captionSize === AICaptionFontSize.SMALL) {
return 'SMALL';
}
if (this.captionSize === AICaptionFontSize.BIG) {
return 'BIG';
}
if (this.captionSize === AICaptionFontSize.LARGE) {
return 'LARGE';
}
return 'NORMAL';
}
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 = '音频写入失败:' + (e as BusinessError).message;
}
}
feedAudioStream 方法生成 640 字节 PCM 块(16kHz/16bit/单声道约 20ms)调用 writeAudio 写入字幕服务:以 440Hz 正弦波生成采样数据,每个采样用两个字节表示(低字节在前、高字节在后的小端序),振幅为 6000(16bit 范围内的中等音量)。writeAudio 调用成功后递增 captionFed 计数器,失败时写入错误信息到 captionErrMsg。这个方法模拟了实时语音输入的音频流,让用户在没有真实麦克风输入的情况下也能体验字幕生成功能。
十三、Notification Kit 方法群深度解析
13.1 沙箱铃声写入与生成
saveRingToSandbox(fileName: string, freq: number, durationMs: number): string {
const hostCtx = this.getUIContext().getHostContext() as common.UIAbilityContext;
if (!hostCtx) {
return '';
}
const appCtx = hostCtx.getApplicationContext();
appCtx.area = contextConstant.AreaMode.EL1;
const dir = appCtx.filesDir;
const path = dir + '/' + fileName;
try {
const data = buildWavBytes(freq, durationMs);
const file = fs.openSync(path, fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY | fs.OpenMode.TRUNC);
fs.writeSync(file.fd, data);
fs.closeSync(file);
} catch (e) {
console.error(`saveRing failed: ${(e as BusinessError).message}`);
return '';
}
return path;
}
genRing(idx: number) {
const ring = this.ringList[idx];
const path = this.saveRingToSandbox(ring.file, ring.freq, ring.duration);
if (path !== '') {
ring.inSandbox = true;
const kb = Math.round((44 + 44100 * ring.duration / 1000 * 2) / 1024);
ring.size = `${kb} KB`;
this.noticeState = `「${ring.name}」已生成到 EL1 沙箱`;
} else {
this.noticeState = '沙箱写入失败,请重试';
}
}
setCurrentRing(idx: number) {
this.currentRing = this.ringList[idx];
}
saveRingToSandbox 方法严格遵循 EL1 沙箱规范:通过 getUIContext().getHostContext() 获取上下文(已废弃 getContext(this)),转型为 common.UIAbilityContext;获取应用上下文后设置 appCtx.area = contextConstant.AreaMode.EL1 切换到 EL1 加密等级沙箱;取 filesDir 目录作为铃声存储路径;调用 buildWavBytes 生成 WAV 数据后以 fs.openSync 创建文件(CREATE | WRITE_ONLY | TRUNC 模式)、fs.writeSync 写入字节、fs.closeSync 关闭文件。成功返回沙箱路径,失败返回空字符串。
genRing 方法是铃声生成链路的核心:调用 saveRingToSandbox 将 WAV 写入 EL1 沙箱,成功后更新 ring.inSandbox 为 true 并计算文件大小(KB = (44 字节头 + 采样率 × 时长 / 1000 × 2 字节) / 1024),更新 noticeState 反馈生成结果。setCurrentRing 方法更新当前铃声引用,发布通知时 sound 字段取当前铃声的沙箱路径。
13.2 通知发布与授权流程
soundPreview(): string {
const hostCtx = this.getUIContext().getHostContext() as common.UIAbilityContext;
if (!hostCtx) {
return "sound: 'uri::' + fileUri.getUriFromPath(path)";
}
const appCtx = hostCtx.getApplicationContext();
appCtx.area = contextConstant.AreaMode.EL1;
const path = appCtx.filesDir + '/' + this.currentRing.file;
return "sound: 'uri::' + fileUri.getUriFromPath('" + path + "')";
}
publishNotice(title: string, text: string) {
const hostCtx = this.getUIContext().getHostContext() as common.UIAbilityContext;
if (!hostCtx) {
this.noticeState = '上下文不可用,发布取消';
return;
}
const appCtx = hostCtx.getApplicationContext();
appCtx.area = contextConstant.AreaMode.EL1;
const sandboxPath = appCtx.filesDir + '/' + this.currentRing.file;
const uri = fileUri.getUriFromPath(sandboxPath);
const request: notificationManager.NotificationRequest = {
id: this.notifyId++,
notificationSlotType: notificationManager.SlotType.SOCIAL_COMMUNICATION,
content: {
notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
normal: {
title: title,
text: text,
additionalText: '铃声:' + this.currentRing.name
}
},
sound: 'uri::' + uri
};
notificationManager.publish(request).then(() => {
this.noticeState = `通知已发布(id ${this.notifyId - 1})`;
this.noticeLogs.unshift(new NoticeLog(title, text + ' · 铃声 ' + this.currentRing.name, nowTime()));
if (this.noticeLogs.length > 8) {
this.noticeLogs.pop();
}
}).catch((err: BusinessError) => {
this.noticeState = '发布失败 ' + err.code + ':' + err.message;
});
}
requestAuth() {
const hostCtx = this.getUIContext().getHostContext() as common.UIAbilityContext;
if (!hostCtx) {
return;
}
notificationManager.requestEnableNotification(hostCtx).then(() => {
this.granted = true;
this.noticeState = '通知授权已开启';
}).catch((err: BusinessError) => {
notificationManager.openNotificationSettings(hostCtx).then(() => {
}).catch((e2: BusinessError) => {
this.granted = false;
this.noticeState = '授权失败 ' + e2.code + ':' + e2.message;
});
});
}
publishNotice 方法发布携带沙箱自定义铃声的通知:构造 NotificationRequest 结构,id 为自增通知 ID(notifyId++),notificationSlotType 设为 SOCIAL_COMMUNICATION 社交通信类型,content.normal 包含 title、text、additionalText 三级文本(附加文本标注铃声名),sound 字段以 'uri::' + uri 前缀格式填入沙箱铃声 URI。发布成功后将通知记录 unshift 到历史流并限制 8 条上限,更新 noticeState 反馈发布结果;失败时写入错误码和错误信息。
requestAuth 方法处理通知授权流程:调用 requestEnableNotification(hostCtx) 发起首次授权弹窗(必须传 context 参数,无参版本已废弃)。成功时置 granted 为 true;失败时(曾被拒绝返回错误码 1600004)调用 openNotificationSettings(hostCtx) 拉起系统通知设置页引导用户手动开启二次授权,形成"首次弹窗 → 拒绝 → 拉起设置页"的完整授权引导链路。
十四、弹窗系统
14.1 遮罩层
@Builder
modalOverlay(onClose: () => void) {
Column() {
Column()
.width('100%').height('100%')
.backgroundColor(COLORS.mask)
}
.width('100%').height('100%')
.onClick(() => {
onClose();
})
}
弹窗系统采用 Stack 层叠遮罩层与表单内容:modalOverlay 为全屏半透黑遮罩(COLORS.mask 为 rgba(0,0,0,0.6) 60% 透明黑色),点击空白处触发 onClose 回调关闭弹窗。遮罩层作为独立 Builder 方法被三个弹窗共享调用,保证了遮罩行为的一致性。Column 嵌套 Column 的结构确保遮罩层在 Stack 中作为底层铺满全屏,表单内容卡在上层居中呈现。
14.2 新增行程弹窗
@Builder
panelAdd(onClose: () => void) {
Stack() {
this.modalOverlay(onClose)
Column({ space: 14 }) {
Row() {
Text('🧭 新增行程').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column().layoutWeight(1)
Text('✕').fontSize(14).fontColor(COLORS.text3).onClick(() => { onClose(); })
}.width('100%')
Text('目的地城市').fontSize(11).fontColor(COLORS.sub).width('100%')
TextInput({ text: this.formCity, placeholder: '如:伦敦' })
.height(38).fontSize(12).fontColor(COLORS.title)
.backgroundColor(COLORS.dark).placeholderColor(COLORS.text3).borderRadius(10)
.onChange((v: string) => { this.formCity = v; })
Text('行程天数').fontSize(11).fontColor(COLORS.sub).width('100%')
TextInput({ text: this.formDays, placeholder: '如:5' })
.height(38).fontSize(12).fontColor(COLORS.title)
.backgroundColor(COLORS.dark).placeholderColor(COLORS.text3).borderRadius(10)
.type(InputType.Number)
.onChange((v: string) => { this.formDays = v; })
Text('签证类型').fontSize(11).fontColor(COLORS.sub).width('100%')
TextInput({ text: this.formVisa, placeholder: '免签 / 落地签 / 电子签 / 需面签' })
.height(38).fontSize(12).fontColor(COLORS.title)
.backgroundColor(COLORS.dark).placeholderColor(COLORS.text3).borderRadius(10)
.onChange((v: string) => { this.formVisa = v; })
Text('行程概要').fontSize(11).fontColor(COLORS.sub).width('100%')
TextInput({ text: this.formPlan, placeholder: '如:大本钟 · 泰晤士游船' })
.height(38).fontSize(12).fontColor(COLORS.title)
.backgroundColor(COLORS.dark).placeholderColor(COLORS.text3).borderRadius(10)
.onChange((v: string) => { this.formPlan = v; })
Row({ space: 10 }) {
Button('取消')
.layoutWeight(1).height(38).fontSize(13)
.backgroundColor(COLORS.dark).fontColor(COLORS.sub).borderRadius(10)
.onClick(() => { onClose(); })
Button('确认新增')
.layoutWeight(1).height(38).fontSize(13)
.backgroundColor(COLORS.cyan).fontColor(COLORS.bg).borderRadius(10)
.onClick(() => { this.doAdd(); })
}.width('100%')
}
.width('86%').padding(18).borderRadius(16).backgroundColor(COLORS.card)
}
.width('100%').height('100%').alignContent(Alignment.Center)
}
新增弹窗 panelAdd 包含城市、天数(InputType.Number 数字键盘)、签证类型、行程概要四个 TextInput,每个输入框上方有标签说明。标题行右侧的 ✕ 图标和底部的取消按钮均可关闭弹窗。确认按钮调用 doAdd() 方法:以 Number.parseInt 解析天数,空字段以默认值兜底(城市"未命名目的地"、天数 3、签证"免签"、概要"行程待规划"),组装 TripItem 后 unshift 置顶行程列表。弹窗内容卡宽度 86%,以 alignContent(Alignment.Center) 在 Stack 中居中呈现,圆角 16 暗蓝灰底卡。
14.3 编辑行程弹窗
@Builder
panelEdit(onClose: () => void) {
Stack() {
this.modalOverlay(onClose)
Column({ space: 14 }) {
Row() {
Text('✏️ 编辑行程').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
Column().layoutWeight(1)
Text('✕').fontSize(14).fontColor(COLORS.text3).onClick(() => { onClose(); })
}.width('100%')
Text('目的地城市').fontSize(11).fontColor(COLORS.sub).width('100%')
TextInput({ text: this.formCity, placeholder: '目的地' })
.height(38).fontSize(12).fontColor(COLORS.title)
.backgroundColor(COLORS.dark).placeholderColor(COLORS.text3).borderRadius(10)
.onChange((v: string) => { this.formCity = v; })
Text('行程天数').fontSize(11).fontColor(COLORS.sub).width('100%')
TextInput({ text: this.formDays, placeholder: '天数' })
.height(38).fontSize(12).fontColor(COLORS.title)
.backgroundColor(COLORS.dark).placeholderColor(COLORS.text3).borderRadius(10)
.type(InputType.Number)
.onChange((v: string) => { this.formDays = v; })
Row({ space: 10 }) {
Button('取消')
.layoutWeight(1).height(38).fontSize(13)
.backgroundColor(COLORS.dark).fontColor(COLORS.sub).borderRadius(10)
.onClick(() => { onClose(); })
Button('保存修改')
.layoutWeight(1).height(38).fontSize(13)
.backgroundColor(COLORS.cyan).fontColor(COLORS.bg).borderRadius(10)
.onClick(() => { this.doEdit(); })
}.width('100%')
}
.width('86%').padding(18).borderRadius(16).backgroundColor(COLORS.card)
}
.width('100%').height('100%').alignContent(Alignment.Center)
}
编辑弹窗 panelEdit 在 openEdit 时通过 formCity = t.city、formDays = t.days.toString()、formVisa = t.visa、formPlan = t.plan 逐一回填当前行程字段到表单缓存,使弹窗打开时输入框已显示当前行程的数据。确认时 doEdit 就地修改 @Observed 实例字段——空字段保留原值(formCity.trim() === '' ? t.city : formCity.trim()),天数解析失败保留原值(Number.isNaN(days) ? t.days : days),确保编辑不会意外清空已有数据。这种"就地修改"模式利用 @Observed 的字段级观察特性,修改后横滑大卡和清单行自动刷新,无需手动触发列表重渲染。
14.4 删除行程确认弹窗
@Builder
panelDel(onClose: () => void) {
Stack() {
this.modalOverlay(onClose)
Column({ space: 16 }) {
Text('🗑 删除行程')
.fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
if (this.delIdx >= 0 && this.delIdx < this.tripList.length) {
Text(`确认删除「${this.tripList[this.delIdx].city}」的行程吗?删除后不可恢复。`)
.fontSize(12).fontColor(COLORS.sub).width('100%')
.textAlign(TextAlign.Center)
} else {
Text('未选中有效行程')
.fontSize(12).fontColor(COLORS.sub).width('100%')
.textAlign(TextAlign.Center)
}
Row({ space: 10 }) {
Button('取消')
.layoutWeight(1).height(38).fontSize(13)
.backgroundColor(COLORS.dark).fontColor(COLORS.sub)
.borderRadius(10)
.onClick(() => { onClose(); })
Button('确认删除')
.layoutWeight(1).height(38).fontSize(13)
.backgroundColor(COLORS.red).fontColor(COLORS.title)
.borderRadius(10)
.onClick(() => { this.doDel(); })
}.width('100%')
}
.width('78%').padding(20)
.borderRadius(16).backgroundColor(COLORS.card)
}
.width('100%').height('100%')
.alignContent(Alignment.Center)
}
删除弹窗 panelDel 显示目标城市名与"删除后不可恢复"警告文案,以居中对齐的提示语增强危险操作的仪式感。delIdx 边界检查(>= 0 && < tripList.length)确保索引有效时才显示城市名,否则显示"未选中有效行程"的兜底文案。确认按钮以 COLORS.red 警示红底色强调危险操作,doDel 调用 splice 从列表移除目标索引项。删除弹窗内容卡宽度 78%(比新增和编辑的 86% 更窄),使删除确认弹窗在视觉上更聚焦、更具"仪式感"。
14.5 CRUD 方法群
openAdd() {
this.formCity = '';
this.formDays = '';
this.formVisa = '免签';
this.formPlan = '';
this.addModal = true;
}
openEdit(idx: number) {
const t = this.tripList[idx];
this.formCity = t.city;
this.formDays = t.days.toString();
this.formVisa = t.visa;
this.formPlan = t.plan;
this.editIdx = idx;
this.editModal = true;
}
openDel(idx: number) {
this.delIdx = idx;
this.delModal = true;
}
doAdd() {
const days = Number.parseInt(this.formDays);
const city = this.formCity.trim() === '' ? '未命名目的地' : this.formCity.trim();
const visa = this.formVisa.trim() === '' ? '免签' : this.formVisa.trim();
const plan = this.formPlan.trim() === '' ? '行程待规划' : this.formPlan.trim();
this.tripList.unshift(new TripItem(city, Number.isNaN(days) ? 3 : days, visa, plan));
this.addModal = false;
}
doEdit() {
if (this.editIdx < 0 || this.editIdx >= this.tripList.length) {
this.editModal = false;
return;
}
const t = this.tripList[this.editIdx];
const days = Number.parseInt(this.formDays);
t.city = this.formCity.trim() === '' ? t.city : this.formCity.trim();
t.days = Number.isNaN(days) ? t.days : days;
t.visa = this.formVisa.trim() === '' ? t.visa : this.formVisa.trim();
t.plan = this.formPlan.trim() === '' ? t.plan : this.formPlan.trim();
this.editModal = false;
}
doDel() {
if (this.delIdx < 0 || this.delIdx >= this.tripList.length) {
this.delModal = false;
return;
}
this.tripList.splice(this.delIdx, 1);
this.delModal = false;
}
toggleRemind(idx: number) {
const item = this.remindList[idx];
item.on = !item.on;
}
CRUD 方法群实现了行程的增删改完整闭环。openAdd 清空表单缓存(签证默认"免签")并打开新增弹窗。openEdit 从 tripList 取当前行程回填到四个表单缓存字段,记录 editIdx 并打开编辑弹窗。openDel 记录 delIdx 并打开删除弹窗。
doAdd 以 Number.parseInt 解析天数,Number.isNaN(days) ? 3 : days 兜底为默认 3 天,空城市兜底为"未命名目的地",组装 TripItem 后 unshift 置顶行程列表并关闭弹窗。doEdit 先做边界检查(editIdx 有效),再就地修改 @Observed 实例的四个字段——空字段保留原值(formCity.trim() === '' ? t.city : formCity.trim()),天数解析失败保留原值(Number.isNaN(days) ? t.days : days)。doDel 同样做边界检查后 splice 移除目标项。toggleRemind 直接翻转 RemindItem 的 on 字段,利用 @Observed 的字段级观察特性驱动时间轴圆点和竖线状态刷新。
三个弹窗通过 addModal/editModal/delModal 三个布尔状态条件渲染,互斥独立,各自持有 onClose 回调函数用于关闭时翻转对应布尔值。
十五、功能模块对比表
| 维度 | 行程 Tab | 地图 Tab | 搜索 Tab | 提醒 Tab | 铃音 Tab | 字幕 Tab | 我的 Tab |
|---|---|---|---|---|---|---|---|
| 布局方式 | 横滑大卡+清单+柱状图 | Toggle行+地图+日志流 | 搜索框+chips+列表 | 授权卡+时间轴+历史 | 预览卡+铃声库列表 | 五区块设置面板 | 渐变大卡+足迹行 |
| 数据模型 | TripItem | EventLog | SearchRecord | RemindItem/NoticeLog | RingItem | CaptionScene | FootRow |
| 字段数 | 4 | 5 | 4 | 4/3 | 6 | 4 | 4 |
| 核心操作 | 新增/编辑/删除行程 | 双长按监听开关 | 关键字搜索/chips | 授权/开关/发布 | 生成WAV/设默认 | 语言/字号/颜色/场景 | — |
| 动画效果 | 柱状图breath波动 | — | — | 授权圆点breath | — | — | 渐变卡呼吸圆点 |
| 状态颜色 | 签证绿/青/紫/橙 | Marker青/POI橙 | 高绿/中橙/低灰 | 已授权绿/未授权橙 | 沙箱绿/未生成橙 | 选中青/紫 | 统计青/橙/紫/绿 |
| 数据量 | 7行程 | 6地标+2种子日志 | 6条Mock | 6提醒+3通知 | 6铃声 | 5场景 | 6足迹国家 |
| 特殊组件 | 横滑Scroll+linearGradient | MapComponent+Toggle | Progress分数条 | Toggle时间轴 | fileIo沙箱写入 | AICaptionComponent | linearGradient渐变 |
| ForEach键值 | idx-city | idx-time | idx-name | idx-time | idx-name | idx-scene | idx-name |
| 容器类型 | Scroll(纵向) | 独占内容区 | Scroll(纵向) | Scroll(纵向) | Scroll(纵向) | Scroll(纵向) | Scroll(纵向) |
对比表从十个维度全面对比了七个 Tab 的特性差异。布局方式维度揭示了每个 Tab 的独特 UI 结构——行程 Tab 三段递进、地图 Tab 双 Toggle + 地图 + 日志、搜索 Tab 四段流水线、提醒 Tab 四段卡片、铃音 Tab 预览 + 列表、字幕 Tab 五区块面板、我的 Tab 渐变大卡 + 清单。数据模型维度展示了七个 @Observed 类的字段数差异,RingItem 以 6 字段最丰富,体现了铃声生成链路对多状态字段的需求。核心操作维度明确了每个 Tab 的主要交互入口。动画效果维度揭示了 breath 全局节拍在三个 Tab 中的联动表现。状态颜色维度展示了每个 Tab 的色彩编码体系。特殊组件维度突出了各 Tab 的标志性 ArkUI 组件运用。
十六、总结与展望
本平台以"夜航深蓝 + 霓虹青"的深色视觉体系为基底,将 HarmonyOS 6.1.1 的三大前沿特性——Map Kit 双长按监听与 POI 搜索、Speech Kit AI 字幕四新字段、Notification Kit EL1 沙箱自定义铃声——有机融合进出境导航的 7 个业务场景中。
在技术架构上,平台采用"状态集中声明 + Builder 分散渲染"的模式。Map Kit 的 setupMapCallback 方法实现五步初始化链(错误检查→控制器获取→事件管理器获取→批量 Marker 添加→双长按监听注册),onMarkerLongClick 与 onPoiLongClick 分别读取 marker.getId()/getPosition() 与 poi.name/poi.position,searchByText 以四参数发起搜索并返回 reliability 相关性分数。Speech Kit 的 AICaptionOptions 通过 buildCaptionOptions 方法组装,四个 6.1.1 新字段齐全,onPrepared 和 onError 回调兜底,writeAudio 以 640 字节 PCM 块写入演示音频流。Notification Kit 的 saveRingToSandbox 方法严格遵循 EL1 沙箱规范,buildWavBytes 生成含 ADSR 包络的 WAV 字节,publishNotice 以 sound: 'uri::' + uri 前缀发布携带自定义铃声的通知,requestAuth 在被拒绝时拉起通知设置页二次引导。
在交互设计上,行程 Tab 的横滑大卡以渐变封面和签证色徽标实现"一屏速览"的目的地管理,清单行的编辑与删除图标直接绑定弹窗入口形成增删改闭环;地图 Tab 的双 Toggle 开关支持运行时随时注册/清除长按监听订阅,事件日志流以 unshift 置顶最新事件并限 12 条上限,保证日志窗口不会无限增长影响性能;搜索 Tab 的快捷 chips 降低了关键字输入成本,reliability 分数条与等级标签同色形成视觉直觉,搜索失败时保留 Mock 数据并提示错误码,兼顾了功能完整性与开发期可演示性;铃音 Tab 的 genRing 方法将 WAV 生成与沙箱写入封装为一步操作,inSandbox 状态实时反馈让用户明确感知铃声的就绪状态;字幕 Tab 的场景推荐卡提供一键应用语言组合的快捷入口,switchSourceLang 实现源语言切换时目标语言自动联动,中文源锁定目标语言的设计避免了无效翻译方向的误选;提醒 Tab 的时间轴以固定行高和竖线填充实现"地铁时刻表"式的视觉节奏,授权卡与铃音 Tab 跨 Tab 联动引导用户完成铃声自定义与通知发布的完整链路。
在色彩体系上,"夜航深蓝 + 霓虹青"的深色主题为跨境旅行场景营造了沉浸式的暗光环境,15 种颜色各有明确的语义角色——霓虹青贯穿主色与 Tab 选中态、落日橙标识 POI 与未授权状态、星紫区分电子签与目标语言、通过绿标识免签与已授权、警示红仅用于删除操作。旅行家渐变大卡的 160° 三色渐变模拟夜航舷窗质感,柱状图的 180° 纵向渐变模拟霓虹灯光由顶向底渐暗,色彩设计始终服务于功能语义而非纯粹装饰。
在数据模型设计上,七个 @Observed 类分别支撑各自 Tab 的列表渲染,@Observed 装饰器使字段级变化被 UI 感知——编辑弹窗就地修改 TripItem 字段后横滑大卡自动刷新、toggleRemind 翻转 on 字段后时间轴圆点变色、genRing 更新 inSandbox 后铃声库列表状态同步。ForEach 的键值生成普遍采用"前缀-索引-特征字段"的复合键格式(如 row-${idx}-${item.city}、ring-${idx}-${ring.name}),保证了列表增删时 ArkUI 差分渲染的正确性。
展望未来,本平台可在以下方向深化:接入 AI 语音评估引擎实现实时语速/停顿/情感分析,丰富跨境讲解的质量反馈维度;引入 AR 实景导航实现虚拟箭头叠加街景指引,解决陌生城市"最后一公里"寻路难题;通过分布式能力实现多设备协同行程管理,让同行旅伴的提醒与日程实时同步;利用穿戴设备采集步频/心率数据丰富出行健康维度,为高海拔口岸或长途飞行提供健康预警;引入实时汇率换算和消费记录分析,为跨境出行的财务规划提供数据支撑。HarmonyOS 的 AI 能力和分布式架构为这些扩展提供了坚实的技术底座。
附录:DevEco Studio 创建新项目与查看 SDK 版本
本章节演示如何使用 DevEco Studio 创建一个 HarmonyOS 新项目,并查看当前 IDE 已安装的 SDK 版本,适合作为其他技术博文的补充操作指南。
一、创建新项目
1.1 进入欢迎界面
启动 DevEco Studio 后,首先看到的是欢迎界面。左侧导航栏默认选中 “项目”,右侧提供三个主要入口:
- 新建项目:从头创建新项目
- 打开项目:打开本地已有项目
- 克隆仓库:从 Git 等版本控制拉取代码
点击 “新建项目” 按钮,进入项目创建向导。

1.2 选择项目模板
在弹出的"新建项目"对话框中,左侧分类标签提供了两种项目类型:
| 类型 | 说明 |
|---|---|
| 应用(Application) | 开发标准的 HarmonyOS 应用,具备完整的 Ability 生命周期 |
| 元服务(Atomic Service) | 开发轻量级的原子化服务,无需安装即可使用 |
选择 “应用” 标签后,右侧展示多种模板。对于大多数场景,推荐选择 “Empty Ability” —— 这是一个最基础的入门模板,仅包含 Hello World 功能,适合从零开始构建应用。

1.3 配置项目信息
点击 “下一步” 后,进入项目配置界面,需要填写以下核心参数:
| 配置项 | 示例值 | 说明 |
|---|---|---|
| 项目名称(Project name) | rollboat | 应用的项目名称,建议使用英文命名 |
| 包名(Bundle name) | com.rollboat.myapplication | 应用唯一标识,采用反向域名格式 |
| 保存路径(Save location) | D:\CodeFactory\rollboat | 项目本地存储路径,避免使用中文和空格 |
| 兼容 SDK(Compatible SDK) | 6.1.1(24) | 目标 HarmonyOS API 版本,点击"查看参考"可了解各版本差异 |
| 模块名称(Module name) | entry | 主模块名称,默认 entry 为应用入口模块 |
| 设备类型(Device types) | ☑ Phone | 勾选目标设备:Phone / Tablet / 2in1 / Car / Wearable / TV |
右侧预览区会实时展示当前模板的默认效果 —— 一个居中显示的 “Hello World” 文本。

1.4 完成创建
确认配置无误后,点击右下角 “完成” 按钮,IDE 将自动执行以下操作:
- 生成项目骨架(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 应用的功能开发。
更多推荐



所有评论(0)