一、技术前言

在跨境旅行服务领域,出境导航正经历从"纸质攻略"到"数字向导"的深刻变革。从签证状态速览到港珠澳大桥口岸集合提醒,从景点 POI 相关性搜索到 AI 字幕多语讲解,每一项能力都需要匹配不同的出行场景、数据模型和交互反馈机制。传统出境导航应用面临三大挑战:地图标注无法长按交互导致地标探索割裂、字幕语言无法灵活切换导致双语讲解断档、通知铃声无法自定义导致行程提醒千篇一律。

HarmonyOS ArkUI 框架为这些挑战提供了系统级解决方案。ArkUI 的声明式 UI 范式通过 @Component 封装可复用组件、@State 管理响应式状态、@Builder 拆分复杂 UI 结构,天然适合"行程-地图-提醒"三层架构。@Observed 装饰器让数据模型字段级变化被 UI 感知,实现"签证状态更新即视图刷新"的流畅体验。aboutToAppearaboutToDisappear 生命周期回调则保障了呼吸动画定时器的正确创建与释放。ArkUI 的核心设计哲学包含三大支柱:声明式渲染——开发者只需描述界面"是什么"而非"怎么构建",框架通过虚拟 DOM diff 算法自动完成最小化 DOM 更新,使 UI 状态与数据模型保持同步;组件化架构——通过 @Component 装饰器将界面拆分为独立可复用的组件单元,每个组件拥有自己的状态管理和生命周期,组件间通过参数传递和回调函数实现松耦合通信;状态驱动机制——@State 装饰器监听变量变化并自动触发关联 UI 的重渲染,@Observed 装饰器使类的实例具备可观察性,当对象属性变更时通知所有引用处刷新,实现数据到视图的单向流动。

本平台深度融合 HarmonyOS 6.1.1 的三大前沿特性。Map Kit 提供地图组件的 onMarkerLongClickonPoiLongClick 双长按监听能力——通过 MapComponentController 获取 MapEventManager 后注册回调,长按地标标注可读取 marker.getId()getPosition(),长按 POI 可读取 poi.namepoi.position,同时 site.searchByText 接口以 querylocationradiuslanguage 四参数发起 POI 关键字搜索并返回 reliability 相关性分数。在地图初始化阶段,setupMapCallback 方法通过 AsyncCallback 回调依次完成错误检查、控制器实例获取、事件管理器装配、批量地标标注添加与双长按监听注册五步链路,其中 addMarker 接受 MarkerOptions 配置项(包含 positionclickablevisiblerotationzIndexalphaanchorUanchorVdraggableflat 共十个字段),以逐个 await 异步添加确保标注全部就绪后再注册监听。Speech Kit 的 AI 字幕组件引入了 sourceLanguagetargetLanguagefontSizefontColor 四个新字段,支持中英双向翻译、中英双语对照、四档字号调节和五色字体预设,配合 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 包含 titletextadditionalText 三级文本,sound 字段填入沙箱铃声 URI 前缀值。授权流程通过 requestEnableNotification 发起首次授权弹窗,当用户曾拒绝时返回错误码 1600004,此时调用 openNotificationSettings 拉起系统通知设置页引导用户手动开启二次授权。

二、整体架构流程图

七大业务 Tab

出境导航主组件

headerMain 头部信息栏

内容区 7 Tab 切换

tabBar 底部导航栏

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

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

Tab1 地图
MapComponent+双长按日志流

Tab2 搜索
关键字搜索+reliability分数条

Tab3 提醒
时间轴+通知授权+发布提醒

Tab4 铃音
铃声坊EL1沙箱库列表

Tab5 字幕
AI字幕五区块设置

Tab6 我的
旅行家渐变大卡+足迹国家

Map Kit
双长按监听+searchByText

Speech Kit
AI字幕四新字段

Notification Kit
EL1沙箱自定义铃声

Column柱状图
breath联动波动

panelAdd 新增行程表单

panelEdit 编辑行程表单

panelDel 删除确认弹窗

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

值得注意的是 Tab 选中色使用 cyan(霓虹青)与主色一致,这是因为霓虹青在深蓝背景上对比度极高,用户视觉定位更迅速。旅行家渐变大卡使用 linearGradientcyanDdarkcard 的 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),同时驱动地图 MapOptionsposition.target 初始视野中心和 searchByTextlocation 搜索基准点。

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 为语言码(如 zhenzh-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_NAMEMONTH_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 };
}

reliabilityScoresearchByText 返回的 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 橙色"的视觉编码。

visaColorstartsWith 前缀匹配四种签证类型:免签绿、落地签青、电子签紫、需面签橙,与行程横滑大卡和清单行的签证徽标保持一致配色。使用 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,保证了时间戳在日志流中对齐排列的视觉一致性。

该函数在 bindMarkerLongClickbindPoiLongClick 中被调用,为每次长按事件生成触发时刻;在 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.cityt.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.SitenameformatAddressdistancereliability 均以 ?? 空值合并兜底,确保可选字段缺失时不崩溃;当搜索失败时(如无 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 记录触发时刻。在 bindMarkerLongClickbindPoiLongClick 回调中,每次长按事件通过 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 为地图初始化回调函数,mapControllermapEventManager 为地图控制器和事件管理器实例(私有非响应式,不需触发 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.timerbreath 状态的翻转联动柱状图高度(±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)。StackalignContent(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° 渐变(darkcard)模拟夜航舷窗质感,卡片内含城市名(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%')

快捷关键字 chipsForEach 遍历 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° 三色渐变(cyanDdarkcard)模拟夜航舷窗质感,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° 纵向渐变(cyancyanD)模拟霓虹灯光由顶向底渐暗。外层 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 类型(仅含 idnameposition 三字段)。长按兴趣点时,通过 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 配置项(包含 positionclickablevisiblerotationzIndexalphaanchorUanchorVdraggableflat 共十个字段),逐个 await addMarker 异步添加。anchorUanchorV 设为 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}),保留当前推荐`;
  }
}

toggleMarkerListentogglePoiListen 实现运行时监听订阅的动态管理:当前为开启状态时调用 offMarkerLongClick()/offPoiLongClick()(不传参表示清除该类型全部订阅)关闭监听,当前为关闭状态时调用 bindMarkerLongClick()/bindPoiLongClick() 重新注册监听,最后翻转布尔开关状态。

runSearch 异步方法发起 POI 关键字搜索:构造 SearchByTextParams(query 为关键字、location 为 CITY_CENTER 香港中环坐标、radius 为 5000 米覆盖主要游客活动区域、language 为 zh 返回中文结果)调用 site.searchByText。成功时将 sites 数组映射为 SearchRecord 列表——每个 site.SitenameformatAddressdistancereliability 均以 ?? 空值合并兜底,确保可选字段缺失时不崩溃;无结果时保留当前推荐并提示;失败时(如无 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 供界面兜底展示。该方法在 AICaptionComponentoptions 参数中被调用,每次状态变化都会重新组装配置。

switchSourceLang 方法实现源语言切换时联动目标语言:中文源时目标语言锁定 zh(无可选项),英文源时默认切双语 zh-en。这种联动设计避免了中文源时选择英文翻译的无效操作。

applyScene 方法一键套用场景推荐的语言组合:从 CAPTION_SCENES 数组取对应索引的场景,将其 srctgt 赋值给当前状态,免去用户手动逐项配置。

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 包含 titletextadditionalText 三级文本(附加文本标注铃声名),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.maskrgba(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、签证"免签"、概要"行程待规划"),组装 TripItemunshift 置顶行程列表。弹窗内容卡宽度 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)
}

编辑弹窗 panelEditopenEdit 时通过 formCity = t.cityformDays = t.days.toString()formVisa = t.visaformPlan = 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 清空表单缓存(签证默认"免签")并打开新增弹窗。openEdittripList 取当前行程回填到四个表单缓存字段,记录 editIdx 并打开编辑弹窗。openDel 记录 delIdx 并打开删除弹窗。

doAddNumber.parseInt 解析天数,Number.isNaN(days) ? 3 : days 兜底为默认 3 天,空城市兜底为"未命名目的地",组装 TripItemunshift 置顶行程列表并关闭弹窗。doEdit 先做边界检查(editIdx 有效),再就地修改 @Observed 实例的四个字段——空字段保留原值(formCity.trim() === '' ? t.city : formCity.trim()),天数解析失败保留原值(Number.isNaN(days) ? t.days : days)。doDel 同样做边界检查后 splice 移除目标项。toggleRemind 直接翻转 RemindItemon 字段,利用 @Observed 的字段级观察特性驱动时间轴圆点和竖线状态刷新。

三个弹窗通过 addModal/editModal/delModal 三个布尔状态条件渲染,互斥独立,各自持有 onClose 回调函数用于关闭时翻转对应布尔值。

十五、功能模块对比表

维度行程 Tab地图 Tab搜索 Tab提醒 Tab铃音 Tab字幕 Tab我的 Tab
布局方式横滑大卡+清单+柱状图Toggle行+地图+日志流搜索框+chips+列表授权卡+时间轴+历史预览卡+铃声库列表五区块设置面板渐变大卡+足迹行
数据模型TripItemEventLogSearchRecordRemindItem/NoticeLogRingItemCaptionSceneFootRow
字段数4544/3644
核心操作新增/编辑/删除行程双长按监听开关关键字搜索/chips授权/开关/发布生成WAV/设默认语言/字号/颜色/场景
动画效果柱状图breath波动授权圆点breath渐变卡呼吸圆点
状态颜色签证绿/青/紫/橙Marker青/POI橙高绿/中橙/低灰已授权绿/未授权橙沙箱绿/未生成橙选中青/紫统计青/橙/紫/绿
数据量7行程6地标+2种子日志6条Mock6提醒+3通知6铃声5场景6足迹国家
特殊组件横滑Scroll+linearGradientMapComponent+ToggleProgress分数条Toggle时间轴fileIo沙箱写入AICaptionComponentlinearGradient渐变
ForEach键值idx-cityidx-timeidx-nameidx-timeidx-nameidx-sceneidx-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 添加→双长按监听注册),onMarkerLongClickonPoiLongClick 分别读取 marker.getId()/getPosition()poi.name/poi.positionsearchByText 以四参数发起搜索并返回 reliability 相关性分数。Speech Kit 的 AICaptionOptions 通过 buildCaptionOptions 方法组装,四个 6.1.1 新字段齐全,onPreparedonError 回调兜底,writeAudio 以 640 字节 PCM 块写入演示音频流。Notification Kit 的 saveRingToSandbox 方法严格遵循 EL1 沙箱规范,buildWavBytes 生成含 ADSR 包络的 WAV 字节,publishNoticesound: '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 将自动执行以下操作:

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

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

在这里插入图片描述

1.5 项目结构概览

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

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

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

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

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

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

在这里插入图片描述


二、查看 SDK 版本

2.1 查看 HarmonyOS SDK

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

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

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

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

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

在这里插入图片描述

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

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

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

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

版本SDK 版本号阶段状态
API Version 246.1.1.100Release✅ 已安装
API Version 236.1.0.28Beta1未安装
API Version 226.0.2.112Release未安装

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

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

在这里插入图片描述


三、小结

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

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


Logo

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

更多推荐