在HarmonyOS 6.1.1的生态环境中,HarmonyOS ArkTS API 24为开发者提供了更加强大的声明式UI开发能力。本文将以一款名为"滴滴山野救援"的户外山地救援应用为案例,深入剖析如何基于HarmonyOS API 24构建一个涵盖SOS紧急求援、步道导航指南、装备清单检查、山地气象预警、运动轨迹记录和个人中心管理六大核心模块的完整移动应用。该应用采用了户外救援橙色与岩石灰工具风格相结合的视觉设计语言,通过路标三角式Tab导航、安全等级头部展示、多类型弹窗交互等设计模式,充分展现了ArkTS在复杂业务场景下的声明式UI编排能力。从数据模型的类型安全定义,到@State状态管理驱动的响应式渲染,再到@Builder装饰器的UI组件复用策略,本文将逐段拆解超过两千行源码中的每一个关键技术点,帮助开发者系统性地掌握HarmonyOS应用开发的工程方法论。无论你是刚接触鸿蒙生态的新手,还是希望深入理解ArkTS高级特性的资深开发者,都能从这次代码剖析中获得可直接复用的实战经验与架构设计思路。


一、整体架构与数据模型设计

代码段1:颜色调色板接口定义

interface ColorPalette {
  primary: string;
  primaryLight: string;
  primaryDark: string;
  accent: string;
  accentLight: string;
  gold: string;
  bg: string;
  cardBg: string;
  textPrimary: string;
  textSecondary: string;
  textHint: string;
  border: string;
  success: string;
  warning: string;
  danger: string;
  white: string;
}

在这里插入图片描述

在HarmonyOS ArkTS API 24的工程实践中,类型安全是构建大型应用的基础保障。这段代码定义了一个名为ColorPalette的接口,用于规范整个应用的颜色体系。与传统的JavaScript项目不同,ArkTS强制要求所有变量和属性必须有明确的类型声明,这种设计哲学从源码层面杜绝了运行时类型错误的可能性。该接口包含了16个颜色属性,覆盖了主色调(primary系列)、强调色(accent系列)、功能色(success/warning/danger)以及文本层次色(textPrimary/textSecondary/textHint)等完整的视觉语言体系。通过接口定义颜色规范,开发者可以在团队协作中确保UI色彩的一致性,任何拼写错误或遗漏都会在编译阶段被捕获。这种做法在户外救援类应用中尤为重要,因为安全等级、风险提示等信息需要通过精确的颜色语义来传达给用户,例如红色代表危险等级,绿色代表安全状态,橙色代表警示提醒。

代码段2:颜色常量实例化

const COLORS: ColorPalette = {
  primary: '#E64A19',
  primaryLight: '#FFAB91',
  primaryDark: '#BF360C',
  accent: '#455A64',
  accentLight: '#CFD8DC',
  gold: '#FFB300',
  bg: '#F4F5F4',
  cardBg: '#FFFFFF',
  textPrimary: '#263238',
  textSecondary: '#607D8B',
  textHint: '#B0BEC5',
  border: '#E4E8E7',
  success: '#2E7D32',
  warning: '#FF8F00',
  danger: '#D32F2F',
  white: '#FFFFFF'
};

在这里插入图片描述

这里将前面定义的ColorPalette接口实例化为一个常量对象COLORS。在HarmonyOS 6.1.1的开发规范中,使用const关键字声明不可变的全局常量是一种推荐做法,它不仅确保了颜色值在应用生命周期内不会被意外修改,还能让编译器进行更好的优化。值得注意的是,这里选用的颜色值体现了户外救援场景的专业性:主色#E64A19是一种深橙色,类似于国际通用的救援橙色标准,能在户外环境中提供高辨识度;#BF360C作为更深的橙色变体用于渐变效果的暗端;强调色#455A64是岩石灰色调,与户外山地主题相呼应。功能色方面,#2E7D32的深绿色用于表示安全状态,#FF8F00的琥珀色用于警告,#D32F2F的红色用于危险标识。文本色采用了三个层次的蓝灰色梯度,从#263238#B0BEC5,形成了清晰的视觉层次结构。这种系统化的颜色管理方式,使得整个应用的视觉风格保持高度统一。

代码段3:救援站数据接口与静态数据

interface RescueStation {
  id: number;
  name: string;
  mountain: string;
  phone: string;
  distance: number;
  members: number;
  altitude: string;
}

const RESCUE_STATIONS: RescueStation[] = [
  { id: 1, name: '海坨山一号救援站', mountain: '海坨山', phone: '010-60801101', distance: 3.2, members: 12, altitude: '1800m' },
  { id: 2, name: '灵山主峰救援站', mountain: '东灵山', phone: '010-60801102', distance: 4.5, members: 9, altitude: '2303m' },
  { id: 3, name: '小五台西台救援点', mountain: '小五台', phone: '0313-7081103', distance: 6.8, members: 15, altitude: '2600m' },
  { id: 4, name: '雾灵山北门救援站', mountain: '雾灵山', phone: '010-81021104', distance: 2.1, members: 8, altitude: '1500m' },
  { id: 5, name: '百花山草甸救援点', mountain: '百花山', phone: '010-60901105', distance: 5.4, members: 7, altitude: '1991m' },
  { id: 6, name: '凤凰岭崖壁救援队', mountain: '凤凰岭', phone: '010-62461106', distance: 1.8, members: 11, altitude: '800m' }
];

在这里插入图片描述

RescueStation接口定义了救援站的核心数据结构,包含了7个字段:唯一标识符、名称、所属山脉、联系电话、距离、驻守人数和海拔信息。每个字段都有明确的类型声明,distance使用number类型便于后续的距离排序和比较运算,而altitude使用string类型则因为海拔值可能包含"约"、"以上"等修饰文字。紧跟其后的RESCUE_STATIONS常量数组提供了6个真实的北京周边山区救援站数据,从距离最近的风凰岭1.8公里到最远的小五台6.8公里,海拔从800米到2600米不等,涵盖了不同难度等级的户外环境。在HarmonyOS ArkTS中,数组类型的声明使用Type[]语法,这种静态类型化的数组在遍历时能获得完整的类型推导支持,使得在后续ForEach渲染列表时可以直接访问对象的属性而无需类型断言。这种将数据模型与静态数据分离的设计模式,使得在接入真实后端API时只需替换数据源而无需修改类型定义。

代码段4:步道与装备数据接口

interface TrailItem {
  id: number;
  name: string;
  mountain: string;
  difficulty: string;
  diffColor: string;
  miles: number;
  elevation: number;
  duration: string;
  scenery: string;
  season: string;
}

interface CheckItem {
  id: number;
  name: string;
  emoji: string;
  category: string;
  essential: boolean;
  done: boolean;
}

在这里插入图片描述

这里定义了两个核心业务数据接口。TrailItem接口描述了登山步道的完整信息,包含10个字段,其中diffColor字段将难度等级与颜色值绑定,使得UI渲染时可以直接使用该颜色值作为标签背景色,无需额外的映射逻辑。difficulty字段使用字符串而非枚举类型,这在原型阶段提供了灵活性,但在生产环境中可以考虑使用ArkTS的联合类型来约束可选值。mileselevation使用number类型,方便后续的数值计算和比较。CheckItem接口则定义了装备清单项的结构,essential布尔值标识该装备是否为必备项,done布尔值记录用户是否已将该装备放入背包。这种将状态字段嵌入数据模型的做法,配合ArkTS的@State装饰器,可以实现装备勾选状态的响应式更新。emoji字段用于在列表中展示直观的图标,这是移动端UI设计的常见手法,能有效减少对图标资源的依赖。

代码段5:气象、轨迹与联系人数据接口

interface WeatherDay {
  id: number;
  day: string;
  icon: string;
  tempHigh: number;
  tempLow: number;
  wind: string;
  risk: string;
  riskColor: string;
}

interface TrackLog {
  id: number;
  name: string;
  date: string;
  miles: number;
  duration: string;
  pace: string;
  calories: number;
}

interface ContactItem {
  id: number;
  name: string;
  phone: string;
  relation: string;
}

在这里插入图片描述

这三个接口分别支撑了气象预报、轨迹记录和紧急联系人三个功能模块的数据层。WeatherDay接口中的riskriskColor字段组合使用,将风险评估文字与对应颜色打包在一起,这种设计让UI渲染逻辑更加简洁——只需直接读取riskColor设置背景色即可。TrackLog接口记录了每次户外运动的详细数据,包括里程、用时、配速和卡路里消耗,这些字段在后续的统计计算中被频繁使用。ContactItem接口的结构相对简单,但relation字段在UI中承担了重要的角色——它决定了联系人在紧急情况下的通知优先级。在HarmonyOS ArkTS API 24中,这种以接口为中心的数据建模方式,配合@State装饰器实现的可观察状态,构成了应用响应式架构的数据层基础。所有这些接口定义都遵循了单一职责原则,每个接口只描述一个领域实体的数据结构,便于维护和扩展。


二、静态数据集与业务常量

代码段6:步道数据集与难度分级

const TRAILS: TrailItem[] = [
  { id: 1, name: '海坨山两日穿越线', mountain: '海坨山', difficulty: '困难', diffColor: '#D32F2F', miles: 22, elevation: 1280, duration: '2 天', scenery: '高山草甸 · 云海', season: '6-10 月' },
  { id: 2, name: '东灵山一日往返', mountain: '东灵山', difficulty: '中等', diffColor: '#FF8F00', miles: 10, elevation: 880, duration: '6 小时', scenery: '华北屋脊 · 界碑', season: '5-10 月' },
  { id: 3, name: '小五台五台连穿', mountain: '小五台', difficulty: '极难', diffColor: '#8E0000', miles: 38, elevation: 2600, duration: '2-3 天', scenery: '金莲花海 · 五台', season: '7-8 月' },
  { id: 4, name: '雾灵山北坡环线', mountain: '雾灵山', difficulty: '简单', diffColor: '#2E7D32', miles: 6, elevation: 350, duration: '3 小时', scenery: '原始森林 · 瀑布', season: '4-11 月' }
];

TRAILS数组提供了10条北京周边热门登山步道的详细数据。在数据设计上,每条步道都包含了完整的评估维度:里程(miles)反映体力消耗、爬升高度(elevation)反映技术难度、预计耗时(duration)辅助行程规划、最佳季节(season)指导出行时间选择。难度分级采用了四级体系:简单(绿色#2E7D32)、中等(橙色#FF8F00)、困难(红色#D32F2F)、极难(深红#8E0000),颜色从冷到暖再到深暖,直观传达了风险递增的语义。这种将难度颜色直接嵌入数据记录的做法,使得在ForEach循环渲染列表项时无需额外的条件判断逻辑,大大简化了模板代码。值得注意的是,小五台五台连穿线的里程达到38公里、爬升2600米,这在户外徒步中属于高难度挑战级别,应用通过diffColor为深红色来警示用户谨慎选择。

代码段7:装备清单与气象预报数据

const CHECK_ITEMS: CheckItem[] = [
  { id: 1, name: '登山鞋(防水中高帮)', emoji: '🥾', category: '穿着', essential: true, done: true },
  { id: 2, name: '冲锋衣 + 保暖中层', emoji: '🧥', category: '穿着', essential: true, done: true },
  { id: 3, name: '速干衣裤两套', emoji: '👕', category: '穿着', essential: true, done: false },
  { id: 7, name: '保温毯 + 急救包', emoji: '🩹', category: '安全', essential: true, done: false },
  { id: 9, name: '2L 水 + 电解质', emoji: '💧', category: '补给', essential: true, done: true }
];

const WEATHER_DAYS: WeatherDay[] = [
  { id: 1, day: '今天', icon: '☀️', tempHigh: 18, tempLow: 8, wind: '西北风 3 级', risk: '适宜登山', riskColor: '#2E7D32' },
  { id: 4, day: '周四', icon: '⛈️', tempHigh: 10, tempLow: 5, wind: '东风 5 级', risk: '不建议', riskColor: '#D32F2F' }
];

在这里插入图片描述

CHECK_ITEMS数组定义了10项户外装备,按照穿着、装备、安全、补给四个类别进行划分。每项装备的essential字段标识是否为必备装备,done字段记录当前准备状态。这套数据在应用初始化时通过CHECK_ITEMS.slice()复制到@State变量中,确保用户对清单的修改不会影响原始常量数据。WEATHER_DAYS数组提供了7天的山地气象预报,每天的riskriskColor组合直观传达了当天的登山适宜度。从"今天"的"适宜登山"(绿色)到"周四"的"不建议"(红色),用户可以一目了然地规划出行时间。在HarmonyOS ArkTS API 24中,这种结构化的静态数据配合ForEach组件进行列表渲染,是构建数据驱动UI的标准模式。数据的emoji字段不仅美化了界面,还降低了跨语言环境下的理解门槛。

代码段8:轨迹记录与周里程统计

const TRACK_LOGS: TrackLog[] = [
  { id: 1, name: '海坨山大环线', date: '10-18', miles: 21.6, duration: '7h42m', pace: "21'24\"", calories: 2380 },
  { id: 2, name: '凤凰岭北线', date: '10-12', miles: 11.2, duration: '4h05m', pace: "21'52\"", calories: 1260 },
  { id: 10, name: '箭扣东西线', date: '06-28', miles: 14.1, duration: '7h05m', pace: "30'07\"", calories: 2060 }
];

const WEEK_MILES: number[] = [6.2, 0, 11.5, 8.4, 0, 21.6, 5.2];

TRACK_LOGS数组记录了用户的历史运动轨迹,包含10条记录,从6月到10月覆盖了半年的户外活动。每条记录的pace字段使用了特殊的格式化字符串(如"21'24\""表示每公里21分24秒),这在ArkTS中需要使用双引号包裹以避免单引号被解析为字符串分隔符。WEEK_MILES数组记录了一周七天的运动里程,其中周二和周五为0表示休息日,周六的21.6公里对应了海坨山大环线。这个数组在后续的柱状图渲染中被用来计算每根柱子的高度比例。在HarmonyOS ArkTS中,数字数组的遍历和计算非常高效,forEach方法配合箭头函数可以在O(n)时间复杂度内完成最大值查找和求和运算。这些静态数据虽然在实际应用中会由后端API提供,但在开发阶段使用本地常量数据可以极大地加速UI开发迭代速度。


三、组件状态管理与核心逻辑

代码段9:主组件入口与状态声明

@Entry
@Component
struct TrailRescuePage {
  @State currentTab: number = 0
  @State showSosModal: boolean = false
  @State showTrailModal: boolean = false
  @State showCheckModal: boolean = false
  @State showContactModal: boolean = false
  @State showTrackDeleteModal: boolean = false
  @State selectedTrail: TrailItem | null = null
  @State selectedTrackName: string = ''
  @State selectedContact: ContactItem | null = null
  @State sosInjury: string = '扭伤骨折'
  @State sosPeople: number = 1
  @State checkList: CheckItem[] = CHECK_ITEMS.slice()
  @State myMiles: number = 116.5
  @State myPeaks: number = 14
  @State myLevel: number = 3
  private tabNames: string[] = ['救援', '步道', '装备', '气象', '轨迹', '我的']
  private tabIcons: string[] = ['🆘', '🥾', '🎒', '🌦️', '📍', '👤']

这是整个应用的核心组件定义。@Entry装饰器标记该组件为应用的入口页面,@Component装饰器声明它是一个ArkTS组件。在HarmonyOS ArkTS API 24中,@State装饰器是实现响应式UI的关键——被它修饰的变量一旦发生变化,框架会自动触发依赖该变量的UI部分的重新渲染。这里声明了多达16个状态变量,可以分为四类:第一类是Tab导航状态(currentTab),控制当前显示的功能页面;第二类是弹窗显示状态(5个布尔值),分别控制SOS弹窗、步道详情弹窗、装备新增弹窗、联系人编辑弹窗和轨迹删除确认弹窗的显隐;第三类是选中数据状态(selectedTrailselectedTrackNameselectedContact),记录用户当前操作的上下文对象;第四类是业务数据状态(checkListcontactListtrackList),这些是可变的数据集合。值得注意的是,selectedTrail使用了联合类型TrailItem | null,这是ArkTS中处理可选值的常见模式,初始值为null表示尚未选中任何步道。private关键字修饰的tabNamestabIcons不需要响应式更新,因此不使用@State装饰器。

代码段10:工具函数与状态计算逻辑

maxMiles(): number {
  let m: number = 0
  WEEK_MILES.forEach((v: number) => {
    if (v > m) {
      m = v
    }
  })
  return m
}

milesBarHeight(v: number): number {
  return Math.round(v / this.maxMiles() * 96)
}

totalMiles(): number {
  let sum: number = 0
  this.trackList.forEach((t: TrackLog) => {
    sum += t.miles
  })
  return Math.round(sum * 10) / 10
}

doneCount(): number {
  let n: number = 0
  this.checkList.forEach((c: CheckItem) => {
    if (c.done) {
      n += 1
    }
  })
  return n
}

在这里插入图片描述

这四个工具函数分别服务于不同的UI计算需求。maxMiles()遍历WEEK_MILES数组找出最大值,用于柱状图的高度归一化计算。milesBarHeight()方法接受一个里程值参数,通过除以最大值再乘以96(像素),得到柱子的实际渲染高度,并使用Math.round()取整以避免亚像素渲染模糊。totalMiles()聚合计算所有轨迹记录的总里程,最后的Math.round(sum * 10) / 10技巧用于保留一位小数精度,这是JavaScript/ArkTS中处理浮点数精度的经典做法。doneCount()统计装备清单中已勾选的数量,配合checkList.length可以计算出完成进度。这些函数在每次UI渲染时都会被重新调用,因为它们依赖的this.trackListthis.checkList@State变量,框架的状态追踪机制会自动处理依赖关系。在HarmonyOS ArkTS API 24中,这种将计算逻辑封装为组件方法的模式,使得模板代码更加简洁,同时也便于单元测试。

代码段11:装备勾选切换逻辑(不可变更新模式)

toggleCheck(id: number): void {
  const next: CheckItem[] = []
  this.checkList.forEach((c: CheckItem) => {
    if (c.id === id) {
      next.push({
        id: c.id,
        name: c.name,
        emoji: c.emoji,
        category: c.category,
        essential: c.essential,
        done: !c.done
      })
    } else {
      next.push(c)
    }
  })
  this.checkList = next
}

toggleCheck方法展示了ArkTS中处理可变数组状态的最佳实践——不可变更新模式。该方法接受一个装备项ID参数,通过遍历当前checkList数组构建一个全新的数组next。在遍历过程中,当遇到目标ID的元素时,创建一个新的对象并翻转done属性;其他元素则直接引用原对象。最后将this.checkList赋值为新数组,触发@State的变更检测和UI重渲染。这种做法虽然看起来比直接修改数组元素属性更加冗长,但它有两个重要优势:首先,ArkTS的@State对数组的变更检测是基于引用比较的,直接修改元素属性不会触发数组引用变化,可能导致UI不更新;而构建新数组则确保引用变化被检测到。其次,不可变更新模式使得状态变化历史可追溯,便于实现撤销/重做功能。在HarmonyOS 6.1.1的开发中,推荐始终使用这种模式来更新数组类型的状态变量,以避免难以调试的渲染问题。

代码段12:紧急联系人保存逻辑

saveContact(): void {
  if (this.selectedContact === null) {
    return
  }
  const cid: number = this.selectedContact.id
  const next: ContactItem[] = []
  this.contactList.forEach((c: ContactItem) => {
    if (c.id === cid) {
      next.push({
        id: c.id,
        name: this.contactName === '' ? c.name : this.contactName,
        phone: this.contactPhone === '' ? c.phone : this.contactPhone,
        relation: this.contactRelation
      })
    } else {
      next.push(c)
    }
  })
  this.contactList = next
  this.showContactModal = false
}

在这里插入图片描述

saveContact方法处理紧急联系人的编辑保存逻辑。方法首先进行空值保护检查——如果selectedContactnull则直接返回,这是防御性编程的标准做法。随后使用与toggleCheck相同的不可变更新模式构建新数组。值得注意的是,在处理姓名和手机号字段时,使用了三元运算符进行空值兜底:如果用户清空了输入框(this.contactName === ''),则保留原有的姓名值。这种设计避免了用户误操作清空重要信息的情况。relation字段直接使用this.contactRelation,因为它通过标签选择器选择,不会出现空值。方法最后关闭弹窗(this.showContactModal = false),这个状态变化会触发条件渲染逻辑移除弹窗组件。整个流程体现了ArkTS中状态管理的核心理念:所有数据变更通过状态赋值完成,UI自动响应状态变化,开发者不需要手动操作DOM。


四、UI渲染架构与页面布局

代码段13:build方法主体架构

build() {
  Stack() {
    Column() {
      this.headerBar()
      Scroll() {
        Column() {
          if (this.currentTab === 0) {
            this.rescueTab()
          } else if (this.currentTab === 1) {
            this.trailTab()
          } else if (this.currentTab === 2) {
            this.gearCheckTab()
          } else if (this.currentTab === 3) {
            this.weatherTab()
          } else if (this.currentTab === 4) {
            this.trackTab()
          } else {
            this.mineTab()
          }
        }
        .width('100%')
        .padding({ left: 12, right: 12, top: 10, bottom: 14 })
      }
      .layoutWeight(1)
      .width('100%')
      .align(Alignment.Top)

      this.trailMarkTabBar()
    }
    .width('100%')
    .height('100%')

    if (this.showSosModal) {
      this.sosModalOverlay(() => {
        this.showSosModal = false
      })
    }
    // ... 其他弹窗条件渲染
  }
  .width('100%')
  .height('100%')
  .backgroundColor(COLORS.bg)
}

build方法是每个ArkTS组件的核心,它定义了组件的UI结构。这里使用了Stack作为最外层容器,Stack是一个堆叠布局容器,子元素按照声明的顺序从底到顶堆叠。第一层是Column,包含三个部分:顶部头部栏(headerBar)、可滚动内容区(Scroll)和底部Tab栏(trailMarkTabBar)。Scroll组件使用了layoutWeight(1)来填充剩余空间,这是HarmonyOS ArkTS中弹性布局的关键属性。内容区内部使用if-else条件语句根据currentTab的值渲染不同的Tab页面构建器,这种条件渲染是ArkTS声明式UI的核心特性之一——当currentTab变化时,框架会自动移除旧页面的组件树并构建新页面的组件树。第二层是弹窗覆盖层,每个弹窗都使用条件语句包裹,只有当对应的状态变量为true时才渲染。Stack的堆叠特性确保弹窗显示在内容层之上。回调函数() => { this.showSosModal = false }作为参数传递给弹窗构建器,用于在用户点击遮罩层时关闭弹窗。

代码段14:页面架构与模态弹窗条件渲染流程

0

1

2

3

4

5

showSosModal

showTrailModal

showCheckModal

showContactModal

showTrackDeleteModal

build 方法入口

Stack 堆叠容器

Column 主内容层

headerBar 顶部头部

Scroll 可滚动内容区

trailMarkTabBar 底部导航栏

currentTab 当前值

rescueTab 救援页

trailTab 步道页

gearCheckTab 装备页

weatherTab 气象页

trackTab 轨迹页

mineTab 我的页

弹窗状态检测

SOS求援弹窗

步道详情弹窗

新增装备弹窗

联系人编辑弹窗

删除确认弹窗

代码段15:头部栏构建器

@Builder
headerBar() {
  Column() {
    Row() {
      Text('⛰️ 滴滴山野救援')
        .fontSize(19)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.white)
      Row() {
        Text('🛡️ 安全等级 B+')
          .fontSize(11)
          .fontColor(COLORS.primaryDark)
          .padding({ left: 8, right: 8, top: 3, bottom: 3 })
          .backgroundColor(COLORS.gold)
          .borderRadius(10)
      }
      .margin({ left: 8 })
      Row() {
        Text('🔔').fontSize(17)
      }
      .width(34).height(34)
      .justifyContent(FlexAlign.Center)
      .backgroundColor('rgba(255,255,255,0.22)')
      .borderRadius(17)
    }
    .width('100%')
    .justifyContent(FlexAlign.SpaceBetween)
    .alignItems(VerticalAlign.Center)
    .padding({ top: 14, bottom: 12 })

@Builder装饰器是HarmonyOS ArkTS API 24中实现UI组件复用的核心机制。headerBar构建器定义了应用的顶部头部栏,包含三个主要元素:应用标题、安全等级标签和通知图标。标题使用了19号字体加粗白色字体,在深色渐变背景上具有高辨识度。安全等级标签使用了金色背景配深色文字,圆角10的胶囊形设计,通过margin({ left: 8 })与标题保持间距。通知图标被放置在一个34x34的半透明白色圆形容器中,使用justifyContent(FlexAlign.Center)实现居中对齐。外层Row使用了FlexAlign.SpaceBetween使三个元素均匀分布在水平方向的两端和中间。alignItems(VerticalAlign.Center)确保所有子元素在垂直方向居中对齐。这种链式调用风格的属性设置是ArkTS声明式UI的标志性特征,每个属性方法返回组件自身的引用,使得可以在一行代码中连续设置多个属性。

代码段16:SOS紧急按钮与天气信息区

      Row() {
        Text('☀️').fontSize(22)
        Column() {
          Text('今日 8°C ~ 18°C · 西北风 3 级')
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
          Text('适宜登山 · 日落 17:12 · 记得带头灯')
            .fontSize(10)
            .fontColor('rgba(255,255,255,0.85)')
            .margin({ top: 3 })
        }
        .alignItems(HorizontalAlign.Start)
        .margin({ left: 8 })
      }
      .alignItems(VerticalAlign.Center)

      Column() {
        Text('SOS')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
        Text('一键求援')
          .fontSize(9)
          .fontColor('rgba(255,255,255,0.9)')
          .margin({ top: 2 })
      }
      .width(86).height(86)
      .justifyContent(FlexAlign.Center)
      .alignItems(HorizontalAlign.Center)
      .borderRadius(43)
      .backgroundColor(COLORS.danger)
      .shadow({ radius: 14, color: 'rgba(211,47,47,0.45)', offsetX: 0, offsetY: 4 })
      .onClick(() => {
        this.showSosModal = true
      })

这是头部栏中最关键的区域——天气信息展示和SOS紧急求援按钮。左侧天气信息区使用了嵌套的Column,将天气图标和文字信息组合在一起,alignItems(HorizontalAlign.Start)使文字左对齐。右侧SOS按钮是整个应用最重要的交互入口,使用了86x86像素的圆形设计(borderRadius(43)正好是半径),红色背景(COLORS.danger)配合半透明红色阴影效果(shadow属性),在视觉上形成强烈的紧急感。阴影的offsetY: 4使按钮看起来有微微浮起的效果,增强了可点击的视觉暗示。点击事件通过onClick设置,将showSosModal状态设置为true,触发SOS弹窗的条件渲染。这个交互流程完美体现了ArkTS的状态驱动UI理念——用户操作修改状态,状态变化驱动UI更新。整个头部栏外层使用了linearGradient属性设置135度线性渐变,从深橙色到亮橙色再到橘黄色,营造出救援主题的视觉氛围。

代码段17:路标三角式Tab导航栏

@Builder
trailMarkTabBar() {
  Row() {
    ForEach(this.tabIcons, (icon: string, idx: number) => {
      Row() {
        if (this.currentTab === idx) {
          Text('▶')
            .fontSize(9)
            .fontColor(COLORS.gold)
            .margin({ right: 4 })
        }
        Column() {
          Text(icon)
            .fontSize(19)
          Text(this.tabNames[idx])
            .fontSize(10)
            .fontWeight(this.currentTab === idx ? FontWeight.Bold : FontWeight.Normal)
            .fontColor(this.currentTab === idx ? COLORS.white : COLORS.textSecondary)
            .margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.Center)
      }
      .layoutWeight(1)
      .padding({ top: 9, bottom: 9 })
      .justifyContent(FlexAlign.Center)
      .alignItems(VerticalAlign.Center)
      .borderRadius(14)
      .backgroundColor(this.currentTab === idx ? COLORS.primary : COLORS.white)
      .scale({ x: this.currentTab === idx ? 1.05 : 1, y: this.currentTab === idx ? 1.05 : 1 })
      .animation({ duration: 200, curve: Curve.EaseOut })
      .margin({ left: 3, right: 3 })
      .onClick(() => {
        this.currentTab = idx
      })
    }, (icon: string, idx: number) => icon + idx.toString())
  }
  .width('100%')
  .padding({ left: 10, right: 10, top: 8, bottom: 8 })
  .backgroundColor(COLORS.bg)
}

trailMarkTabBar构建器实现了应用独有的"路标三角式"Tab导航设计。其核心视觉特征是:当某个Tab被选中时,其左侧会出现一个金色三角箭头作为路标指示,同时背景变为深橙色、文字变粗变白,并伴随1.05倍的缩放动画。ForEach遍历tabIcons数组,第二个参数是渲染函数,接受图标字符串和索引两个参数;第三个参数是键值生成函数,使用icon + idx.toString()确保每个Tab项有唯一标识。选中状态的视觉差异通过多个三元运算符实现:背景色、字体粗细、字体颜色和缩放比例都根据this.currentTab === idx条件动态切换。animation属性设置了200毫秒的缓出动画曲线,使得Tab切换时的缩放效果平滑自然。在HarmonyOS ArkTS API 24中,animation属性会自动应用到其前面的所有属性变化上,这是一种声明式的动画定义方式,开发者无需手动管理动画的开始和结束。


五、功能模块深度解析

代码段18:救援标签页——救援站列表渲染

@Builder
rescueTab() {
  Column() {
    Column() {
      Text('🆘 最近救援站')
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.white)
      Text('海坨山一号救援站 · 距你 3.2km · 12 名队员驻守')
        .fontSize(11)
        .fontColor('rgba(255,255,255,0.9)')
        .margin({ top: 5 })
      Row() {
        Text('☎️ 呼叫救援站')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.primaryDark)
          .padding({ left: 16, right: 16, top: 8, bottom: 8 })
          .backgroundColor(COLORS.gold)
          .borderRadius(14)
      }
      .margin({ top: 10 })
    }
    .width('100%')
    .padding(16)
    .borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .linearGradient({ angle: 135, colors: [['#455A64', 0], ['#37474F', 1]] })

    ForEach(RESCUE_STATIONS, (s: RescueStation) => {
      Row() {
        Column() {
          Text('⛰️').fontSize(24)
        }
        .width(48).height(48)
        .justifyContent(FlexAlign.Center)
        .backgroundColor(COLORS.accentLight)
        .borderRadius(24)

        Column() {
          Text(s.name)
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('☎️ ' + s.phone + ' · 海拔 ' + s.altitude)
            .fontSize(10)
            .fontColor(COLORS.textSecondary)
            .margin({ top: 3 })
          Text('距你 ' + s.distance + 'km · ' + s.members + ' 名队员 · 平均响应 26 分钟')
            .fontSize(9)
            .fontColor(COLORS.textHint)
            .margin({ top: 3 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        .margin({ left: 10 })

        Text('呼叫')
          .fontSize(11)
          .fontColor(COLORS.white)
          .padding({ left: 12, right: 12, top: 5, bottom: 5 })
          .backgroundColor(COLORS.accent)
          .borderRadius(12)
      }
      .width('100%')
      .padding(12)
      .backgroundColor(COLORS.white)
      .borderRadius(14)
      .margin({ bottom: 8 })
      .alignItems(VerticalAlign.Center)
    }, (s: RescueStation) => s.id.toString())

rescueTab构建器渲染了救援功能页面的主要内容。页面顶部是一个使用岩石灰渐变背景的卡片,展示最近救援站的关键信息(名称、距离、驻守人数),并提供一个金色的"呼叫救援站"按钮。下方使用ForEach遍历RESCUE_STATIONS数组渲染所有救援站的列表。每个列表项采用经典的左图标+中文本+右按钮的布局模式:左侧48x48的圆形图标容器使用浅灰色背景,中间的信息区使用layoutWeight(1)填充剩余空间并左对齐,展示站名(加粗主色)、电话和海拔(次要色)、距离和响应时间(提示色)三个层次的信息。右侧"呼叫"按钮使用强调色背景配白色文字。键值生成函数使用s.id.toString()确保列表项的唯一标识。在HarmonyOS ArkTS API 24中,ForEach的键值函数对于列表的增删和重排性能至关重要,稳定的键值能让框架精确地只更新变化的项而非整个列表。

代码段19:步道列表与难度标签渲染

ForEach(TRAILS, (t: TrailItem) => {
  Column() {
    Row() {
      Column() {
        Text(t.mountain)
          .fontSize(10)
          .fontColor(COLORS.white)
          .padding({ left: 6, right: 6, top: 2, bottom: 2 })
          .backgroundColor(COLORS.accent)
          .borderRadius(6)
      }

      Text(t.name)
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .margin({ left: 6 })
        .layoutWeight(1)

      Text(t.difficulty)
        .fontSize(9)
        .fontColor(COLORS.white)
        .padding({ left: 8, right: 8, top: 3, bottom: 3 })
        .backgroundColor(t.diffColor)
        .borderRadius(8)
    }
    .width('100%')
    .alignItems(VerticalAlign.Center)

    Row() {
      Column() {
        Text(t.miles.toString() + 'km')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.primary)
        Text('总里程')
          .fontSize(8)
          .fontColor(COLORS.textHint)
          .margin({ top: 2 })
      }
      .alignItems(HorizontalAlign.Center)
      .layoutWeight(1)
      // ... 其他三列:爬升、耗时、季节
    }
    .width('100%')
    .alignItems(VerticalAlign.Center)
    .margin({ top: 10 })

    Row() {
      Text('🏞️ ' + t.scenery)
        .fontSize(9)
        .fontColor(COLORS.textSecondary)
      Text('查看详情 ›')
        .fontSize(10)
        .fontColor(COLORS.primary)
        .margin({ left: 8 })
    }
    .width('100%')
    .alignItems(VerticalAlign.Center)
    .margin({ top: 8 })
  }
  .width('100%')
  .padding(12)
  .backgroundColor(COLORS.white)
  .borderRadius(14)
  .margin({ top: 8 })
  .onClick(() => {
    this.selectedTrail = t
    this.showTrailModal = true
  })
}, (t: TrailItem) => t.id.toString())

步道列表的渲染展示了ArkTS中复杂数据卡片的构建技巧。每个步道卡片分为三层:顶部行展示山脉标签、步道名称和难度标签;中间行使用四等分列布局展示里程、爬升、耗时和季节四项关键指标;底部行展示景观描述和"查看详情"链接。难度标签直接使用数据中的diffColor字段作为背景色,无需任何条件判断逻辑。四列指标区每列使用layoutWeight(1)等分宽度,内部使用Column实现数值在上、标签在下的双行布局。点击整个卡片会触发onClick事件,将选中的步道对象赋值给selectedTrail状态变量并打开步道详情弹窗。这里值得注意的细节是数值到字符串的转换使用了t.miles.toString()而非字符串模板,这在ArkTS的严格类型检查环境下是更加安全的做法。ForEach的键值函数使用t.id.toString(),确保在列表数据更新时能正确进行diff操作。

代码段20:装备清单与进度条组件

@Builder
gearCheckTab() {
  Column() {
    Row() {
      Text('🎒 出发装备清单')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
      Text('已备 ' + this.doneCount().toString() + '/' + this.checkList.length.toString())
        .fontSize(11)
        .fontWeight(FontWeight.Bold)
        .fontColor(this.doneCount() >= 8 ? COLORS.success : COLORS.warning)
        .margin({ left: 8 })
    }
    .width('100%')
    .alignItems(VerticalAlign.Center)

    Row() {
      Progress({ value: this.doneCount(), total: this.checkList.length, type: ProgressType.Linear })
        .layoutWeight(1)
        .color(this.doneCount() >= 8 ? COLORS.success : COLORS.warning)
      Text(this.doneCount() >= 8 ? '✅ 可以出发' : '⚠️ 还有必备项未备齐')
        .fontSize(9)
        .fontColor(this.doneCount() >= 8 ? COLORS.success : COLORS.warning)
        .margin({ left: 8 })
    }
    .width('100%')
    .alignItems(VerticalAlign.Center)
    .margin({ top: 10 })

装备清单页面的头部展示了ArkTS中条件样式的典型应用。doneCount()函数被多次调用,每次调用的结果用于动态决定文本内容和颜色——当已备装备数量达到8件及以上时显示绿色"可以出发",否则显示橙色"还有必备项未备齐"。Progress组件是HarmonyOS ArkTS内置的进度条组件,通过ProgressType.Linear指定为线性进度条样式,valuetotal属性分别设置当前值和最大值。进度条颜色同样根据完成度动态切换。在每次用户勾选或取消勾选装备时,checkList状态变化触发doneCount()重新计算,进而驱动进度条和状态文字的自动更新。这种从用户操作到状态变化再到UI更新的完整数据流,正是ArkTS声明式编程范式的精髓所在。layoutWeight(1)让进度条占据除状态文字外的所有剩余宽度。

代码段21:装备清单项的交互渲染

ForEach(this.checkList, (c: CheckItem) => {
  Row() {
    Text(c.done ? '☑️' : '⬜')
      .fontSize(18)
      .onClick(() => {
        this.toggleCheck(c.id)
      })

    Text(c.emoji)
      .fontSize(20)
      .margin({ left: 10 })

    Column() {
      Row() {
        Text(c.name)
          .fontSize(12)
          .fontColor(c.done ? COLORS.textHint : COLORS.textPrimary)
        if (c.essential && !c.done) {
          Text('必备')
            .fontSize(8)
            .fontColor(COLORS.danger)
            .padding({ left: 5, right: 5, top: 1, bottom: 1 })
            .backgroundColor('#FDE8E8')
            .borderRadius(6)
            .margin({ left: 6 })
        }
      }
      .alignItems(VerticalAlign.Center)

      Text(c.category + (c.done ? ' · 已放入背包' : ''))
        .fontSize(9)
        .fontColor(COLORS.textHint)
        .margin({ top: 2 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    .margin({ left: 10 })

    if (c.done) {
      Text('OK')
        .fontSize(9)
        .fontColor(COLORS.success)
        .fontWeight(FontWeight.Bold)
    }
  }
  .width('100%')
  .alignItems(VerticalAlign.Center)
  .padding({ top: 11, bottom: 11 })
  .backgroundColor(COLORS.white)
  .borderRadius(12)
  .margin({ top: 6 })
  .onClick(() => {
    this.toggleCheck(c.id)
  })
}, (c: CheckItem) => c.id.toString() + c.done.toString())

装备清单项的渲染包含了丰富的条件逻辑。复选框图标根据done状态在☑️之间切换。装备名称的字体颜色根据完成状态在提示色和主色之间切换,已完成的项目使用更浅的颜色降低视觉优先级。当装备是必备项(essentialtrue)且尚未勾选时,显示一个红色背景的"必备"标签作为提醒。已完成的项目右侧显示绿色"OK"标记。整个行也绑定了onClick事件,使得用户点击行内任何位置都能触发勾选切换。ForEach的键值函数使用了c.id.toString() + c.done.toString(),将ID和完成状态组合作为唯一标识,这样当某个项的完成状态变化时,框架能精确识别该项需要重新渲染。这是一个精心设计的键值策略——如果仅使用ID作为键值,某些情况下可能导致已完成状态的视觉更新不及时。

代码段22:气象预报标签页与温差提示

@Builder
weatherTab() {
  Column() {
    Row() {
      Text('🌦️ 山地气象 · 未来 7 天')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
      Text('数据来自山顶自动站')
        .fontSize(9)
        .fontColor(COLORS.textHint)
        .margin({ left: 8 })
    }

    Row() {
      Column() {
        Text('🌡️ 山顶 6°C')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
        Text('体感 2°C · 湿度 68%')
          .fontSize(9)
          .fontColor('rgba(255,255,255,0.85)')
          .margin({ top: 3 })
      }
      .layoutWeight(1)
      .padding(12)
      .borderRadius(14)
      .linearGradient({ angle: 135, colors: [['#455A64', 0], ['#607D8B', 1]] })

      Column() {
        Text('🌡️ 山脚 14°C')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
        Text('温差 8°C · 备保暖层')
          .fontSize(9)
          .fontColor('rgba(255,255,255,0.85)')
          .margin({ top: 3 })
      }
      .layoutWeight(1)
      .padding(12)
      .borderRadius(14)
      .margin({ left: 8 })
      .linearGradient({ angle: 135, colors: [['#E64A19', 0], ['#F57C00', 1]] })
    }

气象标签页的设计亮点在于山顶和山脚温度的对比展示。两个卡片使用了不同的渐变色——山顶使用岩石灰渐变(冷色调,暗示低温),山脚使用橙色渐变(暖色调,暗示较高温度),通过色彩温度直观传达了海拔与气温的关系。"温差 8°C · 备保暖层"的文字提示直接将气象数据转化为可执行的行动建议,体现了户外救援应用的专业性。下方使用ForEach渲染7天预报列表,每天的天气图标、温度范围、风力信息和风险标签都从WeatherDay数据中直接读取。风险标签使用riskColor作为背景色,绿色表示适宜、橙色表示谨慎、红色表示不建议,这种色彩编码与户外安全标准一致。页面底部还包含一个山地风险提示卡片,列出了午后对流云、温差大和低能见度三种常见山地气象风险的应对建议。

代码段23:轨迹记录与柱状图可视化

Row() {
  ForEach(WEEK_MILES, (v: number, idx: number) => {
    Column() {
      Column() {
      }
      .width(18)
      .height(this.milesBarHeight(v))
      .borderRadius({ topLeft: 9, topRight: 9 })
      .backgroundColor(v >= 10 ? COLORS.primary : COLORS.primaryLight)

      Text(this.weekLabel(idx))
        .fontSize(9)
        .fontColor(COLORS.textSecondary)
        .margin({ top: 4 })
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Center)
  }, (v: number, idx: number) => idx.toString())
}
.width('100%')
.padding({ top: 14, bottom: 12 })
.backgroundColor(COLORS.white)
.borderRadius(16)
.alignItems(VerticalAlign.Bottom)

轨迹标签页中的周里程柱状图是一个纯ArkTS实现的数据可视化组件,没有依赖任何图表库。柱状图使用ForEach遍历WEEK_MILES数组,每根柱子由一个Column容器组成:内部嵌套一个空的Column作为柱子主体,宽18像素,高度由milesBarHeight(v)方法动态计算。柱子顶部圆角通过borderRadius({ topLeft: 9, topRight: 9 })设置,值为宽度的一半,形成半圆形顶部。柱子颜色根据里程值动态切换——超过10公里的使用主色深橙色,不足10公里的使用浅橙色,使高强度运动日一目了然。柱子下方的星期标签通过weekLabel(idx)方法从标签数组中获取。整个图表容器使用alignItems(VerticalAlign.Bottom)确保所有柱子底部对齐。这种利用ArkTS基础组件实现数据可视化的方式,在简单场景下比引入第三方图表库更加轻量高效,且完全可定制。

代码段24:个人中心与统计卡片

@Builder
mineTab() {
  Column() {
    Row() {
      Column() {
        Text('🥾').fontSize(38)
      }
      .width(60).height(60)
      .justifyContent(FlexAlign.Center)
      .backgroundColor(COLORS.accentLight)
      .borderRadius(30)

      Column() {
        Text('老鹰九段')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
        Text('户外体能 Lv.3 · 已完成 14 座千米峰')
          .fontSize(10)
          .fontColor('rgba(255,255,255,0.88)')
          .margin({ top: 4 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      .margin({ left: 12 })

      Column() {
        Text('🛡️').fontSize(14)
        Text('保障中')
          .fontSize(9)
          .fontColor(COLORS.gold)
          .margin({ top: 2 })
      }
      .alignItems(HorizontalAlign.Center)
    }
    .width('100%')
    .padding(16)
    .borderRadius(18)
    .alignItems(VerticalAlign.Center)
    .linearGradient({ angle: 135, colors: [['#BF360C', 0], ['#E64A19', 0.7], ['#455A64', 1]] })

    Row() {
      Column() {
        Text(this.myMiles.toString() + 'km')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.primary)
        Text('年度累计')
          .fontSize(9)
          .fontColor(COLORS.textSecondary)
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      .padding({ top: 12, bottom: 12 })
      .backgroundColor(COLORS.white)
      .borderRadius(14)
      // ... 其他两列:登顶山峰、山野时长
    }
    .width('100%')
    .margin({ top: 12 })

个人中心页面展示了用户档案和统计数据。用户信息卡片使用了三色渐变背景(深橙到亮橙再到岩石灰),体现了户外救援主题的色彩语言。头像区域使用38号字体的emoji图标放在60x60的圆形浅灰色容器中,简洁而有效。统计区域使用了三等分布局,分别展示年度累计里程(主色)、登顶山峰数(强调色)和山野时长(金色),三种颜色对应了不同维度的成就指标。下方还包含紧急联系人列表和设置项区域,联系人列表使用ForEach渲染,每个联系人项右侧有"编辑"按钮触发联系人编辑弹窗。设置项区域包含离线地图管理、山野意外险和卫星消息设备绑定三个入口,每个入口右侧显示当前状态和箭头指示器。整个个人中心的布局体现了信息层次的设计原则——从最重要的用户身份信息到统计数据再到功能设置,自上而下逐步展开。


六、弹窗系统与交互流程

代码段25:SOS求援弹窗与伤情选择器

@Builder
sosModal() {
  Column() {
    Text('🆘 SOS 山野求援')
      .fontSize(17)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.danger)
      .margin({ top: 18 })

    Column() {
      Row() {
        Text('📍').fontSize(14)
        Column() {
          Text('当前定位')
            .fontSize(10)
            .fontColor('rgba(255,255,255,0.8)')
          Text('海坨山 · 大海坨村上山口 · 海拔 1320m')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.Start)
        .margin({ left: 8 })
      }
      .alignItems(VerticalAlign.Center)
      .width('100%')

      Row() {
        Text('📶 卫星短报文可用 · 精度 ±8m · 电量 76%')
          .fontSize(9)
          .fontColor('rgba(255,255,255,0.8)')
          .margin({ top: 6 })
      }
      .width('100%')
    }
    .width('92%')
    .padding(12)
    .borderRadius(14)
    .linearGradient({ angle: 135, colors: [['#B71C1C', 0], ['#D32F2F', 1]] })

    Flex({ wrap: FlexWrap.Wrap }) {
      ForEach(['扭伤骨折', '失温中暑', '迷路走失', '坠落受伤', '高原反应', '其他求助'], (i: string) => {
        Text(i)
          .fontSize(11)
          .fontColor(this.sosInjury === i ? COLORS.white : COLORS.textPrimary)
          .padding({ left: 13, right: 13, top: 7, bottom: 7 })
          .backgroundColor(this.sosInjury === i ? COLORS.danger : COLORS.bg)
          .borderRadius(12)
          .margin({ right: 8, bottom: 8 })
          .onClick(() => {
            this.sosInjury = i
          })
      }, (i: string) => i + this.sosInjury)
    }
    .width('92%')

SOS弹窗是整个应用最核心的交互模块。弹窗顶部展示当前定位信息和卫星通信状态,使用红色渐变背景营造紧急氛围。定位信息卡片不仅展示了地名和海拔,还通过"卫星短报文可用 · 精度 ±8m · 电量 76%"传达了技术可靠性。伤情类型选择器使用了Flex组件配合FlexWrap.Wrap属性实现自动换行的标签布局,6种伤情类型以可点击的标签形式排列。选中状态的标签使用红色背景白色文字,未选中状态使用浅灰背景深色文字,通过this.sosInjury === i的三元表达式实现条件样式。ForEach的键值函数使用了i + this.sosInjury——将标签文本和当前选中值拼接,这样当用户切换选中项时,所有标签的键值都会变化,触发整个标签组的重新渲染,确保选中状态视觉正确更新。同行人数选择器使用了加减按钮的方式,通过onClick事件修改sosPeople状态变量,并设置了1到10的范围限制。

代码段26:SOS弹窗的响应信息展示与确认提交

Row() {
  Column() {
    Text('预计响应')
      .fontSize(9)
      .fontColor(COLORS.textSecondary)
    Text('26 分钟')
      .fontSize(12)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.accent)
      .margin({ top: 2 })
  }
  .alignItems(HorizontalAlign.Center)
  .layoutWeight(1)

  Column() {
    Text('最近救援站')
      .fontSize(9)
      .fontColor(COLORS.textSecondary)
    Text('3.2km')
      .fontSize(12)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.accent)
      .margin({ top: 2 })
  }
  .alignItems(HorizontalAlign.Center)
  .layoutWeight(1)

  Column() {
    Text('联系人同步')
      .fontSize(9)
      .fontColor(COLORS.textSecondary)
    Text('2 人')
      .fontSize(12)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.accent)
      .margin({ top: 2 })
  }
  .alignItems(HorizontalAlign.Center)
  .layoutWeight(1)
}
.width('92%')
.padding({ top: 12, bottom: 12 })
.backgroundColor(COLORS.bg)
.borderRadius(14)
.margin({ top: 12 })

Text('确认发起求援')
  .fontSize(15)
  .fontWeight(FontWeight.Bold)
  .fontColor(COLORS.white)
  .width('90%')
  .textAlign(TextAlign.Center)
  .padding({ top: 13, bottom: 13 })
  .backgroundColor(COLORS.danger)
  .borderRadius(16)
  .margin({ top: 14, bottom: 20 })
  .onClick(() => {
    this.showSosModal = false
  })

SOS弹窗的底部区域展示了三项关键的响应信息:预计响应时间、最近救援站距离和联系人同步数量。这三项信息使用三等分布局,每项包含标签和数值两个层次,数值使用强调色加粗显示,在浅灰色背景的圆角卡片中突出展示。这些信息让用户在发起求援前就能了解预期的救援响应情况,减轻焦虑情绪。底部的"确认发起求援"按钮使用了大面积的红色背景和白色加粗文字,占据了90%的宽度并居中显示,配合textAlign(TextAlign.Center)确保文字水平居中。点击按钮后仅执行this.showSosModal = false关闭弹窗(在演示版中未包含实际的求援发送逻辑),但在实际应用中这里应该调用后端API发送求援信号、触发定位上报和联系人通知。整个SOS弹窗的设计流程体现了"信息透明-用户确认-执行操作"的交互设计原则。

代码段27:弹窗覆盖层架构模式

@Builder
sosModalOverlay(onClose: () => void) {
  Column() {
    Column() {
    }
    .width('100%')
    .height('100%')
    .backgroundColor('rgba(38,50,56,0.6)')
    .position({ x: 0, y: 0 })
    .onClick(() => {
      onClose()
    })

    Column() {
      this.sosModal()
    }
    .width('100%')
    .justifyContent(FlexAlign.End)
  }
  .width('100%')
  .height('100%')
  .zIndex(999)
}

sosModalOverlay构建器定义了一个通用的弹窗覆盖层模式,这种模式在应用的5个弹窗中重复使用。覆盖层由两层组成:第一层是半透明遮罩层,使用rgba(38,50,56,0.6)设置60%透明度的深灰色背景,通过position({ x: 0, y: 0 })width/height: '100%'覆盖整个屏幕,点击遮罩层调用onClose回调函数关闭弹窗。第二层是弹窗内容容器,使用justifyContent(FlexAlign.End)将弹窗内容推到屏幕底部,形成从底部滑入的效果。最外层的zIndex(999)确保弹窗层级高于所有其他内容。这个构建器接受一个onClose: () => void类型的回调函数参数,这是ArkTS中函数类型参数的标准写法。在build方法中调用时,通过箭头函数传递关闭逻辑:() => { this.showSosModal = false }。这种将弹窗内容与覆盖层分离、通过回调函数传递关闭逻辑的设计模式,实现了关注点分离和代码复用,所有5个弹窗的覆盖层都遵循相同的架构。

代码段28:步道详情弹窗

@Builder
trailModal() {
  Column() {
    if (this.selectedTrail !== null) {
      Column() {
        Text('⛰️ ' + this.selectedTrail.mountain + ' · ' + this.selectedTrail.difficulty)
          .fontSize(11)
          .fontColor(COLORS.gold)
        Text(this.selectedTrail.name)
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .margin({ top: 5 })
        Text('🏞️ ' + this.selectedTrail.scenery + ' · 最佳 ' + this.selectedTrail.season)
          .fontSize(11)
          .fontColor('rgba(255,255,255,0.9)')
          .margin({ top: 4 })
      }
      .width('100%')
      .padding({ top: 24, bottom: 20 })
      .alignItems(HorizontalAlign.Center)
      .linearGradient({ angle: 135, colors: [['#BF360C', 0], ['#E64A19', 1]] })
      .borderRadius({ topLeft: 22, topRight: 22 })

      Row() {
        Column() {
          Text(this.selectedTrail.miles.toString() + 'km')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primary)
          Text('总里程')
            .fontSize(9)
            .fontColor(COLORS.textSecondary)
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        // ... 爬升和耗时列
      }
      .width('92%')
      .padding({ top: 12, bottom: 12 })
      .backgroundColor(COLORS.bg)
      .borderRadius(14)
      .margin({ top: 14 })

      Column() {
        Text('补给与下撤点')
          .fontSize(11)
          .fontColor(COLORS.textSecondary)
          .width('100%')
        Row() {
          Text('🚰 出发 3km · 山泉水补给点')
            .fontSize(11)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
        }
        // ... 中段营地和终点接驳信息
      }
      .width('92%')
      .padding(12)
      .backgroundColor(COLORS.white)
      .borderRadius(14)

步道详情弹窗展示了选中步道的完整信息。弹窗头部使用红色渐变背景和圆角顶部,展示山脉名称、难度等级、步道名称和景观信息。中部使用三等分展示里程、爬升和耗时三项核心指标,每项使用对应的主题色(主色橙、强调色灰、金色)进行区分。下方的"补给与下撤点"卡片是步道详情的特色功能,展示了沿途的补给点位置(出发3公里处)、中段营地和避险屋(11公里处)以及终点救援站接驳信息,这些信息对于长距离徒步的行程规划至关重要。弹窗底部还包含一个天气适宜度进度条(使用Progress组件,值为85%)和"一键报备行程"按钮。整个弹窗使用if (this.selectedTrail !== null)进行空值保护,确保在selectedTrailnull时不会渲染任何内容,避免运行时空指针错误。这种防御性编程在ArkTS中尤为重要,因为联合类型的空值检查不会在编译时强制执行。

代码段29:SOS求援交互完整流程

确认

点击遮罩

用户点击SOS按钮

showSosModal = true

条件渲染触发弹窗显示

展示当前定位与卫星状态

用户选择伤情类型

sosInjury 状态更新

标签选中样式自动切换

用户调整同行人数

sosPeople 状态增减

展示响应信息三要素

用户点击确认

showSosModal = false

onClose 回调触发

弹窗消失

代码段30:新增装备弹窗与输入处理

@Builder
checkModal() {
  Column() {
    Text('🎒 新增装备项')
      .fontSize(16)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.textPrimary)
      .margin({ top: 18 })

    Column() {
      Text('装备名称')
        .fontSize(11)
        .fontColor(COLORS.textSecondary)
        .width('100%')
      TextInput({ placeholder: '如:防雨罩 · 墨镜 · 护膝' })
        .fontSize(12)
        .height(40)
        .backgroundColor(COLORS.bg)
        .borderRadius(10)
        .margin({ top: 6 })
        .onChange((v: string) => {
          this.newCheckName = v
        })
    }
    .width('92%')
    .padding(14)
    .backgroundColor(COLORS.white)
    .borderRadius(14)
    .margin({ top: 14 })
    .alignItems(HorizontalAlign.Start)

    Row() {
      ForEach(['穿着', '装备', '安全', '补给'], (c: string) => {
        Text(c)
          .fontSize(11)
          .fontColor(this.newCheckCategory === c ? COLORS.white : COLORS.textPrimary)
          .padding({ left: 15, right: 15, top: 7, bottom: 7 })
          .backgroundColor(this.newCheckCategory === c ? COLORS.accent : COLORS.bg)
          .borderRadius(12)
          .margin({ right: 8 })
          .onClick(() => {
            this.newCheckCategory = c
          })
      }, (c: string) => c + this.newCheckCategory)
    }
    .width('92%')
    .margin({ top: 8 })

    Text('加入清单')
      .fontSize(14)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.white)
      .width('90%')
      .textAlign(TextAlign.Center)
      .padding({ top: 12, bottom: 12 })
      .backgroundColor(COLORS.accent)
      .borderRadius(16)
      .margin({ top: 14, bottom: 20 })
      .onClick(() => {
        this.checkList.unshift({
          id: this.checkList.length + 1,
          name: this.newCheckName === '' ? '新装备项' : this.newCheckName,
          emoji: '🧰',
          category: this.newCheckCategory,
          essential: false,
          done: false
        })
        this.showCheckModal = false
      })

新增装备弹窗展示了ArkTS中表单输入处理的完整模式。TextInput组件通过placeholder属性设置占位提示文本,onChange事件回调将输入值同步到newCheckName状态变量。类别选择器使用标签按钮组的方式,4个类别标签水平排列,选中状态使用强调色背景。点击"加入清单"按钮时,使用this.checkList.unshift()方法在数组头部插入新装备项——unshift方法会修改数组并返回新长度,但由于ArkTS的@State会检测到数组引用变化(unshift在原数组上操作),因此需要配合不可变更新模式。这里实际上直接使用了unshift,在生产环境中更推荐使用[newItem, ...this.checkList]的展开运算符方式来确保状态正确触发。新装备项的name字段使用了三元运算符进行空值兜底,当用户未输入名称时使用"新装备项"作为默认值。

代码段31:联系人编辑弹窗与表单回填

@Builder
contactModal() {
  Column() {
    Text('👤 编辑紧急联系人')
      .fontSize(16)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.textPrimary)
      .margin({ top: 18 })

    if (this.selectedContact !== null) {
      Text('正在编辑:' + this.selectedContact.name)
        .fontSize(10)
        .fontColor(COLORS.textHint)
        .margin({ top: 6 })
    }

    Column() {
      Text('姓名')
        .fontSize(11)
        .fontColor(COLORS.textSecondary)
        .width('100%')
      TextInput({ placeholder: '联系人姓名', text: this.contactName })
        .onChange((v: string) => {
          this.contactName = v
        })

      Text('手机号')
        .fontSize(11)
        .fontColor(COLORS.textSecondary)
        .width('100%')
        .margin({ top: 12 })
      TextInput({ placeholder: '11 位手机号', text: this.contactPhone })
        .onChange((v: string) => {
          this.contactPhone = v
        })
    }
    .width('92%')
    .padding(14)
    .backgroundColor(COLORS.white)
    .borderRadius(14)

    Row() {
      ForEach(['家属', '领队', '山友', '其他'], (r: string) => {
        Text(r)
          .fontSize(11)
          .fontColor(this.contactRelation === r ? COLORS.white : COLORS.textPrimary)
          .padding({ left: 14, right: 14, top: 7, bottom: 7 })
          .backgroundColor(this.contactRelation === r ? COLORS.accent : COLORS.bg)
          .borderRadius(12)
          .margin({ right: 8 })
          .onClick(() => {
            this.contactRelation = r
          })
      }, (r: string) => r + this.contactRelation)
    }
    .width('92%')

    Text('保存修改')
      .onClick(() => {
        this.saveContact()
      })

联系人编辑弹窗的一个重要特性是表单回填——当用户点击"编辑"按钮时,contactNamecontactPhonecontactRelation状态变量会被设置为选中联系人的当前值,TextInput组件通过text属性(而非placeholder)显示这些初始值。关系选择器与装备类别的标签选择器使用相同的交互模式,但4个关系选项(家属、领队、山友、其他)直接对应了户外场景中的紧急联系人类型。点击"保存修改"按钮调用saveContact()方法,该方法使用前面分析的不可变更新模式构建新数组并更新状态。弹窗顶部使用if (this.selectedContact !== null)条件渲染当前正在编辑的联系人名称,提供操作上下文反馈。这种将编辑上下文通过状态变量传递的模式,使得同一个弹窗组件可以复用于不同联系人的编辑,无需为每个联系人创建独立的弹窗实例。

代码段32:轨迹删除确认弹窗

@Builder
trackDeleteModal() {
  Column() {
    Text('⚠️')
      .fontSize(34)
      .margin({ top: 22 })
    Text('删除轨迹记录')
      .fontSize(16)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.textPrimary)
      .margin({ top: 8 })
    Text('「' + this.selectedTrackName + '」删除后无法恢复,里程统计将同步扣减。')
      .fontSize(11)
      .fontColor(COLORS.textSecondary)
      .textAlign(TextAlign.Center)
      .margin({ top: 8 })
      .padding({ left: 24, right: 24 })

    Row() {
      Text('取消')
        .fontSize(13)
        .fontColor(COLORS.textSecondary)
        .layoutWeight(1)
        .textAlign(TextAlign.Center)
        .padding({ top: 11, bottom: 11 })
        .backgroundColor(COLORS.bg)
        .borderRadius(14)
        .onClick(() => {
          this.showTrackDeleteModal = false
        })
      Text('确认删除')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.white)
        .layoutWeight(1)
        .textAlign(TextAlign.Center)
        .padding({ top: 11, bottom: 11 })
        .backgroundColor(COLORS.danger)
        .borderRadius(14)
        .margin({ left: 10 })
        .onClick(() => {
          this.trackList = this.trackList.filter((t: TrackLog) => t.name !== this.selectedTrackName)
          this.showTrackDeleteModal = false
        })
    }
    .width('86%')
    .margin({ top: 18, bottom: 22 })
  }
  .width('100%')
  .backgroundColor(COLORS.white)
  .borderRadius({ topLeft: 22, topRight: 22 })
  .alignItems(HorizontalAlign.Center)
}

轨迹删除确认弹窗采用了警告对话框的设计模式,与SOS弹窗和步道详情弹窗的底部滑入式不同,这个弹窗居中显示,更符合确认对话框的交互习惯。弹窗顶部的警告图标使用34号字体,配合标题和描述文字构成完整的警告信息。描述文字中嵌入了selectedTrackName状态变量,使提示信息具有上下文针对性。底部按钮区域使用左右双按钮布局:"取消"使用灰色背景表示次要操作,"确认删除"使用红色背景表示危险操作,两个按钮通过layoutWeight(1)等分宽度,中间通过margin({ left: 10 })保持间距。确认删除的执行逻辑使用了Array.filter()方法——过滤掉名称匹配selectedTrackName的记录,返回新数组赋值给trackList状态变量。这是不可变更新模式的另一种实现方式,filter方法天然返回新数组,不需要手动构建。删除操作同时关闭弹窗,两个状态变化(trackList更新和showTrackDeleteModal设为false)会被ArkTS框架批量处理,在一次渲染周期内完成UI更新。

代码段33:装备清单状态变更与列表更新流程

渲染错误: Mermaid 渲染失败: Parse error on line 6: ...t = next] E --> F[@State 变更检测触发] ----------------------^ Expecting 'AMP', 'COLON', 'PIPE', 'TESTSTR', 'DOWN', 'DEFAULT', 'NUM', 'COMMA', 'NODE_STRING', 'BRKT', 'MINUS', 'MULT', 'UNICODE_TEXT', got 'LINK_ID'

七、关键技术对比与分析

对比表格1:ArkTS状态管理策略对比

状态类型 装饰器 适用场景 响应式级别 本应用示例
组件内状态 @State 当前组件私有的可变数据 变量引用变化时触发重渲染 currentTab, showSosModal, checkList
父子传递 @Prop 父组件向子组件单向传递 父组件对应@State变化时更新 本应用未使用(单组件架构)
双向同步 @Link 父子组件双向数据绑定 双方变化均同步 本应用未使用(单组件架构)
只读传递 @Provide / @Consume 跨层级共享只读数据 @Provide变化时@Consume更新 本应用未使用(单组件架构)
静态常量 const / private 不可变的配置数据 不参与响应式系统 COLORS, RESCUE_STATIONS, tabNames
计算属性 组件方法 依赖@State的派生值 每次渲染时重新调用 maxMiles(), totalMiles(), doneCount()

对比表格2:弹窗实现方式对比

弹窗类型 触发状态变量 交互模式 位置策略 关闭方式 数据传递方式
SOS求援 showSosModal 底部滑入 FlexAlign.End 点击遮罩/确认按钮 直接读取@State变量
步道详情 showTrailModal 底部滑入 FlexAlign.End 点击遮罩/报备按钮 通过selectedTrail传递
新增装备 showCheckModal 底部滑入 FlexAlign.End 点击遮罩/加入按钮 通过newCheckName等变量
联系人编辑 showContactModal 底部滑入 FlexAlign.End 点击遮罩/保存按钮 通过selectedContact传递
删除确认 showTrackDeleteModal 底部滑入 FlexAlign.End 点击遮罩/取消/确认 通过selectedTrackName传递

对比表格3:列表渲染ForEach键值策略对比

使用场景 键值生成函数 策略说明 优缺点分析
救援站列表 s.id.toString() 纯ID作为键值 优:简单稳定;缺:无法检测内容变化
步道列表 t.id.toString() 纯ID作为键值 优:简单稳定;缺:数据更新时全量重渲染
装备清单 c.id.toString() + c.done.toString() ID+状态组合 优:精确检测状态变化;缺:状态变化时该项重建
气象预报 w.id.toString() 纯ID作为键值 优:静态数据无需复杂键值;缺:不适用于动态数据
轨迹列表 t.id.toString() 纯ID作为键值 优:删除时精确匹配;缺:内容修改需额外处理
SOS伤情标签 i + this.sosInjury 文本+选中状态 优:选中状态切换正确更新;缺:全标签重渲染
类别选择器 c + this.newCheckCategory 类别名+选中状态 优:选中切换正确更新;缺:全标签重渲染

对比表格4:不可变更新模式对比

操作场景 实现方式 代码示例 适用分析
装备勾选切换 forEach构建新数组 遍历原数组,匹配项创建新对象翻转done 适用于需要保持其他项引用不变的场景
联系人保存 forEach构建新数组 遍历原数组,匹配项替换为新对象 与勾选切换相同的模式,保持一致性
轨迹删除 Array.filter this.trackList.filter(t => t.name !== name) 适用于删除操作,filter天然返回新数组
新增装备 Array.unshift this.checkList.unshift(newItem) 在原数组上操作,需注意状态触发问题
数组初始化 Array.slice CHECK_ITEMS.slice() 创建数组浅拷贝,隔离原始常量数据

安装DevEco Studio程序

在这里插入图片描述
选择目标安装目录:

在这里插入图片描述
设置环境变量,但是需要重启一下:

在这里插入图片描述
新建一个空白模板:

在这里插入图片描述
设置API为24的模板项目:
在这里插入图片描述
初始化项目,自动下载相关依赖:

在这里插入图片描述


完整代码:

// ============================================================
// 场景:山野 SOS 求援 + 步道指南 + 装备检查 + 山地气象 + 轨迹记录
// 风格:户外救援橙 + 岩石灰工具风(安全等级头部 + 路标导航)
// Tab 样式:路标三角式(选中左侧 ▶ 箭头指示 + 深橙底)
// 弹框:SOS 求救确认 / 步道详情 / 新增装备项 / 紧急联系人编辑 / 删除轨迹
// ============================================================

interface ColorPalette {
  primary: string;
  primaryLight: string;
  primaryDark: string;
  accent: string;
  accentLight: string;
  gold: string;
  bg: string;
  cardBg: string;
  textPrimary: string;
  textSecondary: string;
  textHint: string;
  border: string;
  success: string;
  warning: string;
  danger: string;
  white: string;
}

const COLORS: ColorPalette = {
  primary: '#E64A19',
  primaryLight: '#FFAB91',
  primaryDark: '#BF360C',
  accent: '#455A64',
  accentLight: '#CFD8DC',
  gold: '#FFB300',
  bg: '#F4F5F4',
  cardBg: '#FFFFFF',
  textPrimary: '#263238',
  textSecondary: '#607D8B',
  textHint: '#B0BEC5',
  border: '#E4E8E7',
  success: '#2E7D32',
  warning: '#FF8F00',
  danger: '#D32F2F',
  white: '#FFFFFF'
};

interface RescueStation {
  id: number;
  name: string;
  mountain: string;
  phone: string;
  distance: number;
  members: number;
  altitude: string;
}

interface TrailItem {
  id: number;
  name: string;
  mountain: string;
  difficulty: string;
  diffColor: string;
  miles: number;
  elevation: number;
  duration: string;
  scenery: string;
  season: string;
}

interface CheckItem {
  id: number;
  name: string;
  emoji: string;
  category: string;
  essential: boolean;
  done: boolean;
}

interface WeatherDay {
  id: number;
  day: string;
  icon: string;
  tempHigh: number;
  tempLow: number;
  wind: string;
  risk: string;
  riskColor: string;
}

interface TrackLog {
  id: number;
  name: string;
  date: string;
  miles: number;
  duration: string;
  pace: string;
  calories: number;
}

interface ContactItem {
  id: number;
  name: string;
  phone: string;
  relation: string;
}

const RESCUE_STATIONS: RescueStation[] = [
  { id: 1, name: '海坨山一号救援站', mountain: '海坨山', phone: '010-60801101', distance: 3.2, members: 12, altitude: '1800m' },
  { id: 2, name: '灵山主峰救援站', mountain: '东灵山', phone: '010-60801102', distance: 4.5, members: 9, altitude: '2303m' },
  { id: 3, name: '小五台西台救援点', mountain: '小五台', phone: '0313-7081103', distance: 6.8, members: 15, altitude: '2600m' },
  { id: 4, name: '雾灵山北门救援站', mountain: '雾灵山', phone: '010-81021104', distance: 2.1, members: 8, altitude: '1500m' },
  { id: 5, name: '百花山草甸救援点', mountain: '百花山', phone: '010-60901105', distance: 5.4, members: 7, altitude: '1991m' },
  { id: 6, name: '凤凰岭崖壁救援队', mountain: '凤凰岭', phone: '010-62461106', distance: 1.8, members: 11, altitude: '800m' }
];

const TRAILS: TrailItem[] = [
  { id: 1, name: '海坨山两日穿越线', mountain: '海坨山', difficulty: '困难', diffColor: '#D32F2F', miles: 22, elevation: 1280, duration: '2 天', scenery: '高山草甸 · 云海', season: '6-10 月' },
  { id: 2, name: '东灵山一日往返', mountain: '东灵山', difficulty: '中等', diffColor: '#FF8F00', miles: 10, elevation: 880, duration: '6 小时', scenery: '华北屋脊 · 界碑', season: '5-10 月' },
  { id: 3, name: '小五台五台连穿', mountain: '小五台', difficulty: '极难', diffColor: '#8E0000', miles: 38, elevation: 2600, duration: '2-3 天', scenery: '金莲花海 · 五台', season: '7-8 月' },
  { id: 4, name: '雾灵山北坡环线', mountain: '雾灵山', difficulty: '简单', diffColor: '#2E7D32', miles: 6, elevation: 350, duration: '3 小时', scenery: '原始森林 · 瀑布', season: '4-11 月' },
  { id: 5, name: '百花山草甸线', mountain: '百花山', difficulty: '简单', diffColor: '#2E7D32', miles: 8, elevation: 620, duration: '4 小时', scenery: '亚高山草甸', season: '5-9 月' },
  { id: 6, name: '凤凰岭飞来石塔线', mountain: '凤凰岭', difficulty: '中等', diffColor: '#FF8F00', miles: 12, elevation: 740, duration: '5 小时', scenery: '花岗岩地貌 · 古塔', season: '全年' },
  { id: 7, name: '箭扣长城东西线', mountain: '箭扣', difficulty: '困难', diffColor: '#D32F2F', miles: 14, elevation: 960, duration: '7 小时', scenery: '野长城 · 天梯', season: '3-11 月' },
  { id: 8, name: '黄草梁七座楼', mountain: '黄草梁', difficulty: '中等', diffColor: '#FF8F00', miles: 16, elevation: 810, duration: '6 小时', scenery: '敌楼群 · 风车', season: '4-10 月' },
  { id: 9, name: '大安山红叶岭', mountain: '大安山', difficulty: '简单', diffColor: '#2E7D32', miles: 7, elevation: 400, duration: '3.5 小时', scenery: '红叶 · 松林', season: '9-11 月' },
  { id: 10, name: '云蒙山长城遗址线', mountain: '云蒙山', difficulty: '中等', diffColor: '#FF8F00', miles: 13, elevation: 700, duration: '5.5 小时', scenery: '云海 · 长城遗址', season: '4-11 月' }
];

const CHECK_ITEMS: CheckItem[] = [
  { id: 1, name: '登山鞋(防水中高帮)', emoji: '🥾', category: '穿着', essential: true, done: true },
  { id: 2, name: '冲锋衣 + 保暖中层', emoji: '🧥', category: '穿着', essential: true, done: true },
  { id: 3, name: '速干衣裤两套', emoji: '👕', category: '穿着', essential: true, done: false },
  { id: 4, name: '40L+ 登山包', emoji: '🎒', category: '装备', essential: true, done: true },
  { id: 5, name: '头灯 + 备用电池', emoji: '🔦', category: '装备', essential: true, done: false },
  { id: 6, name: '登山杖一对', emoji: '🦯', category: '装备', essential: false, done: true },
  { id: 7, name: '保温毯 + 急救包', emoji: '🩹', category: '安全', essential: true, done: false },
  { id: 8, name: '哨子 + 头灯反光条', emoji: '📢', category: '安全', essential: true, done: true },
  { id: 9, name: '2L 水 + 电解质', emoji: '💧', category: '补给', essential: true, done: true },
  { id: 10, name: '高能量路粮 4 份', emoji: '🍫', category: '补给', essential: true, done: false }
];

const WEATHER_DAYS: WeatherDay[] = [
  { id: 1, day: '今天', icon: '☀️', tempHigh: 18, tempLow: 8, wind: '西北风 3 级', risk: '适宜登山', riskColor: '#2E7D32' },
  { id: 2, day: '明天', icon: '⛅', tempHigh: 16, tempLow: 7, wind: '西北风 4 级', risk: '适宜登山', riskColor: '#2E7D32' },
  { id: 3, day: '后天', icon: '🌧️', tempHigh: 12, tempLow: 6, wind: '东风 4 级', risk: '谨慎出行', riskColor: '#FF8F00' },
  { id: 4, day: '周四', icon: '⛈️', tempHigh: 10, tempLow: 5, wind: '东风 5 级', risk: '不建议', riskColor: '#D32F2F' },
  { id: 5, day: '周五', icon: '🌤️', tempHigh: 15, tempLow: 6, wind: '北风 3 级', risk: '适宜登山', riskColor: '#2E7D32' },
  { id: 6, day: '周六', icon: '☀️', tempHigh: 19, tempLow: 9, wind: '微风', risk: '适宜登山', riskColor: '#2E7D32' },
  { id: 7, day: '周日', icon: '🌫️', tempHigh: 14, tempLow: 8, wind: '南风 2 级', risk: '能见度低', riskColor: '#FF8F00' }
];

const TRACK_LOGS: TrackLog[] = [
  { id: 1, name: '海坨山大环线', date: '10-18', miles: 21.6, duration: '7h42m', pace: "21'24\"", calories: 2380 },
  { id: 2, name: '凤凰岭北线', date: '10-12', miles: 11.2, duration: '4h05m', pace: "21'52\"", calories: 1260 },
  { id: 3, name: '东灵山往返', date: '09-28', miles: 10.4, duration: '5h18m', pace: "30'34\"", calories: 1580 },
  { id: 4, name: '香山好汉坡', date: '09-20', miles: 8.6, duration: '2h46m', pace: "19'18\"", calories: 980 },
  { id: 5, name: '百花山草甸', date: '09-06', miles: 8.2, duration: '3h32m', pace: "25'51\"", calories: 1050 },
  { id: 6, name: '雾灵山环线', date: '08-23', miles: 6.4, duration: '2h58m', pace: "27'57\"", calories: 860 },
  { id: 7, name: '大安山红叶', date: '08-09', miles: 7.0, duration: '3h10m', pace: "27'08\"", calories: 920 },
  { id: 8, name: '黄草梁穿越', date: '07-26', miles: 15.8, duration: '6h20m', pace: "24'03\"", calories: 1840 },
  { id: 9, name: '云蒙山遗址', date: '07-12', miles: 13.2, duration: '5h36m', pace: "25'27\"", calories: 1620 },
  { id: 10, name: '箭扣东西线', date: '06-28', miles: 14.1, duration: '7h05m', pace: "30'07\"", calories: 2060 }
];

const WEEK_MILES: number[] = [6.2, 0, 11.5, 8.4, 0, 21.6, 5.2];
const CONTACTS: ContactItem[] = [
  { id: 1, name: '王向导(领队)', phone: '13811228866', relation: '领队' },
  { id: 2, name: '李雪(家属)', phone: '18610335522', relation: '家属' }
];
const RESCUE_CASES: string[][] = [
  ['10-15', '箭扣长城', '游客失温 · 2h 完成转运'],
  ['10-08', '海坨山', '踝关节扭伤 · 直升机备勤'],
  ['09-27', '小五台', '迷路走失 · 夜间定位寻回'],
  ['09-19', '凤凰岭', '中暑脱力 · 担架下撤']
];

@Entry
@Component
struct TrailRescuePage {
  @State currentTab: number = 0
  @State showSosModal: boolean = false
  @State showTrailModal: boolean = false
  @State showCheckModal: boolean = false
  @State showContactModal: boolean = false
  @State showTrackDeleteModal: boolean = false
  @State selectedTrail: TrailItem | null = null
  @State selectedTrackName: string = ''
  @State selectedContact: ContactItem | null = null
  @State sosInjury: string = '扭伤骨折'
  @State sosPeople: number = 1
  @State checkList: CheckItem[] = CHECK_ITEMS.slice()
  @State newCheckName: string = ''
  @State newCheckCategory: string = '装备'
  @State contactName: string = ''
  @State contactPhone: string = ''
  @State contactRelation: string = '家属'
  @State contactList: ContactItem[] = CONTACTS.slice()
  @State trackList: TrackLog[] = TRACK_LOGS.slice()
  @State myMiles: number = 116.5
  @State myPeaks: number = 14
  @State myLevel: number = 3
  private tabNames: string[] = ['救援', '步道', '装备', '气象', '轨迹', '我的']
  private tabIcons: string[] = ['🆘', '🥾', '🎒', '🌦️', '📍', '👤']

  maxMiles(): number {
    let m: number = 0
    WEEK_MILES.forEach((v: number) => {
      if (v > m) {
        m = v
      }
    })
    return m
  }

  milesBarHeight(v: number): number {
    return Math.round(v / this.maxMiles() * 96)
  }

  totalMiles(): number {
    let sum: number = 0
    this.trackList.forEach((t: TrackLog) => {
      sum += t.miles
    })
    return Math.round(sum * 10) / 10
  }

  doneCount(): number {
    let n: number = 0
    this.checkList.forEach((c: CheckItem) => {
      if (c.done) {
        n += 1
      }
    })
    return n
  }

  toggleCheck(id: number): void {
    const next: CheckItem[] = []
    this.checkList.forEach((c: CheckItem) => {
      if (c.id === id) {
        next.push({
          id: c.id,
          name: c.name,
          emoji: c.emoji,
          category: c.category,
          essential: c.essential,
          done: !c.done
        })
      } else {
        next.push(c)
      }
    })
    this.checkList = next
  }

  build() {
    Stack() {
      Column() {
        this.headerBar()
        Scroll() {
          Column() {
            if (this.currentTab === 0) {
              this.rescueTab()
            } else if (this.currentTab === 1) {
              this.trailTab()
            } else if (this.currentTab === 2) {
              this.gearCheckTab()
            } else if (this.currentTab === 3) {
              this.weatherTab()
            } else if (this.currentTab === 4) {
              this.trackTab()
            } else {
              this.mineTab()
            }
          }
          .width('100%')
          .padding({ left: 12, right: 12, top: 10, bottom: 14 })
        }
        .layoutWeight(1)
        .width('100%')
        .align(Alignment.Top)

        this.trailMarkTabBar()
      }
      .width('100%')
      .height('100%')

      if (this.showSosModal) {
        this.sosModalOverlay(() => {
          this.showSosModal = false
        })
      }
      if (this.showTrailModal) {
        this.trailModalOverlay(() => {
          this.showTrailModal = false
        })
      }
      if (this.showCheckModal) {
        this.checkModalOverlay(() => {
          this.showCheckModal = false
        })
      }
      if (this.showContactModal) {
        this.contactModalOverlay(() => {
          this.showContactModal = false
        })
      }
      if (this.showTrackDeleteModal) {
        this.trackDeleteModalOverlay(() => {
          this.showTrackDeleteModal = false
        })
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor(COLORS.bg)
  }

  @Builder
  headerBar() {
    Column() {
      Row() {
        Text('⛰️ 滴滴山野救援')
          .fontSize(19)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
        Row() {
          Text('🛡️ 安全等级 B+')
            .fontSize(11)
            .fontColor(COLORS.primaryDark)
            .padding({ left: 8, right: 8, top: 3, bottom: 3 })
            .backgroundColor(COLORS.gold)
            .borderRadius(10)
        }
        .margin({ left: 8 })
        Row() {
          Text('🔔')
            .fontSize(17)
        }
        .width(34)
        .height(34)
        .justifyContent(FlexAlign.Center)
        .backgroundColor('rgba(255,255,255,0.22)')
        .borderRadius(17)
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      .alignItems(VerticalAlign.Center)
      .padding({ top: 14, bottom: 12 })

      Row() {
        Column() {
          Row() {
            Text('☀️')
              .fontSize(22)
            Column() {
              Text('今日 8°C ~ 18°C · 西北风 3 级')
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.white)
              Text('适宜登山 · 日落 17:12 · 记得带头灯')
                .fontSize(10)
                .fontColor('rgba(255,255,255,0.85)')
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .margin({ left: 8 })
          }
          .alignItems(VerticalAlign.Center)

          Row() {
            Text('📍 海坨山 · 大海坨村上山口')
              .fontSize(10)
              .fontColor('rgba(255,255,255,0.85)')
              .margin({ top: 6 })
          }
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)

        Column() {
          Text('SOS')
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
          Text('一键求援')
            .fontSize(9)
            .fontColor('rgba(255,255,255,0.9)')
            .margin({ top: 2 })
        }
        .width(86)
        .height(86)
        .justifyContent(FlexAlign.Center)
        .alignItems(HorizontalAlign.Center)
        .borderRadius(43)
        .backgroundColor(COLORS.danger)
        .shadow({
          radius: 14,
          color: 'rgba(211,47,47,0.45)',
          offsetX: 0,
          offsetY: 4
        })
        .scale({ x: 1.0, y: 1.0 })
        .onClick(() => {
          this.showSosModal = true
        })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      .alignItems(VerticalAlign.Center)
      .padding({ left: 16, right: 16, top: 14, bottom: 14 })
      .borderRadius(18)
      .linearGradient({
        angle: 135,
        colors: [['#BF360C', 0], ['#E64A19', 0.7], ['#F57C00', 1]]
      })

      Row() {
        ForEach(['🥾 步道', '🌦️ 气象', '🎒 清单', '📍 轨迹', '🆘 救援'], (c: string) => {
          Column() {
            Text(c.split(' ')[0])
              .fontSize(19)
            Text(c.split(' ')[1])
              .fontSize(9)
              .fontColor(COLORS.textPrimary)
              .margin({ top: 3 })
          }
          .layoutWeight(1)
          .padding({ top: 9, bottom: 9 })
          .alignItems(HorizontalAlign.Center)
          .backgroundColor(COLORS.white)
          .borderRadius(12)
          .margin({ right: 6 })
          .onClick(() => {
            if (c.indexOf('步道') >= 0) {
              this.currentTab = 1
            } else if (c.indexOf('气象') >= 0) {
              this.currentTab = 3
            } else if (c.indexOf('清单') >= 0) {
              this.currentTab = 2
            } else if (c.indexOf('轨迹') >= 0) {
              this.currentTab = 4
            }
          })
        }, (c: string) => c)
      }
      .width('100%')
      .margin({ top: 12 })
    }
    .width('100%')
    .padding({ left: 14, right: 14, bottom: 14 })
    .linearGradient({
      angle: 180,
      colors: [['#BF360C', 0], ['#E64A19', 0.6], ['#F4F5F4', 1]]
    })
  }

  @Builder
  trailMarkTabBar() {
    Row() {
      ForEach(this.tabIcons, (icon: string, idx: number) => {
        Row() {
          if (this.currentTab === idx) {
            Text('▶')
              .fontSize(9)
              .fontColor(COLORS.gold)
              .margin({ right: 4 })
          }
          Column() {
            Text(icon)
              .fontSize(19)
            Text(this.tabNames[idx])
              .fontSize(10)
              .fontWeight(this.currentTab === idx ? FontWeight.Bold : FontWeight.Normal)
              .fontColor(this.currentTab === idx ? COLORS.white : COLORS.textSecondary)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Center)
        }
        .layoutWeight(1)
        .padding({ top: 9, bottom: 9 })
        .justifyContent(FlexAlign.Center)
        .alignItems(VerticalAlign.Center)
        .borderRadius(14)
        .backgroundColor(this.currentTab === idx ? COLORS.primary : COLORS.white)
        .scale({ x: this.currentTab === idx ? 1.05 : 1, y: this.currentTab === idx ? 1.05 : 1 })
        .animation({ duration: 200, curve: Curve.EaseOut })
        .margin({ left: 3, right: 3 })
        .onClick(() => {
          this.currentTab = idx
        })
      }, (icon: string, idx: number) => icon + idx.toString())
    }
    .width('100%')
    .padding({ left: 10, right: 10, top: 8, bottom: 8 })
    .backgroundColor(COLORS.bg)
  }

  @Builder
  rescueTab() {
    Column() {
      Column() {
        Text('🆘 最近救援站')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
        Text('海坨山一号救援站 · 距你 3.2km · 12 名队员驻守')
          .fontSize(11)
          .fontColor('rgba(255,255,255,0.9)')
          .margin({ top: 5 })
        Row() {
          Text('☎️ 呼叫救援站')
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primaryDark)
            .padding({ left: 16, right: 16, top: 8, bottom: 8 })
            .backgroundColor(COLORS.gold)
            .borderRadius(14)
        }
        .margin({ top: 10 })
      }
      .width('100%')
      .padding(16)
      .borderRadius(16)
      .alignItems(HorizontalAlign.Center)
      .linearGradient({
        angle: 135,
        colors: [['#455A64', 0], ['#37474F', 1]]
      })

      Text('⛑️ 各山域救援网络')
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .width('100%')
        .margin({ top: 14, bottom: 8 })

      ForEach(RESCUE_STATIONS, (s: RescueStation) => {
        Row() {
          Column() {
            Text('⛰️')
              .fontSize(24)
          }
          .width(48)
          .height(48)
          .justifyContent(FlexAlign.Center)
          .backgroundColor(COLORS.accentLight)
          .borderRadius(24)

          Column() {
            Text(s.name)
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('☎️ ' + s.phone + ' · 海拔 ' + s.altitude)
              .fontSize(10)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 3 })
            Text('距你 ' + s.distance + 'km · ' + s.members + ' 名队员 · 平均响应 26 分钟')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .margin({ left: 10 })

          Text('呼叫')
            .fontSize(11)
            .fontColor(COLORS.white)
            .padding({ left: 12, right: 12, top: 5, bottom: 5 })
            .backgroundColor(COLORS.accent)
            .borderRadius(12)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.white)
        .borderRadius(14)
        .margin({ bottom: 8 })
        .alignItems(VerticalAlign.Center)
      }, (s: RescueStation) => s.id.toString())

      Text('📰 近期山野救援案例')
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .width('100%')
        .margin({ top: 10, bottom: 8 })

      ForEach(RESCUE_CASES, (c: string[]) => {
        Row() {
          Text('📅 ' + c[0])
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(64)
          Text('⛰️ ' + c[1])
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .width(72)
          Text(c[2])
            .fontSize(10)
            .fontColor(COLORS.textSecondary)
            .layoutWeight(1)
          Text('已结案')
            .fontSize(9)
            .fontColor(COLORS.success)
            .padding({ left: 6, right: 6, top: 2, bottom: 2 })
            .backgroundColor('#E8F5E9')
            .borderRadius(8)
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        .padding({ top: 10, bottom: 10 })
        .backgroundColor(COLORS.white)
        .borderRadius(12)
        .margin({ bottom: 6 })
      }, (c: string[]) => c[0])

      Column() {
        Text('🛡️ 出行保障')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
        Row() {
          Text('山野意外险(保额 50 万)')
            .fontSize(11)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
          Text('已生效 · 30 天')
            .fontSize(10)
            .fontColor(COLORS.success)
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        .margin({ top: 10 })
        Row() {
          Text('离线地图包 · 京西山区')
            .fontSize(11)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
          Text('已下载 326MB')
            .fontSize(10)
            .fontColor(COLORS.textSecondary)
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        .margin({ top: 8 })
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.white)
      .borderRadius(16)
      .margin({ top: 10 })
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
  }

  @Builder
  trailTab() {
    Column() {
      Row() {
        Text('🥾 热门步道')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
        Text('10 条 · 按难度分级')
          .fontSize(10)
          .fontColor(COLORS.textSecondary)
          .margin({ left: 8 })
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)

      Row() {
        Column() {
          Text('3')
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.success)
          Text('简单线')
            .fontSize(9)
            .fontColor(COLORS.textSecondary)
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .padding({ top: 10, bottom: 10 })
        .backgroundColor(COLORS.white)
        .borderRadius(12)

        Column() {
          Text('4')
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.warning)
          Text('中等线')
            .fontSize(9)
            .fontColor(COLORS.textSecondary)
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .padding({ top: 10, bottom: 10 })
        .backgroundColor(COLORS.white)
        .borderRadius(12)
        .margin({ left: 8 })

        Column() {
          Text('2')
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.danger)
          Text('困难线')
            .fontSize(9)
            .fontColor(COLORS.textSecondary)
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .padding({ top: 10, bottom: 10 })
        .backgroundColor(COLORS.white)
        .borderRadius(12)
        .margin({ left: 8 })

        Column() {
          Text('1')
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor('#8E0000')
          Text('极难线')
            .fontSize(9)
            .fontColor(COLORS.textSecondary)
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .padding({ top: 10, bottom: 10 })
        .backgroundColor(COLORS.white)
        .borderRadius(12)
        .margin({ left: 8 })
      }
      .width('100%')
      .margin({ top: 10 })

      ForEach(TRAILS, (t: TrailItem) => {
        Column() {
          Row() {
            Column() {
              Text(t.mountain)
                .fontSize(10)
                .fontColor(COLORS.white)
                .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                .backgroundColor(COLORS.accent)
                .borderRadius(6)
            }

            Text(t.name)
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
              .margin({ left: 6 })
              .layoutWeight(1)

            Text(t.difficulty)
              .fontSize(9)
              .fontColor(COLORS.white)
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
              .backgroundColor(t.diffColor)
              .borderRadius(8)
          }
          .width('100%')
          .alignItems(VerticalAlign.Center)

          Row() {
            Column() {
              Text(t.miles.toString() + 'km')
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.primary)
              Text('总里程')
                .fontSize(8)
                .fontColor(COLORS.textHint)
                .margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.Center)
            .layoutWeight(1)

            Column() {
              Text('↑' + t.elevation.toString() + 'm')
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.accent)
              Text('累计爬升')
                .fontSize(8)
                .fontColor(COLORS.textHint)
                .margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.Center)
            .layoutWeight(1)

            Column() {
              Text(t.duration)
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.gold)
              Text('预计耗时')
                .fontSize(8)
                .fontColor(COLORS.textHint)
                .margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.Center)
            .layoutWeight(1)

            Column() {
              Text(t.season)
                .fontSize(10)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.textPrimary)
              Text('最佳季节')
                .fontSize(8)
                .fontColor(COLORS.textHint)
                .margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.Center)
            .layoutWeight(1)
          }
          .width('100%')
          .alignItems(VerticalAlign.Center)
          .margin({ top: 10 })

          Row() {
            Text('🏞️ ' + t.scenery)
              .fontSize(9)
              .fontColor(COLORS.textSecondary)
            Text('查看详情 ›')
              .fontSize(10)
              .fontColor(COLORS.primary)
              .margin({ left: 8 })
          }
          .width('100%')
          .alignItems(VerticalAlign.Center)
          .margin({ top: 8 })
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.white)
        .borderRadius(14)
        .margin({ top: 8 })
        .onClick(() => {
          this.selectedTrail = t
          this.showTrailModal = true
        })
      }, (t: TrailItem) => t.id.toString())
    }
    .width('100%')
  }

  @Builder
  gearCheckTab() {
    Column() {
      Row() {
        Text('🎒 出发装备清单')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
        Text('已备 ' + this.doneCount().toString() + '/' + this.checkList.length.toString())
          .fontSize(11)
          .fontWeight(FontWeight.Bold)
          .fontColor(this.doneCount() >= 8 ? COLORS.success : COLORS.warning)
          .margin({ left: 8 })
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)

      Row() {
        Progress({ value: this.doneCount(), total: this.checkList.length, type: ProgressType.Linear })
          .layoutWeight(1)
          .color(this.doneCount() >= 8 ? COLORS.success : COLORS.warning)
        Text(this.doneCount() >= 8 ? '✅ 可以出发' : '⚠️ 还有必备项未备齐')
          .fontSize(9)
          .fontColor(this.doneCount() >= 8 ? COLORS.success : COLORS.warning)
          .margin({ left: 8 })
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)
      .margin({ top: 10 })

      Row() {
        Text('点击条目切换已备状态')
          .fontSize(10)
          .fontColor(COLORS.textHint)
          .layoutWeight(1)
        Text('+ 新增装备')
          .fontSize(10)
          .fontColor(COLORS.white)
          .padding({ left: 10, right: 10, top: 5, bottom: 5 })
          .backgroundColor(COLORS.accent)
          .borderRadius(10)
          .onClick(() => {
            this.newCheckName = ''
            this.showCheckModal = true
          })
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)
      .margin({ top: 8 })

      ForEach(this.checkList, (c: CheckItem) => {
        Row() {
          Text(c.done ? '☑️' : '⬜')
            .fontSize(18)
            .onClick(() => {
              this.toggleCheck(c.id)
            })

          Text(c.emoji)
            .fontSize(20)
            .margin({ left: 10 })

          Column() {
            Row() {
              Text(c.name)
                .fontSize(12)
                .fontColor(c.done ? COLORS.textHint : COLORS.textPrimary)
              if (c.essential && !c.done) {
                Text('必备')
                  .fontSize(8)
                  .fontColor(COLORS.danger)
                  .padding({ left: 5, right: 5, top: 1, bottom: 1 })
                  .backgroundColor('#FDE8E8')
                  .borderRadius(6)
                  .margin({ left: 6 })
              }
            }
            .alignItems(VerticalAlign.Center)

            Text(c.category + (c.done ? ' · 已放入背包' : ''))
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .margin({ left: 10 })

          if (c.done) {
            Text('OK')
              .fontSize(9)
              .fontColor(COLORS.success)
              .fontWeight(FontWeight.Bold)
          }
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        .padding({ top: 11, bottom: 11 })
        .backgroundColor(COLORS.white)
        .borderRadius(12)
        .margin({ top: 6 })
        .onClick(() => {
          this.toggleCheck(c.id)
        })
      }, (c: CheckItem) => c.id.toString() + c.done.toString())
    }
    .width('100%')
  }

  @Builder
  weatherTab() {
    Column() {
      Row() {
        Text('🌦️ 山地气象 · 未来 7 天')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
        Text('数据来自山顶自动站')
          .fontSize(9)
          .fontColor(COLORS.textHint)
          .margin({ left: 8 })
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)

      Row() {
        Column() {
          Text('🌡️ 山顶 6°C')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
          Text('体感 2°C · 湿度 68%')
            .fontSize(9)
            .fontColor('rgba(255,255,255,0.85)')
            .margin({ top: 3 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)
        .padding(12)
        .borderRadius(14)
        .linearGradient({
          angle: 135,
          colors: [['#455A64', 0], ['#607D8B', 1]]
        })

        Column() {
          Text('🌡️ 山脚 14°C')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
          Text('温差 8°C · 备保暖层')
            .fontSize(9)
            .fontColor('rgba(255,255,255,0.85)')
            .margin({ top: 3 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)
        .padding(12)
        .borderRadius(14)
        .margin({ left: 8 })
        .linearGradient({
          angle: 135,
          colors: [['#E64A19', 0], ['#F57C00', 1]]
        })
      }
      .width('100%')
      .margin({ top: 10 })

      ForEach(WEATHER_DAYS, (w: WeatherDay) => {
        Row() {
          Text(w.day)
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .width(44)

          Text(w.icon)
            .fontSize(20)
            .width(36)

          Column() {
            Text(w.tempHigh.toString() + '° / ' + w.tempLow.toString() + '°')
              .fontSize(12)
              .fontColor(COLORS.textPrimary)
            Text(w.wind)
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .margin({ left: 8 })

          Text(w.risk)
            .fontSize(10)
            .fontColor(COLORS.white)
            .padding({ left: 10, right: 10, top: 4, bottom: 4 })
            .backgroundColor(w.riskColor)
            .borderRadius(10)
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        .padding({ top: 11, bottom: 11 })
        .backgroundColor(COLORS.white)
        .borderRadius(12)
        .margin({ top: 6 })
      }, (w: WeatherDay) => w.id.toString())

      Column() {
        Text('⚠️ 山地风险提示')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.danger)
        Text('· 午后山区易起对流云,14:00 后避免横切暴露山脊')
          .fontSize(10)
          .fontColor(COLORS.textSecondary)
          .margin({ top: 6 })
        Text('· 秋末日出日落温差大,务必携带保暖层与头灯')
          .fontSize(10)
          .fontColor(COLORS.textSecondary)
          .margin({ top: 4 })
        Text('· 能见度低于 50m 时立即停止行进并原地扎营报备')
          .fontSize(10)
          .fontColor(COLORS.textSecondary)
          .margin({ top: 4 })
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.white)
      .borderRadius(16)
      .margin({ top: 12 })
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
  }

  @Builder
  trackTab() {
    Column() {
      Row() {
        Text('📍 我的轨迹')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
        Text(this.trackList.length.toString() + ' 条记录 · 共 ' + this.totalMiles().toString() + 'km')
          .fontSize(10)
          .fontColor(COLORS.textSecondary)
          .margin({ left: 8 })
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)

      Text('本周里程(km)')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .width('100%')
        .margin({ top: 12, bottom: 6 })

      Row() {
        ForEach(WEEK_MILES, (v: number, idx: number) => {
          Column() {
            Column() {
            }
            .width(18)
            .height(this.milesBarHeight(v))
            .borderRadius({ topLeft: 9, topRight: 9 })
            .backgroundColor(v >= 10 ? COLORS.primary : COLORS.primaryLight)

            Text(this.weekLabel(idx))
              .fontSize(9)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 4 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
        }, (v: number, idx: number) => idx.toString())
      }
      .width('100%')
      .padding({ top: 14, bottom: 12 })
      .backgroundColor(COLORS.white)
      .borderRadius(16)
      .alignItems(VerticalAlign.Bottom)

      ForEach(this.trackList, (t: TrackLog) => {
        Row() {
          Column() {
            Text('⛰️')
              .fontSize(22)
          }
          .width(46)
          .height(46)
          .justifyContent(FlexAlign.Center)
          .backgroundColor(COLORS.accentLight)
          .borderRadius(23)

          Column() {
            Text(t.name)
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Row() {
              Text('📅 ' + t.date)
                .fontSize(9)
                .fontColor(COLORS.textHint)
              Text('· ' + t.miles.toString() + 'km')
                .fontSize(9)
                .fontColor(COLORS.primary)
                .margin({ left: 6 })
              Text('· 用时 ' + t.duration)
                .fontSize(9)
                .fontColor(COLORS.textSecondary)
                .margin({ left: 6 })
            }
            .alignItems(VerticalAlign.Center)
            .margin({ top: 3 })
            Row() {
              Text('配速 ' + t.pace + '/km')
                .fontSize(9)
                .fontColor(COLORS.accent)
              Text('消耗 ' + t.calories.toString() + ' kcal')
                .fontSize(9)
                .fontColor(COLORS.textHint)
                .margin({ left: 8 })
            }
            .alignItems(VerticalAlign.Center)
            .margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .margin({ left: 10 })

          Text('删除')
            .fontSize(10)
            .fontColor(COLORS.danger)
            .padding({ left: 9, right: 9, top: 4, bottom: 4 })
            .backgroundColor('#FDE8E8')
            .borderRadius(10)
            .onClick(() => {
              this.selectedTrackName = t.name
              this.showTrackDeleteModal = true
            })
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.white)
        .borderRadius(14)
        .margin({ top: 8 })
        .alignItems(VerticalAlign.Center)
      }, (t: TrackLog) => t.id.toString())
    }
    .width('100%')
  }

  weekLabel(idx: number): string {
    const labels: string[] = ['一', '二', '三', '四', '五', '六', '日']
    return labels[idx]
  }

  @Builder
  mineTab() {
    Column() {
      Row() {
        Column() {
          Text('🥾')
            .fontSize(38)
        }
        .width(60)
        .height(60)
        .justifyContent(FlexAlign.Center)
        .backgroundColor(COLORS.accentLight)
        .borderRadius(30)

        Column() {
          Text('老鹰九段')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
          Text('户外体能 Lv.3 · 已完成 14 座千米峰')
            .fontSize(10)
            .fontColor('rgba(255,255,255,0.88)')
            .margin({ top: 4 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        .margin({ left: 12 })

        Column() {
          Text('🛡️')
            .fontSize(14)
          Text('保障中')
            .fontSize(9)
            .fontColor(COLORS.gold)
            .margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.Center)
      }
      .width('100%')
      .padding(16)
      .borderRadius(18)
      .alignItems(VerticalAlign.Center)
      .linearGradient({
        angle: 135,
        colors: [['#BF360C', 0], ['#E64A19', 0.7], ['#455A64', 1]]
      })

      Row() {
        Column() {
          Text(this.myMiles.toString() + 'km')
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primary)
          Text('年度累计')
            .fontSize(9)
            .fontColor(COLORS.textSecondary)
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .padding({ top: 12, bottom: 12 })
        .backgroundColor(COLORS.white)
        .borderRadius(14)

        Column() {
          Text(this.myPeaks.toString())
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.accent)
          Text('登顶山峰')
            .fontSize(9)
            .fontColor(COLORS.textSecondary)
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .padding({ top: 12, bottom: 12 })
        .backgroundColor(COLORS.white)
        .borderRadius(14)
        .margin({ left: 8 })

        Column() {
          Text('38h')
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.gold)
          Text('山野时长')
            .fontSize(9)
            .fontColor(COLORS.textSecondary)
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .padding({ top: 12, bottom: 12 })
        .backgroundColor(COLORS.white)
        .borderRadius(14)
        .margin({ left: 8 })
      }
      .width('100%')
      .margin({ top: 12 })

      Column() {
        Row() {
          Text('🚨 紧急联系人')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('SOS 时自动同步位置')
            .fontSize(9)
            .fontColor(COLORS.textHint)
            .margin({ left: 8 })
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)

        ForEach(this.contactList, (c: ContactItem) => {
          Row() {
            Text('👤')
              .fontSize(18)
            Column() {
              Row() {
                Text(c.name)
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                Text(c.relation)
                  .fontSize(8)
                  .fontColor(COLORS.primary)
                  .padding({ left: 5, right: 5, top: 1, bottom: 1 })
                  .backgroundColor(COLORS.accentLight)
                  .borderRadius(6)
                  .margin({ left: 6 })
              }
              .alignItems(VerticalAlign.Center)
              Text('📞 ' + c.phone)
                .fontSize(10)
                .fontColor(COLORS.textSecondary)
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            .margin({ left: 10 })

            Text('编辑')
              .fontSize(10)
              .fontColor(COLORS.white)
              .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              .backgroundColor(COLORS.accent)
              .borderRadius(10)
              .onClick(() => {
                this.selectedContact = c
                this.contactName = c.name
                this.contactPhone = c.phone
                this.contactRelation = c.relation
                this.showContactModal = true
              })
          }
          .width('100%')
          .alignItems(VerticalAlign.Center)
          .padding({ top: 10, bottom: 10 })
        }, (c: ContactItem) => c.id.toString() + c.name + c.phone + c.relation)
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.white)
      .borderRadius(16)
      .margin({ top: 12 })
      .alignItems(HorizontalAlign.Start)

      Column() {
        Row() {
          Text('🗺️')
            .fontSize(16)
          Text('离线地图管理')
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
            .margin({ left: 10 })
          Text('3 个区域 ›')
            .fontSize(10)
            .fontColor(COLORS.textHint)
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        .padding({ top: 10, bottom: 10 })

        Row() {
          Text('🛡️')
            .fontSize(16)
          Text('山野意外险')
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
            .margin({ left: 10 })
          Text('续保 ›')
            .fontSize(10)
            .fontColor(COLORS.textHint)
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        .padding({ top: 10, bottom: 10 })

        Row() {
          Text('📟')
            .fontSize(16)
          Text('卫星消息设备绑定')
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
            .margin({ left: 10 })
          Text('未绑定 ›')
            .fontSize(10)
            .fontColor(COLORS.warning)
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        .padding({ top: 10, bottom: 10 })
      }
      .width('100%')
      .padding({ left: 14, right: 14, top: 6, bottom: 6 })
      .backgroundColor(COLORS.white)
      .borderRadius(16)
      .margin({ top: 12 })
    }
    .width('100%')
  }

  @Builder
  sosModal() {
    Column() {
      Text('🆘 SOS 山野求援')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.danger)
        .margin({ top: 18 })

      Column() {
        Row() {
          Text('📍')
            .fontSize(14)
          Column() {
            Text('当前定位')
              .fontSize(10)
              .fontColor('rgba(255,255,255,0.8)')
            Text('海坨山 · 大海坨村上山口 · 海拔 1320m')
              .fontSize(11)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.white)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .margin({ left: 8 })
        }
        .alignItems(VerticalAlign.Center)
        .width('100%')

        Row() {
          Text('📶 卫星短报文可用 · 精度 ±8m · 电量 76%')
            .fontSize(9)
            .fontColor('rgba(255,255,255,0.8)')
            .margin({ top: 6 })
        }
        .width('100%')
      }
      .width('92%')
      .padding(12)
      .borderRadius(14)
      .alignItems(HorizontalAlign.Start)
      .linearGradient({
        angle: 135,
        colors: [['#B71C1C', 0], ['#D32F2F', 1]]
      })
      .margin({ top: 12 })

      Text('伤情类型')
        .fontSize(11)
        .fontColor(COLORS.textSecondary)
        .width('92%')
        .margin({ top: 14 })

      Flex({ wrap: FlexWrap.Wrap }) {
        ForEach(['扭伤骨折', '失温中暑', '迷路走失', '坠落受伤', '高原反应', '其他求助'], (i: string) => {
          Text(i)
            .fontSize(11)
            .fontColor(this.sosInjury === i ? COLORS.white : COLORS.textPrimary)
            .padding({ left: 13, right: 13, top: 7, bottom: 7 })
            .backgroundColor(this.sosInjury === i ? COLORS.danger : COLORS.bg)
            .borderRadius(12)
            .margin({ right: 8, bottom: 8 })
            .onClick(() => {
              this.sosInjury = i
            })
        }, (i: string) => i + this.sosInjury)
      }
      .width('92%')

      Row() {
        Text('同行人数')
          .fontSize(12)
          .fontColor(COLORS.textPrimary)
          .layoutWeight(1)
        Text('-')
          .fontSize(16)
          .fontColor(this.sosPeople > 1 ? COLORS.primary : COLORS.textHint)
          .padding({ left: 14, right: 14 })
          .onClick(() => {
            if (this.sosPeople > 1) {
              this.sosPeople -= 1
            }
          })
        Text(this.sosPeople.toString() + ' 人')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
        Text('+')
          .fontSize(16)
          .fontColor(COLORS.primary)
          .padding({ left: 14, right: 14 })
          .onClick(() => {
            if (this.sosPeople < 10) {
              this.sosPeople += 1
            }
          })
      }
      .width('92%')
      .alignItems(VerticalAlign.Center)
      .margin({ top: 10 })

      Row() {
        Column() {
          Text('预计响应')
            .fontSize(9)
            .fontColor(COLORS.textSecondary)
          Text('26 分钟')
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.accent)
            .margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.Center)
        .layoutWeight(1)

        Column() {
          Text('最近救援站')
            .fontSize(9)
            .fontColor(COLORS.textSecondary)
          Text('3.2km')
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.accent)
            .margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.Center)
        .layoutWeight(1)

        Column() {
          Text('联系人同步')
            .fontSize(9)
            .fontColor(COLORS.textSecondary)
          Text('2 人')
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.accent)
            .margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.Center)
        .layoutWeight(1)
      }
      .width('92%')
      .padding({ top: 12, bottom: 12 })
      .backgroundColor(COLORS.bg)
      .borderRadius(14)
      .margin({ top: 12 })

      Text('确认发起求援')
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.white)
        .width('90%')
        .textAlign(TextAlign.Center)
        .padding({ top: 13, bottom: 13 })
        .backgroundColor(COLORS.danger)
        .borderRadius(16)
        .margin({ top: 14, bottom: 20 })
        .onClick(() => {
          this.showSosModal = false
        })
    }
    .width('100%')
    .backgroundColor(COLORS.white)
    .borderRadius({ topLeft: 22, topRight: 22 })
    .alignItems(HorizontalAlign.Center)
  }

  @Builder
  sosModalOverlay(onClose: () => void) {
    Column() {
      Column() {
      }
      .width('100%')
      .height('100%')
      .backgroundColor('rgba(38,50,56,0.6)')
      .position({ x: 0, y: 0 })
      .onClick(() => {
        onClose()
      })

      Column() {
        this.sosModal()
      }
      .width('100%')
      .justifyContent(FlexAlign.End)
    }
    .width('100%')
    .height('100%')
    .zIndex(999)
  }

  @Builder
  trailModal() {
    Column() {
      if (this.selectedTrail !== null) {
        Column() {
          Text('⛰️ ' + this.selectedTrail.mountain + ' · ' + this.selectedTrail.difficulty)
            .fontSize(11)
            .fontColor(COLORS.gold)
          Text(this.selectedTrail.name)
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .margin({ top: 5 })
          Text('🏞️ ' + this.selectedTrail.scenery + ' · 最佳 ' + this.selectedTrail.season)
            .fontSize(11)
            .fontColor('rgba(255,255,255,0.9)')
            .margin({ top: 4 })
        }
        .width('100%')
        .padding({ top: 24, bottom: 20 })
        .alignItems(HorizontalAlign.Center)
        .linearGradient({
          angle: 135,
          colors: [['#BF360C', 0], ['#E64A19', 1]]
        })
        .borderRadius({ topLeft: 22, topRight: 22 })

        Row() {
          Column() {
            Text(this.selectedTrail.miles.toString() + 'km')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.primary)
            Text('总里程')
              .fontSize(9)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)

          Column() {
            Text('↑' + this.selectedTrail.elevation.toString() + 'm')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.accent)
            Text('累计爬升')
              .fontSize(9)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)

          Column() {
            Text(this.selectedTrail.duration)
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.gold)
            Text('预计耗时')
              .fontSize(9)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
        }
        .width('92%')
        .padding({ top: 12, bottom: 12 })
        .backgroundColor(COLORS.bg)
        .borderRadius(14)
        .margin({ top: 14 })

        Column() {
          Text('补给与下撤点')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width('100%')
          Row() {
            Text('🚰 出发 3km · 山泉水补给点')
              .fontSize(11)
              .fontColor(COLORS.textPrimary)
              .layoutWeight(1)
          }
          .width('100%')
          .alignItems(VerticalAlign.Center)
          .margin({ top: 8 })
          Row() {
            Text('⛺ 中段 11km · 营地与避险屋')
              .fontSize(11)
              .fontColor(COLORS.textPrimary)
              .layoutWeight(1)
          }
          .width('100%')
          .alignItems(VerticalAlign.Center)
          .margin({ top: 6 })
          Row() {
            Text('🚑 终点 · 救援站与公路接驳')
              .fontSize(11)
              .fontColor(COLORS.textPrimary)
              .layoutWeight(1)
          }
          .width('100%')
          .alignItems(VerticalAlign.Center)
          .margin({ top: 6 })
        }
        .width('92%')
        .padding(12)
        .backgroundColor(COLORS.white)
        .borderRadius(14)
        .margin({ top: 10 })
        .alignItems(HorizontalAlign.Start)

        Row() {
          Text('今日天气适宜度')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
          Progress({ value: 85, total: 100, type: ProgressType.Linear })
            .layoutWeight(1)
            .color(COLORS.success)
            .margin({ left: 10, right: 10 })
          Text('85')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.success)
        }
        .width('92%')
        .alignItems(VerticalAlign.Center)
        .margin({ top: 12 })

        Text('一键报备行程')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .width('90%')
          .textAlign(TextAlign.Center)
          .padding({ top: 12, bottom: 12 })
          .backgroundColor(COLORS.primary)
          .borderRadius(16)
          .margin({ top: 14, bottom: 20 })
          .onClick(() => {
            this.showTrailModal = false
          })
      }
    }
    .width('100%')
    .backgroundColor(COLORS.white)
    .alignItems(HorizontalAlign.Center)
  }

  @Builder
  trailModalOverlay(onClose: () => void) {
    Column() {
      Column() {
      }
      .width('100%')
      .height('100%')
      .backgroundColor('rgba(38,50,56,0.6)')
      .position({ x: 0, y: 0 })
      .onClick(() => {
        onClose()
      })

      Column() {
        this.trailModal()
      }
      .width('100%')
      .justifyContent(FlexAlign.End)
    }
    .width('100%')
    .height('100%')
    .zIndex(999)
  }

  @Builder
  checkModal() {
    Column() {
      Text('🎒 新增装备项')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .margin({ top: 18 })

      Column() {
        Text('装备名称')
          .fontSize(11)
          .fontColor(COLORS.textSecondary)
          .width('100%')
        TextInput({ placeholder: '如:防雨罩 · 墨镜 · 护膝' })
          .fontSize(12)
          .height(40)
          .backgroundColor(COLORS.bg)
          .borderRadius(10)
          .margin({ top: 6 })
          .onChange((v: string) => {
            this.newCheckName = v
          })
      }
      .width('92%')
      .padding(14)
      .backgroundColor(COLORS.white)
      .borderRadius(14)
      .margin({ top: 14 })
      .alignItems(HorizontalAlign.Start)

      Text('所属类别')
        .fontSize(11)
        .fontColor(COLORS.textSecondary)
        .width('92%')
        .margin({ top: 12 })

      Row() {
        ForEach(['穿着', '装备', '安全', '补给'], (c: string) => {
          Text(c)
            .fontSize(11)
            .fontColor(this.newCheckCategory === c ? COLORS.white : COLORS.textPrimary)
            .padding({ left: 15, right: 15, top: 7, bottom: 7 })
            .backgroundColor(this.newCheckCategory === c ? COLORS.accent : COLORS.bg)
            .borderRadius(12)
            .margin({ right: 8 })
            .onClick(() => {
              this.newCheckCategory = c
            })
        }, (c: string) => c + this.newCheckCategory)
      }
      .width('92%')
      .margin({ top: 8 })

      Row() {
        Text('默认标记为「未备齐」状态')
          .fontSize(10)
          .fontColor(COLORS.textHint)
      }
      .width('92%')
      .margin({ top: 10 })

      Text('加入清单')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.white)
        .width('90%')
        .textAlign(TextAlign.Center)
        .padding({ top: 12, bottom: 12 })
        .backgroundColor(COLORS.accent)
        .borderRadius(16)
        .margin({ top: 14, bottom: 20 })
        .onClick(() => {
          this.checkList.unshift({
            id: this.checkList.length + 1,
            name: this.newCheckName === '' ? '新装备项' : this.newCheckName,
            emoji: '🧰',
            category: this.newCheckCategory,
            essential: false,
            done: false
          })
          this.showCheckModal = false
        })
    }
    .width('100%')
    .backgroundColor(COLORS.white)
    .borderRadius({ topLeft: 22, topRight: 22 })
    .alignItems(HorizontalAlign.Center)
  }

  @Builder
  checkModalOverlay(onClose: () => void) {
    Column() {
      Column() {
      }
      .width('100%')
      .height('100%')
      .backgroundColor('rgba(38,50,56,0.6)')
      .position({ x: 0, y: 0 })
      .onClick(() => {
        onClose()
      })

      Column() {
        this.checkModal()
      }
      .width('100%')
      .justifyContent(FlexAlign.End)
    }
    .width('100%')
    .height('100%')
    .zIndex(999)
  }

  @Builder
  contactModal() {
    Column() {
      Text('👤 编辑紧急联系人')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .margin({ top: 18 })

      if (this.selectedContact !== null) {
        Text('正在编辑:' + this.selectedContact.name)
          .fontSize(10)
          .fontColor(COLORS.textHint)
          .margin({ top: 6 })
      }

      Column() {
        Text('姓名')
          .fontSize(11)
          .fontColor(COLORS.textSecondary)
          .width('100%')
        TextInput({ placeholder: '联系人姓名', text: this.contactName })
          .fontSize(12)
          .height(40)
          .backgroundColor(COLORS.bg)
          .borderRadius(10)
          .margin({ top: 6 })
          .onChange((v: string) => {
            this.contactName = v
          })

        Text('手机号')
          .fontSize(11)
          .fontColor(COLORS.textSecondary)
          .width('100%')
          .margin({ top: 12 })
        TextInput({ placeholder: '11 位手机号', text: this.contactPhone })
          .fontSize(12)
          .height(40)
          .backgroundColor(COLORS.bg)
          .borderRadius(10)
          .margin({ top: 6 })
          .onChange((v: string) => {
            this.contactPhone = v
          })
      }
      .width('92%')
      .padding(14)
      .backgroundColor(COLORS.white)
      .borderRadius(14)
      .margin({ top: 14 })
      .alignItems(HorizontalAlign.Start)

      Text('与你的关系')
        .fontSize(11)
        .fontColor(COLORS.textSecondary)
        .width('92%')
        .margin({ top: 12 })

      Row() {
        ForEach(['家属', '领队', '山友', '其他'], (r: string) => {
          Text(r)
            .fontSize(11)
            .fontColor(this.contactRelation === r ? COLORS.white : COLORS.textPrimary)
            .padding({ left: 14, right: 14, top: 7, bottom: 7 })
            .backgroundColor(this.contactRelation === r ? COLORS.accent : COLORS.bg)
            .borderRadius(12)
            .margin({ right: 8 })
            .onClick(() => {
              this.contactRelation = r
            })
        }, (r: string) => r + this.contactRelation)
      }
      .width('92%')
      .margin({ top: 8 })

      Text('保存修改')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.white)
        .width('90%')
        .textAlign(TextAlign.Center)
        .padding({ top: 12, bottom: 12 })
        .backgroundColor(COLORS.primary)
        .borderRadius(16)
        .margin({ top: 16, bottom: 20 })
        .onClick(() => {
          this.saveContact()
        })
    }
    .width('100%')
    .backgroundColor(COLORS.white)
    .borderRadius({ topLeft: 22, topRight: 22 })
    .alignItems(HorizontalAlign.Center)
  }

  saveContact(): void {
    if (this.selectedContact === null) {
      return
    }
    const cid: number = this.selectedContact.id
    const next: ContactItem[] = []
    this.contactList.forEach((c: ContactItem) => {
      if (c.id === cid) {
        next.push({
          id: c.id,
          name: this.contactName === '' ? c.name : this.contactName,
          phone: this.contactPhone === '' ? c.phone : this.contactPhone,
          relation: this.contactRelation
        })
      } else {
        next.push(c)
      }
    })
    this.contactList = next
    this.showContactModal = false
  }

  @Builder
  contactModalOverlay(onClose: () => void) {
    Column() {
      Column() {
      }
      .width('100%')
      .height('100%')
      .backgroundColor('rgba(38,50,56,0.6)')
      .position({ x: 0, y: 0 })
      .onClick(() => {
        onClose()
      })

      Column() {
        this.contactModal()
      }
      .width('100%')
      .justifyContent(FlexAlign.End)
    }
    .width('100%')
    .height('100%')
    .zIndex(999)
  }

  @Builder
  trackDeleteModal() {
    Column() {
      Text('⚠️')
        .fontSize(34)
        .margin({ top: 22 })
      Text('删除轨迹记录')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .margin({ top: 8 })
      Text('「' + this.selectedTrackName + '」删除后无法恢复,里程统计将同步扣减。')
        .fontSize(11)
        .fontColor(COLORS.textSecondary)
        .textAlign(TextAlign.Center)
        .margin({ top: 8 })
        .padding({ left: 24, right: 24 })

      Row() {
        Text('取消')
          .fontSize(13)
          .fontColor(COLORS.textSecondary)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .padding({ top: 11, bottom: 11 })
          .backgroundColor(COLORS.bg)
          .borderRadius(14)
          .onClick(() => {
            this.showTrackDeleteModal = false
          })
        Text('确认删除')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .padding({ top: 11, bottom: 11 })
          .backgroundColor(COLORS.danger)
          .borderRadius(14)
          .margin({ left: 10 })
          .onClick(() => {
            this.trackList = this.trackList.filter((t: TrackLog) => t.name !== this.selectedTrackName)
            this.showTrackDeleteModal = false
          })
      }
      .width('86%')
      .margin({ top: 18, bottom: 22 })
    }
    .width('100%')
    .backgroundColor(COLORS.white)
    .borderRadius({ topLeft: 22, topRight: 22 })
    .alignItems(HorizontalAlign.Center)
  }

  @Builder
  trackDeleteModalOverlay(onClose: () => void) {
    Column() {
      Column() {
      }
      .width('100%')
      .height('100%')
      .backgroundColor('rgba(38,50,56,0.6)')
      .position({ x: 0, y: 0 })
      .onClick(() => {
        onClose()
      })

      Column() {
        this.trackDeleteModal()
      }
      .width('100%')
      .justifyContent(FlexAlign.End)
    }
    .width('100%')
    .height('100%')
    .zIndex(999)
  }
}


在这里插入图片描述

八、总结

本文基于HarmonyOS 6.1.1平台,以HarmonyOS ArkTS API 24为技术基座,对"滴滴山野救援"应用的完整源码进行了深度剖析。该应用涵盖了户外山地救援场景下的六大功能模块:SOS紧急求援、步道导航指南、装备清单检查、山地气象预警、运动轨迹记录和个人中心管理,共计超过两千行ArkTS源码。

从架构设计层面来看,该应用采用了典型的单组件多构建器架构模式。整个应用由一个@Entry @Component修饰的TrailRescuePage组件承载,通过17个@Builder方法将UI拆分为可复用的构建器单元。这种架构在中小型应用中具有开发效率高、状态管理简单、组件间通信成本低的优势。@State装饰器驱动的响应式状态管理是整个应用的数据核心,16个状态变量覆盖了Tab导航、弹窗显隐、选中数据和业务数据四个维度,所有UI变化都通过状态变更自动驱动。

从数据建模层面来看,应用定义了6个接口(ColorPaletteRescueStationTrailItemCheckItemWeatherDayTrackLogContactItem)和8个静态常量数组,构成了完整的类型安全数据层。ArkTS的强类型特性确保了从数据定义到UI渲染的全链路类型安全,编译时即可捕获字段拼写错误和类型不匹配问题。颜色管理系统通过ColorPalette接口集中管理16个颜色值,配合难度颜色嵌入数据记录的设计,实现了视觉语言与业务数据的解耦。

从交互设计层面来看,应用实现了5种弹窗交互模式,统一采用底部滑入式设计和遮罩层点击关闭机制。弹窗的覆盖层架构通过回调函数参数实现关闭逻辑的灵活传递,5个弹窗复用了相同的覆盖层模式。不可变更新模式在装备勾选、联系人保存和轨迹删除三个场景中得到了一致应用,确保了@State数组变更的正确检测和UI更新。ForEach的键值生成策略根据不同场景采用了差异化的设计——静态数据使用纯ID、动态状态数据使用ID加状态值的组合,在渲染性能和状态正确性之间取得了平衡。

从可视化层面来看,应用中的周里程柱状图完全使用ArkTS基础组件实现,通过动态计算柱子高度和条件颜色切换,展示了在不引入第三方图表库的情况下实现数据可视化的能力。Progress线性进度条组件在装备清单完成度展示和步道天气适宜度展示中得到了有效应用。linearGradient渐变属性在头部栏、救援站卡片、气象温度卡片等处广泛使用,通过不同角度和颜色组合营造了丰富的视觉层次。

Logo

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

更多推荐