在这里插入图片描述

一、引言:攀登者的数字伙伴

在户外运动日益流行的今天,登山爱好者们需要一款专业、美观且功能完善的移动应用来记录攀登历程、管理装备清单、规划登山路线。这款登山户外 App 正是为满足这一需求而精心打造的全功能登山助手。App 以岩灰色为主色调,搭配活力四射的登山橙作为点缀,整体视觉风格既沉稳专业又充满户外运动的激情与活力。

从业务功能维度来看,这款 App 涵盖了登山活动的全生命周期管理:山峰图鉴收录了从入门级到专业级的十座经典雪山,每座山峰都标注了海拔高度、难度等级和热度标识;路线模块提供了八条精选登山路线,包含难度分级和里程信息;装备清单功能帮助登山者系统管理十二件核心装备,实时追踪打包进度和总重量;我的页面则以攀登者档案为核心,展示累计攀登里程、上升高度、登顶座数等关键数据,并配有徽章系统和攀登记录。

技术实现上,该应用采用了 HarmonyOS ArkTS 声明式UI开发框架,充分利用了 @Component@State@Builder 等装饰器特性,构建了一套清晰、高效的组件化架构。整体采用底部 Tab 导航 + 页面内容区的经典移动端布局模式,五个 Tab 页面(首页、山峰、路线、装备、我的)各司其职,通过枚举类型和状态变量实现无缝切换。弹窗交互统一采用 modalOverlay 叠加层方案,支持新增山峰、编辑装备、删除记录等多种操作场景。

在数据层设计方面,应用通过 TypeScript interface 严格定义了九种数据模型,从颜色调色板到山峰、装备、记录、路线、补给、徽章等业务实体,每个接口都经过精心设计,字段命名语义化,类型约束明确。Mock 数据层则提供了丰富的模拟数据,确保在开发阶段就能展示完整的业务流程和视觉效果。

本文将从架构设计、数据模型、颜色系统、状态管理、组件实现等多个维度,对这款登山户外 App 进行全方位的深度技术解析,帮助读者全面理解 ArkTS 声明式UI开发的最佳实践。


二、整体架构流程图

在这里插入图片描述

下面的架构图展示了 App 的整体分层结构,从顶部的入口组件到底部的数据层,各层之间职责分明、依赖清晰。

模型层

数据层

工具函数层

弹窗组件层

页面组件层

导航层

入口层

HikingApp 主组件

底部Tab导航栏

contentArea 内容切换器

HikingHomeContent 首页

HikingPeakContent 山峰页

HikingRouteContent 路线页

HikingGearContent 装备页

HikingMineContent 我的页

addPeakModal 新增山峰

editPeakModal 编辑山峰

addRouteModal 新增路线

editGearModal 编辑装备

deleteRecordModal 删除记录

getLevelColor 难度配色

formatHeight 海拔格式化

COLORS 颜色常量

TAB_CONFIG Tab配置

PEAK_LIST 山峰数据

GEAR_LIST 装备数据

RECORD_LIST 记录数据

ROUTE_LIST 路线数据

WEEK_HIKE 周里程数据

SUPPLY_LIST 补给数据

BADGE_LIST 徽章数据

HikingColorPalette

TabMeta

PeakMeta

GearMeta

RecordMeta

RouteMeta

WeekHikeMeta

SupplyMeta

BadgeMeta


三、颜色与主题系统详解

在这里插入图片描述

3.1 颜色调色板接口设计

颜色系统是整个应用视觉风格的基石。该应用通过一个精心设计的 HikingColorPalette 接口来规范所有颜色的使用,确保整个应用的视觉一致性和可维护性。

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

这个接口包含了 16 个颜色字段,涵盖了主色系、辅助色、背景色、文字色、边框色和功能色六大类别。其中主色系提供了 primary(岩灰主色)、primaryLight(浅岩灰)、primaryDark(深岩灰)三个层次,支持渐变效果和深浅变化的设计需求。辅助色系包含 accent(登山橙)和 accentLight(浅橙),用于强调和突出关键操作。

文字颜色分为三个层级:textPrimary 用于标题和重要信息,textSecondary 用于次要描述,textHint 用于提示和占位文字,形成清晰的视觉层次。功能色包括 success(成功绿)、warning(警告黄)、danger(危险红),分别对应不同的业务状态反馈。

3.2 配色方案的设计理念

const COLORS: HikingColorPalette = {
  primary: '#546E7A',
  primaryLight: '#90A4AE',
  primaryDark: '#37474F',
  accent: '#FF6D00',
  accentLight: '#FFE0B2',
  slate: '#78909C',
  bg: '#F2F4F5',
  cardBg: '#FFFFFF',
  textPrimary: '#263238',
  textSecondary: '#5D6B73',
  textHint: '#A3AFB5',
  border: '#E3E8EB',
  success: '#43A047',
  warning: '#FFA000',
  danger: '#E53935',
  white: '#FFFFFF'
}

配色方案以岩灰色 #546E7A 为主色调,传递出专业、稳重、可靠的品牌调性,非常契合登山户外运动的严肃性和专业性。登山橙 #FF6D00 作为强调色,既代表了日出和活力,又能在灰色基调中形成强烈的视觉对比,有效引导用户注意力。

背景色采用浅灰 #F2F4F5,卡片背景为纯白,通过阴影和圆角营造出层次感和空间感。文字颜色从深灰到浅灰逐步过渡,确保了良好的可读性和视觉舒适度。功能色选用了经典的语义化配色:绿色代表成功/安全,黄色代表警告/注意,红色代表危险/错误,用户能够快速理解状态含义。

值得注意的是,应用大量使用了 linearGradient 线性渐变来增强视觉表现力。例如首页头卡采用了从深岩灰到岩灰再到登山橙的 135 度渐变,营造出日出山脊的氛围感,与登山主题高度契合。

3.3 常量配置方式

颜色常量采用对象字面量的方式进行集中管理,所有颜色值统一存储在 COLORS 常量中,通过接口 HikingColorPalette 进行类型约束。这种设计模式具有以下优点:

  • 集中管理:所有颜色定义在一个地方,便于统一修改和维护
  • 类型安全:通过 interface 约束,确保每个颜色字段都存在且类型正确
  • 语义化命名:字段名称表达颜色的用途而非具体色值,提高代码可读性
  • 易于扩展:如需新增颜色变量,只需在接口和常量中添加对应字段即可

四、数据模型层深度解析

在这里插入图片描述

数据模型是应用的骨架,决定了数据的组织方式和业务逻辑的表达能力。该应用共定义了 9 个数据接口,每个接口都经过精心设计,字段含义清晰,业务价值明确。

4.1 TabMeta - Tab导航元数据

interface TabMeta {
  title: string
  icon: string
}

TabMeta 接口定义了底部导航栏每个 Tab 的元数据,包含 title(标题文字)和 icon(图标表情)两个字段。使用表情符号作为图标是一种轻量级的实现方案,无需引入额外的图标库,同时具有良好的跨平台兼容性。每个 Tab 对应一个业务模块,通过 TAB_CONFIG 配置对象统一管理。

4.2 PeakMeta - 山峰数据模型

interface PeakMeta {
  name: string
  emoji: string
  height: number
  level: string
  isHot: boolean
}

PeakMeta 是山峰实体的数据模型,包含五个核心字段:

  • name:山峰名称,用于唯一标识和展示
  • emoji:山峰表情图标,增强视觉识别度
  • height:海拔高度(米),number 类型便于排序和计算
  • level:难度等级(入门级/进阶级/挑战级/专业级),用于难度分级展示
  • isHot:是否热门,布尔类型,用于热门标签展示

这个模型覆盖了山峰的基本属性,既包含客观信息(名称、海拔),也包含主观分类(难度、热度)。海拔高度使用 number 类型而非 string,为后续的排序、筛选、格式化等操作提供了便利。

4.3 GearMeta - 装备数据模型

interface GearMeta {
  name: string
  emoji: string
  weight: number
  isDone: boolean
  isNew: boolean
}

GearMeta 装备模型包含装备名称、图标、重量、是否已打包、是否新品五个字段。weight 字段使用 number 类型,支持总重量统计计算。isDone 字段用于标记装备是否已打包,配合装备清单的完成度统计。isNew 字段标记新装备,在UI上显示"新"标签,引导用户关注新添加的装备。

4.4 RecordMeta - 攀登记录模型

interface RecordMeta {
  name: string
  emoji: string
  date: string
  distance: number
  time: string
  isDone: boolean
}

RecordMeta 记录了每次攀登活动的关键数据:名称、图标、日期、距离、用时和是否完成。distance 用 number 类型存储里程数据,便于累计统计。time 用 string 类型存储格式化后的用时(如"9h32m"),直接展示即可。isDone 区分登顶成功和未完赛的记录,在列表中用不同颜色标识。

4.5 RouteMeta - 路线数据模型

interface RouteMeta {
  name: string
  emoji: string
  level: string
  distance: number
  isNew: boolean
}

RouteMeta 路线模型描述登山路线信息,包含名称、图标、难度等级、里程和是否新路线。难度等级与山峰模型保持一致的命名体系,通过 getLevelColor 函数统一映射颜色,确保视觉一致性。

4.6 WeekHikeMeta - 周里程数据模型

interface WeekHikeMeta {
  label: string
  value: number
}

WeekHikeMeta 是柱状图的数据模型,用于展示每周各天的登山里程。label 是横轴标签(周一到周日),value 是对应的里程数值。这个简洁的模型支持通用的柱状图渲染,通过 ForEach 循环即可生成完整的图表。

4.7 SupplyMeta - 补给清单模型

interface SupplyMeta {
  name: string
  emoji: string
  desc: string
  isDone: boolean
}

SupplyMeta 补给清单模型用于管理登山途中的食物和饮水补给。desc 字段提供补给品的简要描述(如"含电解质"、“高能补给”),帮助用户了解补给用途。isDone 标记是否已装入背包。

4.8 BadgeMeta - 徽章模型

interface BadgeMeta {
  name: string
  emoji: string
  desc: string
  isNew: boolean
}

BadgeMeta 徽章模型定义了成就徽章系统的数据结构。每个徽章有名称、图标、描述和是否新获得四个属性。徽章系统是游戏化设计的重要组成部分,通过 isNew 标记新获得的徽章,增强用户的成就感和探索欲。


五、配置常量层分析

在这里插入图片描述

5.1 TAB_CONFIG 导航配置

const TAB_CONFIG: Record<string, TabMeta> = {
  'HOME': { title: '首页', icon: '⛰️' },
  'PEAK': { title: '山峰', icon: '🗻' },
  'ROUTE': { title: '路线', icon: '🧭' },
  'GEAR': { title: '装备', icon: '🎒' },
  'MINE': { title: '我的', icon: '👤' }
}

Tab 导航配置采用 Record<string, TabMeta> 的类型定义,以枚举键值的形式管理五个 Tab 的配置信息。这种配置驱动的设计模式有以下优势:

  • 解耦:导航配置与组件逻辑分离,修改导航项无需改动组件代码
  • 可扩展:新增或删除 Tab 只需修改配置对象
  • 类型安全:TypeScript 的 Record 类型确保键值对结构正确
  • 集中管理:所有导航项一目了然,便于维护

配合 HikingTab 枚举类型使用,实现了配置与状态的完美对应。

5.2 业务数据常量设计

应用定义了 7 组业务数据常量,包括山峰列表、装备列表、记录列表、路线列表、周里程数据、补给清单和徽章列表。每组数据都采用数组形式存储,元素类型为对应的 Meta 接口。

这种 Mock 数据层的设计具有以下特点:

  • 数据真实丰富:每组数据都提供了 6-12 条记录,覆盖不同的业务场景
  • 类型严格:数组元素都有明确的接口类型约束
  • 命名规范:常量全部使用大写下划线命名法,与变量区分
  • 就近原则:数据常量定义在对应接口之后,保持代码结构清晰

六、Mock数据层分析

在这里插入图片描述

6.1 数据设计思路

Mock 数据层是应用开发中的重要环节,它使得前端开发可以独立于后端进行,同时也为 UI 调试和演示提供了丰富的数据支撑。该应用的 Mock 数据设计遵循了以下原则:

多样性原则:每个数据列表都包含多种类型的数据。以山峰列表为例,涵盖了从入门级到专业级的四个难度等级,海拔从 5025 米到 7546 米不等,既有热门山峰也有非热门山峰,确保 UI 展示的丰富性。

真实性原则:数据内容贴近真实业务场景。装备列表中的物品都是实际登山活动中常见的装备,重量数据也符合实际(背包 2.1kg、睡袋 1.8kg 等)。攀登记录的距离和时间也都在合理范围内。

覆盖性原则:数据设计充分考虑了各种边界情况和状态展示。例如装备列表中既有已打包的(isDone: true)也有待打包的,既有新品也有常规装备,确保各种 UI 状态都能得到展示。

6.2 数据结构特色

以山峰数据为例:

const PEAK_LIST: PeakMeta[] = [
  { name: '玉龙雪山', emoji: '🏔️', height: 5596, level: '入门级', isHot: true },
  { name: '四姑娘山·大峰', emoji: '🗻', height: 5025, level: '入门级', isHot: true },
  { name: '哈巴雪山', emoji: '❄️', height: 5396, level: '进阶级', isHot: true },
  // ... 更多数据
]

数据结构具有以下特色:

  • 使用 emoji 作为视觉图标,轻量且直观
  • 难度等级使用中文描述,便于用户理解
  • 海拔使用 number 类型,支持数值计算和排序
  • 布尔类型的状态字段(isHot、isNew、isDone)便于条件渲染

七、主入口组件逐段分析

在这里插入图片描述

7.1 状态管理设计

@Entry
@Component
struct HikingApp {
  @State activeTab: HikingTab = HikingTab.HOME

主入口组件 HikingApp 使用 @Entry 装饰器标记为应用入口,@Component 装饰器标记为自定义组件。状态管理方面,仅使用了一个 @State 装饰的 activeTab 变量来跟踪当前激活的 Tab 页。

@State 是 ArkTS 中最基础的状态装饰器,用于标记组件内部的响应式状态变量。当 activeTab 的值发生变化时,ArkUI 框架会自动重新渲染依赖该状态的 UI 部分,实现页面的无缝切换。

使用枚举类型 HikingTab 作为状态变量的类型,相比直接使用字符串或数字具有以下优势:

  • 类型安全:编译器可以检查枚举值的合法性
  • 代码提示:IDE 可以提供枚举值的自动补全
  • 可读性:枚举成员名称语义化,代码更易理解

7.2 @Builder 自定义构建器设计

应用使用了两个 @Builder 装饰的自定义构建函数:contentAreatabItem

contentArea 内容区域构建器
@Builder contentArea() {
  Column() {
    if (this.activeTab === HikingTab.HOME) {
      HikingHomeContent()
    } else if (this.activeTab === HikingTab.PEAK) {
      HikingPeakContent()
    } else if (this.activeTab === HikingTab.ROUTE) {
      HikingRouteContent()
    } else if (this.activeTab === HikingTab.GEAR) {
      HikingGearContent()
    } else if (this.activeTab === HikingTab.MINE) {
      HikingMineContent()
    }
  }
  .width('100%')
}

contentArea 构建器负责根据当前激活的 Tab 渲染对应的页面组件。它使用 if-else 条件判断语句,根据 activeTab 的值选择性地渲染五个页面组件中的一个。

这种条件渲染的方式非常直观,每个页面组件都是独立的 @Component 结构体,具有良好的封装性。当用户切换 Tab 时,只有内容区域会重新渲染,底部导航栏保持不变,提供了流畅的用户体验。

tabItem 导航项构建器
@Builder tabItem(icon: string, label: string, tab: HikingTab) {
  Column() {
    Text(icon).fontSize(20)
    Text(label).fontSize(10)
      .fontColor(this.activeTab === tab ? COLORS.primary : COLORS.textHint)
      .fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
      .margin({ top: 2 })
  }
  .layoutWeight(1)
  .onClick(() => { this.activeTab = tab })
}

tabItem 构建器接收三个参数:icon(图标表情)、label(文字标签)和 tab(对应的枚举值)。它创建一个垂直布局的 Column,包含图标和文字两部分。

这个构建器的设计非常巧妙,体现了以下技术要点:

动态样式:通过三元表达式根据 this.activeTab === tab 的判断结果动态设置文字颜色和字重。激活状态使用主色加粗,未激活状态使用提示色常规字重,清晰地表达当前选中状态。

均等分布.layoutWeight(1) 使得每个 Tab 项在水平方向上等分空间,无论 Tab 数量多少都能均匀分布。

点击事件.onClick(() => { this.activeTab = tab }) 绑定点击事件,点击时更新 activeTab 状态,触发 UI 重新渲染。

7.3 build 方法的整体布局结构

build() {
  Column() {
    this.contentArea()
    Row() {
      this.tabItem(TAB_CONFIG.HOME.icon, TAB_CONFIG.HOME.title, HikingTab.HOME)
      this.tabItem(TAB_CONFIG.PEAK.icon, TAB_CONFIG.PEAK.title, HikingTab.PEAK)
      this.tabItem(TAB_CONFIG.ROUTE.icon, TAB_CONFIG.ROUTE.title, HikingTab.ROUTE)
      this.tabItem(TAB_CONFIG.GEAR.icon, TAB_CONFIG.GEAR.title, HikingTab.GEAR)
      this.tabItem(TAB_CONFIG.MINE.icon, TAB_CONFIG.MINE.title, HikingTab.MINE)
    }
    .width('100%')
    .padding({ top: 8, bottom: 8 })
    .backgroundColor(COLORS.white)
    .shadow({ radius: 10, color: '#26323826', offsetY: -2 })
  }
  .width('100%').height('100%')
  .backgroundColor(COLORS.bg)
}

整体布局采用经典的垂直 Column 结构,上方是内容区域 contentArea,下方是底部导航栏 Row。内容区域使用 .layoutWeight 的隐式方式(Column 的子组件默认撑满剩余空间)占据大部分屏幕空间,底部导航栏固定高度。

底部导航栏的设计细节:

  • 白色背景配合顶部阴影,形成悬浮感
  • 阴影使用半透明的岩灰色 #26323826,与主色调呼应
  • offsetY: -2 使阴影只出现在上方,符合底部导航的视觉习惯
  • 上下各 8vp 的内边距,确保点击区域舒适

最外层 Column 设置了背景色为 COLORS.bg(浅灰色),宽度和高度都设为 100%,撑满整个屏幕。


八、各页面组件逐段代码分析

在这里插入图片描述

8.1 首页组件 HikingHomeContent

首页是应用的核心展示页面,内容最为丰富,包含等高线山脊头卡、周里程柱状图、热门山峰列表、路线难度横滑、补给清单等多个模块。

状态与弹窗定义
@Component
struct HikingHomeContent {
  @State showAddPeakModal: boolean = false
  @State peakName: string = ''
  @State peakHeight: string = ''

首页组件定义了三个状态变量:showAddPeakModal 控制新增山峰弹窗的显示与隐藏,peakNamepeakHeight 分别存储弹窗表单中输入的山峰名称和海拔。

这种设计模式中,弹窗的表单数据由页面组件持有,弹窗组件通过闭包访问这些状态变量。这是 ArkTS 中常见的模式,因为 @Builder 定义的构建函数可以访问所属组件的 this 上下文。

modalOverlay 遮罩层构建器
@Builder modalOverlay(onClose: () => void) {
  Column()
    .width('100%').height('100%')
    .backgroundColor('rgba(0,0,0,0.55)')
    .onClick(onClose)
}

modalOverlay 是一个通用的遮罩层构建器,接收一个 onClose 回调函数作为参数。它创建一个全屏的半透明黑色遮罩(透明度 55%),点击遮罩时触发关闭回调。

这种设计体现了良好的复用性:所有弹窗都使用相同的遮罩层,只需要传入不同的关闭回调即可。遮罩层的透明度设置为 0.55,既能突出弹窗内容,又不会完全遮挡背景,视觉效果柔和。

addPeakModal 新增山峰弹窗
@Builder addPeakModal() {
  Stack() {
    this.modalOverlay(() => { this.showAddPeakModal = false })
    Column() {
      Row() {
        Text('🗻 新增山峰').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
        Column().layoutWeight(1)
        Text('✕').fontSize(18).fontColor(COLORS.textHint).onClick(() => { this.showAddPeakModal = false })
      }
      .width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
      Divider().color(COLORS.border)
      Column() {
        Text('山峰名称').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 12, left: 20 })
        TextInput({ placeholder: '如 玉龙雪山' })
          .placeholderColor(COLORS.textHint).fontSize(14).width('90%')
          .backgroundColor(COLORS.bg).borderRadius(8)
          .margin({ left: 20, right: 20, top: 4 })
          .onChange((v: string) => { this.peakName = v })
        Text('海拔(米)').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 14, left: 20 })
        TextInput({ placeholder: '如 5596' })
          .placeholderColor(COLORS.textHint).fontSize(14).width('90%')
          .backgroundColor(COLORS.bg).borderRadius(8)
          .margin({ left: 20, right: 20, top: 4 })
          .onChange((v: string) => { this.peakHeight = v })
        Row() {
          Text('取消').fontSize(14).fontColor(COLORS.textSecondary)
            .layoutWeight(1).textAlign(TextAlign.Center)
            .padding({ top: 10, bottom: 10 })
            .backgroundColor(COLORS.bg).borderRadius(8)
            .onClick(() => { this.showAddPeakModal = false })
          Column().width(12)
          Text('收藏').fontSize(14).fontColor(COLORS.white)
            .layoutWeight(1).textAlign(TextAlign.Center)
            .padding({ top: 10, bottom: 10 })
            .backgroundColor(COLORS.primary).borderRadius(8)
            .onClick(() => { this.showAddPeakModal = false })
        }
        .margin({ left: 20, right: 20, top: 20, bottom: 20 })
      }
      .constraintSize({ maxHeight: '70%' })
    }
    .width('88%')
    .backgroundColor(COLORS.cardBg).borderRadius(16)
  }
  .width('100%').height('100%')
}

新增山峰弹窗采用 Stack 堆叠布局,底层是半透明遮罩层,上层是弹窗内容卡片。弹窗宽度为屏幕的 88%,居中显示,圆角 16vp,背景为白色卡片色。

弹窗内容分为三个区域:

  1. 标题栏:左侧显示弹窗标题和图标,右侧是关闭按钮,中间用 Column().layoutWeight(1) 撑开空间
  2. 表单区:包含山峰名称和海拔两个输入框,每个输入框都有标签文字和 TextInput 组件
  3. 操作按钮区:取消和收藏两个按钮,左右并排,各占一半宽度

输入框的设计细节丰富:背景色使用浅灰色 COLORS.bg,圆角 8vp,占位文字使用提示色 COLORS.textHint。每个输入框通过 .onChange 回调实时更新对应的状态变量。

按钮区域采用 layoutWeight(1) 实现两个按钮的等宽分布,取消按钮使用灰色背景灰色文字的次要按钮样式,收藏按钮使用主色背景白色文字的主要按钮样式,形成明确的视觉层级。

等高线山脊头卡
Column() {
  Stack() {
    Column().width('100%').height(26).borderRadius(13)
      .backgroundColor('rgba(255,255,255,0.10)')
      .margin({ top: 6, left: 20, right: 20 })
    Column().width('88%').height(22).borderRadius(11)
      .backgroundColor('rgba(255,255,255,0.14)')
      .margin({ top: 26, left: 26, right: 26 })
    Column().width('76%').height(18).borderRadius(9)
      .backgroundColor('rgba(255,255,255,0.18)')
      .margin({ top: 46, left: 30, right: 30 })
    Column() {
      Row() {
        Column() {
          Text('🧗 当前海拔').fontSize(10).fontColor('#CFD8DC')
          Text('4,280').fontSize(32).fontWeight(FontWeight.Bold).fontColor(COLORS.white)
            .margin({ top: 2 })
          Text('米 · 大本营').fontSize(10).fontColor('#CFD8DC').margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Column() {
          Text('🌡️ -3°C').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.white)
          Text('体感 -8°C').fontSize(9).fontColor('#CFD8DC').margin({ top: 4 })
        }
        .alignItems(HorizontalAlign.End)
      }
      .width('100%')
      Row() {
        Text('⏱️ 上升 2.4h').fontSize(10).fontColor(COLORS.white)
          .padding({ left: 12, right: 12, top: 6, bottom: 6 })
          .backgroundColor('rgba(255,255,255,0.15)').borderRadius(12)
        Text('📏 坡度 32%').fontSize(10).fontColor(COLORS.white)
          .padding({ left: 12, right: 12, top: 6, bottom: 6 })
          .backgroundColor('rgba(255,255,255,0.15)').borderRadius(12)
          .margin({ left: 8 })
        Text('💨 风速 5级').fontSize(10).fontColor(COLORS.white)
          .padding({ left: 12, right: 12, top: 6, bottom: 6 })
          .backgroundColor('rgba(255,255,255,0.15)').borderRadius(12)
          .margin({ left: 8 })
      }
      .width('100%').margin({ top: 12 })
    }
    .width('100%')
    .padding({ left: 18, right: 18, top: 76, bottom: 20 })
  }
  .width('100%')
}
.width('100%')
.linearGradient({ angle: 135, colors: [[COLORS.primaryDark, 0.0], [COLORS.primary, 0.8], [COLORS.accent, 1.0]] })
.borderRadius({ bottomLeft: 28, bottomRight: 28 })

首页的头卡设计是整个应用的视觉亮点,采用了等高线山脊的创意设计。通过 Stack 堆叠三层不同宽度和高度的半透明白色圆角矩形,模拟出等高线的视觉效果。三层等高线从下到上宽度递减、透明度递增,营造出山脉的纵深感。

头卡内容区包含当前海拔大数字展示、温度信息和三个数据标签。海拔数字使用 32fp 的大号粗体白色字,极具视觉冲击力。三个数据标签(上升时间、坡度、风速)采用胶囊形状的半透明背景设计,与等高线风格统一。

头卡的背景使用三色线性渐变:从深岩灰到岩灰再到登山橙,角度 135 度,模拟日出时分山脊被阳光照亮的效果。底部使用大圆角(28vp)处理,形成流畅的曲线过渡。

周里程柱状图
Column() {
  Row() {
    Text('📊 本周里程(km)').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
    Column().layoutWeight(1)
    Text('合计 81 km').fontSize(11).fontColor(COLORS.primary).fontWeight(FontWeight.Bold)
  }
  .width('100%')
  Row() {
    ForEach(WEEK_HIKE, (v: WeekHikeMeta, i: number) => {
      Column() {
        Text(v.value + '').fontSize(9).fontColor(COLORS.textSecondary)
        Stack({ alignContent: Alignment.Bottom }) {
          Column().width(16).height(70).backgroundColor(COLORS.border).borderRadius(8)
          Column().width(16).height(Number(v.value) * 2.8).backgroundColor(
            i === 5 || i === 6 ? COLORS.accent : COLORS.primary)
            .borderRadius(8)
        }
        .width(16).height(70).margin({ top: 4 })
        Text(v.label).fontSize(9).fontColor(COLORS.textHint).margin({ top: 4 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
    }, (v: WeekHikeMeta, i: number) => v.label + i)
  }
  .width('100%').margin({ top: 12 })
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.cardBg).borderRadius(16)
.margin({ left: 14, right: 14, top: 14 })

周里程柱状图模块采用纯 CSS 方式实现,无需引入第三方图表库。核心实现思路是:

  1. 数据驱动:使用 ForEach 遍历 WEEK_HIKE 数组,动态生成七个柱子
  2. 堆叠柱状:每个柱子使用 Stack 堆叠两层 Column,底层是灰色背景柱(高度固定 70vp),上层是彩色数据柱(高度 = 值 × 2.8)
  3. 底部对齐:通过 Stack({ alignContent: Alignment.Bottom }) 确保数据柱从底部开始增长
  4. 周末高亮:判断索引是否为 5 或 6(周六、周日),周末使用登山橙色,工作日使用岩灰色,突出周末登山活动更多的特点

柱状图顶部显示数值,底部显示星期标签,形成完整的图表信息展示。标题栏右侧显示合计里程,方便用户快速了解总体情况。

热门山峰列表
Text('🗻 热门山峰').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
  .margin({ left: 16, top: 18 })
Column() {
  ForEach(PEAK_LIST.slice(0, 5), (p: PeakMeta) => {
    Row() {
      Text(p.emoji).fontSize(20)
      Column() {
        Text(p.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
        Text('海拔 ' + p.height + 'm · ' + p.level).fontSize(9).fontColor(COLORS.textSecondary)
          .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      .margin({ left: 10 })
      Text(p.level).fontSize(9).fontWeight(FontWeight.Bold)
        .fontColor(getLevelColor(p.level))
    }
    .width('100%')
    .padding({ top: 10, bottom: 10 })
    Divider().color(COLORS.border)
  }, (p: PeakMeta) => p.name)
}
.width('100%')
.padding({ left: 14, right: 14 })
.backgroundColor(COLORS.white).borderRadius(16)
.margin({ left: 14, right: 14, top: 10 })

热门山峰列表只展示前 5 座山峰(使用 PEAK_LIST.slice(0, 5)),作为首页的精选内容。每一行的布局采用经典的三列结构:左侧图标、中间文字信息、右侧难度标签。

中间文字区域使用 Column 垂直排列山峰名称和描述信息,.layoutWeight(1) 确保中间区域占据剩余空间,实现左右两端对齐的效果。

右侧难度标签使用 getLevelColor(p.level) 函数动态获取颜色,不同难度等级显示不同颜色:入门级为绿色、进阶级为黄色、挑战级为橙色、专业级为红色,形成直观的难度视觉标识。

路线难度横滑
Text('🧭 热门路线').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
  .margin({ left: 16, top: 18 })
Scroll() {
  Row() {
    ForEach(ROUTE_LIST, (r: RouteMeta) => {
      Column() {
        Text(r.emoji).fontSize(22)
        Text(r.name).fontSize(10).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
          .margin({ top: 6 }).maxLines(1)
        Text(r.level + ' · ' + r.distance + 'km').fontSize(8)
          .fontColor(getLevelColor(r.level)).margin({ top: 3 })
      }
      .width(92)
      .padding(10)
      .backgroundColor(COLORS.white).borderRadius(12)
      .margin({ right: 10 })
      .alignItems(HorizontalAlign.Center)
    }, (r: RouteMeta) => r.name)
  }
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.margin({ left: 14, top: 10 })

热门路线采用横向滚动的卡片布局,这是移动端展示同类物品的常用模式。使用 Scroll 组件包裹 Row,设置 .scrollable(ScrollDirection.Horizontal) 实现水平滚动,.scrollBar(BarState.Off) 隐藏滚动条,使界面更简洁。

每个路线卡片固定宽度 92vp,包含图标、名称和难度里程信息,居中对齐。卡片之间有 10vp 的间距,形成呼吸感。路线名称使用 .maxLines(1) 限制单行显示,避免文字溢出。

补给清单
Text('🎒 补给清单').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
  .margin({ left: 16, top: 18 })
Column() {
  ForEach(SUPPLY_LIST, (s: SupplyMeta) => {
    Row() {
      Text(s.emoji).fontSize(16)
      Column() {
        Text(s.name).fontSize(11).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
        Text(s.desc).fontSize(8).fontColor(COLORS.textHint).margin({ top: 2 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      .margin({ left: 8 })
      Text(s.isDone ? '已装包' : '待补充').fontSize(9)
        .fontColor(s.isDone ? COLORS.success : COLORS.warning)
    }
    .width('100%')
    .padding({ top: 9, bottom: 9 })
    Divider().color(COLORS.border)
  }, (s: SupplyMeta) => s.name)
}
.width('100%')
.padding({ left: 14, right: 14 })
.backgroundColor(COLORS.white).borderRadius(16)
.margin({ left: 14, right: 14, top: 10 })

补给清单位于首页下方,展示登山途中需要携带的食物和饮水补给。每行包含图标、名称描述和状态三部分。状态文字根据 isDone 字段动态切换:已装包显示绿色,待补充显示黄色,提醒用户及时准备。

描述文字使用 8fp 的小号提示色,提供补给品的补充说明,如"含电解质"、"高能补给"等,帮助用户了解每种补给的用途和价值。

8.2 山峰页面组件 HikingPeakContent

山峰页面是山峰图鉴的完整展示页面,包含头卡统计和全部山峰列表。

山峰头卡
Column() {
  Text('🗻 山峰图鉴').fontSize(11).fontColor('#CFD8DC')
  Text('已收藏 8/10 座').fontSize(24).fontWeight(FontWeight.Bold).fontColor(COLORS.white)
    .margin({ top: 4 })
  Text('已完成 6 座 · 待攀登 4 座').fontSize(10).fontColor('#CFD8DC').margin({ top: 6 })
}
.width('100%')
.padding(20)
.linearGradient({ angle: 135, colors: [[COLORS.primaryDark, 0.0], [COLORS.primary, 1.0]] })
.alignItems(HorizontalAlign.Center)
.borderRadius({ bottomLeft: 28, bottomRight: 28 })

山峰页面的头卡采用居中布局,展示山峰图鉴的统计信息:已收藏数量、已完成数量和待攀登数量。背景使用从深岩灰到岩灰的双色渐变,整体风格沉稳大气。

头卡的文字层次分明:小标题使用小号浅灰色,主数据使用大号白色粗体,辅助信息使用小号浅灰色。通过字号和颜色的差异构建清晰的视觉层级,让用户一眼就能抓住关键信息。

全部山峰列表
Text('🗻 全部山峰').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
  .margin({ left: 16, top: 18 })
Column() {
  ForEach(PEAK_LIST, (p: PeakMeta) => {
    Row() {
      Text(p.emoji).fontSize(20)
      Column() {
        Text(p.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
        Row() {
          Text(p.level).fontSize(9).fontColor(getLevelColor(p.level))
          if (p.isHot) {
            Text('热').fontSize(8).fontColor(COLORS.white)
              .padding({ left: 4, right: 4, top: 1, bottom: 1 })
              .backgroundColor(COLORS.danger).borderRadius(6)
              .margin({ left: 6 })
          }
        }
        .margin({ top: 4 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      .margin({ left: 10 })
      Column() {
        Text(formatHeight(p.height)).fontSize(12).fontWeight(FontWeight.Bold)
          .fontColor(p.height >= 7000 ? COLORS.primary : COLORS.accent)
        Text('m').fontSize(8).fontColor(COLORS.textHint).margin({ top: 2 })
      }
      .alignItems(HorizontalAlign.End)
    }
    .width('100%')
    .padding({ top: 11, bottom: 11 })
    Divider().color(COLORS.border)
  }, (p: PeakMeta) => p.name)
}
.width('100%')
.padding({ left: 14, right: 14 })
.backgroundColor(COLORS.white).borderRadius(16)
.margin({ left: 14, right: 14, top: 10 })

全部山峰列表展示了完整的 10 座山峰数据。相比首页的热门山峰列表,这里的信息更丰富:

  1. 难度 + 热门标签:山峰下方显示难度等级和热门标签(如果 isHot 为 true)。热门标签使用红色背景白色文字的圆角小标签,醒目且不占空间。

  2. 海拔高度格式化:右侧海拔使用 formatHeight 函数格式化,7000 米以上的山峰以公里为单位显示(如 7.546),7000 米以下以米为单位显示。颜色方面,7000 米以上使用主色岩灰,7000 米以下使用登山橙,通过颜色区分不同海拔级别。

  3. 海拔单位:数字下方单独显示 “m” 单位,使用小号提示色,保持整体的简洁。

8.3 路线页面组件 HikingRouteContent

路线页面展示精选登山路线,每条路线包含名称、难度等级、里程等信息。

路线头卡
Column() {
  Text('🧭 经典路线').fontSize(11).fontColor('#CFD8DC')
  Text('8 条精选路线').fontSize(24).fontWeight(FontWeight.Bold).fontColor(COLORS.white)
    .margin({ top: 4 })
  Text('含等高线图 · 路书 · 危险路段标注').fontSize(10).fontColor('#CFD8DC').margin({ top: 6 })
}
.width('100%')
.padding(20)
.linearGradient({ angle: 135, colors: [[COLORS.primary, 0.0], [COLORS.accent, 1.0]] })
.alignItems(HorizontalAlign.Center)
.borderRadius({ bottomLeft: 28, bottomRight: 28 })

路线页面的头卡使用从岩灰到登山橙的渐变,与首页头卡的配色方案呼应但渐变方向不同,形成差异化的视觉体验。头卡文字突出"8 条精选路线",并补充说明路线包含等高线图、路书和危险路段标注等专业信息。

路线列表
Text('🧭 全部路线').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
  .margin({ left: 16, top: 18 })
Column() {
  ForEach(ROUTE_LIST, (r: RouteMeta) => {
    Row() {
      Text(r.emoji).fontSize(20)
      Column() {
        Text(r.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
        Row() {
          Text(r.level + '难度').fontSize(9).fontColor(getLevelColor(r.level))
          if (r.isNew) {
            Text('新').fontSize(8).fontColor(COLORS.white)
              .padding({ left: 4, right: 4, top: 1, bottom: 1 })
              .backgroundColor(COLORS.success).borderRadius(6)
              .margin({ left: 6 })
          }
        }
        .margin({ top: 4 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      .margin({ left: 10 })
      Text(r.distance + ' km').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.primary)
    }
    .width('100%')
    .padding({ top: 11, bottom: 11 })
    Divider().color(COLORS.border)
  }, (r: RouteMeta) => r.name)
}
.width('100%')
.padding({ left: 14, right: 14 })
.backgroundColor(COLORS.white).borderRadius(16)
.margin({ left: 14, right: 14, top: 10 })

路线列表与山峰列表的布局结构类似,但右侧显示的是里程信息(如 “21.8 km”),使用主色岩灰色加粗显示,突出路线的距离数据。新路线使用绿色的"新"标签,与山峰的红色"热"标签形成区分,避免视觉混淆。

8.4 装备页面组件 HikingGearContent

装备页面帮助用户管理登山装备,追踪打包进度。

装备头卡
Column() {
  Row() {
    Column() {
      Text('🎒 装备清单').fontSize(11).fontColor('#CFD8DC')
      Text('打包完成 8/12 件').fontSize(24).fontWeight(FontWeight.Bold).fontColor(COLORS.white)
        .margin({ top: 4 })
      Text('总重 11.9 kg · 含备用品').fontSize(10).fontColor('#CFD8DC').margin({ top: 6 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    Column() {
      Text('11.9').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.white)
      Text('kg 总重').fontSize(9).fontColor('#CFD8DC').margin({ top: 2 })
    }
    .alignItems(HorizontalAlign.End)
  }
  .width('100%')
}
.width('100%')
.padding(18)
.linearGradient({ angle: 135, colors: [[COLORS.primaryDark, 0.0], [COLORS.slate, 1.0]] })
.borderRadius({ bottomLeft: 28, bottomRight: 28 })

装备页面的头卡采用左右布局,左侧显示打包进度信息(8/12 件)和总重量,右侧突出显示总重量的大数字。这种设计让用户一眼就能看到装备的总重量,这是登山活动中非常重要的参数。

背景渐变使用深岩灰到石板灰的组合,色调偏冷偏暗,传达出装备的专业感和重量感。

装备列表
Text('🎒 全部装备').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
  .margin({ left: 16, top: 18 })
Column() {
  ForEach(GEAR_LIST, (g: GearMeta) => {
    Row() {
      Text(g.emoji).fontSize(20)
      Column() {
        Text(g.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
        Row() {
          Text(g.weight + ' kg').fontSize(9).fontColor(COLORS.textSecondary)
          if (g.isNew) {
            Text('新').fontSize(8).fontColor(COLORS.white)
              .padding({ left: 4, right: 4, top: 1, bottom: 1 })
              .backgroundColor(COLORS.success).borderRadius(6)
              .margin({ left: 6 })
          }
        }
        .margin({ top: 4 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      .margin({ left: 10 })
      Column() {
        Text(g.isDone ? '已打包' : '待打包').fontSize(9)
          .fontColor(g.isDone ? COLORS.success : COLORS.warning)
        Text('编辑').fontSize(9).fontColor(COLORS.primary).margin({ top: 6 })
          .onClick(() => { this.showEditGearModal = true })
      }
      .alignItems(HorizontalAlign.End)
    }
    .width('100%')
    .padding({ top: 11, bottom: 11 })
    Divider().color(COLORS.border)
  }, (g: GearMeta) => g.name)
}
.width('100%')
.padding({ left: 14, right: 14 })
.backgroundColor(COLORS.white).borderRadius(16)
.margin({ left: 14, right: 14, top: 10, bottom: 20 })

装备列表的右侧区域包含两行信息:状态(已打包/待打包)和编辑按钮。状态文字用颜色区分打包进度,编辑按钮提供进入编辑弹窗的入口。点击"编辑"文字时,设置 showEditGearModal = true 来显示编辑装备弹窗。

装备的重量信息显示在名称下方,使用小号灰色文字,让用户了解每件装备的重量贡献。新品标记为绿色"新"标签,引导用户关注新添加的装备。

8.5 我的页面组件 HikingMineContent

我的页面是攀登者的个人中心,展示攀登档案、徽章收藏和攀登记录。

攀登者档案头卡
Column() {
  Row() {
    Column() {
      Text('⛰️ 攀登者档案').fontSize(11).fontColor('#CFD8DC')
      Text('山鹰 · LV5').fontSize(22).fontWeight(FontWeight.Bold).fontColor(COLORS.white)
        .margin({ top: 4 })
      Text('累计攀登 128 km · 上升 9,860m').fontSize(10).fontColor('#CFD8DC').margin({ top: 6 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    Column() {
      Text('6').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.white)
      Text('登顶座数').fontSize(9).fontColor('#CFD8DC').margin({ top: 2 })
    }
    .alignItems(HorizontalAlign.End)
  }
  .width('100%')
  Row() {
    Text('🏅 徽章 8 枚').fontSize(10).fontColor(COLORS.white)
      .padding({ left: 12, right: 12, top: 6, bottom: 6 })
      .backgroundColor('rgba(255,255,255,0.15)').borderRadius(12)
    Text('🧭 路线 12 条').fontSize(10).fontColor(COLORS.white)
      .padding({ left: 12, right: 12, top: 6, bottom: 6 })
      .backgroundColor('rgba(255,255,255,0.15)').borderRadius(12)
      .margin({ left: 8 })
    Text('📷 照片 356 张').fontSize(10).fontColor(COLORS.white)
      .padding({ left: 12, right: 12, top: 6, bottom: 6 })
      .backgroundColor('rgba(255,255,255,0.15)').borderRadius(12)
      .margin({ left: 8 })
  }
  .width('100%').margin({ top: 14 })
}
.width('100%')
.padding(18)
.linearGradient({ angle: 135, colors: [[COLORS.primaryDark, 0.0], [COLORS.primaryLight, 1.0]] })
.borderRadius({ bottomLeft: 28, bottomRight: 28 })

我的页面头卡信息最为丰富,分为上下两部分。上部是攀登者基本信息:昵称(山鹰 · LV5)、累计攀登里程和上升高度、登顶座数。下部是三个数据标签:徽章数量、路线数量、照片数量。

数据标签采用半透明白色背景的胶囊形状,与首页头卡的数据标签风格一致,保持了设计语言的统一性。背景使用从深岩灰到浅岩灰的渐变,营造出明亮、积极的氛围。

山峰徽章横滑
Text('🏅 我的徽章').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
  .margin({ left: 16, top: 18 })
Scroll() {
  Row() {
    ForEach(BADGE_LIST, (b: BadgeMeta) => {
      Column() {
        Text(b.emoji).fontSize(22)
        Text(b.name).fontSize(9).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
          .margin({ top: 6 })
        Text(b.desc).fontSize(8).fontColor(COLORS.textHint).margin({ top: 2 })
      }
      .width(84)
      .padding(10)
      .backgroundColor(COLORS.white).borderRadius(12)
      .margin({ right: 10 })
      .alignItems(HorizontalAlign.Center)
    }, (b: BadgeMeta) => b.name)
  }
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.margin({ left: 14, top: 10 })

徽章墙采用横向滚动的卡片布局,每个徽章卡片宽度 84vp,包含徽章图标、名称和描述。徽章是游戏化设计的重要元素,通过成就系统激励用户持续使用应用。新获得的徽章可以通过 isNew 字段标记,在UI上突出显示。

攀登记录列表
Text('🗻 攀登记录').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
  .margin({ left: 16, top: 18 })
Column() {
  ForEach(RECORD_LIST, (r: RecordMeta) => {
    Row() {
      Text(r.emoji).fontSize(20)
      Column() {
        Text(r.name).fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
        Text(r.date + ' · ' + r.distance + ' km · ' + r.time).fontSize(9)
          .fontColor(COLORS.textSecondary).margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      .margin({ left: 10 })
      Column() {
        Text(r.isDone ? '登顶✓' : '未完赛').fontSize(9)
          .fontColor(r.isDone ? COLORS.success : COLORS.warning)
        Text('删除').fontSize(9).fontColor(COLORS.danger).margin({ top: 6 })
          .onClick(() => { this.showDeleteRecordModal = true })
      }
      .alignItems(HorizontalAlign.End)
    }
    .width('100%')
    .padding({ top: 11, bottom: 11 })
    Divider().color(COLORS.border)
  }, (r: RecordMeta) => r.name + r.date)
}
.width('100%')
.padding({ left: 14, right: 14 })
.backgroundColor(COLORS.white).borderRadius(16)
.margin({ left: 14, right: 14, top: 10, bottom: 20 })

攀登记录列表展示用户的历史攀登活动,每条记录包含名称、日期、距离和用时。右侧显示登顶状态和删除按钮。登顶成功的记录显示绿色的"登顶✓",未完赛的显示黄色的"未完赛"。

删除按钮使用红色文字,点击后弹出删除确认弹窗。删除操作属于危险操作,需要二次确认,这是良好的UX设计实践。


九、弹窗组件详解

9.1 modalOverlay 的实现原理

应用中所有弹窗都使用了统一的 modalOverlay 遮罩层构建器,这是一种典型的组合模式。每个页面组件内部都定义了自己的 modalOverlay 方法,虽然代码重复,但保证了每个弹窗的独立性和封装性。

遮罩层 弹窗组件 页面组件 用户 遮罩层 弹窗组件 页面组件 用户 点击触发按钮 showXxxModal = true 渲染弹窗(条件渲染) 渲染遮罩层 显示半透明黑色遮罩 显示弹窗内容 点击遮罩 触发 onClose 回调 showXxxModal = false 移除弹窗 弹窗消失

弹窗的显示逻辑使用条件渲染 if (this.showXxxModal) { this.xxxModal() },当状态变量为 true 时渲染弹窗,为 false 时不渲染。这种方式简单直接,易于理解和维护。

9.2 新增/编辑类弹窗设计模式

新增山峰、编辑山峰、新增路线、编辑装备等弹窗都遵循相同的设计模式:

  1. Stack 堆叠布局:底层遮罩 + 上层内容卡片
  2. 标题栏:左侧标题 + 右侧关闭按钮
  3. 分隔线:标题栏与表单区之间的分隔线
  4. 表单区:标签 + 输入框的组合,支持多个输入字段
  5. 操作按钮:取消 + 确认/保存的双按钮布局

以编辑装备弹窗为例:

@Builder editGearModal() {
  Stack() {
    this.modalOverlay(() => { this.showEditGearModal = false })
    Column() {
      // 标题栏
      Row() {
        Text('🎒 编辑装备').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
        Column().layoutWeight(1)
        Text('✕').fontSize(18).fontColor(COLORS.textHint).onClick(() => { this.showEditGearModal = false })
      }
      .width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
      Divider().color(COLORS.border)
      // 表单区
      Column() {
        Text('装备名称').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 12, left: 20 })
        TextInput({ placeholder: '如 高山靴' })
          .placeholderColor(COLORS.textHint).fontSize(14).width('90%')
          .backgroundColor(COLORS.bg).borderRadius(8)
          .margin({ left: 20, right: 20, top: 4 })
          .onChange((v: string) => { this.gearName = v })
        Text('重量(kg)').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 14, left: 20 })
        TextInput({ placeholder: '如 1.8' })
          .placeholderColor(COLORS.textHint).fontSize(14).width('90%')
          .backgroundColor(COLORS.bg).borderRadius(8)
          .margin({ left: 20, right: 20, top: 4 })
          .onChange((v: string) => { this.gearWeight = v })
        // 操作按钮
        Row() {
          Text('取消').fontSize(14).fontColor(COLORS.textSecondary)
            .layoutWeight(1).textAlign(TextAlign.Center)
            .padding({ top: 10, bottom: 10 })
            .backgroundColor(COLORS.bg).borderRadius(8)
            .onClick(() => { this.showEditGearModal = false })
          Column().width(12)
          Text('保存').fontSize(14).fontColor(COLORS.white)
            .layoutWeight(1).textAlign(TextAlign.Center)
            .padding({ top: 10, bottom: 10 })
            .backgroundColor(COLORS.primary).borderRadius(8)
            .onClick(() => { this.showEditGearModal = false })
        }
        .margin({ left: 20, right: 20, top: 20, bottom: 20 })
      }
      .constraintSize({ maxHeight: '70%' })
    }
    .width('88%')
    .backgroundColor(COLORS.cardBg).borderRadius(16)
  }
  .width('100%').height('100%')
}

表单区使用 .constraintSize({ maxHeight: '70%' }) 限制最大高度为屏幕的 70%,防止内容过多时弹窗超出屏幕范围。这是一个重要的边界处理,确保在各种屏幕尺寸下都有良好的显示效果。

9.3 删除确认弹窗设计模式

删除记录弹窗是另一种弹窗类型——确认对话框。它的设计更加简洁,重点在于提示用户确认危险操作。

@Builder deleteRecordModal() {
  Stack() {
    this.modalOverlay(() => { this.showDeleteRecordModal = false })
    Column() {
      Text('🏔️ 删除攀登记录').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
        .margin({ top: 24 })
      Text('删除后将无法恢复该次攀登数据,确定删除吗?')
        .fontSize(13).fontColor(COLORS.textSecondary).margin({ top: 10 })
      Row() {
        Text('取消').fontSize(14).fontColor(COLORS.textSecondary)
          .layoutWeight(1).textAlign(TextAlign.Center)
          .padding({ top: 10, bottom: 10 })
          .backgroundColor(COLORS.bg).borderRadius(8)
          .onClick(() => { this.showDeleteRecordModal = false })
        Column().width(12)
        Text('确认删除').fontSize(14).fontColor(COLORS.white)
          .layoutWeight(1).textAlign(TextAlign.Center)
          .padding({ top: 10, bottom: 10 })
          .backgroundColor(COLORS.danger).borderRadius(8)
          .onClick(() => { this.showDeleteRecordModal = false })
      }
      .width('100%')
      .margin({ left: 20, right: 20, top: 20, bottom: 20 })
    }
    .width('82%')
    .backgroundColor(COLORS.cardBg).borderRadius(16)
  }
  .width('100%').height('100%')
}

删除确认弹窗的特点:

  • 宽度更小:82% 屏幕宽度,比表单弹窗的 88% 更窄,突出警示感
  • 无标题栏:没有关闭按钮和分隔线,布局更简洁
  • 居中文字:提示文字居中对齐,强调确认信息
  • 危险按钮:确认按钮使用红色(danger)背景,警示用户这是危险操作
  • 明确提示:文案明确说明"删除后将无法恢复",让用户清楚操作后果

十、核心UI组件分析

10.1 卡片组件设计

应用中大量使用卡片式布局,每个功能模块都是一个独立的卡片。卡片设计遵循统一的规范:

  • 圆角:16vp 圆角,圆润柔和
  • 背景:纯白 COLORS.cardBg
  • 内边距:16vp 或 14vp,保持内容与边框的舒适距离
  • 外边距:左右 14vp,顶部 10-14vp,卡片之间留有空隙
  • 阴影:头卡和底部导航有阴影,普通列表卡片通过背景色和间距区分层次

这种卡片化设计的优势:

  • 模块清晰:每个卡片代表一个功能模块,信息组织有序
  • 视觉舒适:留白充足,不会让用户感到信息过载
  • 易于扩展:新增功能模块只需添加新的卡片即可

10.2 列表组件实现

列表是应用中最常见的组件形式,山峰列表、装备列表、记录列表、路线列表、补给清单等都是列表的不同表现形式。

列表的统一实现模式:

  1. 使用 Column 作为列表容器
  2. 使用 ForEach 遍历数据数组生成列表项
  3. 每个列表项使用 Row 水平布局
  4. 列表项之间使用 Divider 分隔线
  5. 提供 key 函数(第二个回调参数)优化渲染性能
ForEach(PEAK_LIST, (p: PeakMeta) => {
  // 列表项内容
}, (p: PeakMeta) => p.name)  // key 函数

ForEach 的第三个参数是 key 生成函数,用于为每个列表项生成唯一标识。ArkUI 框架使用 key 来判断哪些元素需要重新渲染,从而优化列表性能。使用业务数据的唯一标识(如山峰名称)作为 key 是最佳实践。

10.3 柱状图组件实现

周里程柱状图是应用中最具技术含量的 UI 组件之一,完全使用声明式 UI 语法实现,无需引入第三方图表库。

核心实现要点:

数据数组 WEEK_HIKE

ForEach 遍历

每个数据项生成一个柱子

Stack 堆叠两层 Column

底层: 灰色背景柱 固定高度

上层: 彩色数据柱 高度=数值*系数

底部对齐 alignContent: Bottom

周末高亮: 判断索引改变颜色

柱状图的高度计算使用简单的线性映射:height = value * 2.8。这个系数 2.8 是根据设计稿的视觉效果调整得出的,确保数据柱在背景柱内有合适的比例。

周末高亮的实现通过判断索引 i === 5 || i === 6 来完成,周六和周日使用登山橙色,工作日使用岩灰色。这种视觉差异化既美观又实用,让用户一眼就能看出周末的活动量。

10.4 横向滚动组件

热门路线、徽章墙等模块使用了横向滚动的卡片列表。实现方式是 Scroll 组件包裹 Row 组件:

Scroll() {
  Row() {
    ForEach(ROUTE_LIST, (r: RouteMeta) => {
      // 卡片内容
    })
  }
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)

关键属性:

  • .scrollable(ScrollDirection.Horizontal):设置滚动方向为水平
  • .scrollBar(BarState.Off):隐藏滚动条,保持界面简洁

横向滚动是移动端非常常见的交互模式,适合展示同类项目的集合,既节省垂直空间,又符合用户的滑动操作习惯。


十一、技术亮点总结表格

功能模块 技术实现方式 设计模式 代码量估计 复用性
底部Tab导航 @State + 枚举 + @Builder 状态驱动渲染 约60行
页面切换 条件渲染 if-else 策略模式 约20行
弹窗系统 Stack遮罩 + 条件渲染 组合模式 约80行/个
柱状图 Stack堆叠 + ForEach + 动态高度 数据驱动视图 约40行
列表组件 Column + ForEach + Divider 迭代器模式 约30行/个
横向滚动 Scroll + Row + ForEach 滚动容器模式 约25行/个
颜色系统 interface + const对象 配置驱动 约30行
数据模型 TypeScript interface 契约模式 约80行
难度配色 getLevelColor函数 策略函数模式 约10行
头卡渐变 linearGradient + Stack等高线 视觉分层 约60行/个
徽章系统 横向滚动卡片 + isNew标记 游戏化设计 约30行
删除确认 模态对话框 + 危险按钮 二次确认模式 约40行

安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// ============================================================
// 528.ets 登山户外 APP — HikingApp
// 主色调: 岩灰 #546E7A + 登山橙 #FF6D00
// 布局: 等高线山脊头卡(海拔大数字) + 周里程柱状图 + 山峰列表
//       + 装备清单 + 路线难度横滑 + 补给站
//       + 我的记录 + 山峰徽章 + 天气服务
// 弹窗: 新增山峰 / 编辑装备 / 删除记录 (全部 modalOverlay)
// ============================================================

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

interface TabMeta {
  title: string
  icon: string
}

interface PeakMeta {
  name: string
  emoji: string
  height: number
  level: string
  isHot: boolean
}

interface GearMeta {
  name: string
  emoji: string
  weight: number
  isDone: boolean
  isNew: boolean
}

interface RecordMeta {
  name: string
  emoji: string
  date: string
  distance: number
  time: string
  isDone: boolean
}

interface RouteMeta {
  name: string
  emoji: string
  level: string
  distance: number
  isNew: boolean
}

interface WeekHikeMeta {
  label: string
  value: number
}

interface SupplyMeta {
  name: string
  emoji: string
  desc: string
  isDone: boolean
}

interface BadgeMeta {
  name: string
  emoji: string
  desc: string
  isNew: boolean
}

const COLORS: HikingColorPalette = {
  primary: '#546E7A',
  primaryLight: '#90A4AE',
  primaryDark: '#37474F',
  accent: '#FF6D00',
  accentLight: '#FFE0B2',
  slate: '#78909C',
  bg: '#F2F4F5',
  cardBg: '#FFFFFF',
  textPrimary: '#263238',
  textSecondary: '#5D6B73',
  textHint: '#A3AFB5',
  border: '#E3E8EB',
  success: '#43A047',
  warning: '#FFA000',
  danger: '#E53935',
  white: '#FFFFFF'
}

const TAB_CONFIG: Record<string, TabMeta> = {
  'HOME': { title: '首页', icon: '⛰️' },
  'PEAK': { title: '山峰', icon: '🗻' },
  'ROUTE': { title: '路线', icon: '🧭' },
  'GEAR': { title: '装备', icon: '🎒' },
  'MINE': { title: '我的', icon: '👤' }
}

const PEAK_LIST: PeakMeta[] = [
  { name: '玉龙雪山', emoji: '🏔️', height: 5596, level: '入门级', isHot: true },
  { name: '四姑娘山·大峰', emoji: '🗻', height: 5025, level: '入门级', isHot: true },
  { name: '哈巴雪山', emoji: '❄️', height: 5396, level: '进阶级', isHot: true },
  { name: '四姑娘山·二峰', emoji: '⛰️', height: 5276, level: '进阶级', isHot: true },
  { name: '那玛峰', emoji: '🌄', height: 5588, level: '挑战级', isHot: false },
  { name: '玉珠峰', emoji: '🌨️', height: 6178, level: '挑战级', isHot: false },
  { name: '雀儿山', emoji: '🦅', height: 6168, level: '专业级', isHot: false },
  { name: '慕士塔格峰', emoji: '🏔️', height: 7546, level: '专业级', isHot: false },
  { name: '博格达峰', emoji: '🧗', height: 5445, level: '进阶级', isHot: false },
  { name: '雪宝顶', emoji: '❄️', height: 5588, level: '挑战级', isHot: false }
]

const GEAR_LIST: GearMeta[] = [
  { name: '登山背包 50L', emoji: '🎒', weight: 2.1, isDone: true, isNew: false },
  { name: '冲锋衣(三合一)', emoji: '🧥', weight: 1.2, isDone: true, isNew: false },
  { name: '高山靴', emoji: '🥾', weight: 1.8, isDone: true, isNew: true },
  { name: '冰镐', emoji: '⛏️', weight: 0.9, isDone: true, isNew: false },
  { name: '冰爪', emoji: '🦶', weight: 1.0, isDone: true, isNew: false },
  { name: '安全头盔', emoji: '🪖', weight: 0.6, isDone: true, isNew: false },
  { name: '头灯', emoji: '🔦', weight: 0.2, isDone: true, isNew: false },
  { name: '登山杖×2', emoji: '🥢', weight: 0.6, isDone: true, isNew: false },
  { name: '睡袋(-20℃)', emoji: '🛌', weight: 1.8, isDone: false, isNew: true },
  { name: '高山帐篷', emoji: '⛺', weight: 2.8, isDone: false, isNew: false },
  { name: '炊具套装', emoji: '🍳', weight: 1.4, isDone: false, isNew: false },
  { name: '急救包', emoji: '🩹', weight: 0.5, isDone: false, isNew: true }
]

const RECORD_LIST: RecordMeta[] = [
  { name: '玉龙雪山登顶', emoji: '🏔️', date: '08-10', distance: 18.6, time: '9h32m', isDone: true },
  { name: '四姑娘山大峰', emoji: '🗻', date: '07-22', distance: 15.2, time: '8h15m', isDone: true },
  { name: '哈巴雪山登顶', emoji: '❄️', date: '06-30', distance: 21.8, time: '11h40m', isDone: true },
  { name: '四姑娘山二峰', emoji: '⛰️', date: '05-18', distance: 16.9, time: '9h05m', isDone: true },
  { name: '那玛峰拉练', emoji: '🌄', date: '04-26', distance: 12.4, time: '6h50m', isDone: true },
  { name: '玉珠峰适应', emoji: '🌨️', date: '04-02', distance: 8.6, time: '4h20m', isDone: true },
  { name: '雀儿山尝试', emoji: '🦅', date: '03-14', distance: 14.2, time: '7h35m', isDone: true },
  { name: '博格达徒步', emoji: '🧗', date: '02-20', distance: 26.5, time: '13h10m', isDone: false },
  { name: '雪宝顶拉练', emoji: '❄️', date: '01-28', distance: 10.8, time: '5h25m', isDone: false },
  { name: '慕士塔格C1', emoji: '🏔️', date: '01-05', distance: 6.2, time: '3h40m', isDone: false }
]

const ROUTE_LIST: RouteMeta[] = [
  { name: '大峰传统路线', emoji: '🥾', level: '入门', distance: 15.2, isNew: true },
  { name: '哈巴北坡路线', emoji: '⛏️', level: '进阶', distance: 21.8, isNew: true },
  { name: '二峰东南坡', emoji: '🧗', level: '进阶', distance: 16.9, isNew: false },
  { name: '那玛峰西线', emoji: '🌄', level: '挑战', distance: 19.5, isNew: false },
  { name: '玉珠峰南坡', emoji: '🌨️', level: '挑战', distance: 12.8, isNew: false },
  { name: '雀儿山新路线', emoji: '🦅', level: '专业', distance: 24.6, isNew: true },
  { name: '慕士塔格传统', emoji: '🏔️', level: '专业', distance: 32.4, isNew: false },
  { name: '博格达环线', emoji: '🧭', level: '挑战', distance: 48.0, isNew: false }
]

const WEEK_HIKE: WeekHikeMeta[] = [
  { label: '周一', value: 5 },
  { label: '周二', value: 8 },
  { label: '周三', value: 6 },
  { label: '周四', value: 12 },
  { label: '周五', value: 10 },
  { label: '周六', value: 22 },
  { label: '周日', value: 18 }
]

const SUPPLY_LIST: SupplyMeta[] = [
  { name: '饮用水 3L', emoji: '💧', desc: '含电解质', isDone: true },
  { name: '能量胶×6', emoji: '⚡', desc: '高能补给', isDone: true },
  { name: '压缩饼干', emoji: '🍪', desc: '高热主食', isDone: true },
  { name: '坚果混合包', emoji: '🥜', desc: '蛋白补充', isDone: true },
  { name: '保温水壶', emoji: '🫖', desc: '热饮备好', isDone: false },
  { name: '巧克力×4', emoji: '🍫', desc: '快速供能', isDone: false }
]

const BADGE_LIST: BadgeMeta[] = [
  { name: '首座雪山', emoji: '🏔️', desc: '登顶第一座', isNew: true },
  { name: '5000米征服者', emoji: '🗻', desc: '海拔5000+', isNew: true },
  { name: '夜攀勇士', emoji: '🌙', desc: '夜间冲顶', isNew: true },
  { name: '重装达人', emoji: '🎒', desc: '负重15kg+', isNew: false },
  { name: '四季攀登者', emoji: '❄️', desc: '四季登顶', isNew: false },
  { name: '路线开拓者', emoji: '🧭', desc: '新路线首登', isNew: false },
  { name: '高山协作', emoji: '🤝', desc: '协助队员10次', isNew: false },
  { name: '无氧登顶', emoji: '🫁', desc: '8000米无氧', isNew: false }
]

function getLevelColor(level: string): string {
  if (level === '入门' || level === '入门级') {
    return COLORS.success
  } else if (level === '进阶' || level === '进阶级') {
    return COLORS.warning
  } else if (level === '挑战' || level === '挑战级') {
    return COLORS.accent
  }
  return COLORS.danger
}

function formatHeight(height: number): string {
  if (height >= 7000) {
    return (height / 1000) + ''
  }
  return height + ''
}

enum HikingTab {
  HOME,
  PEAK,
  ROUTE,
  GEAR,
  MINE
}

@Entry
@Component
struct HikingApp {
  @State activeTab: HikingTab = HikingTab.HOME

  @Builder contentArea() {
    Column() {
      if (this.activeTab === HikingTab.HOME) {
        HikingHomeContent()
      } else if (this.activeTab === HikingTab.PEAK) {
        HikingPeakContent()
      } else if (this.activeTab === HikingTab.ROUTE) {
        HikingRouteContent()
      } else if (this.activeTab === HikingTab.GEAR) {
        HikingGearContent()
      } else if (this.activeTab === HikingTab.MINE) {
        HikingMineContent()
      }
    }
    .width('100%')
  }

  @Builder tabItem(icon: string, label: string, tab: HikingTab) {
    Column() {
      Text(icon).fontSize(20)
      Text(label).fontSize(10)
        .fontColor(this.activeTab === tab ? COLORS.primary : COLORS.textHint)
        .fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
        .margin({ top: 2 })
    }
    .layoutWeight(1)
    .onClick(() => { this.activeTab = tab })
  }

  build() {
    Column() {
      this.contentArea()
      Row() {
        this.tabItem(TAB_CONFIG.HOME.icon, TAB_CONFIG.HOME.title, HikingTab.HOME)
        this.tabItem(TAB_CONFIG.PEAK.icon, TAB_CONFIG.PEAK.title, HikingTab.PEAK)
        this.tabItem(TAB_CONFIG.ROUTE.icon, TAB_CONFIG.ROUTE.title, HikingTab.ROUTE)
        this.tabItem(TAB_CONFIG.GEAR.icon, TAB_CONFIG.GEAR.title, HikingTab.GEAR)
        this.tabItem(TAB_CONFIG.MINE.icon, TAB_CONFIG.MINE.title, HikingTab.MINE)
      }
      .width('100%')
      .padding({ top: 8, bottom: 8 })
      .backgroundColor(COLORS.white)
      .shadow({ radius: 10, color: '#26323826', offsetY: -2 })
    }
    .width('100%').height('100%')
    .backgroundColor(COLORS.bg)
  }
}

@Component
struct HikingHomeContent {
  @State showAddPeakModal: boolean = false
  @State peakName: string = ''
  @State peakHeight: string = ''

  @Builder modalOverlay(onClose: () => void) {
    Column()
      .width('100%').height('100%')
      .backgroundColor('rgba(0,0,0,0.55)')
      .onClick(onClose)
  }

  @Builder addPeakModal() {
    Stack() {
      this.modalOverlay(() => { this.showAddPeakModal = false })
      Column() {
        Row() {
          Text('🗻 新增山峰').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
          Column().layoutWeight(1)
          Text('✕').fontSize(18).fontColor(COLORS.textHint).onClick(() => { this.showAddPeakModal = false })
        }
        .width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
        Divider().color(COLORS.border)
        Column() {
          Text('山峰名称').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 12, left: 20 })
          TextInput({ placeholder: '如 玉龙雪山' })
            .placeholderColor(COLORS.textHint).fontSize(14).width('90%')
            .backgroundColor(COLORS.bg).borderRadius(8)
            .margin({ left: 20, right: 20, top: 4 })
            .onChange((v: string) => { this.peakName = v })
          Text('海拔(米)').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 14, left: 20 })
          TextInput({ placeholder: '如 5596' })
            .placeholderColor(COLORS.textHint).fontSize(14).width('90%')
            .backgroundColor(COLORS.bg).borderRadius(8)
            .margin({ left: 20, right: 20, top: 4 })
            .onChange((v: string) => { this.peakHeight = v })
          Row() {
            Text('取消').fontSize(14).fontColor(COLORS.textSecondary)
              .layoutWeight(1).textAlign(TextAlign.Center)
              .padding({ top: 10, bottom: 10 })
              .backgroundColor(COLORS.bg).borderRadius(8)
              .onClick(() => { this.showAddPeakModal = false })
            Column().width(12)
            Text('收藏').fontSize(14).fontColor(COLORS.white)
              .layoutWeight(1).textAlign(TextAlign.Center)
              .padding({ top: 10, bottom: 10 })
              .backgroundColor(COLORS.primary).borderRadius(8)
              .onClick(() => { this.showAddPeakModal = false })
          }
          .margin({ left: 20, right: 20, top: 20, bottom: 20 })
        }
        .constraintSize({ maxHeight: '70%' })
      }
      .width('88%')
      .backgroundColor(COLORS.cardBg).borderRadius(16)
    }
    .width('100%').height('100%')
  }

  build() {
    Scroll() {
      Column() {
        // 等高线山脊头卡(海拔大数字)
        Column() {
          Stack() {
            Column().width('100%').height(26).borderRadius(13)
              .backgroundColor('rgba(255,255,255,0.10)')
              .margin({ top: 6, left: 20, right: 20 })
            Column().width('88%').height(22).borderRadius(11)
              .backgroundColor('rgba(255,255,255,0.14)')
              .margin({ top: 26, left: 26, right: 26 })
            Column().width('76%').height(18).borderRadius(9)
              .backgroundColor('rgba(255,255,255,0.18)')
              .margin({ top: 46, left: 30, right: 30 })
            Column() {
              Row() {
                Column() {
                  Text('🧗 当前海拔').fontSize(10).fontColor('#CFD8DC')
                  Text('4,280').fontSize(32).fontWeight(FontWeight.Bold).fontColor(COLORS.white)
                    .margin({ top: 2 })
                  Text('米 · 大本营').fontSize(10).fontColor('#CFD8DC').margin({ top: 2 })
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)
                Column() {
                  Text('🌡️ -3°C').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.white)
                  Text('体感 -8°C').fontSize(9).fontColor('#CFD8DC').margin({ top: 4 })
                }
                .alignItems(HorizontalAlign.End)
              }
              .width('100%')
              Row() {
                Text('⏱️ 上升 2.4h').fontSize(10).fontColor(COLORS.white)
                  .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                  .backgroundColor('rgba(255,255,255,0.15)').borderRadius(12)
                Text('📏 坡度 32%').fontSize(10).fontColor(COLORS.white)
                  .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                  .backgroundColor('rgba(255,255,255,0.15)').borderRadius(12)
                  .margin({ left: 8 })
                Text('💨 风速 5级').fontSize(10).fontColor(COLORS.white)
                  .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                  .backgroundColor('rgba(255,255,255,0.15)').borderRadius(12)
                  .margin({ left: 8 })
              }
              .width('100%').margin({ top: 12 })
            }
            .width('100%')
            .padding({ left: 18, right: 18, top: 76, bottom: 20 })
          }
          .width('100%')
        }
        .width('100%')
        .linearGradient({ angle: 135, colors: [[COLORS.primaryDark, 0.0], [COLORS.primary, 0.8], [COLORS.accent, 1.0]] })
        .borderRadius({ bottomLeft: 28, bottomRight: 28 })

        // 周里程柱状图
        Column() {
          Row() {
            Text('📊 本周里程(km)').fontSize(15).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
            Column().layoutWeight(1)
            Text('合计 81 km').fontSize(11).fontColor(COLORS.primary).fontWeight(FontWeight.Bold)
          }
          .width('100%')
          Row() {
            ForEach(WEEK_HIKE, (v: WeekHikeMeta, i: number) => {
              Column() {
                Text(v.value + '').fontSize(9).fontColor(COLORS.textSecondary)
                Stack({ alignContent: Alignment.Bottom }) {
                  Column().width(16).height(70).backgroundColor(COLORS.border).borderRadius(8)
                  Column().width(16).height(Number(v.value) * 2.8).backgroundColor(
                    i === 5 || i === 6 ? COLORS.accent : COLORS.primary)
                    .borderRadius(8)
                }
                .width(16).height(70).margin({ top: 4 })
                Text(v.label).fontSize(9).fontColor(COLORS.textHint).margin({ top: 4 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
            }, (v: WeekHikeMeta, i: number) => v.label + i)
          }
          .width('100%').margin({ top: 12 })
        }
        .width('100%')
        .padding(16)
        .backgroundColor(COLORS.cardBg).borderRadius(16)
        .margin({ left: 14, right: 14, top: 14 })

        // 热门山峰列表
        Text('🗻 热门山峰').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
          .margin({ left: 16, top: 18 })
        Column() {
          ForEach(PEAK_LIST.slice(0, 5), (p: PeakMeta) => {
            Row() {
              Text(p.emoji).fontSize(20)
              Column() {
                Text(p.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
                Text('海拔 ' + p.height + 'm · ' + p.level).fontSize(9).fontColor(COLORS.textSecondary)
                  .margin({ top: 3 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              .margin({ left: 10 })
              Text(p.level).fontSize(9).fontWeight(FontWeight.Bold)
                .fontColor(getLevelColor(p.level))
            }
            .width('100%')
            .padding({ top: 10, bottom: 10 })
            Divider().color(COLORS.border)
          }, (p: PeakMeta) => p.name)
        }
        .width('100%')
        .padding({ left: 14, right: 14 })
        .backgroundColor(COLORS.white).borderRadius(16)
        .margin({ left: 14, right: 14, top: 10 })

        // 路线难度横滑
        Text('🧭 热门路线').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
          .margin({ left: 16, top: 18 })
        Scroll() {
          Row() {
            ForEach(ROUTE_LIST, (r: RouteMeta) => {
              Column() {
                Text(r.emoji).fontSize(22)
                Text(r.name).fontSize(10).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
                  .margin({ top: 6 }).maxLines(1)
                Text(r.level + ' · ' + r.distance + 'km').fontSize(8)
                  .fontColor(getLevelColor(r.level)).margin({ top: 3 })
              }
              .width(92)
              .padding(10)
              .backgroundColor(COLORS.white).borderRadius(12)
              .margin({ right: 10 })
              .alignItems(HorizontalAlign.Center)
            }, (r: RouteMeta) => r.name)
          }
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .margin({ left: 14, top: 10 })

        // 补给清单
        Text('🎒 补给清单').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
          .margin({ left: 16, top: 18 })
        Column() {
          ForEach(SUPPLY_LIST, (s: SupplyMeta) => {
            Row() {
              Text(s.emoji).fontSize(16)
              Column() {
                Text(s.name).fontSize(11).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
                Text(s.desc).fontSize(8).fontColor(COLORS.textHint).margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              .margin({ left: 8 })
              Text(s.isDone ? '已装包' : '待补充').fontSize(9)
                .fontColor(s.isDone ? COLORS.success : COLORS.warning)
            }
            .width('100%')
            .padding({ top: 9, bottom: 9 })
            Divider().color(COLORS.border)
          }, (s: SupplyMeta) => s.name)
        }
        .width('100%')
        .padding({ left: 14, right: 14 })
        .backgroundColor(COLORS.white).borderRadius(16)
        .margin({ left: 14, right: 14, top: 10 })

        Text('🗻 收藏新山峰').fontSize(13).fontColor(COLORS.white)
          .width('90%').textAlign(TextAlign.Center)
          .padding({ top: 12, bottom: 12 })
          .backgroundColor(COLORS.primary).borderRadius(10)
          .margin({ left: 20, right: 20, top: 16, bottom: 20 })
          .onClick(() => { this.showAddPeakModal = true })

        if (this.showAddPeakModal) {
          this.addPeakModal()
        }
      }
      .width('100%')
    }
    .scrollBar(BarState.Off)
  }
}

@Component
struct HikingPeakContent {
  @State showEditPeakModal: boolean = false
  @State peakName2: string = ''
  @State peakLevel: string = ''

  @Builder modalOverlay(onClose: () => void) {
    Column()
      .width('100%').height('100%')
      .backgroundColor('rgba(0,0,0,0.55)')
      .onClick(onClose)
  }

  @Builder editPeakModal() {
    Stack() {
      this.modalOverlay(() => { this.showEditPeakModal = false })
      Column() {
        Row() {
          Text('🗻 编辑山峰').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
          Column().layoutWeight(1)
          Text('✕').fontSize(18).fontColor(COLORS.textHint).onClick(() => { this.showEditPeakModal = false })
        }
        .width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
        Divider().color(COLORS.border)
        Column() {
          Text('山峰名称').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 12, left: 20 })
          TextInput({ placeholder: '如 哈巴雪山' })
            .placeholderColor(COLORS.textHint).fontSize(14).width('90%')
            .backgroundColor(COLORS.bg).borderRadius(8)
            .margin({ left: 20, right: 20, top: 4 })
            .onChange((v: string) => { this.peakName2 = v })
          Text('难度等级').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 14, left: 20 })
          TextInput({ placeholder: '如 进阶级' })
            .placeholderColor(COLORS.textHint).fontSize(14).width('90%')
            .backgroundColor(COLORS.bg).borderRadius(8)
            .margin({ left: 20, right: 20, top: 4 })
            .onChange((v: string) => { this.peakLevel = v })
          Row() {
            Text('取消').fontSize(14).fontColor(COLORS.textSecondary)
              .layoutWeight(1).textAlign(TextAlign.Center)
              .padding({ top: 10, bottom: 10 })
              .backgroundColor(COLORS.bg).borderRadius(8)
              .onClick(() => { this.showEditPeakModal = false })
            Column().width(12)
            Text('保存').fontSize(14).fontColor(COLORS.white)
              .layoutWeight(1).textAlign(TextAlign.Center)
              .padding({ top: 10, bottom: 10 })
              .backgroundColor(COLORS.primary).borderRadius(8)
              .onClick(() => { this.showEditPeakModal = false })
          }
          .margin({ left: 20, right: 20, top: 20, bottom: 20 })
        }
        .constraintSize({ maxHeight: '70%' })
      }
      .width('88%')
      .backgroundColor(COLORS.cardBg).borderRadius(16)
    }
    .width('100%').height('100%')
  }

  build() {
    Scroll() {
      Column() {
        // 山峰头卡
        Column() {
          Text('🗻 山峰图鉴').fontSize(11).fontColor('#CFD8DC')
          Text('已收藏 8/10 座').fontSize(24).fontWeight(FontWeight.Bold).fontColor(COLORS.white)
            .margin({ top: 4 })
          Text('已完成 6 座 · 待攀登 4 座').fontSize(10).fontColor('#CFD8DC').margin({ top: 6 })
        }
        .width('100%')
        .padding(20)
        .linearGradient({ angle: 135, colors: [[COLORS.primaryDark, 0.0], [COLORS.primary, 1.0]] })
        .alignItems(HorizontalAlign.Center)
        .borderRadius({ bottomLeft: 28, bottomRight: 28 })

        // 全部山峰列表
        Text('🗻 全部山峰').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
          .margin({ left: 16, top: 18 })
        Column() {
          ForEach(PEAK_LIST, (p: PeakMeta) => {
            Row() {
              Text(p.emoji).fontSize(20)
              Column() {
                Text(p.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
                Row() {
                  Text(p.level).fontSize(9).fontColor(getLevelColor(p.level))
                  if (p.isHot) {
                    Text('热').fontSize(8).fontColor(COLORS.white)
                      .padding({ left: 4, right: 4, top: 1, bottom: 1 })
                      .backgroundColor(COLORS.danger).borderRadius(6)
                      .margin({ left: 6 })
                  }
                }
                .margin({ top: 4 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              .margin({ left: 10 })
              Column() {
                Text(formatHeight(p.height)).fontSize(12).fontWeight(FontWeight.Bold)
                  .fontColor(p.height >= 7000 ? COLORS.primary : COLORS.accent)
                Text('m').fontSize(8).fontColor(COLORS.textHint).margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.End)
            }
            .width('100%')
            .padding({ top: 11, bottom: 11 })
            Divider().color(COLORS.border)
          }, (p: PeakMeta) => p.name)
        }
        .width('100%')
        .padding({ left: 14, right: 14 })
        .backgroundColor(COLORS.white).borderRadius(16)
        .margin({ left: 14, right: 14, top: 10 })

        Text('🗻 编辑山峰资料').fontSize(13).fontColor(COLORS.white)
          .width('90%').textAlign(TextAlign.Center)
          .padding({ top: 12, bottom: 12 })
          .backgroundColor(COLORS.primary).borderRadius(10)
          .margin({ left: 20, right: 20, top: 16, bottom: 20 })
          .onClick(() => { this.showEditPeakModal = true })

        if (this.showEditPeakModal) {
          this.editPeakModal()
        }
      }
      .width('100%')
    }
    .scrollBar(BarState.Off)
  }
}

@Component
struct HikingRouteContent {
  @State showAddRouteModal: boolean = false
  @State routeName: string = ''
  @State routeDistance: string = ''

  @Builder modalOverlay(onClose: () => void) {
    Column()
      .width('100%').height('100%')
      .backgroundColor('rgba(0,0,0,0.55)')
      .onClick(onClose)
  }

  @Builder addRouteModal() {
    Stack() {
      this.modalOverlay(() => { this.showAddRouteModal = false })
      Column() {
        Row() {
          Text('🧭 新增路线').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
          Column().layoutWeight(1)
          Text('✕').fontSize(18).fontColor(COLORS.textHint).onClick(() => { this.showAddRouteModal = false })
        }
        .width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
        Divider().color(COLORS.border)
        Column() {
          Text('路线名称').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 12, left: 20 })
          TextInput({ placeholder: '如 哈巴北坡路线' })
            .placeholderColor(COLORS.textHint).fontSize(14).width('90%')
            .backgroundColor(COLORS.bg).borderRadius(8)
            .margin({ left: 20, right: 20, top: 4 })
            .onChange((v: string) => { this.routeName = v })
          Text('路线里程(km)').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 14, left: 20 })
          TextInput({ placeholder: '如 21.8' })
            .placeholderColor(COLORS.textHint).fontSize(14).width('90%')
            .backgroundColor(COLORS.bg).borderRadius(8)
            .margin({ left: 20, right: 20, top: 4 })
            .onChange((v: string) => { this.routeDistance = v })
          Row() {
            Text('取消').fontSize(14).fontColor(COLORS.textSecondary)
              .layoutWeight(1).textAlign(TextAlign.Center)
              .padding({ top: 10, bottom: 10 })
              .backgroundColor(COLORS.bg).borderRadius(8)
              .onClick(() => { this.showAddRouteModal = false })
            Column().width(12)
            Text('保存').fontSize(14).fontColor(COLORS.white)
              .layoutWeight(1).textAlign(TextAlign.Center)
              .padding({ top: 10, bottom: 10 })
              .backgroundColor(COLORS.primary).borderRadius(8)
              .onClick(() => { this.showAddRouteModal = false })
          }
          .margin({ left: 20, right: 20, top: 20, bottom: 20 })
        }
        .constraintSize({ maxHeight: '70%' })
      }
      .width('88%')
      .backgroundColor(COLORS.cardBg).borderRadius(16)
    }
    .width('100%').height('100%')
  }

  build() {
    Scroll() {
      Column() {
        // 路线头卡
        Column() {
          Text('🧭 经典路线').fontSize(11).fontColor('#CFD8DC')
          Text('8 条精选路线').fontSize(24).fontWeight(FontWeight.Bold).fontColor(COLORS.white)
            .margin({ top: 4 })
          Text('含等高线图 · 路书 · 危险路段标注').fontSize(10).fontColor('#CFD8DC').margin({ top: 6 })
        }
        .width('100%')
        .padding(20)
        .linearGradient({ angle: 135, colors: [[COLORS.primary, 0.0], [COLORS.accent, 1.0]] })
        .alignItems(HorizontalAlign.Center)
        .borderRadius({ bottomLeft: 28, bottomRight: 28 })

        // 全部路线列表
        Text('🧭 全部路线').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
          .margin({ left: 16, top: 18 })
        Column() {
          ForEach(ROUTE_LIST, (r: RouteMeta) => {
            Row() {
              Text(r.emoji).fontSize(20)
              Column() {
                Text(r.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
                Row() {
                  Text(r.level + '难度').fontSize(9).fontColor(getLevelColor(r.level))
                  if (r.isNew) {
                    Text('新').fontSize(8).fontColor(COLORS.white)
                      .padding({ left: 4, right: 4, top: 1, bottom: 1 })
                      .backgroundColor(COLORS.success).borderRadius(6)
                      .margin({ left: 6 })
                  }
                }
                .margin({ top: 4 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              .margin({ left: 10 })
              Text(r.distance + ' km').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.primary)
            }
            .width('100%')
            .padding({ top: 11, bottom: 11 })
            Divider().color(COLORS.border)
          }, (r: RouteMeta) => r.name)
        }
        .width('100%')
        .padding({ left: 14, right: 14 })
        .backgroundColor(COLORS.white).borderRadius(16)
        .margin({ left: 14, right: 14, top: 10 })

        Text('🧭 新增路线').fontSize(13).fontColor(COLORS.white)
          .width('90%').textAlign(TextAlign.Center)
          .padding({ top: 12, bottom: 12 })
          .backgroundColor(COLORS.primary).borderRadius(10)
          .margin({ left: 20, right: 20, top: 16, bottom: 20 })
          .onClick(() => { this.showAddRouteModal = true })

        if (this.showAddRouteModal) {
          this.addRouteModal()
        }
      }
      .width('100%')
    }
    .scrollBar(BarState.Off)
  }
}

@Component
struct HikingGearContent {
  @State showEditGearModal: boolean = false
  @State gearName: string = ''
  @State gearWeight: string = ''

  @Builder modalOverlay(onClose: () => void) {
    Column()
      .width('100%').height('100%')
      .backgroundColor('rgba(0,0,0,0.55)')
      .onClick(onClose)
  }

  @Builder editGearModal() {
    Stack() {
      this.modalOverlay(() => { this.showEditGearModal = false })
      Column() {
        Row() {
          Text('🎒 编辑装备').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
          Column().layoutWeight(1)
          Text('✕').fontSize(18).fontColor(COLORS.textHint).onClick(() => { this.showEditGearModal = false })
        }
        .width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
        Divider().color(COLORS.border)
        Column() {
          Text('装备名称').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 12, left: 20 })
          TextInput({ placeholder: '如 高山靴' })
            .placeholderColor(COLORS.textHint).fontSize(14).width('90%')
            .backgroundColor(COLORS.bg).borderRadius(8)
            .margin({ left: 20, right: 20, top: 4 })
            .onChange((v: string) => { this.gearName = v })
          Text('重量(kg)').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 14, left: 20 })
          TextInput({ placeholder: '如 1.8' })
            .placeholderColor(COLORS.textHint).fontSize(14).width('90%')
            .backgroundColor(COLORS.bg).borderRadius(8)
            .margin({ left: 20, right: 20, top: 4 })
            .onChange((v: string) => { this.gearWeight = v })
          Row() {
            Text('取消').fontSize(14).fontColor(COLORS.textSecondary)
              .layoutWeight(1).textAlign(TextAlign.Center)
              .padding({ top: 10, bottom: 10 })
              .backgroundColor(COLORS.bg).borderRadius(8)
              .onClick(() => { this.showEditGearModal = false })
            Column().width(12)
            Text('保存').fontSize(14).fontColor(COLORS.white)
              .layoutWeight(1).textAlign(TextAlign.Center)
              .padding({ top: 10, bottom: 10 })
              .backgroundColor(COLORS.primary).borderRadius(8)
              .onClick(() => { this.showEditGearModal = false })
          }
          .margin({ left: 20, right: 20, top: 20, bottom: 20 })
        }
        .constraintSize({ maxHeight: '70%' })
      }
      .width('88%')
      .backgroundColor(COLORS.cardBg).borderRadius(16)
    }
    .width('100%').height('100%')
  }

  build() {
    Scroll() {
      Column() {
        // 装备头卡
        Column() {
          Row() {
            Column() {
              Text('🎒 装备清单').fontSize(11).fontColor('#CFD8DC')
              Text('打包完成 8/12 件').fontSize(24).fontWeight(FontWeight.Bold).fontColor(COLORS.white)
                .margin({ top: 4 })
              Text('总重 11.9 kg · 含备用品').fontSize(10).fontColor('#CFD8DC').margin({ top: 6 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Column() {
              Text('11.9').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.white)
              Text('kg 总重').fontSize(9).fontColor('#CFD8DC').margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.End)
          }
          .width('100%')
        }
        .width('100%')
        .padding(18)
        .linearGradient({ angle: 135, colors: [[COLORS.primaryDark, 0.0], [COLORS.slate, 1.0]] })
        .borderRadius({ bottomLeft: 28, bottomRight: 28 })

        // 全部装备列表
        Text('🎒 全部装备').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
          .margin({ left: 16, top: 18 })
        Column() {
          ForEach(GEAR_LIST, (g: GearMeta) => {
            Row() {
              Text(g.emoji).fontSize(20)
              Column() {
                Text(g.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
                Row() {
                  Text(g.weight + ' kg').fontSize(9).fontColor(COLORS.textSecondary)
                  if (g.isNew) {
                    Text('新').fontSize(8).fontColor(COLORS.white)
                      .padding({ left: 4, right: 4, top: 1, bottom: 1 })
                      .backgroundColor(COLORS.success).borderRadius(6)
                      .margin({ left: 6 })
                  }
                }
                .margin({ top: 4 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              .margin({ left: 10 })
              Column() {
                Text(g.isDone ? '已打包' : '待打包').fontSize(9)
                  .fontColor(g.isDone ? COLORS.success : COLORS.warning)
                Text('编辑').fontSize(9).fontColor(COLORS.primary).margin({ top: 6 })
                  .onClick(() => { this.showEditGearModal = true })
              }
              .alignItems(HorizontalAlign.End)
            }
            .width('100%')
            .padding({ top: 11, bottom: 11 })
            Divider().color(COLORS.border)
          }, (g: GearMeta) => g.name)
        }
        .width('100%')
        .padding({ left: 14, right: 14 })
        .backgroundColor(COLORS.white).borderRadius(16)
        .margin({ left: 14, right: 14, top: 10, bottom: 20 })

        if (this.showEditGearModal) {
          this.editGearModal()
        }
      }
      .width('100%')
    }
    .scrollBar(BarState.Off)
  }
}

@Component
struct HikingMineContent {
  @State showDeleteRecordModal: boolean = false

  @Builder modalOverlay(onClose: () => void) {
    Column()
      .width('100%').height('100%')
      .backgroundColor('rgba(0,0,0,0.55)')
      .onClick(onClose)
  }

  @Builder deleteRecordModal() {
    Stack() {
      this.modalOverlay(() => { this.showDeleteRecordModal = false })
      Column() {
        Text('🏔️ 删除攀登记录').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
          .margin({ top: 24 })
        Text('删除后将无法恢复该次攀登数据,确定删除吗?')
          .fontSize(13).fontColor(COLORS.textSecondary).margin({ top: 10 })
        Row() {
          Text('取消').fontSize(14).fontColor(COLORS.textSecondary)
            .layoutWeight(1).textAlign(TextAlign.Center)
            .padding({ top: 10, bottom: 10 })
            .backgroundColor(COLORS.bg).borderRadius(8)
            .onClick(() => { this.showDeleteRecordModal = false })
          Column().width(12)
          Text('确认删除').fontSize(14).fontColor(COLORS.white)
            .layoutWeight(1).textAlign(TextAlign.Center)
            .padding({ top: 10, bottom: 10 })
            .backgroundColor(COLORS.danger).borderRadius(8)
            .onClick(() => { this.showDeleteRecordModal = false })
        }
        .width('100%')
        .margin({ left: 20, right: 20, top: 20, bottom: 20 })
      }
      .width('82%')
      .backgroundColor(COLORS.cardBg).borderRadius(16)
    }
    .width('100%').height('100%')
  }

  build() {
    Scroll() {
      Column() {
        // 攀登者档案头卡
        Column() {
          Row() {
            Column() {
              Text('⛰️ 攀登者档案').fontSize(11).fontColor('#CFD8DC')
              Text('山鹰 · LV5').fontSize(22).fontWeight(FontWeight.Bold).fontColor(COLORS.white)
                .margin({ top: 4 })
              Text('累计攀登 128 km · 上升 9,860m').fontSize(10).fontColor('#CFD8DC').margin({ top: 6 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Column() {
              Text('6').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.white)
              Text('登顶座数').fontSize(9).fontColor('#CFD8DC').margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.End)
          }
          .width('100%')
          Row() {
            Text('🏅 徽章 8 枚').fontSize(10).fontColor(COLORS.white)
              .padding({ left: 12, right: 12, top: 6, bottom: 6 })
              .backgroundColor('rgba(255,255,255,0.15)').borderRadius(12)
            Text('🧭 路线 12 条').fontSize(10).fontColor(COLORS.white)
              .padding({ left: 12, right: 12, top: 6, bottom: 6 })
              .backgroundColor('rgba(255,255,255,0.15)').borderRadius(12)
              .margin({ left: 8 })
            Text('📷 照片 356 张').fontSize(10).fontColor(COLORS.white)
              .padding({ left: 12, right: 12, top: 6, bottom: 6 })
              .backgroundColor('rgba(255,255,255,0.15)').borderRadius(12)
              .margin({ left: 8 })
          }
          .width('100%').margin({ top: 14 })
        }
        .width('100%')
        .padding(18)
        .linearGradient({ angle: 135, colors: [[COLORS.primaryDark, 0.0], [COLORS.primaryLight, 1.0]] })
        .borderRadius({ bottomLeft: 28, bottomRight: 28 })

        // 山峰徽章横滑
        Text('🏅 我的徽章').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
          .margin({ left: 16, top: 18 })
        Scroll() {
          Row() {
            ForEach(BADGE_LIST, (b: BadgeMeta) => {
              Column() {
                Text(b.emoji).fontSize(22)
                Text(b.name).fontSize(9).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
                  .margin({ top: 6 })
                Text(b.desc).fontSize(8).fontColor(COLORS.textHint).margin({ top: 2 })
              }
              .width(84)
              .padding(10)
              .backgroundColor(COLORS.white).borderRadius(12)
              .margin({ right: 10 })
              .alignItems(HorizontalAlign.Center)
            }, (b: BadgeMeta) => b.name)
          }
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .margin({ left: 14, top: 10 })

        // 攀登记录列表
        Text('🗻 攀登记录').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
          .margin({ left: 16, top: 18 })
        Column() {
          ForEach(RECORD_LIST, (r: RecordMeta) => {
            Row() {
              Text(r.emoji).fontSize(20)
              Column() {
                Text(r.name).fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
                Text(r.date + ' · ' + r.distance + ' km · ' + r.time).fontSize(9)
                  .fontColor(COLORS.textSecondary).margin({ top: 3 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              .margin({ left: 10 })
              Column() {
                Text(r.isDone ? '登顶✓' : '未完赛').fontSize(9)
                  .fontColor(r.isDone ? COLORS.success : COLORS.warning)
                Text('删除').fontSize(9).fontColor(COLORS.danger).margin({ top: 6 })
                  .onClick(() => { this.showDeleteRecordModal = true })
              }
              .alignItems(HorizontalAlign.End)
            }
            .width('100%')
            .padding({ top: 11, bottom: 11 })
            Divider().color(COLORS.border)
          }, (r: RecordMeta) => r.name + r.date)
        }
        .width('100%')
        .padding({ left: 14, right: 14 })
        .backgroundColor(COLORS.white).borderRadius(16)
        .margin({ left: 14, right: 14, top: 10, bottom: 20 })

        if (this.showDeleteRecordModal) {
          this.deleteRecordModal()
        }
      }
      .width('100%')
    }
    .scrollBar(BarState.Off)
  }
}


十二、结尾总结

在这里插入图片描述

这款登山户外 App 是 ArkTS 声明式UI开发的优秀实践范例,从架构设计到组件实现都展现了高超的技术水准和良好的工程素养。通过本文的深度解析,我们可以从中提炼出许多值得学习和借鉴的设计思想和实现技巧。

在架构设计层面,应用采用了经典的分层架构:数据层(接口定义和常量配置)→ 工具层(辅助函数)→ 组件层(页面和弹窗组件)→ 入口层(主组件)。各层职责明确,依赖关系清晰,代码结构井然有序。特别是数据模型层的设计,通过 9 个 interface 精确描述了每个业务实体的结构,为整个应用的数据流转提供了坚实的类型保障。这种"契约先行"的开发模式,使得团队协作更加高效,代码质量更有保障。

在组件设计层面,应用充分利用了 ArkTS 的 @Builder 自定义构建函数特性,将可复用的 UI 片段抽象为独立的构建器。如 tabItemmodalOverlay 等构建函数,在多个页面中都有应用,有效减少了代码重复。每个页面组件都是独立的 @Component 结构体,拥有自己的状态和方法,封装性良好,便于独立开发和测试。

在状态管理方面,应用采用了简洁的 @State 装饰器方案,状态变量都定义在使用它的组件内部,遵循了状态最小化原则。Tab 切换状态提升到主组件,弹窗状态则保留在各页面组件内部,这种状态分布方式既保证了组件间的通信效率,又避免了状态过度集中导致的维护困难。

在视觉设计层面,应用建立了完整的颜色系统,从主色、辅助色到文字色、功能色,每个颜色都有明确的语义和用途。渐变效果的运用尤为出色,头卡区域的三色渐变模拟出日出山脊的壮丽景象,与登山主题完美契合。卡片化布局、圆角设计、阴影效果等细节处理也都恰到好处,整体视觉风格统一且专业。

在交互设计层面,弹窗系统采用了统一的 modalOverlay 遮罩方案,新增、编辑、删除等操作都有对应的弹窗交互。删除操作采用二次确认模式,有效防止误操作。表单弹窗的输入验证、按钮状态、关闭方式等细节都考虑周全,用户体验流畅自然。

对于学习 ArkTS 开发的读者来说,这个项目具有很高的参考价值。它涵盖了声明式UI开发的核心概念:@Component 组件化、@State 状态管理、@Builder 自定义构建、ForEach 列表渲染、条件渲染、事件处理等。通过研读和模仿这个项目的代码,可以快速掌握 ArkTS 开发的精髓,提升自己的前端开发能力。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐