一、技术前言

在这里插入图片描述

在出境旅游服务领域,跨境旅行向导应用是连接旅行者与陌生世界的数字枢纽。从签证状态速览到目的地横滑选卡,从地图地标长按探索到 POI 相关性搜索,从行程提醒时间轴到沙箱自定义通知铃声,再到 AI 字幕多语实时讲解——每一个功能模块都需要精确的地理感知能力、流畅的跨语言交互链路和可定制化的通知体系。传统旅行类应用往往面临三大痛点:地图交互停留在"只看不能摸"的被动层面、外语沟通依赖第三方翻译 App 跳转割裂体验、通知铃声千篇一律导致重要提醒被忽略。

在这里插入图片描述
HarmonyOS ArkUI 框架以其声明式 UI 范式为这些问题提供了系统级的解决方案。ArkUI 基于 TypeScript 扩展的 ArkTS 语言,通过 @Component 装饰器封装可复用组件,通过 @State@Observed 等状态管理装饰器实现数据驱动渲染,通过 @Builder 方法将复杂的 UI 结构拆分为可组合的构建块。这种架构天然适合旅行场景中"地理数据-多语视图-通知交互"紧耦合的需求——一个 currentTab 状态索引即可在七个完全不同布局的 Tab 间无缝切换,而跨 Tab 的共享状态(如通知授权状态、当前铃声、字幕语言组合)统一声明在组件顶层,实现数据全局流动。

在这里插入图片描述
本应用深度融合了 HarmonyOS 6.1.1 的三大前沿特性。Map Kit 提供了 MapComponent 地图组件与 site.searchByText 关键字搜索能力链——通过 onMarkerLongClickonPoiLongClick 双长按监听实现地标与兴趣点的交互式探索,搜索结果携带 reliability 相关性分数让用户直观判断结果可信度。Speech Kit 提供了 AICaptionComponent AI 字幕组件——6.1.1 版本新增 sourceLanguage(源语言)、targetLanguage(目标语言)、fontSize(字体大小枚举)、fontColor(字体颜色)四个全新字段,实现从英文导游讲解到中英双语字幕的实时转换。Notification Kit 实现了 EL1 沙箱自定义铃声链路——通过 buildWavBytes 生成正弦波 PCM 音频,写入 EL1 沙箱 filesDir,再以 'uri::' + fileUri.getUriFromPath(沙箱路径) 填入 NotificationRequest.sound,让登机叮咚、口岸钟声、集合哨声等不同场景的提醒拥有差异化铃声。

在这里插入图片描述

二、整体架构流程图

渲染错误: Mermaid 渲染失败: Parse error on line 24: ...raph "S1["跨Tab共享状态层"]" SH1["通知授权 -----------------------^ Expecting 'SEMI', 'NEWLINE', 'SPACE', 'EOF', 'GRAPH', 'DIR', 'subgraph', 'SQS', 'end', 'AMP', 'COLON', 'START_LINK', 'STYLE', 'LINKSTYLE', 'CLASSDEF', 'CLASS', 'CLICK', 'DOWN', 'UP', 'NUM', 'NODE_STRING', 'BRKT', 'MINUS', 'MULT', 'UNICODE_TEXT', got 'STR'

整体架构以 Page1210 为根组件,采用 Stack 容器实现页面层叠:底层是 Column 纵向布局的头部区域 + 内容区 + 底部 Tab 栏,顶层是全屏弹窗遮罩。内容区通过 currentTab 状态索引在 7 个 @Builder 方法间切换,每个 Tab 拥有完全独立的布局结构。值得注意的是,地图 Tab 由于 MapComponent 需要有界高度才能正确渲染,因此被单独拎出 if 分支不进入 Scroll 容器,其余六个 Tab 统一包裹在 Scroll + Column 的纵向滚动容器中。三大特性(Map Kit 双长按监听、Speech Kit AI 字幕四字段、Notification Kit 沙箱铃声)分别挂载在地图、字幕、铃音三个 Tab 上,但它们的状态变量统一声明在组件顶层,通过虚线所示的共享状态层实现跨 Tab 数据流动——例如铃音 Tab 选定的 currentRing 会被提醒 Tab 的发布通知逻辑直接读取,字幕 Tab 的 srcLang/tgtLang 会反映到头部状态胶囊。

在这里插入图片描述

三、色彩体系设计

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;    // 弹窗遮罩
}

这个接口的设计哲学是"语义化命名"——每个字段名不是颜色值描述,而是用途语义。cyan 不叫"蓝绿色"而叫"霓虹青",因为它在应用中代表的是旅行途中的霓虹指引色;orange 不叫"橙色"而叫"落日橙",呼应旅行场景中的落日意象。text3 用数字后缀表明它是三级弱化文本,在视觉层次中排在 titlesub 之后。

在这里插入图片描述

3.2 COLORS 常量逐色分析

const COLORS: ColorPalette = {
  bg: '#0D1522',      // 夜航深蓝:极深蓝黑色,模拟夜间航行时的深邃天幕
  card: '#16233A',    // 深海军蓝:卡片底色,比背景略亮一档
  dark: '#1D2E4A',    // 次级容器底色(状态胶囊 / 进度条底 / 输入框底)
  title: '#E8F0FA',    // 冷白:主标题色,高对比度保证暗光环境可读
  sub: '#9DB4D0',     // 雾蓝灰:副标题与正文文本,层次柔和过渡
  text3: '#67819E',   // 暗蓝灰:三级弱文本,辅助信息不抢视觉焦点
  cyan: '#38C8D8',    // 霓虹青:主色,Tab选中/进度条/主按钮/柱状图渐变起点
  cyanD: '#2298A8',   // 霓虹青深色:渐变终点/选中按钮深色态
  orange: '#FF8A4C',  // 落日橙:辅助暖色,未授权/POI标识/中相关搜索
  purple: '#8A7FE8',  // 星紫:电子签徽标/字幕语言胶囊
  green: '#4EC98A',   // 通过绿:免签徽标/已授权/高相关/已就绪
  red: '#E86060',     // 警示红:删除操作/错误信息
  line: '#223450',    // 分割线:低对比度不干扰内容
  tabOn: '#38C8D8',   // Tab选中色与主色一致
  mask: 'rgba(0,0,0,0.6)' // 半透黑遮罩
};

色彩设计遵循"夜航指路"原则:霓虹青作为主色贯穿全应用——Tab 选中态、柱状图渐变、主按钮背景、进度条颜色全部使用 cyan,形成统一的视觉锚点;落日橙作为辅助暖色用于"需要注意"的语义场景(未授权、POI 长按、中相关搜索结果);通过绿与警示红形成"安全/危险"的二元对比,分别用于免签徽标和删除操作。星紫作为特殊语义色,专门标识电子签与字幕语言设置,在深色背景中具有良好的辨识度。头部三个特性状态胶囊分别使用 cyan(长按监听)、purple(字幕语言)、green/orange(通知授权)三色,用户一眼即可判断三大 Kit 的运行状态。

在这里插入图片描述

四、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 接口只有 iconlabel 两个字段,极简设计让 Tab 栏的扩展成本极低——添加一个 Tab 只需在数组中追加一个对象。七个 Tab 按旅行者的使用动线排列:先看行程总览(行程),再查地图地标(地图),搜周边 POI(搜索),设行程提醒(提醒),选提醒铃声(铃音),用字幕翻译(字幕),最后查看个人主页(我的)。Emoji 图标的选择也经过精心考量——🧭指南针代表行程规划、🗺️地图代表地理探索、🗣对话气泡代表语言翻译,每个图标都在视觉层面传达了该 Tab 的核心功能。

在这里插入图片描述

4.2 头部 Tab 联动副标题

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

TAB_SUBS 数组与 TAB_LIST 一一对应,当用户切换 Tab 时,头部区域的副标题会通过 Text(TAB_SUBS[this.currentTab]) 联动更新。这种设计让头部不再是一个静态标题栏,而是一个动态信息面板——用户切换到搜索 Tab 时,头部立即显示"景点 POI 相关性搜索",起到功能引导和状态确认的双重作用。

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

CITY_CENTER 定义为香港中环坐标,既是 MapComponent 初始化视野的中心点,也是 POI 搜索的 location 基准。MARKER_SPOTS 精选六处跨境游客常用地标——从维港夜景到太平山观景台,从旺角市集到迪士尼乐园,再到港珠澳大桥口岸这条跨境枢纽线,每个标注点都携带 tag 字段用于分类标识。这些数据在 setupMapCallback 中被遍历,逐个 await this.mapController.addMarker(markerOptions) 添加到地图上。

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

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

QUICK_QUERIES 是搜索 Tab 的快捷关键字 chips,覆盖跨境游客高频 POI 类型。字幕语言选项分两组:SRC_LANGS 只含中文和英文(因为 AICaption 的源语言仅支持这两种),TGT_LANGS_EN 是英文源时的目标语言选项——当源语言为英文时,目标可以是中文、英文或中英双语;但当源语言切换为中文时,目标语言锁定 zh 不可选(中文源无翻译方向)。SIZE_OPTIONS 使用 AICaptionFontSize 枚举的四个值,CAPTION_FONT_COLORS 预设五种字幕字体颜色,从纯白到粉红覆盖不同视觉风格。

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;
  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_DAYS 是行程 Tab 柱状图的数据源,展示近六个月每月跨境出行的天数,breath 状态会联动柱状图高度产生呼吸波动效果。FOOT_ROWS 是"我的" Tab 的足迹国家清单,每行包含国旗 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 返回的 reliability 分数(0~1 的浮点数)映射为三档视觉等级:≥0.8 显示绿色"高相关"标签,≥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 函数为地图长按事件日志中的 MarkerPOI 两种类型分配不同颜色——霓虹青标识地图上标注的地标,落日橙标识搜索返回的兴趣点,让用户在日志流中一眼区分事件来源。visaColor 函数将四种签证类型映射为四色:免签绿色(最便利)、落地签青色(中等便利)、电子签紫色(需提前申请)、需面签橙色(最复杂)。这两个函数体现了"状态→颜色"的统一设计模式,让应用中所有需要状态可视化的地方都通过函数调用而非硬编码实现。

5.3 时间戳与 WAV 音频生成

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

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

nowTime 是一个简单的时间格式化函数,用 padStart 补零生成 HH:mm:ss 格式时间戳,用于长按事件日志和通知历史。buildWavBytes 是通知铃声链路的核心函数——它生成一个标准的 WAV 音频文件(44 字节 RIFF/WAVE 头 + 16bit 单声道 PCM 数据)。函数接收频率和时长参数,用 DataView 逐字节写入 WAV 文件头(RIFF 标识、文件大小、WAVE 格式、fmt 子块、PCM 编码、单声道、44100 采样率、16bit 量化),然后逐采样生成正弦波数据。特别精妙的是包络设计:env 实现前 20ms 的起音平滑(避免咔嗒声),decay 实现整体自然衰减(模拟铃声的物理特性),最终振幅 v = sin(2πf·t) × 0.5 × env × decay 生成听感自然的铃声波形。不同频率对应不同铃声:990Hz 是登机叮咚的高频清脆音、523Hz 是口岸钟声的中频沉稳音、880Hz 是集合哨声的穿透音。

六、数据模型层

本应用共定义了七个 @Observed 数据模型类,覆盖行程、搜索、事件日志、提醒、铃声、字幕场景和通知历史全部业务实体。

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

TripItem 是业务主 Tab(行程)的核心实体,也是弹窗系统绑定的数据对象。四个字段分别表示目的地城市、行程天数、签证类型和行程概要。使用 @Observed 装饰意味着当 doEdit 方法中直接修改 t.city = ... 时,UI 会自动刷新——这是 ArkUI 响应式编程的核心机制。初始数据 TRIP_LIST 包含七个热门跨境目的地,从免签的香港、新加坡、首尔到电子签的东京、大阪,再到需面签的巴黎,覆盖了不同签证类型的典型场景。

6.2 SearchRecord 搜索结果条目

@Observed export class SearchRecord {
  name: string;
  address: string;
  distance: number;
  reliability: number;
}

SearchRecord 封装 site.searchByText 的返回结果,四个字段分别对应地点名称、格式化地址、直线距离和相关性分数。reliability 字段是 Map Kit 6.1.1 的关键能力——搜索结果不再只是"有/无"的二元判断,而是携带 0~1 的可信度分数,让旅行者能够量化评估搜索结果的可靠性。初始 Mock 数据 SEARCH_MOCK 故意覆盖高(0.94)、中(0.71)、低(0.22)三档,便于演示分数条的三色映射效果。

6.3 EventLog 长按事件日志

@Observed export class EventLog {
  type: string;
  name: string;
  lat: number;
  lng: number;
  time: string;
}

EventLog 记录地图长按事件的完整信息:类型(Marker/POI)、名称、经纬度和触发时刻。日志采用 unshift 置顶策略——新事件出现在列表最上方,最多保留 12 条,超出时 pop 移除最旧条目。这种设计让用户长按地图后能立即在最上方看到最新事件,形成"操作→反馈"的即时闭环。

6.4 RemindItem 行程提醒条目

@Observed export class RemindItem {
  time: string;
  title: string;
  repeat: string;
  on: boolean;
}

RemindItemon 布尔字段驱动时间轴上的圆点颜色、竖线颜色和文字颜色全部联动变化——开启时霓虹青圆点 + 青色竖线 + 冷白标题 + 绿色"提醒开启中";关闭时灰色圆点 + 灰色竖线 + 灰色标题 + 灰色"已暂停"。toggleRemind 方法直接修改 item.on = !item.on,由于 @Observed 的响应式特性,UI 自动刷新。

6.5 RingItem 铃声条目

@Observed export class RingItem {
  name: string;
  file: string;
  freq: number;
  duration: number;
  size: string;
  inSandbox: boolean;
}

RingIteminSandbox 布尔字段是铃声链路的关键状态——初始为 false,当 genRing 方法将 WAV 音频成功写入 EL1 沙箱后置为 true,同时更新 size 字段显示文件大小。size 初始为 '—'(未生成),生成后显示 "XX KB",让用户直观看到铃声文件已落盘。六个预设铃声覆盖登机、口岸、巴士、行李、集合、夜航六个旅行场景。

6.6 CaptionScene 字幕场景与 NoticeLog 通知历史

@Observed export class CaptionScene {
  scene: string;
  desc: string;
  src: string;
  tgt: string;
}

@Observed export class NoticeLog {
  title: string;
  text: string;
  time: string;
}

CaptionScene 是字幕 Tab 的场景推荐卡数据——五个场景从"双语导览讲解"到"中文讲解复盘",每个场景预设了推荐的源语言和目标语言组合,点击场景卡即可一键套用。NoticeLog 是通知历史流的数据条目,记录每次发布通知的标题、正文和时刻,最多保留 8 条。

七、组件主体结构

7.1 状态变量声明

Page1210 组件的状态变量分为五组:

Tab 与弹窗状态——currentTab 控制当前显示的 Tab 索引;addModal/editModal/delModal 三个布尔值控制三个弹窗的显隐;editIdx/delIdx 记录当前编辑或删除的行程索引。

弹窗表单缓存——formCity/formDays/formVisa/formPlan 四个字符串缓存新增和编辑表单的输入值,openAdd 时清空、openEdit 时回填、doAdd/doEdit 时读取。

动画状态——breath 布尔值每秒翻转一次,驱动呼吸圆点、柱状图高度、通知授权状态卡的圆点透明度联动波动;timer 保存 setInterval 返回的定时器 ID。

业务数据数组——tripList/remindList/ringList/noticeLogs 四个 @Observed 数组分别绑定四个 Tab 的列表数据。

三大 Kit 状态——Map Kit 状态包括 mapOptions(地图初始化配置)、mapCallback(初始化回调)、mapController(控制器)、mapEventManager(事件管理器)、eventLogs(日志流)、markerListenOn/poiListenOn(双长按开关)、queryInput/searchRecords/searchState(搜索三件套);Speech Kit 状态包括 captionController(字幕控制器)、captionShown(显示状态)、srcLang/tgtLang(语言组合)、captionSize/captionColor(字号字体色)、captionReady/captionErrMsg/captionFed(就绪/错误/音频计数);Notification 状态包括 granted(授权状态)、notifyId(ID 自增基数)、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 装配地图初始化回调(注册 Marker/POI 长按监听);调用 notificationManager.isNotificationEnabled() 异步查询通知授权状态并更新 granted;启动每秒翻转 breath 的定时器驱动呼吸动画。aboutToDisappear 清除定时器防止内存泄漏。这三步初始化的顺序有讲究——地图回调必须最先装配,因为 MapComponent 在 build 阶段就会触发回调,如果回调未就绪会导致地图初始化失败。

7.3 build() 根构建

build() {
  Stack() {
    Column() {
      this.headerMain()
      Divider().strokeWidth(1).color(COLORS.line)
      if (this.currentTab === 1) {
        this.tabMap()
      } else {
        Scroll() {
          Column({ space: 12 }) {
            // ...根据 currentTab 切换 Tab Builder
          }.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 栏;顶层是三个条件渲染的弹窗。内容区的核心逻辑是 if (this.currentTab === 1) 分支——地图 Tab 由于 MapComponent 需要有界高度才能正确渲染(否则会高度为 0 不可见),因此被单独拎出不进入 Scroll 容器,直接用 layoutWeight(1) 占满剩余空间。其余六个 Tab 统一包裹在 Scroll 容器中,通过 edgeEffect(EdgeEffect.Spring) 实现弹性滚动效果,scrollBar(BarState.Off) 隐藏滚动条保持视觉干净。弹窗采用回调函数 onClose 模式——父组件传入关闭逻辑 () => { this.addModal = false; },弹窗内部任意位置调用 onClose() 即可关闭,实现了弹窗与父组件的解耦。

八、头部区域详解

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

头部区域是一个 Row 横向三段式布局:左侧是应用名"环球向导"加 Tab 联动副标题,右侧是三个特性状态胶囊,最右端是呼吸圆点。

左侧 Column 使用 layoutWeight(1) 占据剩余空间,副标题通过 TAB_SUBS[this.currentTab] 索引读取当前 Tab 对应的描述文案,maxLines(1) 配合 textOverflow({ overflow: TextOverflow.Ellipsis }) 确保长文本不会撑破布局而是优雅省略。

右侧三个状态胶囊是头部的信息密度核心——第一个胶囊显示"🗺️ 长按On/Off",颜色由 markerListenOn || poiListenOn 决定(任一监听开启即为 On 态,显示霓虹青);第二个胶囊显示"🗣 src→tgt"语言组合,如"en→zh-en"表示英文源中英双语目标,使用星紫色与字幕 Tab 的语言选项色一致;第三个胶囊显示"🔔 已授权/未授权",绿色表示通知已授权,橙色表示需要授权。三个胶囊让用户在头部一屏之内即可掌握三大 Kit 的运行状态。

最右端的呼吸圆点 Circle 使用 COLORS.cyan 填充,opacitybreath 为 true 时为 1、为 false 时为 0.25,每秒切换一次形成呼吸闪烁效果,暗示应用"活着"——正在持续运行。

九、Tab0 行程:目的地横滑与签证速览

行程 Tab 是应用的主界面,由四个功能区块组成:目的地横滑大卡、行程清单行、新增行程入口和月度出行天数柱状图。

9.1 目的地横滑大卡

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 实现水平滚动,每个卡片固定宽度 170。卡片内部三段式布局:顶部是城市名加天数控章(霓虹青底白字),中部是行程概要(最多两行省略),底部是签证类型标签——小圆点加文字使用 visaColor 函数映射的颜色(免签绿、落地签青、电子签紫、需面签橙)。卡片背景使用 135 度线性渐变从 darkcard,营造微微凸起的立体感。水平滚动条通过 scrollBar(BarState.Off) 隐藏,依靠手势滑动操作。

9.2 行程清单行与新增入口

行程清单行是竖向排列的完整行程列表,每行包含城市名、签证标签、天数、行程概要和编辑/删除两个操作按钮。编辑按钮 ✏️ 点击调用 this.openEdit(idx) 打开编辑弹窗(回填当前行程字段),删除按钮 🗑 点击调用 this.openDel(idx) 打开删除确认弹窗。新增行程入口是一个全宽霓虹青按钮,点击调用 this.openAdd() 清空表单缓存后打开新增弹窗。

9.3 月度柱状图

柱状图区块通过 this.chartCard() Builder 方法渲染,详见第十六章。

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

地图 Tab 是 Map Kit 特性的核心承载,由监听开关行、MapComponent 本体和长按事件日志流三部分组成。

10.1 监听开关行

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

两个 Toggle 开关分别控制 Marker 和 POI 的长按监听——Marker 开关选中色为霓虹青,POI 开关选中色为落日橙,与 typeColor 函数中的颜色映射保持一致。toggleMarkerListen 方法的逻辑是:如果当前已开启则调用 offMarkerLongClick() 清除订阅(不传参表示清除该类型全部订阅),如果当前已关闭则重新调用 bindMarkerLongClick() 注册监听,然后翻转 markerListenOn 状态。右侧"长按地标/POI 试试"是操作引导文案。

10.2 MapComponent 本体

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

MapComponent 接收两个参数:mapOptions 定义地图初始视野(中心点为香港中环 CITY_CENTER,缩放级别 13),mapCallback 是异步初始化回调。回调内部执行四步:错误检查 → 获取控制器 → 获取事件管理器 → 批量添加 Marker → 注册双长按监听。addMarker 是异步操作,逐个 awaittry-catch 捕获异常,六个地标标注点逐个添加到地图上。两个长按监听 bindMarkerLongClickbindPoiLongClick 分别注册 onMarkerLongClickonPoiLongClick 回调——6.1.1 版本的 onMarkerLongClick 参数类型为 map.Marker,可读取 getId()getPosition()onPoiLongClick 参数类型为 mapCommon.Poi,仅 id/name/position 三字段。两者触发后都 unshift 一条 EventLog 到日志流顶部,超过 12 条时 pop 移除最旧条目。

10.3 长按事件日志流

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,内部使用 Scroll 容器实现日志条目滚动。每条日志是一个四列 Row:类型标签(使用 typeColor 映射的霓虹青/落日橙背景白字胶囊)、名称(雾蓝灰文本弹性宽度)、经纬度(等宽字体 monospace 四位小数)、时间戳。底部有一行等宽字体提示文案"off 不传参=清除该类型全部订阅",这是 6.1.1 版本 offMarkerLongClick/offPoiLongClick 的 API 特性说明。

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

搜索 Tab 是 Map Kit site.searchByText 能力的展示窗口,由搜索框、快捷关键字 chips、搜索状态文案和结果列表四部分组成。

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

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

搜索框是 TextInput + Button 的组合,输入框 onChange 实时更新 queryInput 状态,搜索按钮触发 runSearch 方法。快捷关键字 chips 使用 ForEach 渲染六个高频 POI 类型,选中态(当前 queryInput 等于该关键字)显示霓虹青底白字,未选中态显示暗底雾蓝灰字。点击 chip 会同时设置 queryInput 和触发搜索,实现一步到位的快捷搜索体验。

11.2 runSearch 搜索逻辑

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 是 async 方法,封装了 site.searchByText 的完整调用链。搜索参数包含关键字 query、中心坐标 location(香港中环)、搜索半径 radius(5000 米)和语言 language(中文)。搜索结果通过 sites.map 映射为 SearchRecord 数组,每个 Sitename/formatAddress/distance/reliability 字段都使用 ?? 空值合并运算符兜底。特别值得注意的是异常处理——无 AGC 配置或无网络时会进入 catch 分支,此时保留 Mock 数据并显示"搜索失败"提示,体现调用链的完整性而非直接崩溃。

11.3 结果列表与分数条

结果列表使用 List + ListItem 渲染,每条结果是一个 Column 包含三行:名称加等级标签(reliabilityScore 映射的高/中/低相关标签)、格式化地址、距离加分数条加分数值。分数条使用 Progress 组件的 ProgressType.Linear 类型,valuereliability * 100,颜色使用 reliabilityScore 映射的等级色——高相关绿色、中相关橙色、低相关灰色,让用户直观判断每条搜索结果的可信度。

十二、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)

授权状态卡的核心是一个呼吸圆点——fill 颜色由 granted 决定(绿色已授权/橙色未授权),opacitybreath 联动(1 或 0.4 每秒切换)。当 granted 为 false 时显示"去授权"按钮,点击调用 requestAuth 方法。requestAuth 的逻辑是:调用 notificationManager.requestEnableNotification(hostCtx) 首次调用弹系统授权框;如果用户曾拒绝(返回错误码 1600004),则调用 notificationManager.openNotificationSettings(hostCtx) 拉起通知设置页引导用户手动开启。底部说明文案特别指出"铃声来自铃音 Tab 的沙箱自定义铃声",揭示了跨 Tab 数据流动——提醒 Tab 发布通知时读取的 currentRing 正是铃音 Tab 设定的值。

12.2 行程提醒时间轴

时间轴采用四列 Row 布局:时间列(固定 52 宽,显示时刻和重复规则)、竖线列(固定 20 宽,圆点加竖线填充行高)、提醒内容列(弹性宽度,显示标题和开启/暂停状态)、开关列(Toggle 开关)。行高固定 72,竖线通过 Column().layoutWeight(1).width(2) 填充行高,颜色由 item.on 决定(开启霓虹青/关闭灰色)。圆点颜色同样联动 item.on 状态,开启时霓虹青、关闭时灰色。Toggle 的 onChange 调用 toggleRemind(idx) 直接翻转 item.on 布尔值,@Observed 的响应式特性自动刷新整行 UI。

12.3 发布行程提醒与通知历史

发布提醒区块显示当前铃声名称和沙箱就绪状态,点击"发布行程提醒"按钮调用 publishNotice('行程提醒', '集合出发前 30 分钟...')publishNotice 方法是通知铃声链路的终点——它先设置 EL1 沙箱区域,读取当前铃声的沙箱路径,调用 fileUri.getUriFromPath 转为 URI,再以 'uri::' + uri 填入 NotificationRequest.sound 字段,最后 notificationManager.publish 发布通知。通知历史流使用 ForEach 渲染 noticeLogs 数组,每条通知显示标题、正文和时间戳,新通知 unshift 置顶最多保留 8 条。

十三、Tab4 铃音:EL1 沙箱铃声工坊

铃音 Tab 是 Notification Kit 沙箱自定义铃声特性的核心承载,由当前铃声预览卡和铃声库列表两部分组成。

13.1 当前铃声预览卡

预览卡显示当前默认铃声的名称、频率/时长/大小信息和沙箱状态。底部一行流程说明文案"生成 WAV → 写入 EL1 files → 设默认 → sound 填 uri:: 前缀发布"概括了整个铃声链路的四步流程。soundPreview 方法实时拼装 sound 字段的完整值字符串展示给开发者,格式为 "sound: 'uri::' + fileUri.getUriFromPath('沙箱路径')"

13.2 铃声库列表

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

每行铃声左侧是选中指示圆点(当前铃声霓虹青/非当前灰色),中间是铃声名称、沙箱状态标签和等宽字体的技术参数行(文件名、频率、时长、大小),右侧是"生成"和"设默认"两个操作按钮。

genRing 方法是铃声生成的核心链路:调用 saveRingToSandboxbuildWavBytes 生成的 WAV 音频写入 EL1 沙箱 filesDir 目录,成功后置 ring.inSandbox = true,计算文件大小 "XX KB" 更新到 ring.size,并刷新 noticeState 状态文案。saveRingToSandbox 方法内部使用 getUIContext().getHostContext() 获取 UIAbility 上下文(注意 getContext(this) 已废弃),设置 appCtx.area = contextConstant.AreaMode.EL1 确保在 EL1 沙箱区域操作,然后 fs.openSync 打开文件、fs.writeSync 写入音频字节、fs.closeSync 关闭文件。"设默认"按钮调用 setCurrentRing 将该铃声设为 currentRing,后续发布通知时 sound 字段即取此铃声的沙箱路径。选中态按钮使用 cyanD(深霓虹青)区分于未选中的 cyan

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

字幕 Tab 是 Speech Kit AICaptionComponent 特性的核心承载,由五个配置区块组成:实时预览区、语言设置区、字号选择区、字体颜色区、场景推荐区。

14.1 AICaptionComponent 实时预览

AICaptionComponent({
  isShown: this.captionShown,
  controller: this.captionController,
  options: this.buildCaptionOptions()
})
.width('100%')
.height(110)
.borderRadius(10)

AICaptionComponent 接收三个参数:isShown@Link 双向绑定——直接传 @State captionShown 引用,修改 captionShown 即可控制字幕显隐;controllerAICaptionController 实例,用于调用 writeAudio 写入音频流;optionsbuildCaptionOptions 方法组装,封装了 6.1.1 新增的四字段配置。

buildCaptionOptions 方法是 Speech Kit 特性的集大成者:

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

四个新字段分别是:sourceLanguage(源语言 ‘zh’/‘en’)、targetLanguage(目标语言 ‘zh’/‘en’/‘zh-en’)、fontSizeAICaptionFontSize 枚举四档)、fontColorResourceColor 类型,传 '#RRGGBB' 字符串)。两个回调 onPreparedonError 分别在字幕服务就绪和异常时触发,更新 captionReadycaptionErrMsg 状态。

预览区还包含"开启/隐藏字幕"按钮和"写入演示音频"按钮。后者调用 feedAudioStream 方法——生成 640 字节的 PCM 音频块(16kHz/16bit/单声道约 20ms),通过 captionController.writeAudio(audioData) 写入字幕引擎,每写入一次 captionFed 计数器加一,让用户通过计数直观感知音频流写入。

14.2 语言设置联动

源语言选择使用两个 chip(中文/英文),点击调用 switchSourceLang(code) 方法。该方法的核心逻辑是联动目标语言:如果切换为中文源,目标语言锁定 zh(中文源无翻译方向);如果切换为英文源,目标语言默认切为 zh-en(中英双语)。当源语言为中文时,目标语言区域显示"中文(锁定 zh)“加说明文案"中文源无翻译方向可选”;当源语言为英文时,目标语言区域显示三个可选 chip(中文/英文/中英双语),选中态使用星紫色与头部胶囊一致。

14.3 字号四档与字体颜色五色卡

字号选择区使用 ForEach 渲染 SIZE_OPTIONS 四档,每个选项是一个 Column 包含预览字(大A/大/小/标)、字号名称和选中态边框。预览字的 fontSize 根据 AICaptionFontSize 枚举值动态计算——LARGE 为 20、BIG 为 17、SMALL 为 11、NORMAL 为 14,让用户在选择前就能预览字号效果。选中态使用 COLORS.dark 底色加 1 宽 COLORS.cyan 边框,未选中态使用 COLORS.card 底色无边框。

字体颜色区使用 ForEach 渲染 CAPTION_FONT_COLORS 五色卡,每张卡片是一个 30x30 的圆形色块加"使用中"或"#N"标签。选中态使用 2 宽 COLORS.cyan 边框,未选中态使用 1 宽 COLORS.line 边框。点击设置 this.captionColor = c 即可更新字幕字体颜色。

14.4 场景推荐卡

五个场景卡从"双语导览讲解"到"中文讲解复盘",每张卡片显示场景名、说明文案和推荐语言组合(如 en→zh-en)。点击调用 applyScene(idx) 方法,一次性套用该场景推荐的 srcLangtgtLang,省去用户逐个选择语言的操作。底部如果存在 captionErrMsg 则显示 onError 兜底错误信息。

十五、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 度线性渐变,从 cyanD(深霓虹青)经过 dark(次级容器底色)过渡到 card(卡片底色),形成从霓虹青到深蓝的渐变封面效果。顶部是旅行家头像加称号和开通天数,右侧有呼吸圆点(COLORS.cyan 填充,breath 联动透明度)。底部是四列统计数据——足迹国家(12,霓虹青)、解锁城市(38,落日橙)、出行天数(28,星紫)、待启程(6,通过绿),四个数字使用四种主题色,让统计数据本身成为色彩展示。

15.2 足迹国家清单行

清单行使用 ForEach 渲染 FOOT_ROWS 六个国家/地区,每行包含国旗 emoji(20 号大字)、国家名(弹性宽度)、解锁城市数和最近到访月份标签。版本声明行显示"环球向导 v6.1.1 · Map Kit + Speech Kit + Notification 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 的传统方式手绘实现,而非引入图表库。每根柱子是一个三段 Column:顶部天数标签、中间柱体容器、底部月份标签。柱体容器的关键设计是固定高度 92 + justifyContent(FlexAlign.End) 让柱子从底部对齐生长。柱体本身是一个 Columnwidth('100%')heightbreath 状态动态计算——呼吸态(breath=true)高度为 14 + v * 9,非呼吸态高度为 12 + v * 9,差值为 2 的呼吸波动效果。柱体使用 180 度线性渐变从 COLORS.cyan(霓虹青)到 COLORS.cyanD(深霓虹青),borderRadius(4) 让柱顶圆角。六根柱子通过 ForEachlayoutWeight(1) 等宽排列,数据值 v 从 MONTH_DAYS 数组读取(3/5/2/6/4/8),柱体高度与数据值成正比,数据越大柱子越高。

这种手绘柱状图的优势在于零依赖、完全可控——颜色、高度、间距、动画全部由开发者掌控,且 breath 联动效果只需要一个 height 表达式即可实现。底部"合计 28 天"是 MONTH_DAYS 数组求和的结果。

十七、底部 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 栏是单排七项的导航栏,使用 Row + ForEach 渲染。每个 Tab 是一个 Column 包含 emoji 图标和文字标签,layoutWeight(1) 等宽排列。选中态与未选中态的视觉差异通过三个维度体现:图标 opacity(选中 1 / 未选中 0.55)、标签 fontColor(选中 tabOn 霓虹青 / 未选中 text3 暗蓝灰)、标签 fontWeight(选中 Bold / 未选中 Normal)。点击设置 this.currentTab = idx 即可切换 Tab,由于 currentTab@State,切换后内容区的 if 分支自动重新渲染对应 Tab 的 Builder。Tab 栏顶部有一条 1 宽的 COLORS.line 分割线,与内容区视觉分离。

十八、弹窗系统

弹窗系统由一个通用遮罩层和三个业务弹窗组成,采用 Stack 叠加在主界面上方。

18.1 通用遮罩层

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

modalOverlay 是一个全屏半透明黑色遮罩(rgba(0,0,0,0.6)),点击任意位置调用 onClose() 关闭弹窗。这个 Builder 接收一个回调函数参数,是所有弹窗共享的遮罩层。

18.2 新增行程弹窗

新增弹窗使用 Stack 叠加遮罩层和表单 Column。表单包含四个字段:目的地城市(TextInput 文本输入)、行程天数(TextInput 数字输入 InputType.Number)、签证类型(TextInput 文本输入,placeholder 提示四种可选值)、行程概要(TextInput 文本输入)。底部是"取消"和"确认新增"两个按钮,取消调用 onClose() 关闭弹窗,确认调用 doAdd() 方法。doAdd 将表单缓存组装为 TripItem 实例,unshift 置顶到 tripList 数组,然后关闭弹窗。表单对空值做了兜底处理——城市为空时填充"未命名目的地"、天数为 NaN 时默认 3、签证为空时填充"免签"、行程为空时填充"行程待规划"。

18.3 编辑行程弹窗

编辑弹窗的结构与新增弹窗一致,但 openEdit(idx) 方法会先回填当前行程字段到表单缓存——formCity/formDays/formVisa/formPlan 全部从 tripList[idx] 读取。doEdit 方法就地修改 @Observed 实例字段(t.city = ...t.days = ... 等),空值时保留原值不变,利用 @Observed 的响应式特性自动刷新 UI。

18.4 删除行程确认弹窗

删除弹窗比新增和编辑更窄(78% 宽度 vs 86%),突出"危险操作"的聚焦感。弹窗显示确认文案"确认删除「城市名」的行程吗?删除后不可恢复",底部"取消"按钮使用 COLORS.dark 底色,"确认删除"按钮使用 COLORS.red 警示红底色加冷白文字。doDel 方法调用 this.tripList.splice(this.delIdx, 1) 从数组中移除该行程,利用 @Observed 数组的响应式特性自动刷新列表。

十九、功能模块对比表

功能模块核心 API/组件关键状态变量数据模型颜色语义交互特色
行程 TabForEach + Scroll 横滑 + 手绘柱状图tripList, breathTripItem签证四色(免签绿/落地签青/电子签紫/需面签橙)横滑大卡 + 清单行编辑/删除 + 柱状图呼吸波动
地图 TabMapComponent + onMarkerLongClick + onPoiLongClickmapController, eventLogs, markerListenOn, poiListenOnEventLogMarker霓虹青 / POI落日橙双长按监听开关 + 事件日志unshift置顶 + 经纬度monospace
搜索 Tabsite.searchByTextqueryInput, searchRecords, searchStateSearchRecordreliability三档(高绿/中橙/低灰)快捷chips + 分数条Progress + 空值合并兜底
提醒 TabnotificationManager.publish + requestEnableNotificationgranted, remindList, noticeLogs, currentRingRemindItem, NoticeLog已授权绿/未授权橙 + 开启青/暂停灰时间轴四列布局 + 去授权二次引导 + 跨Tab读取铃声
铃音 TabfileIo + fileUri + buildWavBytesringList, currentRing, noticeStateRingItem选中霓虹青/未选灰 + 沙箱绿/未生成橙WAV字节级生成 + EL1沙箱写入 + sound实时预览
字幕 TabAICaptionComponent + AICaptionController + writeAudiocaptionShown, srcLang, tgtLang, captionSize, captionColor, captionFedCaptionScene语言星紫 + 就绪绿/错误红四新字段配置 + 语言联动锁定 + 场景一键套用
我的 TablinearGradient + ForEachbreathFootRow四色统计(青/橙/紫/绿)渐变大卡 + 呼吸圆点 + 足迹清单
头部区域@Builder headerMaincurrentTab, markerListenOn, srcLang, tgtLang, granted, breath三胶囊三色(青/紫/绿橙)Tab联动副标题 + 特性状态一屏速览
底部Tab栏@Builder tabBarcurrentTab选中霓虹青/未选中暗蓝灰单排七项 + opacity+fontColor+fontWeight三维选中态
弹窗系统@Builder panelAdd/Edit/Del + modalOverlayaddModal, editModal, delModal, formXxxTripItem新增/编辑青 + 删除红回调关闭模式 + 表单缓存回填 + 空值兜底

二十、总结与展望

本应用以"环球向导"为产品形态,以"夜航深蓝 + 霓虹青 + 落日橙"为色彩语言,以"地图探索 + AI 字幕 + 沙箱铃声"为三引擎,完整呈现了 HarmonyOS ArkUI 在跨境旅行场景下的技术深度与设计美学。

从技术架构看,应用展示了 ArkUI 声明式范式的三大优势:一是 @State + @Observed 的响应式数据流让"数据修改即 UI 刷新"成为默认行为,无论是行程列表的增删改、提醒开关的翻转、还是铃声沙箱状态的变更,都无需手动操作 DOM;二是 @Builder 方法将 1900+ 行的复杂组件拆分为十余个可组合的构建块,每个 Tab 独立一个 Builder,头部、Tab 栏、弹窗各自封装,可读性和可维护性大幅提升;三是跨 Tab 状态共享天然支持——三大 Kit 的状态统一声明在组件顶层,铃音 Tab 选定的铃声被提醒 Tab 的通知发布逻辑直接读取,字幕 Tab 的语言组合反映到头部胶囊,实现了"一处修改,多处联动"的数据流动。

从特性集成看,三大 Kit 的深度集成体现了 HarmonyOS 6.1.1 的平台能力厚度。Map Kit 的 searchByText 携带 reliability 相关性分数,让搜索结果从"有/无"的二元判断升级为"可信度量化"的精细评估;onMarkerLongClickonPoiLongClick 双长按监听让地图从"只看不能摸"升级为"可交互探索"。Speech Kit 的 AICaptionComponent 新增四字段(sourceLanguage/targetLanguage/fontSize/fontColor)让 AI 字幕从"固定样式"升级为"可定制多语实时翻译"。Notification Kit 的 EL1 沙箱自定义铃声链路让通知从"千篇一律的系统铃声"升级为"场景化差异化铃声"。

从设计哲学看,色彩体系遵循"语义化命名 + 状态映射函数"的原则——ColorPalette 接口的字段名是用途语义而非颜色值描述,reliabilityScore/typeColor/visaColor 三个函数将业务状态统一映射为颜色,避免硬编码散落。呼吸动画 breath 作为全局生命信号,同时驱动头部圆点、柱状图高度、通知授权圆点和旅行家大卡圆点四处 UI,用最简单的布尔翻转实现了"应用活着"的视觉暗示。

展望未来,本应用可在以下方向继续深化:一是地图 Tab 可接入 mapCommon.MapOptionsrotateGestureszoomGestures 配置,支持手势旋转和缩放;二是搜索 Tab 可接入 site.searchByCategory 按类别搜索,返回更丰富的 POI 分类结果;三是字幕 Tab 可接入真实麦克风音频流替代 feedAudioStream 的演示数据,实现真实的实时语音转字幕;四是铃音 Tab 可接入音频编辑能力让用户自定义铃声频率和时长;五是行程 Tab 可接入日历组件实现行程日期的可视化管理;六是通知历史可接入 notificationManager.getActiveNotificationCount 实时显示未读通知数。随着 HarmonyOS 的持续演进,ArkUI 的声明式能力和三大 Kit 的特性矩阵将为旅行类应用提供更广阔的创新空间。

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

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


一、创建新项目

1.1 进入欢迎界面

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

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

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

在这里插入图片描述

1.2 选择项目模板

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

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

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

在这里插入图片描述

1.3 配置项目信息

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

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

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

在这里插入图片描述

1.4 完成创建

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

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

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

在这里插入图片描述

1.5 项目结构概览

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

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

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

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

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

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

在这里插入图片描述


二、查看 SDK 版本

2.1 查看 HarmonyOS SDK

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

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

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

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

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

在这里插入图片描述

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

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

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

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

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

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

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

在这里插入图片描述


三、小结

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

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


本文基于 DevEco Studio 6.1.1 Release 版本编写,不同版本界面可能存在细微差异。

Logo

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

更多推荐