登山户外 App 深度技术解析:基于HarmonyOS API 24状态管理方面,仅使用了一个 @State 装饰的 activeTab 变量来跟踪当前激活的 Tab 页

一、引言:攀登者的数字伙伴
在户外运动日益流行的今天,登山爱好者们需要一款专业、美观且功能完善的移动应用来记录攀登历程、管理装备清单、规划登山路线。这款登山户外 App 正是为满足这一需求而精心打造的全功能登山助手。App 以岩灰色为主色调,搭配活力四射的登山橙作为点缀,整体视觉风格既沉稳专业又充满户外运动的激情与活力。
从业务功能维度来看,这款 App 涵盖了登山活动的全生命周期管理:山峰图鉴收录了从入门级到专业级的十座经典雪山,每座山峰都标注了海拔高度、难度等级和热度标识;路线模块提供了八条精选登山路线,包含难度分级和里程信息;装备清单功能帮助登山者系统管理十二件核心装备,实时追踪打包进度和总重量;我的页面则以攀登者档案为核心,展示累计攀登里程、上升高度、登顶座数等关键数据,并配有徽章系统和攀登记录。
技术实现上,该应用采用了 HarmonyOS ArkTS 声明式UI开发框架,充分利用了 @Component、@State、@Builder 等装饰器特性,构建了一套清晰、高效的组件化架构。整体采用底部 Tab 导航 + 页面内容区的经典移动端布局模式,五个 Tab 页面(首页、山峰、路线、装备、我的)各司其职,通过枚举类型和状态变量实现无缝切换。弹窗交互统一采用 modalOverlay 叠加层方案,支持新增山峰、编辑装备、删除记录等多种操作场景。
在数据层设计方面,应用通过 TypeScript interface 严格定义了九种数据模型,从颜色调色板到山峰、装备、记录、路线、补给、徽章等业务实体,每个接口都经过精心设计,字段命名语义化,类型约束明确。Mock 数据层则提供了丰富的模拟数据,确保在开发阶段就能展示完整的业务流程和视觉效果。
本文将从架构设计、数据模型、颜色系统、状态管理、组件实现等多个维度,对这款登山户外 App 进行全方位的深度技术解析,帮助读者全面理解 ArkTS 声明式UI开发的最佳实践。
二、整体架构流程图

下面的架构图展示了 App 的整体分层结构,从顶部的入口组件到底部的数据层,各层之间职责分明、依赖清晰。
三、颜色与主题系统详解

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 装饰的自定义构建函数:contentArea 和 tabItem。
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 控制新增山峰弹窗的显示与隐藏,peakName 和 peakHeight 分别存储弹窗表单中输入的山峰名称和海拔。
这种设计模式中,弹窗的表单数据由页面组件持有,弹窗组件通过闭包访问这些状态变量。这是 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,背景为白色卡片色。
弹窗内容分为三个区域:
- 标题栏:左侧显示弹窗标题和图标,右侧是关闭按钮,中间用
Column().layoutWeight(1)撑开空间 - 表单区:包含山峰名称和海拔两个输入框,每个输入框都有标签文字和 TextInput 组件
- 操作按钮区:取消和收藏两个按钮,左右并排,各占一半宽度
输入框的设计细节丰富:背景色使用浅灰色 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 方式实现,无需引入第三方图表库。核心实现思路是:
- 数据驱动:使用
ForEach遍历WEEK_HIKE数组,动态生成七个柱子 - 堆叠柱状:每个柱子使用 Stack 堆叠两层 Column,底层是灰色背景柱(高度固定 70vp),上层是彩色数据柱(高度 = 值 × 2.8)
- 底部对齐:通过
Stack({ alignContent: Alignment.Bottom })确保数据柱从底部开始增长 - 周末高亮:判断索引是否为 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 座山峰数据。相比首页的热门山峰列表,这里的信息更丰富:
-
难度 + 热门标签:山峰下方显示难度等级和热门标签(如果
isHot为 true)。热门标签使用红色背景白色文字的圆角小标签,醒目且不占空间。 -
海拔高度格式化:右侧海拔使用
formatHeight函数格式化,7000 米以上的山峰以公里为单位显示(如 7.546),7000 米以下以米为单位显示。颜色方面,7000 米以上使用主色岩灰,7000 米以下使用登山橙,通过颜色区分不同海拔级别。 -
海拔单位:数字下方单独显示 “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 方法,虽然代码重复,但保证了每个弹窗的独立性和封装性。
弹窗的显示逻辑使用条件渲染 if (this.showXxxModal) { this.xxxModal() },当状态变量为 true 时渲染弹窗,为 false 时不渲染。这种方式简单直接,易于理解和维护。
9.2 新增/编辑类弹窗设计模式
新增山峰、编辑山峰、新增路线、编辑装备等弹窗都遵循相同的设计模式:
- Stack 堆叠布局:底层遮罩 + 上层内容卡片
- 标题栏:左侧标题 + 右侧关闭按钮
- 分隔线:标题栏与表单区之间的分隔线
- 表单区:标签 + 输入框的组合,支持多个输入字段
- 操作按钮:取消 + 确认/保存的双按钮布局
以编辑装备弹窗为例:
@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 列表组件实现
列表是应用中最常见的组件形式,山峰列表、装备列表、记录列表、路线列表、补给清单等都是列表的不同表现形式。
列表的统一实现模式:
- 使用
Column作为列表容器 - 使用
ForEach遍历数据数组生成列表项 - 每个列表项使用
Row水平布局 - 列表项之间使用
Divider分隔线 - 提供 key 函数(第二个回调参数)优化渲染性能
ForEach(PEAK_LIST, (p: PeakMeta) => {
// 列表项内容
}, (p: PeakMeta) => p.name) // key 函数
ForEach 的第三个参数是 key 生成函数,用于为每个列表项生成唯一标识。ArkUI 框架使用 key 来判断哪些元素需要重新渲染,从而优化列表性能。使用业务数据的唯一标识(如山峰名称)作为 key 是最佳实践。
10.3 柱状图组件实现
周里程柱状图是应用中最具技术含量的 UI 组件之一,完全使用声明式 UI 语法实现,无需引入第三方图表库。
核心实现要点:
柱状图的高度计算使用简单的线性映射: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 片段抽象为独立的构建器。如 tabItem、modalOverlay 等构建函数,在多个页面中都有应用,有效减少了代码重复。每个页面组件都是独立的 @Component 结构体,拥有自己的状态和方法,封装性良好,便于独立开发和测试。
在状态管理方面,应用采用了简洁的 @State 装饰器方案,状态变量都定义在使用它的组件内部,遵循了状态最小化原则。Tab 切换状态提升到主组件,弹窗状态则保留在各页面组件内部,这种状态分布方式既保证了组件间的通信效率,又避免了状态过度集中导致的维护困难。
在视觉设计层面,应用建立了完整的颜色系统,从主色、辅助色到文字色、功能色,每个颜色都有明确的语义和用途。渐变效果的运用尤为出色,头卡区域的三色渐变模拟出日出山脊的壮丽景象,与登山主题完美契合。卡片化布局、圆角设计、阴影效果等细节处理也都恰到好处,整体视觉风格统一且专业。
在交互设计层面,弹窗系统采用了统一的 modalOverlay 遮罩方案,新增、编辑、删除等操作都有对应的弹窗交互。删除操作采用二次确认模式,有效防止误操作。表单弹窗的输入验证、按钮状态、关闭方式等细节都考虑周全,用户体验流畅自然。
对于学习 ArkTS 开发的读者来说,这个项目具有很高的参考价值。它涵盖了声明式UI开发的核心概念:@Component 组件化、@State 状态管理、@Builder 自定义构建、ForEach 列表渲染、条件渲染、事件处理等。通过研读和模仿这个项目的代码,可以快速掌握 ArkTS 开发的精髓,提升自己的前端开发能力。
更多推荐



所有评论(0)