一、技术前言

在出境旅游服务加速数字化的当下,跨境旅行正从"纸质攻略+离线地图"向"一站式智能行程簿"演进。从签证状态速览到目的地横滑选卡,从地图地标长按探索到景点 POI 相关性搜索,从行程提醒时间轴到沙箱自定义通知铃声,再到 AI 字幕多语讲解——每一项能力都对应着一个独立的业务场景和一套差异化的交互模式。传统旅行应用面临三大痛点:地图交互停留在只读浏览层缺乏事件监听能力、通知铃声只能用系统预设无法个性化定制、多语讲解依赖外部翻译应用导致体验割裂。一个优秀的海外行程簿应用需要将这七大功能模块(行程管理、地图探索、POI 搜索、行程提醒、沙箱铃声、AI 字幕、旅行家主页)融合在统一的深色主题界面中,同时保持各模块交互的独立性和状态联动的实时性。

HarmonyOS ArkUI 框架为这些痛点提供了系统级解决方案。ArkUI 的声明式 UI 范式通过 @Component 封装可复用组件、@State 管理响应式状态、@Builder 拆分复杂 UI 结构,天然适合"行程-地图-提醒"多 Tab 架构。@Observed 装饰器让数据模型字段级变化被 UI 感知,实现"数据更新即视图刷新"的流畅体验——当用户在编辑弹窗中修改行程天数时,@ObservedTripItem 实例字段变更立即触发横滑大卡和清单行的同步刷新,无需手动调用重渲染方法。@Entry 标记入口组件,Stack 容器层叠主界面与全屏弹窗,ForEach 驱动列表渲染——这套组合拳让七 Tab 异构布局在一个组件内有序共存。状态驱动机制的核心优势在于:开发者只需声明"UI 是状态函数",框架自动追踪依赖关系并执行最小化 DOM diff,这极大降低了多 Tab 共享状态的同步复杂度。

本平台深度融合 HarmonyOS 6.1.1 的三大前沿特性。Map Kit 提供地图组件的 MapComponent 渲染能力与 MapEventManager 事件管理器,通过 onMarkerLongClickonPoiLongClick 双长按监听实现地标探索交互——Marker 长按回调参数为 map.Marker 类型,可读取 getId()getPosition() 两个方法返回的标注 ID 和经纬度坐标;POI 长按回调参数为 mapCommon.Poi 类型,仅含 idnameposition 三个字段,相比 Marker 更轻量。同时 site.searchByText 接口返回的 reliability 相关性分数(取值区间 [0,1])为 POI 搜索结果提供精准排序依据,分数越高匹配度越高。Speech Kit 的 AI 字幕组件 AICaptionComponent 引入了 sourceLanguage(‘zh’|‘en’)、targetLanguage(‘zh’|‘en’|‘zh-en’)、fontSizeAICaptionFontSize 枚举四档)、fontColorResourceColor 类型)四个新字段,支持中英双向翻译、中英双语对照、四档字号调节和五色字体预设,配合 writeAudio 640 字节 PCM 块写入实现实时语音转字幕。Notification Kit 在 EL1 沙箱区域生成自定义 WAV 铃声文件,通过 fileUri.getUriFromPath 转换为沙箱 URI 后以 sound: 'uri::' + uri 形式注入通知请求,实现通知铃声的完全个性化定制。

二、整体架构流程图

数据模型层

弹窗实体绑定

三大特性链路

七 Tab 布局层

主组件层

Page 主组件

headerMain 头部应用名+联动副标题+三特性胶囊

内容区 7 Tab 切换

tabBar 底部单排导航

弹窗系统 新增/编辑/删除

Tab0 行程
横滑大卡+清单行+柱状图

Tab1 地图
MapComponent+长按日志流

Tab2 搜索
关键字+reliability分数条

Tab3 提醒
时间轴+通知授权卡

Tab4 铃音
EL1沙箱库+sound预览

Tab5 字幕
AI字幕五区块

Tab6 我的
渐变大卡+足迹清单

Map Kit
searchByText可靠性+双长按监听

Speech Kit
AI字幕四新字段

Notification Kit
EL1沙箱自定义铃声

panelAdd 新增行程表单

panelEdit 编辑回填就地修改

panelDel 删除危险确认

TripItem 行程

SearchRecord 搜索结果

EventLog 事件日志

RemindItem 提醒条目

RingItem 铃声条目

CaptionScene 字幕场景

NoticeLog 通知历史

架构以 Page 为根组件,使用 Stack 容器层叠:底层 Column 纵向排列头部、分割线、Scroll 内容区和底部 Tab 栏,顶层是三个独立弹窗各自条件渲染。内容区通过 currentTab 在七个 Builder 方法间切换,地图 Tab 因 MapComponent 需有界高度而独占内容区不进 Scroll 容器,其余六个 Tab 共享主滚动容器。三大特性分散在地图(双长按监听)、铃音(EL1 沙箱铃声)和字幕(AI 字幕四字段)三个 Tab 上,状态变量统一声明在组件顶层实现跨 Tab 共享与联动。数据模型层的七个 @Observed 类分别支撑各自 Tab 的列表渲染,TripItem 同时被弹窗系统引用以实现 CRUD 操作,RingIteminSandbox 字段联动铃音 Tab 与提醒 Tab 的通知 sound 链路。

三、色彩体系设计

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 深蓝分割线,低对比度不干扰内容。tabOncyan 同值,保证 Tab 选中态与主色一致。mask 为半透明黑色 rgba(0,0,0,0.6),弹窗遮罩使用 RGBA 格式实现 60% 透明度。

3.3 色彩语义角色分配

功能色

cyan #38C8D8 主色

orange #FF8A4C 暖色

purple #8A7FE8 电子签

green #4EC98A 通过

red #E86060 警示

文本层

title #E8F0FA 主标题

sub #9DB4D0 副标题

text3 #67819E 弱文本

背景层

bg #0D1522 页面背景

card #16233A 卡片底色

dark #1D2E4A 次级容器

色彩语义角色分配遵循三条原则。第一,背景层从深到浅形成三级梯度:bg 最暗、card 中间、dark 最浅但仍在暗色范围内,确保视觉层级分明。第二,文本层从亮到暗形成三级对比:title 最亮、sub 中间、text3 最暗,让信息按重要性递减呈现。第三,功能色按业务语义分配:cyan 用于主操作和主交互、orange 用于辅助暖色和 POI 标识、purple 用于电子签和字幕语言、green 用于通过和已就绪、red 用于删除和错误——五种功能色各司其职不交叉使用。

四、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 接口定义了底部导航项的最小数据结构:icon 为 emoji 字符串,label 为中文标签文字。TAB_LIST 常量数组按顺序声明七个 Tab 项,分别对应行程、地图、搜索、提醒、铃音、字幕和我的。这种将导航元数据与 UI 渲染分离的设计使 Tab 配置可独立维护,新增或调整 Tab 只需修改数组而无需触碰 @Builder 方法。底部导航栏在 tabBar() 构建器中通过 ForEach 遍历此数组渲染,选中态通过 currentTab 索引与 index 比较判断。七个 Tab 覆盖了跨境旅行从行程规划到多语讲解的完整场景链路。

4.2 头部联动副标题定义

const TAB_SUBS: string[] = [
  '跨境行程总览与签证速览',
  '香港地标长按探索',
  '景点 POI 相关性搜索',
  '行程提醒与本地通知',
  '沙箱自定义通知铃声',
  'AI 字幕多语讲解',
  '旅行家主页与足迹'
];

TAB_SUBS 数组与 TAB_LIST 一一对应——当用户切换 Tab 时,头部副标题联动显示当前 Tab 的功能描述。例如选中"地图"时头部副标题变为"香港地标长按探索",选中"字幕"时变为"AI 字幕多语讲解"。这种联动设计让用户在任何 Tab 下都能通过头部快速确认当前功能定位。副标题使用 text3 暗蓝灰着色,字号 11,单行省略,视觉权重低于主标题,形成主次分明的信息层级。

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: '口岸' }
];

城市中心定位于香港中环(纬度 22.3193、经度 114.1694),作为跨境游枢纽,是地图初始视野中心和 POI 搜索的 location 基准点。SpotItem 接口定义了门店标注点的四字段结构,MARKER_SPOTS 数组包含六处香港地标标注点,覆盖夜景、观景、海滨、市集、乐园、口岸六大跨境游客高频场景。这些数据在 setupMapCallback 中通过 mapController.addMarker 批量添加到地图上。每个标注点的 tag 字段虽未在 UI 中直接展示,但保留了业态分类能力,未来可扩展为地图标注的差异化图标或颜色映射。

4.4 搜索快捷关键字与字幕语言选项

const QUICK_QUERIES: string[] = ['景点', '餐厅', '地铁站', '酒店', '口岸', '博物馆'];

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

QUICK_QUERIES 定义了搜索 Tab 的六个快捷关键字 chips,覆盖跨境游客高频 POI 类型:景点、餐厅、地铁站、酒店、口岸、博物馆。点击任一 chip 自动填入关键字并触发搜索,省去手动输入的步骤。LangOption 接口定义了语言选项的两字段结构:code 为语言码(‘zh’|‘en’|‘zh-en’),name 为中文展示名。SRC_LANGS 源语言仅支持中文和英文两种,TGT_LANGS_EN 为英文源时的目标语言选项(中文、英文、中英双语三档)。当源语言为中文时,目标语言锁定为 zh(无翻译方向可选),这一约束通过 switchSourceLang 函数实现联动。

4.5 字幕字号选项与字体颜色预设

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'];

SizeOption 接口将 AICaptionFontSize 枚举值与中文展示名绑定,SIZE_OPTIONS 数组声明了四档字号:SMALL(小号)、NORMAL(标准)、BIG(大号)、LARGE(超大)。字号选项在字幕 Tab 中以卡片网格展示,每张卡片显示预览字号和名称,选中态使用霓虹青高亮和边框。CAPTION_FONT_COLORS 预设了五种字体颜色:冷白 #FFFFFF、霓虹青 #7CE8F5、暖橙 #FFD9A8、薄荷绿 #C9F2D9、樱花粉 #FFC2CE,覆盖五种视觉风格,让用户根据字幕背景和个人偏好自由切换。

4.6 月度出行数据与足迹国家清单

const MONTH_NAME: string[] = ['03', '04', '05', '06', '07', '08'];
const MONTH_DAYS: number[] = [3, 5, 2, 6, 4, 8];

interface FootRow {
  flag: string;
  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_NAMEMONTH_DAYS 为近 6 个月跨境出行天数数据(3/5/2/6/4/8 天),合计 28 天,驱动行程 Tab 的柱状图。每根柱子高度由 breath 状态驱动微波动——breathtrue 时高度为 14 + v * 9,为 false 时为 12 + v * 9,差值 2 像素形成呼吸效果。FootRow 接口定义了足迹国家清单行的四字段结构,FOOT_ROWS 包含六条数据(中国香港 18 城、日本 6 城、泰国 3 城、新加坡 1 城、韩国 2 城、中国澳门 2 城),每行包含国旗 emoji、国家名、解锁城市数和最近到访月份。

五、工具函数群

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 函数将 site.searchByText 返回的 [0,1] 区间相关性分数映射为三档标签和颜色:0.8 以上为高相关(通过绿 #4EC98A)、0.5 至 0.8 为中相关(落日橙 #FF8A4C)、0.5 以下为低相关(暗蓝灰 #67819E)。该函数返回 ScoreLevel 结构体,包含 labelcolor 两个字段,在搜索结果列表中同时驱动等级标签着色和分数条颜色,让用户一眼判断 POI 搜索结果的可信度。阈值 0.8 和 0.5 是经验值——高于 0.8 的 POI 通常名称和地址完全匹配关键字,0.5 到 0.8 为部分匹配,低于 0.5 为弱关联。Mock 数据中 香港太空馆 的 reliability 为 0.94 属高相关,重庆大厦 为 0.22 属低相关,覆盖三档全量。

5.2 长按事件类型颜色映射

function typeColor(type: string): string {
  if (type === 'Marker') {
    return COLORS.cyan;
  }
  if (type === 'POI') {
    return COLORS.orange;
  }
  return COLORS.text3;
}

typeColor 函数区分地图长按事件类型——Marker 长按用霓虹青 #38C8D8 标识、POI 长按用落日橙 #FF8A4C 标识,两种颜色对应地图 Tab 双长按监听 Toggle 的选中色,保持视觉一致性。该函数在事件日志流中驱动类型徽标着色,让用户通过颜色快速区分事件来源。默认返回 text3 暗蓝灰作为兜底色,防止未来新增事件类型时未覆盖导致着色异常。

5.3 签证类型颜色映射

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

visaColor 函数通过字符串前缀匹配实现签证类型到颜色的映射:免签绿(#4EC98A,传递"顺利通过"语义)、落地签青(#38C8D8,主色标识"到达办理")、电子签紫(#8A7FE8,数字标识"线上申请")、需面签橙(#FF8A4C,暖色警示"需到领事馆")。该函数在行程 Tab 的横滑大卡和清单行中驱动签证色徽标着色,让用户一屏速览各目的地签证类型。使用 startsWith 前缀匹配而非精确匹配,是因为 Mock 数据中包含"免签备案"这种复合前缀,前缀匹配可兼容更多变体。

5.4 当前时刻格式化

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 将个位数补零(如 909),保证时间格式统一为两位数。该函数在 bindMarkerLongClickbindPoiLongClickpublishNotice 三处调用,为每条事件日志和通知记录打上实时时间戳。相比于直接拼接 getHours() 等返回值,补零操作确保了视觉对齐——等宽字体下 09:12:4009:15:03 各列对齐,阅读体验更整洁。

5.5 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));
    }
  };
  // RIFF 容器标识
  writeStr(0, 'RIFF');
  view.setUint32(4, 36 + dataSize, true);
  writeStr(8, 'WAVE');
  // fmt 子块
  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 量化
  // data 子块
  writeStr(36, 'data');
  view.setUint32(40, dataSize, true);
  // PCM 正弦波采样 + 起音包络 + 自然衰减
  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 函数是铃音 Tab 的核心工具,它根据频率和时长参数生成符合 WAV 规范的音频字节流。整个函数分为三个阶段。

第一阶段是头部写入。44 字节头部包含 RIFF 容器标识(writeStr(0, 'RIFF'))、文件大小(36 + dataSize)、WAVE 格式标识、fmt 子块(PCM 编码声明、采样率 44100Hz、单声道、16bit 量化、字节率 sampleRate * 2、块对齐 2)。writeStr 辅助函数通过 charCodeAt 将字符串逐字节写入 DataViewsetUint32setUint16 的第三个参数 true 表示小端序(WAV 标准字节序)。

第二阶段是 PCM 采样生成。音频数据段通过正弦波公式 Math.sin(2 * Math.PI * freq * t) 生成 PCM 采样值,其中 freq 为频率参数(如 990Hz 对应"登机叮咚"、523Hz 对应"口岸钟声")。每个采样值乘以 0.5 降幅度(避免满幅削波),再乘以起音包络 env 和自然衰减 decay

第三阶段是包络处理。起音包络 env 在前 20ms 内从 0 线性渐升到 1(Math.min(1, i / (sampleRate * 0.02))),模拟真实铃声的起音阶段避免咔哒声;自然衰减 decay 从 1 线性递减到 0(Math.max(0, 1 - t / (durationMs / 1000))),模拟铃声随时间衰减的物理特性。最终采样值通过 Math.round(v * 32767) 量化为 16bit 整数并写入 DataView。该函数生成的字节流直接写入 EL1 沙箱文件,作为通知自定义铃声的音频源。

六、数据模型层

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 装饰器使其字段级变化被 UI 感知。四个字段分别表示目的地城市、行程天数、签证类型和行程概要。TRIP_LIST 初始包含七条数据——从香港免签备案到巴黎需面签,覆盖免签、落地签、电子签、需面签四种签证类型。TripItem 同时绑定三个弹窗:新增弹窗 unshift 置顶新实例、编辑弹窗就地修改字段驱动刷新、删除弹窗 splice 移除条目。@Observed 的关键字段级监听特性在编辑弹窗中发挥核心作用——doEdit 方法直接修改 tripList[editIdx]citydaysvisaplan 字段而非替换整个数组元素,框架自动检测字段变更并刷新引用该实例的所有 UI 组件(横滑大卡和清单行同步更新)。

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 封装 POI 搜索结果的四个核心字段:名称、地址、距离和相关性分数。SEARCH_MOCK 初始 Mock 数据覆盖高、中、低三档可靠性分数(0.94 到 0.22),距离从 420 米到 1320 米递增。当真实搜索失败时(无 AGC 配置或无网络),runSearch 方法保留 Mock 数据并提示错误码,体现调用链完整性。reliability 字段驱动搜索结果列表的等级标签着色(reliabilityScore 函数)和分数条颜色(Progress 线性进度条),让用户通过视觉量化判断搜索结果的可信度。

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 记录地图长按事件流,包含事件类型(Marker/POI)、名称、经纬度和触发时刻。EVENT_SEED 预置两条演示数据让用户进入地图 Tab 即见日志效果。Marker 长按回调通过 marker.getId()marker.getPosition() 读取坐标与 ID,POI 长按回调通过 poi.namepoi.position 读取名称与坐标,两者均 unshift 置顶事件日志并限制最多保留 12 条(超过则 pop 移除末尾),形成实时事件日志流。经纬度在 UI 中使用 toFixed(4) 保留四位小数并以等宽字体 monospace 显示,保证各列对齐。

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 支撑提醒 Tab 的时间轴布局,四个字段分别表示提醒时刻、标题、重复规则和开关状态。REMIND_LIST 包含六条提醒数据,覆盖值机、入住、集合、景点预约、跨境巴士和夜间导览六种跨境旅行场景,重复规则分"出行日"和"单次"两种。on 字段驱动时间轴的竖线颜色(开启霓虹青/关闭分割线色)和圆点颜色,以及提醒内容列的文字颜色和状态文案(“提醒开启中”/“已暂停”)。toggleRemind 方法直接翻转 item.on 字段,利用 @Observed 的字段级监听实现就地刷新。

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 是铃音 Tab 的核心实体,六个字段分别表示铃声名、沙箱文件名、生成频率、时长、文件大小和沙箱状态。RING_LIST 包含六款预设铃声,频率从 440Hz 到 990Hz 覆盖不同音高,时长从 800ms 到 1500ms 覆盖短促到悠长。inSandbox 字段是铃音 Tab 的状态枢纽——初始为 falsesize'—',当 WAV 字节成功写入 EL1 沙箱后 genRing 方法将 inSandbox 置为 true 并计算文件大小 KB 数更新 size 字段。@Observed 的字段级监听确保 inSandbox 变更后铃声库列表的"沙箱/未生成"标签和"EL1 ✓/未落盘"预览卡同步刷新。

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 定义了字幕 Tab 的场景推荐卡数据结构,四个字段分别表示场景名、说明、推荐源语言和推荐目标语言。CAPTION_SCENES 预设五个跨境讲解场景:双语导览(en→zh-en)、外语点餐(en→zh)、问路翻译(en→zh)、机场广播(en→zh-en)、中文复盘(zh→zh)。点击场景卡调用 applyScene(idx) 方法一次性套用推荐的源语言和目标语言组合,省去用户手动逐项设置。五个场景覆盖了从出境到回国的完整多语交互链路。

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 记录已发布通知的标题、正文和发布时刻。NOTICE_SEED 预置三条通知历史让用户进入提醒 Tab 即见效果。publishNotice 方法成功发布通知后 unshift 置顶新的 NoticeLog 实例并限制最多保留 8 条(超过则 pop 移除末尾)。通知历史流以倒序展示,每条记录显示标题(sub 雾蓝灰加粗)、发布时刻(text3 暗蓝灰)和正文(text3 暗蓝灰,最多两行省略),底部使用 dark 次级容器底色与卡片形成层次区分。

七、组件主体与状态管理

7.1 状态变量分层声明

@Entry
@Component
struct Page1278 {
  // --- 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 = '';

  // --- 动画状态 ---
  @State breath: boolean = false;
  timer: number = -1;

组件主体通过 @Entry @Component 标记为入口组件,状态变量分为五层。第一层是 Tab 切换状态:currentTab 数字索引驱动内容区在七个 @Builder 方法间切换,初始值为 0 对应行程 Tab。第二层是弹窗状态:addModal/editModal/delModal 三个布尔值控制三个弹窗的条件渲染,editIdxdelIdx 缓存当前操作的行程索引。第三层是弹窗表单缓存:formCity/formDays/formVisa/formPlan 四个字符串暂存用户在弹窗中输入的表单值,打开弹窗时由 openAdd/openEdit 初始化。第四层是动画状态:breath 布尔值每秒翻转驱动多处微交互,timer 保存 setInterval 返回的定时器 ID。第五层是业务数据数组,将在下文详述。

  // --- 业务数据数组 ---
  @State tripList: TripItem[] = TRIP_LIST;
  @State remindList: RemindItem[] = REMIND_LIST;
  @State ringList: RingItem[] = RING_LIST;
  @State noticeLogs: NoticeLog[] = NOTICE_SEED;

  // --- 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 = '待搜索(等待实时搜索)';

业务数据数组层包含四个 @State 数组,分别初始化为 TRIP_LISTREMIND_LISTRING_LISTNOTICE_SEED。Map Kit 状态层包括地图选项 mapOptions(定位香港中环、缩放 13 级)、地图回调 mapCallback、控制器 mapController、事件管理器 mapEventManager(四个均为 private@State,不触发 UI 重渲染),以及长按事件日志流 eventLogs、双长按监听开关 markerListenOn/poiListenOn、搜索关键字 queryInput、搜索结果数组 searchRecords 和搜索状态文案 searchState

  // --- Speech Kit 状态 ---
  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 状态层包含字幕控制器 captionControllerprivateAICaptionController 实例)、字幕显示状态 captionShown(通过 @Link 双向绑定到 AICaptionComponentisShown 参数)、源语言 srcLang(默认 ‘en’)、目标语言 tgtLang(默认 ‘zh-en’)、字号枚举 captionSize(默认 NORMAL)、字体颜色 captionColor(默认冷白)、就绪状态 captionReady、错误信息 captionErrMsg 和已写入音频块计数 captionFed。Notification 状态层包含授权状态 granted、通知 ID 自增基数 notifyIdprivate@State)、当前铃声 currentRing 和发布结果反馈 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、注册双长按监听五个步骤。第二项是查询通知授权状态——notificationManager.isNotificationEnabled() 返回 Promise,异步更新 granted 状态并联动头部三特性胶囊和提醒 Tab 的授权状态卡。第三项是启动呼吸动画定时器——setInterval 每秒翻转 breath 布尔值,breath 状态联动头部呼吸圆点透明度、柱状图柱体高度微波动、通知授权状态卡圆点透明度和旅行家渐变大卡呼吸圆点——一个定时器驱动四处微交互,体现状态驱动的声明式 UI 优势。

aboutToDisappear 清除定时器防止内存泄漏。如果不在组件销毁时调用 clearInterval(this.timer),定时器会持续运行导致已销毁组件的状态被修改,可能引发空指针异常或内存泄漏。

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 从上到下依次是头部 headerMain()、1 像素分割线(COLORS.line 深蓝色)、内容区和底部 Tab 栏 tabBar()

内容区采用条件分支策略:地图 Tab(currentTab === 1)因 MapComponent 需有界高度而独占内容区直接渲染(不进 Scroll),这是 MapComponent 的特殊要求——它需要确定的高度才能正确渲染地图瓦片,放入无限高度的 Scroll 容器会导致地图高度为 0。其余六个 Tab 共享 Scroll 滚动容器并设置 layoutWeight(1) 占满分割线与 Tab 栏之间的剩余高度。滚动容器关闭滚动条(scrollBar(BarState.Off))、启用弹簧边缘效果(edgeEffect(EdgeEffect.Spring)),内容区设置 14 水平内边距和 12/16 垂直内边距。

弹窗系统通过三个布尔状态条件渲染——addModaleditModaldelModal 任一为 true 时对应弹窗覆盖在主界面上方。Stack 容器使用 alignContent(Alignment.Center) 使弹窗居中显示,背景色为 COLORS.bg 夜航深蓝。

八、头部详解

@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 })
}

头部由四部分组成。第一部分是左侧应用名"海外行程簿"(字号 20、粗体、冷白色)与 Tab 联动副标题(字号 11、暗蓝灰、单行省略)。副标题通过 TAB_SUBS[this.currentTab] 索引获取当前 Tab 的功能描述,切换 Tab 时副标题即时联动变化。左侧 Column 使用 layoutWeight(1) 占满主行剩余宽度并左对齐。

第二部分是右侧三特性状态胶囊群,纵向排列三个胶囊。地图长按监听状态胶囊显示"长按On"或"长按Off",当 markerListenOnpoiListenOn 任一为 true 时显示霓虹青"On",否则显示暗蓝灰"Off"。字幕语言方向胶囊以星紫色显示 srcLang→tgtLang(如"en→zh-en"),让用户一眼掌握当前字幕翻译方向。通知授权状态胶囊显示"已授权"(通过绿)或"未授权"(落日橙),驱动提醒 Tab 的授权状态卡。

第三部分是最右侧呼吸圆点,使用 Circle 组件绘制 8x8 像素霓虹青圆点,透明度由 breath 状态驱动在 1 和 0.25 之间每秒翻转。整个头部使用 14 水平内边距和 12 垂直内边距,与内容区的 14 水平内边距对齐。

九、Tab0 行程:横滑大卡与清单行

行程 Tab 是业务主 Tab,由四部分纵向构成,每部分都有独立的布局逻辑和数据联动。

9.1 标题行与行程计数

Row() {
  Text('🧭 目的地横滑 · 签证状态一屏速览')
    .fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
  Column().layoutWeight(1)
  Text(`${this.tripList.length} 个行程`)
    .fontSize(10).fontColor(COLORS.text3)
}.width('100%')

标题行使用 Row 横向布局:左侧标题文字(粗体、冷白色),中间用 Column().layoutWeight(1) 占据剩余空间实现右对齐,右侧行程计数(暗蓝灰)。行程计数通过 ${this.tripList.length} 动态显示当前行程总数,新增或删除行程后立即联动更新。

9.2 目的地横滑大卡

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 容器,ForEach 遍历 tripList 渲染每张卡片。卡片宽度固定 170,内含城市名(字号 18、粗体)、天数标签(霓虹青底、夜航深蓝字、圆角胶囊)、行程概要(雾蓝灰、最多两行省略)和签证色徽标(visaColor 函数着色的 6px 圆点 + 文字)。卡片背景使用 135 度 linearGradientdarkcard 渐变模拟封面效果。横向滚动关闭滚动条,ForEach 的键值函数 ${idx}-${item.city} 确保数组变更时正确 diff。编辑行程后,@ObservedTripItem 字段变更触发对应卡片的局部刷新,无需重绘整个列表。

9.3 行程清单行

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%')

行程清单行以纵向 Column 容器排列,每行使用横向 Row 布局:指南针 emoji、行程信息列(城市名 + 签证徽标 + 天数 + 概要)和编辑/删除两个操作入口。编辑按钮调用 openEdit(idx) 回填表单字段后打开编辑弹窗,删除按钮调用 openDel(idx) 打开删除确认弹窗。每行使用卡片底色、12 内边距和 12 圆角,与横滑大卡保持一致的视觉风格。清单行的键值函数 row-${idx}-${item.city} 与横滑大卡不同,确保两个 ForEach 独立 diff。

9.4 新增行程入口

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

新增行程入口使用霓虹青全宽按钮,高度 42,居中显示"+ 新增行程"粗体文字(夜航深蓝色),圆角 12。点击调用 openAdd() 方法清空表单缓存(formCity/formDays/formPlan 置空、formVisa 设为"免签")并打开新增弹窗。按钮使用霓虹青底色与夜航深蓝文字形成高对比度,在深色主题中醒目突出。

十、Tab1 地图:双长按监听与日志流

地图 Tab 是三大特性中 Map Kit 的集中展示区,由三部分纵向构成。

10.1 双 Toggle 监听开关行

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

双 Toggle 开关行包含 Marker 长按监听(霓虹青)和 POI 长按监听(落日橙)两个开关。Toggle 切换时调用 toggleMarkerListentogglePoiListen——开启时调用 bindMarkerLongClick/bindPoiLongClick 注册监听,关闭时调用 offMarkerLongClick/offPoiLongClick 清除该类型全部订阅(不传参即清除全部)。Toggle 的 selectedColor 分别使用霓虹青和落日橙,与事件日志流中的类型徽标颜色一致,形成视觉一致性引导。右侧提示文字"长按地标/POI 试试"引导用户交互。

10.2 MapComponent 本体

MapComponent({ mapOptions: this.mapOptions, mapCallback: this.mapCallback })
  .layoutWeight(1).width('100%').borderRadius(12)

MapComponent 通过 layoutWeight(1) 占满 Toggle 行与日志流之间的剩余高度。地图选项 mapOptions 在组件状态层声明,定位香港中环(纬度 22.3193、经度 114.1694)、缩放 13 级。mapCallbackaboutToAppear 中通过 setupMapCallback 装配为异步回调函数,该回调在地图初始化完成后被框架调用。地图组件使用 12 圆角与整体卡片风格统一。

10.3 地图初始化回调链

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();
    // 批量添加地标 Marker
    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 方法将异步回调函数赋值给 mapCallback,该回调在 MapComponent 初始化完成后被框架调用。回调内部依次执行五个步骤。第一步是错误判空——如果 err 不为空则打印错误日志并 return,防止后续操作在无效控制器上执行。第二步是获取控制器和事件管理器——mapController.getEventManager() 返回 MapEventManager 实例,后续的长按监听注册和注销都通过此实例执行。第三步是批量添加地标 Marker——遍历 MARKER_SPOTS 数组,为每个标注点构造 MarkerOptions(含位置、可点击、可见、锚点等参数),逐个 await + try-catch 调用 addMarker。第四步和第五步是注册 Marker 长按监听和 POI 长按监听。

10.4 长按事件日志流

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)

长按事件日志流固定高度 172 可滚动,顶部标题行显示日志计数。每条日志使用横向 Row 布局:类型徽标(typeColor 着色,Marker 霓虹青/POI 落日橙,夜航深蓝字)、名称(雾蓝灰、单行省略、layoutWeight(1) 占满中间空间)、经纬度(等宽字体四位小数 toFixed(4))和触发时刻。日志流使用 Scroll 容器,关闭滚动条、启用弹簧边缘效果。底部等宽字体提示"off 不传参=清除该类型全部订阅",说明 offMarkerLongClick/offPoiLongClick 不传参即清除全部订阅的 API 特性。

十一、Tab2 搜索:关键字与可靠性分数条

搜索 Tab 展示 site.searchByText 的调用链,由四部分构成。

11.1 搜索框与触发按钮

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%')

搜索框使用 TextInput 组件,text 参数绑定 queryInput 状态实现双向同步。搜索按钮使用霓虹青底色,点击调用 runSearch 方法发起 POI 搜索。输入框和按钮共享同一 Row,输入框使用 layoutWeight(1) 占满按钮左侧的剩余宽度。

11.2 快捷关键字 chips

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 使用横向 Row 布局六个 POI 类型胶囊。点击 chip 自动填入关键字(this.queryInput = q)并触发搜索(this.runSearch()),省去用户手动输入和点击搜索按钮两步操作。当前选中的 chip 使用霓虹青底色高亮,未选中的使用 dark 次级容器底色。

11.3 搜索执行与结果映射

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}),保留当前推荐`;
  }
}

runSearch 方法构造 SearchByTextParams(关键字 query、城市中心位置 location、5000 米搜索半径 radius、中文语言 language),调用 site.searchByText 获取结果。成功时将 site.Site[] 通过 map 方法映射为 SearchRecord[] 更新列表——每个 site.SitenameformatAddressdistancereliability 四个字段使用 ?? 空值合并运算符兜底(s.name ?? '未命名地点'),防止可选字段为 undefined 时崩溃。搜索失败时(无 AGC 配置或无网络)捕获异常并提示错误码,保留 Mock 数据不替换,体现调用链完整性。

11.4 结果列表与 reliability 分数条

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)

结果列表每项使用三层结构:第一层是名称行(粗体名称 + 等级标签徽标),第二层是格式化地址(雾蓝灰、单行省略),第三层是距离 + reliability 分数条 + 分数值。分数条使用 Progress 线性进度条,value 映射为 rec.reliability * 100(将 0~1 区间映射到 0~100 进度),颜色随等级变化(reliabilityScore 函数返回的 color)。分数值用等宽字体两位小数右对齐显示(textAlign(TextAlign.End)),保证各条目的分数值列对齐。整个列表使用 List 组件关闭滚动条,依赖外层 Scroll 容器的滚动能力。

十二、Tab3 提醒:时间轴与通知授权

提醒 Tab 由四部分构成,是 Notification Kit 特性的联动展示区。

12.1 通知授权状态卡

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)

通知授权状态卡显示授权状态——已授权时显示通过绿圆点 + “通知授权:已开启”(通过绿),未授权时显示落日橙圆点 + “通知授权:未开启”(落日橙)+ "去授权"按钮。圆点透明度由 breath 状态驱动在 1 和 0.4 之间每秒翻转,形成呼吸闪烁效果吸引用户注意。未授权时点击"去授权"按钮调用 requestAuth 方法——该方法先调用 requestEnableNotification 请求授权,若用户曾拒绝(返回 1600004)则调用 openNotificationSettings 拉起通知设置页引导手动开启。底部说明文字告知用户铃声来自铃音 Tab 的沙箱自定义铃声,建立两个 Tab 间的联动认知。

12.2 行程提醒时间轴

Column() {
  ForEach(this.remindList, (item: RemindItem, idx: number) => {
    Row() {
      // 时间列(固定宽度 52)
      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%')

行程提醒时间轴使用固定行高 72 的四列布局。第一列是时间列(固定宽度 52),显示提醒时刻(粗体)和重复规则。第二列是竖线列(固定宽度 20),顶部圆点(8x8 像素)颜色随 on 状态变化(开启霓虹青/关闭暗蓝灰),下方竖线(2 像素宽,layoutWeight(1) 填充行高)颜色同样随 on 状态变化(开启霓虹青/关闭分割线色)。第三列是提醒内容列,显示标题和状态文案(“提醒开启中”/“已暂停”),文字颜色随 on 状态变化。第四列是 Toggle 开关列,切换调用 toggleRemind(idx) 直接翻转 item.on 字段,@Observed 的字段级监听立即刷新该行的圆点、竖线、文字颜色和状态文案。

12.3 发布行程提醒

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)

发布行程提醒区块顶部显示当前铃声信息(铃声名 + 沙箱状态),让用户在发布前确认 sound 链路就绪状态。“发布行程提醒"霓虹青按钮调用 publishNotice 方法,携带标题"行程提醒"和正文"集合出发前 30 分钟,请核对证件与签证材料”。底部 noticeState 状态文案显示发布结果反馈,如"通知已发布(id 101)“或"发布失败 1600004:…”。publishNotice 方法通过 fileUri.getUriFromPath 将沙箱路径转换为 URI,以 sound: 'uri::' + uri 形式注入通知请求,实现携带自定义铃声的通知发布。

12.4 通知历史流

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%')

通知历史流以 NoticeLog 倒序展示已发布通知的标题(雾蓝灰加粗)、发布时刻(暗蓝灰)和正文(暗蓝灰,最多两行省略)。每条记录使用 dark 次级容器底色与外层卡片形成层次区分。publishNotice 成功后 unshift 置顶新的 NoticeLog 实例并限制最多保留 8 条(超过则 pop 移除末尾),确保历史流不会无限增长。

十三、Tab4 铃音:EL1 沙箱与 sound 预览

铃音 Tab 是 Notification Kit 特性的核心展示区,由三部分构成。

13.1 当前铃声预览卡

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)

当前铃声预览卡显示铃声名(粗体)、频率/时长/大小三元组(等宽字体)和沙箱状态徽标("EL1 ✓"通过绿/"未落盘"落日橙)。底部说明文字"生成 WAV → 写入 EL1 files → 设默认 → sound 填 uri:: 前缀发布"完整描述了 sound 链路的四个阶段,让用户理解从铃声生成到通知发布的完整流程。当用户在铃声库中点击"生成"后,currentRinginSandboxsize 字段更新,预览卡同步刷新。

13.2 铃声库列表

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%')

铃声库列表展示六款预设铃声(登机叮咚、口岸钟声、巴士到站、行李转盘、集合哨声、夜航星光),每项左侧圆点标识当前默认铃声(霓虹青/分割线色),中间显示铃声名、沙箱状态和文件信息(等宽字体),右侧"生成"和"设默认"两个操作按钮。"生成"按钮调用 genRing 方法——该方法调用 saveRingToSandboxbuildWavBytes 生成的 WAV 字节写入 EL1 沙箱 filesDir 目录,成功后更新 inSandboxtrue 并计算文件大小 KB 数。"设默认"按钮调用 setCurrentRing 方法将选中铃声设为 currentRing,已选中的按钮使用 cyanD 深霓虹青底色区分。

13.3 沙箱写入与 sound 预览方法

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;   // 必须在 EL1 沙箱下
  const dir = appCtx.filesDir;                   // EL1 区域 files 目录
  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;
}

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 + "')";
}

saveRingToSandbox 方法通过 getUIContext().getHostContext() 获取上下文(已废弃 getContext(this)),设置 appCtx.area = contextConstant.AreaMode.EL1 确保写入 EL1 加密等级沙箱区域。然后通过 fs.openSync 以 CREATE | WRITE_ONLY | TRUNC 模式打开文件,fs.writeSync 写入 WAV 字节,fs.closeSync 关闭文件描述符。沙箱写入失败时返回空字符串,发布通知时回退系统铃声。

soundPreview 方法展示 sound 字段的完整值:沙箱路径 → fileUri.getUriFromPath 转换 → 'uri::' + uri 前缀拼接,让用户在发布前预览 sound 字段的实际取值。当 hostCtx 不可用时返回方法签名字符串作为兜底提示。

十四、Tab5 字幕:AI 字幕五区块

字幕 Tab 是 Speech Kit 的集中展示区,由五个区块构成。

14.1 区块一: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.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)

区块一是 AICaptionComponent 实时预览,高度固定 110 像素。isShown 通过 @Link 双向绑定 captionShown 状态——当用户点击"开启字幕"按钮翻转 captionShown 时,@Link 机制将状态变更同步到组件内部驱动字幕显隐。controller 传入 AICaptionController 实例,options 调用 buildCaptionOptions 组装。顶部标题行右侧显示就绪状态(通过绿"已就绪"/暗蓝灰"初始化中"),由 onPrepared 回调置 captionReadytrue。错误信息行在 captionErrMsg 非空时显示红色错误提示,由 onError 回调填充。下方两个按钮:翻转字幕显隐和写入演示音频(调用 feedAudioStream 生成 640 字节 PCM 块调用 writeAudio 写入并计数)。

14.2 区块二:源语言/目标语言联动

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)"无可选项),英文源时目标语言可选中文、英文或中英双语。这一联动逻辑通过 if (this.srcLang === 'zh') 条件分支实现:中文源时渲染锁定提示行,英文源时渲染 TGT_LANGS_EN 三档可选胶囊(选中态星紫色)。源语言胶囊选中态使用霓虹青,目标语言胶囊选中态使用星紫色,通过颜色区分两个语言维度。

14.3 区块三:字号四档选择

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 枚举的 SMALL/NORMAL/BIG/LARGE。每档显示预览字符(小/标/大/大A,字号随枚举值递增)和名称。选中态使用霓虹青高亮文字、dark 底色和 1 像素霓虹青边框,未选中态使用雾蓝灰文字、卡片底色和无边框。点击设置 captionSize 后,buildCaptionOptions 方法重新组装 AICaptionOptions 并传入 AICaptionComponent,字幕字号即时变化。

14.4 区块四:字体颜色五色卡

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)

区块四是字体颜色五色卡,使用 ForEach 遍历 CAPTION_FONT_COLORS 常量数组渲染五个圆形色块(冷白、霓虹青、暖橙、薄荷绿、樱花粉)。选中态色块使用 2 像素霓虹青边框和"使用中"文字,未选中态使用 1 像素分割线色边框和序号文字。点击设置 captionColor 后,buildCaptionOptions 方法重新组装并传入 AICaptionComponent,字幕字体颜色即时变化。

14.5 区块五:跨境讲解场景卡

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%')

区块五是跨境讲解场景卡,ForEach 遍历 CAPTION_SCENES 五个场景(双语导览、外语点餐、问路翻译、机场广播、中文复盘),每项显示场景名、说明和推荐语言方向徽标。点击场景卡调用 applyScene(idx) 一次性套用推荐的源语言和目标语言组合(如点击"双语导览讲解"设置 srcLang='en'tgtLang='zh-en'),省去用户在区块二中手动逐项设置。底部在 captionErrMsg 非空时显示 onError 兜底错误信息。

14.6 buildCaptionOptions 方法

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 方法组装 AICaptionOptions 时体现四个新字段:sourceLanguage(‘zh’|‘en’)、targetLanguage(‘zh’|‘en’|‘zh-en’)、fontSizeAICaptionFontSize 枚举)、fontColorResourceColor 类型,此处传入 #RRGGBB 字符串)。同时设置 onPrepared 回调置 captionReadytrue 并清空错误信息,onError 回调捕获错误信息填充 captionErrMsg。该方法在 AICaptionComponentoptions 参数中被调用,每次状态变更(语言/字号/颜色)后重新组装并传入组件。

十五、Tab6 我的:渐变大卡与足迹清单

我的 Tab 由三部分构成。

15.1 旅行家渐变大卡

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]] })

旅行家渐变大卡使用 160 度 linearGradientcyanD(霓虹青深色)经 dark(次级容器底色)到 card(卡片底色)的三段渐变,模拟从霓虹青到深蓝的渐变封面效果。上半部分显示旅行家 emoji(30 号)、身份标题"环球线旅行家 · VoyagerPro"和开通天数,右侧呼吸圆点透明度由 breath 状态驱动在 1 和 0.3 之间每秒翻转。下半部分四列统计数据:足迹国家 12(霓虹青)、解锁城市 38(落日橙)、出行天数 28(星紫)、待启程 6(通过绿),四个数字使用四种功能色,避免单色单调。

15.2 足迹国家清单行

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%')

足迹国家清单行展示六条数据(中国香港 18 城、日本 6 城、泰国 3 城、新加坡 1 城、韩国 2 城、中国澳门 2 城),每行包含国旗 emoji(20 号)、国家名(冷白色、layoutWeight(1) 占满中间空间)、解锁城市数(雾蓝灰)和最近到访月份(暗蓝灰、dark 底色胶囊)。每行使用卡片底色、12 内边距和 12 圆角,与整体设计语言一致。

15.3 版本与特性声明

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

版本声明行居中显示"海外行程簿 v6.1.1 · Map Kit + Speech Kit + Notification Kit"和"多语旅行服务 · 深色主题"两行文字,使用 text3 暗蓝灰最小字号 9,视觉权重最低不抢焦点。声明信息告知用户当前版本号和集成的三大 Kit,以及应用的主题定位。

十六、图表卡片:月度柱状图

@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 绘制。六根柱子对应 03 至 08 月的出行天数(3/5/2/6/4/8 天),每根柱子的高度由 breath 状态驱动微波动——breathtrue 时高度为 14 + v * 9,为 false 时为 12 + v * 9,差值 2 像素形成呼吸效果。柱子使用 180 度 linearGradient 从霓虹青到霓虹青深色渐变,容器固定高度 92 并通过 justifyContent(FlexAlign.End) 实现底部对齐。柱子顶部显示天数标签(雾蓝灰),底部显示月份名(暗蓝灰),整体形成简洁的数据可视化卡片。标题行右侧显示"合计 28 天"总计信息,让用户一屏掌握半年出行概况。

十七、底部 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 栏采用单排七项布局,每项通过 layoutWeight(1) 等分宽度。选中态图标不透明度为 1、文字为霓虹青(tabOn)加粗;未选中态图标不透明度 0.55、文字为暗蓝灰常规字重。点击切换 currentTab 即可驱动整个内容区重新渲染对应 Tab 的 Builder。Tab 栏顶部使用 1 像素分割线(COLORS.line 深蓝色)与内容区分隔,背景色为卡片底色(#16233A 深海军蓝),与整体深色主题保持一致。七项 Tab 等分宽度后每项约 51 像素宽(以 360 像素屏宽计),emoji 图标 18 号和文字 9 号在等分宽度内居中显示,留有舒适的点击区域。

十八、弹窗系统

弹窗系统由三个独立弹窗和一个共享遮罩层组成,通过 addModal/editModal/delModal 三个布尔状态条件渲染。

18.1 全屏遮罩层

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

遮罩层使用半透明黑色(rgba(0,0,0,0.6))覆盖全屏,点击空白处触发 onClose 回调关闭弹窗。每个弹窗 Builder 内部先调用 modalOverlay(onClose) 铺底,再叠加表单内容。遮罩层的 onClick 绑定在 Column 容器上,而表单内容层叠在上方,点击表单内容不会触发关闭(事件被表单容器拦截),只有点击遮罩空白处才关闭。

18.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)
}

新增弹窗包含城市、天数(InputType.Number 数字键盘)、签证类型、行程概要四个表单字段。每个字段上方有标签文字(雾蓝灰),输入框使用 dark 次级容器底色、10 圆角。打开时 openAdd 清空表单缓存并设置签证默认值"免签"。确认新增 doAdd 对表单进行空值兜底(城市为空填"未命名目的地"、天数为空填 3、签证为空填"免签"、概要为空填"行程待规划"),然后 unshift 置顶新的 TripItem 实例。弹窗宽度 86%,Stack 容器使用 alignContent(Alignment.Center) 居中显示。

18.3 编辑行程弹窗

/** 确认编辑:就地修改 @Observed 实例字段驱动刷新 */
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;
}

编辑弹窗的表单结构与新增一致,打开时 openEdit(idx) 回填当前行程字段到表单缓存。确认编辑 doEdit 对索引进行边界校验(editIdx < 0 || editIdx >= tripList.length 时直接关闭),然后就地修改 @Observed 实例的字段——城市为空保留原值、天数为 NaN 保留原值,这种就地修改方式利用 @Observed 的字段级监听特性,无需替换整个数组即可驱动 UI 刷新。横滑大卡和清单行中引用该 TripItem 实例的组件自动检测字段变更并局部重渲染。

18.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)
}

删除弹窗采用危险操作红色确认设计。显示目标城市名称和"删除后不可恢复"提示,确认删除 doDel 对索引进行边界校验后 splice 移除条目。删除弹窗宽度 78%(比新增/编辑弹窗的 86% 更窄),以突出危险操作的聚焦感。"确认删除"按钮使用警示红底色和冷白文字,与"取消"按钮的 dark 底色形成强对比,引导用户审慎操作。当 delIdx 越界时显示"未选中有效行程"提示而非崩溃,增强健壮性。

十九、功能模块对比表

功能模块 对应 Tab 核心 API / 特性 数据模型 关键交互
行程管理 行程 @Observed + 弹窗 CRUD TripItem 横滑大卡 + 清单行 + 新增/编辑/删除
地图探索 地图 Map Kit MapComponent + MapEventManager EventLog/SpotItem Marker/POI 双长按监听 + 事件日志流
POI 搜索 搜索 Map Kit site.searchByText SearchRecord 关键字输入 + reliability 分数条排序
行程提醒 提醒 Notification Kit publish RemindItem/NoticeLog 时间轴 + 授权卡 + 通知历史流
沙箱铃声 铃音 Notification Kit EL1 沙箱 + fileUri RingItem WAV 生成 + EL1 写入 + sound 预览
AI 字幕 字幕 Speech Kit AICaptionComponent CaptionScene 四新字段 + 五区块面板 + 场景推荐
旅行家主页 我的 linearGradient + ForEach FootRow 渐变大卡 + 足迹清单 + 版本声明
月度图表 行程(内嵌) Column + ForEach 柱状图 MONTH_DAYS breath 联动高度微波动
通知授权 提醒 Notification Kit requestEnableNotification 首次授权 + 设置页二次引导
呼吸动画 全局 setInterval + @State breath 圆点透明度 + 柱状图 + 授权圆点
弹窗系统 全局 Stack + 条件渲染 表单缓存 新增/编辑/删除三弹窗 + 遮罩层
语言联动 字幕 switchSourceLang 条件分支 LangOption 中文源锁定 + 英文源三档可选

二十、状态联动关系图

铃声 sound 链

生成按钮

genRing

saveRingToSandbox

buildWavBytes 生成PCM

写入 EL1 filesDir

inSandbox=true size更新

设默认按钮

currentRing 更新

sound=uri::+uri

publishNotice 发布

行程 CRUD 链

新增按钮

openAdd 清空表单

编辑按钮

openEdit 回填字段

删除按钮

openDel 缓存索引

doAdd unshift 置顶

doEdit 就地修改字段

doDel splice 移除

tripList 变更

横滑大卡刷新

清单行刷新

行程计数刷新

呼吸动画链

setInterval 1秒

breath 翻转

头部呼吸圆点
opacity 1↔0.25

柱状图柱高
14+v*9↔12+v*9

授权圆点
opacity 1↔0.4

旅行家圆点
opacity 1↔0.3

三大状态联动链贯穿整个应用。呼吸动画链由一个 setInterval 定时器驱动,每秒翻转 breath 布尔值,联动头部呼吸圆点、柱状图柱高、授权圆点和旅行家圆点四处微交互——一处状态变更驱动多处视觉反馈,体现声明式 UI 的状态驱动优势。行程 CRUD 链通过弹窗系统实现新增/编辑/删除三种操作,@ObservedTripItem 字段级监听确保数据变更后横滑大卡、清单行和行程计数同步刷新。铃声 sound 链从 WAV 字节生成到 EL1 沙箱写入再到通知发布,串联了铃音 Tab 和提醒 Tab 两个独立模块。

二十一、总结与展望

本文深度解析了一个基于 HarmonyOS ArkUI 框架的海外行程簿应用,该应用以"夜航深蓝 + 霓虹青 + 落日橙"深色主题为视觉基调,通过七 Tab 异构布局覆盖了跨境旅行从行程规划到多语讲解的完整场景。核心技术贡献体现在三个方面。

第一,Map Kit 的深度集成实现了从"只读地图"到"交互地图"的跨越。MapEventManageronMarkerLongClickonPoiLongClick 双长按监听让用户可以长按地标标注和 POI 兴趣点触发事件回调——Marker 长按回调通过 marker.getPosition()marker.getId() 读取坐标与 ID,POI 长按回调通过 poi.namepoi.position 读取名称与坐标,两者均 unshift 置顶事件日志并限制 12 条上限。配合 site.searchByText 返回的 reliability 相关性分数实现 POI 搜索结果的精准排序与可视化展示——分数条使用 Progress 线性进度条,值映射为 reliability * 100,颜色随等级变化(高相关通过绿/中相关落日橙/低相关暗蓝灰)。这为跨境游客提供了"探索-搜索-记录"的完整地图交互闭环。

第二,Notification Kit 的 EL1 沙箱自定义铃声链路实现了通知铃声的完全个性化。从 buildWavBytes 函数生成符合 WAV 规范的正弦波音频字节(44 字节头 + 16bit 单声道 PCM 采样 + 起音包络 + 自然衰减),到 saveRingToSandbox 方法在 EL1 沙箱区域写入文件(appCtx.area = contextConstant.AreaMode.EL1),再到 fileUri.getUriFromPath 转换沙箱路径为 URI 并以 sound: 'uri::' + uri 形式注入通知请求——整条链路让用户可以为值机提醒、集合出发、入住通知等不同场景定制专属铃声,摆脱系统预设铃声的单调限制。requestAuth 方法的二次授权引导(requestEnableNotification 失败后调用 openNotificationSettings 拉起设置页)进一步保证了通知权限的可获得性。

第三,Speech Kit 的 AI 字幕组件通过 sourceLanguagetargetLanguagefontSizefontColor 四个新字段实现了多语讲解的灵活配置。源语言与目标语言的联动逻辑(中文源锁定 zh、英文源可选中文/英文/中英双语)配合五档字号(SMALL/NORMAL/BIG/LARGE)和五色字体预设(冷白/霓虹青/暖橙/薄荷绿/樱花粉),让跨境游客在双语导览、外语点餐、问路翻译、机场广播听译等不同场景下都能获得最佳的字幕阅读体验。AICaptionComponentisShown 通过 @Link 双向绑定实现字幕显隐的即时切换,writeAudio 方法接收 640 字节 PCM 块(16kHz/16bit/单声道约 20ms)实现实时语音转字幕的演示链路。

展望未来,该应用可在以下方向持续演进:一是引入 MapComponent 的路径规划能力,实现行程 Tab 横滑大卡到地图 Tab 的导航跳转,让用户从"查看目的地"到"导航到目的地"一气呵成;二是扩展 AI 字幕的 writeAudio 链路,接入真实麦克风采集实现从演示音频到实时语音转字幕的闭环,配合 AICaptionControllerstart/stop 方法实现完整的语音识别生命周期管理;三是深化 Notification Kit 的通知交互,利用通知动作按钮实现"一键值机"“一键导航到集合点"等快捷操作,让通知从被动提醒升级为主动操作入口;四是接入行程数据持久化,让 TripItemRemindItem 跨会话保存到关系型数据库或分布式数据服务,构建真正的"终身版海外行程簿”;五是扩展 RingItem 的音频源,支持用户从本地音乐库导入自定义铃声片段,突破正弦波生成的音色限制。HarmonyOS ArkUI 的声明式范式和 @Observed 字段级监听为这些扩展提供了坚实的技术底座,让功能演进无需重构即可平滑叠加。

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

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


一、创建新项目

1.1 进入欢迎界面

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

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

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

在这里插入图片描述

1.2 选择项目模板

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

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

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

在这里插入图片描述

1.3 配置项目信息

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

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

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

在这里插入图片描述

1.4 完成创建

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

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

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

在这里插入图片描述

1.5 项目结构概览

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

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

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

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

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

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

在这里插入图片描述


二、查看 SDK 版本

2.1 查看 HarmonyOS SDK

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

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

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

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

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

在这里插入图片描述

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

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

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

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

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

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

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

在这里插入图片描述


三、小结

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

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


Logo

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

更多推荐