露营户外 App 深度技术解析:基于HarmonyOS API 24组件内部只有一个状态变量 activeTab,使用 @State 装饰器修饰,初始值为 CampingTab.HOME(首页)
一、开篇引言

随着生活水平的提高和健康意识的增强,户外露营已经从小众爱好发展成为大众休闲的热门选择。从精致露营(Glamping)到徒步探险,从星空篝火到山野垂钓,越来越多的人选择走出城市,在大自然中寻找内心的宁静。本文将深入剖析一款基于 ArkTS 开发的露营户外 App,从架构设计、数据模型、页面组件到交互逻辑,全方位展示其技术实现的精髓。
这款露营户外 App 是一款专为露营爱好者打造的移动应用,涵盖了营地探索、装备管理、活动参与、打卡记录和个人成长等核心功能。App 采用军绿色与篝火橙的配色方案,将军旅风格的沉稳与篝火露营的热情完美融合,营造出浓郁的户外氛围。技术上,应用基于 ArkTS 声明式 UI 框架,通过 @Component、@State、@Builder 等装饰器构建了一套模块化、可复用的组件体系。
从业务场景来看,这款 App 覆盖了露营活动的全流程:出发前可以浏览营地信息、检查装备清单、报名活动项目;露营中可以查看天气预报、参与营地活动、记录打卡;露营后可以回顾行程、升级技能、收藏营地。五个 Tab 页面各司其职,形成了完整的用户旅程闭环。
技术实现上,这款 App 展现了诸多亮点:统一的颜色调色板接口设计保障了视觉一致性,丰富的数据模型支撑起多样的业务场景,枚举驱动的导航系统简洁高效,自定义 Builder 构建器提升了代码复用率,bindSheet 弹窗机制提供了流畅的交互体验。特别值得一提的是天气预报组件、装备清单管理、露营技能树等特色功能的实现,充分体现了户外主题的专业性。本文将逐一深入分析这些技术细节,带领读者领略这款露营 App 的架构之美。
二、整体架构流程图

在深入代码之前,让我们先通过架构图从宏观层面了解这款露营户外 App 的整体结构和数据流向。
数据交互流程图
组件复用关系图
三、颜色与主题系统详解

3.1 颜色调色板接口设计
颜色系统是 App 视觉风格的灵魂。这款露营户外 App 采用了接口定义 + 常量实现的双层颜色架构,先定义接口规范,再提供具体色值。
interface CampingColorPalette {
primary: string
primaryLight: string
primaryDark: string
accent: string
accentLight: string
earth: string
bg: string
cardBg: string
textPrimary: string
textSecondary: string
textHint: string
border: string
success: string
warning: string
danger: string
white: string
}
CampingColorPalette 接口定义了 16 个颜色字段,构成了完整的调色板系统。这种接口先行的设计方式有诸多优势:
类型安全:TypeScript/ArkTS 的类型系统会在编译时检查颜色使用是否正确,避免拼写错误。
文档价值:接口本身就是最好的文档,开发者可以通过接口快速了解可用的颜色种类和命名规范。
扩展性好:新增颜色时只需在接口中添加字段并在常量中赋值,所有使用颜色的地方都会自动获得类型提示。
字段命名采用了语义化策略。primary 系列是主色调(军绿),accent 系列是强调色(篝火橙),earth 是大地色系的辅助色,textPrimary/textSecondary/textHint 三级文本色阶保证了信息层次,success/warning/danger 三个语义色分别对应成功、警告、错误状态。
3.2 配色方案的设计理念
const COLORS: CampingColorPalette = {
primary: '#33691E',
primaryLight: '#689F38',
primaryDark: '#1B5E20',
accent: '#FF8F00',
accentLight: '#FFB74D',
earth: '#D7CCC8',
bg: '#F5F6F0',
cardBg: '#FFFFFF',
textPrimary: '#1B5E20',
textSecondary: '#558B2F',
textHint: '#AED581',
border: '#DCEDC8',
success: '#43A047',
warning: '#FB8C00',
danger: '#E53935',
white: '#FFFFFF'
}
这款 App 的配色方案围绕"户外、自然、活力"的主题展开,将军绿和篝火橙两种看似冲突的颜色巧妙地融合在一起。
主色调——军绿色 #33691E:这是一种深沉的橄榄绿色,让人联想到军装、山林和大自然。军绿色传递出稳重、坚韧、可靠的感觉,与露营所需的专业装备感和户外探险精神高度契合。primaryLight 浅绿 #689F38 和 primaryDark 深绿 #1B5E20 构成了完整的绿色色阶,分别用于浅色背景和深色文本。
强调色——篝火橙 #FF8F00:这是一种温暖明亮的橙色,如同夜幕下的篝火,充满活力和温暖感。橙色与绿色形成鲜明的对比,在界面中起到画龙点睛的作用,用于突出重要信息和交互元素。accentLight 浅橙 #FFB74D 用于渐变和次要强调。
大地色 #D7CCC8:这是一种浅灰褐色,象征泥土和大地。它主要用于输入框背景、标签背景等次要 UI 元素,既保持了自然主题的一致性,又提供了足够的视觉层次。
背景色 #F5F6F0:一种略带绿意的米白色,比纯白更柔和,也更符合户外自然的主题。浅绿色调的背景让整个 App 看起来清爽而有生机。
文本色的设计也很有特色——主要文本色 #1B5E20 直接使用深绿色,而非常规的深灰色。这让文字本身就带有主题色彩,整个界面的色调更加统一。次要文本 #558B2F 和提示文本 #AED581 也都是绿色系的不同深浅,形成了从深到浅的绿色文本色阶。边框色 #DCEDC8 是一种淡绿色,同样服务于整体绿色主题。
这种"主题色贯穿文本"的设计手法大胆而有效,它让整个 App 的视觉风格高度统一,用户一眼就能感受到浓浓的户外露营氛围。
3.3 工具函数:等级颜色映射
function getLevelColor(level: string): string {
if (level === '精通') {
return COLORS.accent
} else if (level === '熟练') {
return COLORS.primaryLight
}
return COLORS.textHint
}
getLevelColor 函数根据技能等级返回对应的颜色。精通等级使用篝火橙强调色,代表最高成就;熟练等级使用浅绿主色,代表中等水平;入门等级使用浅绿色提示色,代表初级阶段。颜色从橙到绿再到浅绿,形成了清晰的等级视觉标识,用户可以通过颜色快速识别技能的掌握程度。
这种"等级-颜色"映射的设计模式在游戏化系统中非常常见。将抽象的等级概念转化为直观的颜色差异,降低了用户的认知成本,也为技能系统增添了成就感和探索动力。
四、数据模型层深度解析
数据模型是应用的骨骼,决定了业务逻辑的承载能力。这款露营户外 App 定义了 9 个数据接口,覆盖营地、装备、活动、天气、打卡、技能等多个业务领域。
4.1 CampMeta 营地接口
interface CampMeta {
name: string
emoji: string
location: string
price: number
rating: number
isHot: boolean
}
CampMeta 营地模型包含 6 个核心字段。name 是营地名称,emoji 用表情符号作为营地的视觉标识,location 表示所在位置,price 是人均价格,rating 是用户评分,isHot 标记是否为热门营地。
这个接口的设计体现了列表展示的"最小完备"原则——只包含卡片展示所需的字段,没有冗余信息。价格和评分都使用数值类型,便于后续的排序、筛选和动态样式计算。isHot 布尔字段控制热门标签的显示,是电商/列表类应用的常见设计。
4.2 GearMeta 装备接口
interface GearMeta {
name: string
emoji: string
category: string
weight: number
isDone: boolean
isNew: boolean
}
GearMeta 装备模型是露营 App 的特色数据模型之一。它包含装备名称、图标、分类、重量、是否已准备、是否为新装备等字段。
category 分类字段用于标识装备的类别(住宿、睡眠、炊具、家具、照明、安全、工具、娱乐等),便于用户按类别管理装备。weight 重量字段是一个实用的数据点,露营爱好者通常会非常关注装备的总重量,尤其是背包客群体。
isDone 字段标记装备是否已准备好(已打包),这是装备清单功能的核心状态——用户可以勾选已准备的装备,系统自动统计已备数量和总重。isNew 字段标记新添加的装备,起到视觉提示的作用,帮助用户快速识别新增项。
4.3 ActivityMeta 活动接口
interface ActivityMeta {
name: string
emoji: string
time: string
desc: string
isHot: boolean
}
ActivityMeta 活动模型描述营地提供的各类活动。time 字段是活动时间(格式如"19:30"),desc 是活动简介。活动是露营体验的重要组成部分,丰富的活动安排能提升营地的吸引力和用户的参与度。
4.4 WeekCampMeta 周露营接口
interface WeekCampMeta {
label: string
value: number
}
WeekCampMeta 是周露营天数的数据模型,用于柱状图展示。label 是星期标签,value 是当天的露营天数。这个接口采用了通用的 label-value 结构,使得柱状图组件可以复用,不与具体业务耦合。
4.5 WeatherMeta 天气接口
interface WeatherMeta {
day: string
emoji: string
temp: string
wind: string
isGood: boolean
}
WeatherMeta 天气模型是露营 App 的特色功能。露营受天气影响极大,因此天气预报是露营者非常关注的信息。这个接口包含日期、天气图标(emoji)、温度、风力和是否适合露营的标记。
isGood 布尔字段是一个亮点设计——它不是简单的天气好坏判断,而是基于"是否适合露营"的综合评估。比如雨天可能不适合露营,而晴天则适合。这种业务化的标记比单纯展示天气信息更有价值,体现了产品思维。
4.6 CheckinMeta 打卡接口
interface CheckinMeta {
name: string
emoji: string
date: string
people: number
isDone: boolean
}
CheckinMeta 打卡记录模型记录用户的露营历史。people 字段是同行人数,记录每次露营的规模。isDone 区分已完成的露营和待进行的露营,帮助用户回顾过去、规划未来。
4.7 SkillMeta 技能接口
interface SkillMeta {
name: string
emoji: string
level: string
isDone: boolean
}
SkillMeta 技能模型是这款 App 最具游戏化特色的数据模型。它定义了露营相关的各项技能及其掌握程度。level 字段有"精通"、“熟练”、"入门"三个等级,isDone 标记是否已解锁该技能。
技能系统的引入为 App 增添了成长感和趣味性。用户通过不断露营和学习,可以解锁更多技能、提升技能等级,形成"使用-成长-成就"的正向循环。这种游戏化设计能有效提升用户粘性和活跃度。
4.8 TabMeta 与 MenuMeta
interface TabMeta {
title: string
icon: string
}
interface MenuMeta {
icon: string
name: string
sub: string
}
TabMeta 和 MenuMeta 是两个通用型接口,分别用于导航栏和功能菜单。它们的存在体现了"配置驱动"的设计思想——将 UI 展示数据与业务逻辑分离,通过数据配置来驱动界面渲染。
五、配置常量层分析

5.1 TAB_CONFIG 导航配置
const TAB_CONFIG: Record<string, TabMeta> = {
'HOME': { title: '首页', icon: '🏕️' },
'CAMP': { title: '营地', icon: '⛺' },
'GEAR': { title: '装备', icon: '🎒' },
'ACT': { title: '活动', icon: '🔥' },
'MINE': { title: '我的', icon: '👤' }
}
TAB_CONFIG 定义了五个 Tab 的配置信息,使用 Record<string, TabMeta> 类型确保键值对结构的一致性。五个 Tab 分别是:首页(🏕️ 露营)、营地(⛺ 帐篷)、装备(🎒 背包)、活动(🔥 篝火)、我的(👤 用户)。
每个 Tab 的图标选择都与其功能高度契合:露营帐篷代表首页概览,单帐篷代表营地浏览,背包代表装备管理,篝火代表活动参与,人像代表个人中心。这种图标与功能的强关联设计降低了用户的认知成本,即使不看文字也能大致猜到 Tab 的功能。
5.2 CampingTab 枚举
enum CampingTab {
HOME,
CAMP,
GEAR,
ACT,
MINE
}
CampingTab 枚举定义了五个 Tab 的标识符。枚举与配置常量配合使用,形成"逻辑标识 + 展示数据"的双层结构。在代码逻辑中使用枚举值(如 CampingTab.HOME),保证类型安全和代码可读性;在 UI 渲染时从配置常量中读取展示数据(如 TAB_CONFIG.HOME.title),实现数据与逻辑的解耦。
使用数字枚举(默认从 0 开始)的优势是比较效率高,且 IDE 自动补全支持好。相比于字符串字面量,枚举提供了更好的开发体验和编译时检查。
5.3 配置驱动的设计模式
总结来看,这款 App 的配置层采用了经典的"接口-枚举-常量"三层架构:
- 接口层:定义数据结构,提供类型约束和文档
- 枚举层:定义业务标识符,用于逻辑判断
- 常量层:提供具体数据,集中管理配置
这种分层设计的优势在于:
- 职责清晰:每一层都有明确的职责,修改时不会越级影响
- 类型安全:TypeScript/ArkTS 的类型系统全程护航
- 易于维护:配置集中管理,修改一处全局生效
- 扩展性好:新增功能时按层添加,结构清晰
六、Mock 数据层分析

6.1 Mock 数据的设计策略
const CAMP_LIST: CampMeta[] = [
{ name: '星空营地', emoji: '🌌', location: '青海湖', price: 268, rating: 4.9, isHot: true },
{ name: '松林营地', emoji: '🌲', location: '莫干山', price: 198, rating: 4.7, isHot: true },
{ name: '湖畔营地', emoji: '🏞️', location: '千岛湖', price: 228, rating: 4.8, isHot: true },
// ... 共10个营地
]
Mock 数据是原型开发的重要组成部分。这款 App 的 Mock 数据设计非常用心,不是简单的重复填充,而是精心设计了多样化、真实感强的数据。
营地数据(CAMP_LIST) 包含 10 个营地,分布在青海湖、莫干山、千岛湖、呼伦贝尔、敦煌、稻城、桐庐、安吉、涠洲岛、阳朔等 10 个不同的地点。营地类型丰富多样,有星空营地、松林营地、湖畔营地、草原营地、沙漠营地、雪山营地等,几乎涵盖了所有主流的露营场景。价格从 168 元到 458 元不等,评分从 4.4 到 4.9 分布自然。
这种多样化的 Mock 数据有多重价值:
- 视觉丰富度:不同的 emoji 图标让列表不单调
- 场景覆盖:各种类型的营地都有展示,验证设计的通用性
- 边界测试:长名称、低评分、非热门等边界情况都有覆盖
- 真实感:数据分布符合真实规律,不会出现所有评分都是 5.0 的假数据
6.2 装备数据的设计亮点
const GEAR_LIST: GearMeta[] = [
{ name: '四季帐篷', emoji: '⛺', category: '住宿', weight: 3.2, isDone: true, isNew: false },
{ name: '羽绒睡袋', emoji: '🛌', category: '睡眠', weight: 1.5, isDone: true, isNew: false },
{ name: '防潮垫', emoji: '🟫', category: '睡眠', weight: 0.8, isDone: true, isNew: true },
// ... 共12件装备
]
装备清单数据是这款 App 的一大特色。12 件装备涵盖了住宿、睡眠、炊具、家具、照明、安全、工具、娱乐等 8 个类别。每件装备都有具体的重量(精确到 0.1kg),总重 12.8kg,这与实际露营的装备重量大致相当,非常真实。
装备的"已备/待备"状态也分布合理——7 件已备、5 件待备,模拟了用户正在整理装备的中间状态。新增装备(isNew)标记也分布在不同的装备上,测试了新装备标签的展示效果。
这种真实感强的 Mock 数据不仅让界面看起来专业,也能帮助产品经理和设计师更好地评估功能设计是否合理——比如装备分类是否清晰、重量信息是否有用、已备/待备的区分是否直观等。
6.3 天气数据的业务化设计
const WEATHER_LIST: WeatherMeta[] = [
{ day: '今天', emoji: '☀️', temp: '26°', wind: '微风', isGood: true },
{ day: '明天', emoji: '⛅', temp: '24°', wind: '3级', isGood: true },
{ day: '后天', emoji: '🌧️', temp: '19°', wind: '4级', isGood: false },
// ... 共6天
]
天气预报数据包含未来 6 天的天气信息。设计亮点在于 isGood 字段——它不是简单地根据天气好坏来标记,而是从"是否适合露营"的角度进行判断。比如晴天、多云是适合露营的,雨天是不适合的。
配合这个字段,UI 上会显示"宜露营"或"不宜"的标签,直接告诉用户天气对露营的影响。这种"从原始数据中提炼业务价值"的设计思路,体现了产品思维的深度——用户不需要自己判断雨天能不能露营,App 已经帮你做好了决策辅助。
6.4 技能数据的游戏化设计
const SKILL_LIST: SkillMeta[] = [
{ name: '帐篷搭建', emoji: '⛺', level: '精通', isDone: true },
{ name: '生火技巧', emoji: '🔥', level: '熟练', isDone: true },
{ name: '绳结技术', emoji: '🪢', level: '入门', isDone: true },
// ... 共10项技能
]
技能系统数据包含 10 项露营相关技能,等级分布合理:2 项精通、4 项熟练、4 项入门(其中 3 项未解锁)。这种设计模拟了一个有一定露营经验但仍有成长空间的用户状态。
技能的选择也很有代表性:帐篷搭建、生火技巧、绳结技术、野外导航、急救知识、观星识别、钓鱼技巧、户外烹饪、皮划艇、攀岩基础,涵盖了露营的各个方面。每项技能都有对应的 emoji 图标,既美观又直观。
游戏化的技能系统能有效提升用户粘性——用户会有动力去解锁更多技能、提升等级,从而增加 App 的使用频率和时长。
七、主入口组件逐段分析

7.1 状态管理设计
@Entry
@Component
struct CampingApp {
@State activeTab: CampingTab = CampingTab.HOME
CampingApp 是应用的主入口组件。@Entry 装饰器标记这是页面入口,@Component 标记这是一个组件。
组件内部只有一个状态变量 activeTab,使用 @State 装饰器修饰,初始值为 CampingTab.HOME(首页)。@State 是 ArkTS 的响应式状态装饰器,被它修饰的变量会被 UI 框架追踪,当变量值改变时,所有依赖它的 UI 都会自动重新渲染。
单一状态变量控制页面切换的设计非常简洁。整个应用的导航状态一目了然——看 activeTab 的值就知道当前在哪个页面。相比于复杂的路由系统,这种基于状态的 Tab 切换在五个 Tab 的规模下更加轻量高效,状态流向清晰可控。
7.2 contentArea 内容构建器
@Builder contentArea() {
Column() {
if (this.activeTab === CampingTab.HOME) {
CampingHomeContent()
} else if (this.activeTab === CampingTab.CAMP) {
CampingCampContent()
} else if (this.activeTab === CampingTab.GEAR) {
CampingGearContent()
} else if (this.activeTab === CampingTab.ACT) {
CampingActivityContent()
} else if (this.activeTab === CampingTab.MINE) {
CampingMineContent()
}
}
.width('100%')
}
contentArea 是一个 @Builder 自定义构建函数,负责渲染内容区域。它根据 activeTab 的值,通过 if-else 条件判断来渲染对应的页面组件。
这种条件渲染的模式简洁直观:当 activeTab 变化时,框架自动检测到依赖关系,重新执行条件判断,替换显示的组件。每个页面组件都是独立的 @Component,有自己的状态和逻辑,实现了关注点分离。
使用 @Builder 封装内容区域的好处是将页面切换逻辑从主 build 方法中抽离出来,让主布局结构更清晰。build 方法只需要关注整体框架(内容区 + 导航栏),具体的内容渲染交给专门的构建函数处理。
7.3 tabItem 导航项构建器
@Builder tabItem(icon: string, label: string, tab: CampingTab) {
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 构建函数创建底部导航栏的每个 Tab 项。它接收三个参数:图标 icon、标签 label 和对应的枚举值 tab。
每个 Tab 项是一个垂直排列的 Column:上方是 20px 的 emoji 图标,下方是 10px 的标签文字。文字的颜色和字重根据选中状态动态变化——选中时使用主色粗体,未选中时使用提示色常规字重。
.layoutWeight(1) 让每个 Tab 项平均分配导航栏的宽度,保证五个 Tab 等宽排列。.onClick 事件在用户点击时更新 activeTab 状态,触发页面切换。
这个构建器的设计充分体现了组件复用的思想——五个 Tab 项结构完全相同,只是内容不同。通过参数化的构建函数,避免了五遍重复代码,同时保证了 Tab 样式的一致性。后续如需修改 Tab 样式,只需修改这一处,所有 Tab 同步更新。
7.4 build 方法整体布局
build() {
Column() {
this.contentArea()
Row() {
this.tabItem(TAB_CONFIG.HOME.icon, TAB_CONFIG.HOME.title, CampingTab.HOME)
this.tabItem(TAB_CONFIG.CAMP.icon, TAB_CONFIG.CAMP.title, CampingTab.CAMP)
this.tabItem(TAB_CONFIG.GEAR.icon, TAB_CONFIG.GEAR.title, CampingTab.GEAR)
this.tabItem(TAB_CONFIG.ACT.icon, TAB_CONFIG.ACT.title, CampingTab.ACT)
this.tabItem(TAB_CONFIG.MINE.icon, TAB_CONFIG.MINE.title, CampingTab.MINE)
}
.width('100%')
.padding({ top: 8, bottom: 8 })
.backgroundColor(COLORS.white)
.shadow({ radius: 10, color: '#33691E26', offsetY: -2 })
}
.width('100%').height('100%')
.backgroundColor(COLORS.bg)
}
build 方法定义了整体布局结构。外层是一个全屏 Column,分为上下两部分:上方是内容区域 contentArea,下方是底部导航栏 Row。
导航栏是一个白色背景的 Row,上下各 8px 内边距。导航栏有一个向上的阴影(offsetY: -2),阴影颜色使用主色的半透明版本(#33691E26,即军绿色加上约 15% 的不透明度)。这个细节设计很用心——阴影颜色与主题色一致而不是使用通用的黑色,让界面看起来更加协调,阴影也融入了整体绿色主题。
页面背景使用 COLORS.bg 淡绿米色,与白色导航栏和卡片形成柔和的对比。整体视觉风格清爽自然,符合户外露营的主题。
八、各页面组件逐段代码分析

8.1 首页组件 CampingHomeContent
首页是用户进入 App 后看到的第一个页面,承担着信息汇总和功能导航的双重角色。露营 App 的首页内容丰富,包含营位状态头卡、天气预报、周打卡柱状图、热门营地横滑和新增营地按钮等多个模块。
8.1.1 状态定义与弹窗基础
@Component
struct CampingHomeContent {
@State showAddCampModal: boolean = false
@State campName: string = ''
@State campLocation: string = ''
首页组件定义了三个内部状态:showAddCampModal 控制新增营地弹窗的显示隐藏,campName 和 campLocation 分别存储用户输入的营地名称和位置。
弹窗显示状态与表单数据分离管理是一种良好的设计模式。显示状态只负责弹窗的开关,表单数据由独立的状态变量管理。这样设计的好处是,即使弹窗关闭,用户之前输入的内容也可以保留,再次打开时不需要重新输入。
8.1.2 遮罩层构建器
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.55)')
.onClick(onClose)
}
modalOverlay 是通用遮罩层构建函数,接收一个关闭回调函数作为参数。遮罩层是全屏半透明黑色(55% 不透明度),点击时调用传入的关闭回调。
这种"回调注入"的设计模式非常灵活——遮罩层不需要知道具体的关闭逻辑,只负责在被点击时调用传入的函数。不同弹窗可以复用同一个遮罩实现,只需传入各自的关闭逻辑即可。
8.1.3 新增营地弹窗
@Builder addCampModal() {
Stack() {
this.modalOverlay(() => { this.showAddCampModal = false })
Column() {
Row() {
Text('🏕️ 新增营地').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
Column().layoutWeight(1)
Text('✕').fontSize(18).fontColor(COLORS.textHint).onClick(() => { this.showAddCampModal = 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.earth).borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
.onChange((v: string) => { this.campName = v })
Text('营地位置').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 14, left: 20 })
TextInput({ placeholder: '如 青海湖' })
.placeholderColor(COLORS.textHint).fontSize(14).width('90%')
.backgroundColor(COLORS.earth).borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
.onChange((v: string) => { this.campLocation = v })
Row() {
Text('取消').fontSize(14).fontColor(COLORS.textSecondary)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.earth).borderRadius(8)
.onClick(() => { this.showAddCampModal = 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.showAddCampModal = 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%,圆角 16px。
弹窗结构分为三部分:
- 标题栏:左侧是带露营图标的标题,右侧是关闭按钮,中间用空 Column 撑开。
- 分隔线:使用
Divider组件,颜色为边框色(淡绿色)。 - 表单区:包含营地名称和营地位置两个输入框,输入框背景使用大地色
COLORS.earth,与整体户外主题呼应。 - 操作按钮:取消按钮使用大地色背景,发布按钮使用军绿色主色背景,视觉权重分明。
弹窗内容设置了 constraintSize({ maxHeight: '70%' }),限制最大高度为屏幕的 70%,防止内容过多时弹窗溢出屏幕。这种约束设计保证了在各种屏幕尺寸下的可用性。
8.1.4 营位状态+温度头卡
Stack() {
Column()
.width('100%').height(180)
.linearGradient({ angle: 135, colors: [[COLORS.primary, 0.0], [COLORS.primaryDark, 1.0]] })
.borderRadius({ bottomLeft: 24, bottomRight: 24 })
Column() {
Row() {
Column() {
Text('🔥 篝火已点燃').fontSize(11).fontColor('#AED581')
Text('26°C').fontSize(42).fontWeight(FontWeight.Bold).fontColor(COLORS.white)
.margin({ top: 2 })
Text('星空营地 · 青海湖').fontSize(10).fontColor('#AED581').margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Stack() {
Column().width(72).height(72).borderRadius(36)
.backgroundColor('rgba(255,143,0,0.2)')
Column().width(50).height(50).borderRadius(25)
.backgroundColor(COLORS.accent)
Text('🔥').fontSize(34)
}
.width(72).height(72)
}
.width('100%')
.padding({ left: 20, right: 20, top: 30 })
Row() {
Text('👥 4人 · 🏕️ 2顶帐篷').fontSize(10).fontColor('#AED581')
Column().layoutWeight(1)
Text('Day 2 / 3').fontSize(10).fontColor(COLORS.accentLight)
.backgroundColor('rgba(255,255,255,0.15)')
.borderRadius(8).padding({ left: 6, right: 6, top: 2, bottom: 2 })
}
.width('100%')
.padding({ left: 20, right: 20, top: 12 })
}
}
.width('100%').height(180)
营位状态头卡是首页最醒目的视觉元素,高度 180px,渐变背景从主色军绿到深军绿。
头卡内容分为上下两行:
上行左侧是核心信息——状态提示"🔥 篝火已点燃"、大大的温度数字"26°C"、营地名称和位置。状态提示使用浅绿色文字,温度使用 42px 的白色粗体,是整个头卡的视觉焦点。右侧是一个三层同心圆装饰:最外层是半透明橙色圆环(篝火橙的 20% 不透明度),中间是橙色实心圆,最上层是火焰 emoji。橙色装饰在绿色背景上格外醒目,篝火的意象也与露营主题高度契合。
下行左侧显示同行人数和帐篷数,右侧显示露营进度"Day 2 / 3"。进度标签使用半透明白色背景和橙色文字,形成徽章效果,直观展示当前露营的进度。
这个头卡设计的信息层次非常清晰:温度最大最醒目(用户最关心的信息),状态和营地名次之,人数和进度作为补充信息。通过字号、颜色、字重的差异,用户可以从上到下、从主到次快速获取信息。
8.1.5 天气预报横滑
Column() {
Text('🌤️ 营地天气预报').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).margin({ left: 16, top: 16 })
Scroll() {
Row() {
ForEach(WEATHER_LIST, (w: WeatherMeta) => {
Column() {
Text(w.day).fontSize(10).fontColor(COLORS.textSecondary)
Text(w.emoji).fontSize(28).margin({ top: 6 })
Text(w.temp).fontSize(12).fontColor(COLORS.textPrimary)
.fontWeight(FontWeight.Bold).margin({ top: 4 })
Text(w.wind).fontSize(8).fontColor(COLORS.textHint).margin({ top: 2 })
if (w.isGood) {
Text('宜露营').fontSize(7).fontColor(COLORS.success)
.backgroundColor(COLORS.border).borderRadius(4)
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
.margin({ top: 4 })
} else {
Text('不宜').fontSize(7).fontColor(COLORS.danger)
.backgroundColor('#FFCDD2').borderRadius(4)
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
.margin({ top: 4 })
}
}
.width(60)
.margin({ left: 4 })
.alignItems(HorizontalAlign.Center)
})
}
.padding({ left: 16, right: 16 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.margin({ top: 8 })
}
.width('100%')
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.margin({ left: 12, right: 12, top: 12 })
天气预报模块是露营 App 的特色功能之一。它以水平滚动的卡片形式展示未来几天的天气信息。
每个天气单元是一个 60px 宽的 Column,垂直排列着日期、天气图标、温度、风力和露营建议标签:
- 日期:10px 次要文字色
- 天气图标:28px emoji
- 温度:12px 粗体主文字色
- 风力:8px 提示色
- 露营建议:7px 小标签,"宜露营"用绿色背景+绿色文字,"不宜"用浅红背景+红色文字
天气预报使用水平滚动展示,每个单元宽度 60px,左间距 4px。滚动条设为隐藏,保持界面整洁。整个天气模块放在一个白色圆角卡片中,与其他内容模块保持一致的卡片样式。
这个天气预报组件的设计亮点在于"宜露营"标签。相比于一般的天气 App 只展示天气数据,这款露营 App 从用户的核心需求出发,直接告诉用户"这样的天气适不适合露营",减少了用户的决策成本,体现了场景化设计的思维。
8.1.6 周打卡柱状图
Column() {
Text('📊 本周露营天数').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).margin({ left: 16, top: 16 })
Row() {
ForEach(WEEK_CAMP, (w: WeekCampMeta) => {
Column() {
Stack({ alignContent: Alignment.Bottom }) {
Column().width(22).height(85)
.backgroundColor(COLORS.earth)
.borderRadius(4)
Column().width(22).height(Number(w.value) * 9)
.linearGradient({ angle: 0, colors: [[COLORS.accent, 0.0], [COLORS.primary, 1.0]] })
.borderRadius(4)
}
.width(22).height(85)
Text(w.label).fontSize(9).fontColor(COLORS.textSecondary)
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 8 })
}
周露营天数柱状图是一个纯代码实现的数据可视化组件。它用 Stack 堆叠布局实现柱子效果:底层是大地色的背景柱(全高 85px),上层是从橙到绿的渐变柱(高度为数值乘以 9),底部对齐。
柱子宽度 22px,圆角 4px。七个柱子通过 layoutWeight(1) 平均分布在一行中。柱状图整体放在白色卡片中,与天气预报卡片样式一致。
柱状图使用橙色到绿色的垂直渐变(角度 0,即从上到下),上橙下绿。这种配色与 App 的军绿+篝火橙主题完全一致,即使是数据可视化组件也保持了视觉风格的统一性。
8.2 营地页面 CampingCampContent
营地页面展示所有可选营地的列表,采用双列卡片布局,方便用户浏览和比较。
// 营地双列
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(CAMP_LIST, (c: CampMeta) => {
Column() {
Stack() {
Column().width('100%').height(70).borderRadius(12)
.linearGradient({ angle: 135, colors: [[COLORS.primaryLight, 0.0], [COLORS.accentLight, 1.0]] })
Text(c.emoji).fontSize(38)
if (c.isHot) {
Text('🔥').fontSize(14).position({ x: 96, y: -4 })
}
}
.width('100%').height(70)
Text(c.name).fontSize(12).fontColor(COLORS.textPrimary)
.fontWeight(FontWeight.Medium).margin({ top: 8 }).maxLines(1)
Row() {
Text('📍').fontSize(8)
Text(c.location).fontSize(9).fontColor(COLORS.textSecondary)
Text(' · ')
Row() {
Text('★').fontSize(8).fontColor(COLORS.warning)
Text(c.rating + '').fontSize(9).fontColor(COLORS.textSecondary)
}
}
.margin({ top: 2 })
Row() {
Text('¥' + c.price).fontSize(13).fontColor(COLORS.accent)
.fontWeight(FontWeight.Bold)
Text('/人').fontSize(9).fontColor(COLORS.textHint)
Text(' 预订').fontSize(11).fontColor(COLORS.white)
.backgroundColor(COLORS.primary).borderRadius(10)
.padding({ left: 10, right: 10, top: 3, bottom: 3 })
.margin({ left: 8 })
}
.margin({ top: 4 })
}
.width('48%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ bottom: 8 })
})
}
营地列表使用双列卡片布局,通过 Flex 组件的 FlexWrap.Wrap 和 FlexAlign.SpaceBetween 实现。每张卡片宽度 48%,两张卡片之间自动留出间距。
每张卡片包含以下内容:
- 图片区域:70px 高,使用浅绿到浅橙的 135 度渐变背景,中间放置营地 emoji 图标。热门营地右上角有火焰标记。
- 营地名称:12px 中等字重,限制一行。
- 位置和评分:位置图标 + 地点名称 + 评分星星,使用小字号次要文字色。
- 价格和预订按钮:价格使用橙色粗体突出,右侧有军绿色的"预订"按钮,引导用户进行下一步操作。
与民宿 App 的房源卡片相比,露营 App 的营地卡片多了一个"预订"按钮。这是因为营地的转化路径更短——用户浏览营地后可以直接预订,而民宿通常需要进入详情页查看更多信息后再决策。这个小小的差异体现了不同业务场景下的 UX 设计考量。
8.3 装备页面 CampingGearContent
装备页面是露营 App 的核心功能页面之一,帮助用户管理露营装备清单,确保出发前不遗漏任何重要装备。
8.3.1 编辑装备弹窗
@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 })
Text(this.selectedGear).fontSize(14).fontColor(COLORS.textPrimary)
.margin({ left: 20, top: 4 })
Text('重量(kg)').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 14, left: 20 })
TextInput({ placeholder: '如 3.2' })
.placeholderColor(COLORS.textHint).fontSize(14).width('90%')
.backgroundColor(COLORS.earth).borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
Text('备注').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 14, left: 20 })
TextInput({ placeholder: '输入备注信息' })
.placeholderColor(COLORS.textHint).fontSize(14).width('90%')
.backgroundColor(COLORS.earth).borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
Row() {
Text('取消').fontSize(14).fontColor(COLORS.textSecondary)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.earth).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%')
}
编辑装备弹窗与新增营地弹窗结构相似,但表单字段不同。它包含三个字段:
- 装备名称:只读显示,让用户确认正在编辑哪件装备
- 重量:数值输入,用户可以修改装备重量
- 备注:文本输入,用户可以添加备注信息
装备名称只读而不允许修改,这是一个合理的设计——装备的名称是其身份标识,通常不会改变,而重量和备注是可能需要调整的属性。这种"关键信息只读 + 可变信息可编辑"的设计模式在编辑类功能中很常见。
弹窗的操作按钮与其他弹窗一致:取消按钮使用大地色次要样式,保存按钮使用军绿色主色实心样式。整体视觉语言保持统一。
8.3.2 装备列表
ForEach(GEAR_LIST, (g: GearMeta) => {
Row() {
Stack() {
Column().width(44).height(44).borderRadius(12)
.backgroundColor(COLORS.earth)
Text(g.emoji).fontSize(26)
.opacity(g.isDone ? 1 : 0.5)
}
.width(44).height(44).margin({ right: 12 })
Column() {
Text(g.name).fontSize(14).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Medium)
Row() {
Text(g.category).fontSize(10).fontColor(COLORS.primary)
.backgroundColor(COLORS.border).borderRadius(4)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
Text(' ' + g.weight + 'kg').fontSize(10).fontColor(COLORS.textSecondary)
if (g.isNew) {
Text(' NEW').fontSize(9).fontColor(COLORS.white)
.backgroundColor(COLORS.danger).borderRadius(4)
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
}
}
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
if (g.isDone) {
Text('✓').fontSize(16).fontColor(COLORS.success).margin({ right: 12 })
Text('编辑').fontSize(11).fontColor(COLORS.primary)
.border({ width: 1, color: COLORS.primary }).borderRadius(12)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.onClick(() => {
this.selectedGear = g.name
this.showEditGearModal = true
})
} else {
Text('待备').fontSize(10).fontColor(COLORS.warning)
.backgroundColor('#FFF3E0').borderRadius(8)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.onClick(() => {
this.selectedGear = g.name
this.showEditGearModal = true
})
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 10, bottom: 10 })
.backgroundColor(COLORS.cardBg)
.border({ width: { bottom: 1 }, color: COLORS.border })
})
装备列表是装备页面的核心内容。每件装备是一个水平排列的 Row,包含左侧图标、中间信息和右侧状态/操作。
左侧是 44x44px 的大地色圆角图标,图标使用 emoji。有趣的是,图标的不透明度根据装备状态变化——已准备的装备不透明度为 1(正常显示),待备的装备不透明度为 0.5(半透明显示)。这种视觉处理巧妙地传达了装备的准备状态,不需要额外的文字说明,用户一眼就能看出哪些装备已经打包好了。
中间是装备的详细信息,垂直排列:
- 装备名称:14px 中等字重主文字色
- 分类、重量、新装备标记:分类是一个绿色文字+淡绿背景的小标签,重量是灰色小字,新装备是一个红色背景的"NEW"标签
右侧根据装备状态显示不同的内容:
- 已装备:显示绿色对勾 ✓ 和"编辑"边框按钮
- 待备:显示橙色"待备"标签
点击编辑按钮或待备标签都会打开编辑弹窗,只是触发的场景不同。
装备列表的设计信息密度高但层次清晰——名称最醒目,分类和重量作为辅助信息,状态和操作在右侧提供行动入口。颜色编码(绿色已备、橙色待备、红色新增)让用户可以快速扫描和识别。
8.4 活动页面 CampingActivityContent
活动页面展示营地提供的各类活动,用户可以浏览活动详情并参与报名。页面包含活动头卡、活动双列卡片和删除活动弹窗。
8.4.1 删除活动弹窗
@Builder deleteActModal() {
Stack() {
this.modalOverlay(() => { this.showDeleteModal = false })
Column() {
Text('⚠️ 删除活动').fontSize(18).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ top: 24 })
Text('确定要删除「' + this.selectedAct + '」吗?')
.fontSize(13).fontColor(COLORS.textSecondary)
.textAlign(TextAlign.Center)
.margin({ left: 20, right: 20, top: 12 })
Row() {
Text('取消').fontSize(14).fontColor(COLORS.textSecondary)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.earth).borderRadius(8)
.onClick(() => { this.showDeleteModal = 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.showDeleteModal = false })
}
.margin({ left: 20, right: 20, top: 24, bottom: 24 })
}
.width('80%')
.backgroundColor(COLORS.cardBg).borderRadius(16)
}
.width('100%').height('100%')
}
删除活动弹窗是一个确认对话框,结构简洁:警告标题 + 确认文字 + 双操作按钮。
删除按钮使用红色(危险色 COLORS.danger)背景,这是 UX 设计的标准实践——破坏性操作使用警示颜色,让用户在点击前仔细确认,防止误删。取消按钮使用大地色次要样式,与删除按钮形成对比。
弹窗宽度为 80%,比表单弹窗窄,因为内容较少,窄一些显得更紧凑聚焦。居中的文字布局也符合确认对话框的常见设计模式。
8.5 我的页面 CampingMineContent
我的页面是功能最丰富的页面,包含个人信息、数据统计、露营技能、打卡记录和功能菜单等多个模块。
8.5.1 露营技能网格
Text('🎯 露营技能').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ left: 16, top: 16, bottom: 8 })
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(SKILL_LIST, (s: SkillMeta) => {
Column() {
Stack() {
Column().width(44).height(44).borderRadius(12)
.backgroundColor(s.isDone ? COLORS.earth : '#F5F5F5')
Text(s.emoji).fontSize(26)
.opacity(s.isDone ? 1 : 0.3)
}
.width(44).height(44)
Text(s.name).fontSize(9).fontColor(COLORS.textPrimary).margin({ top: 6 })
Text(s.level).fontSize(8)
.fontColor(getLevelColor(s.level))
.margin({ top: 1 })
}
.width('19%')
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.margin({ bottom: 6 })
.alignItems(HorizontalAlign.Center)
})
}
露营技能模块是这款 App 最具游戏化特色的功能。它使用五列网格布局(每个技能宽度 19%,五列共占 95%,剩余 5% 由 SpaceBetween 分配为间距)展示所有技能。
每个技能卡片是一个小小的正方形卡片,包含:
- 技能图标:44x44px 圆角正方形。已解锁的技能使用大地色背景和正常不透明度,未解锁的技能使用灰色背景和 30% 不透明度,形成"锁定"的视觉效果。
- 技能名称:9px 小字号主文字色。
- 技能等级:8px 超小字号,颜色通过
getLevelColor函数动态计算——精通为橙色,熟练为浅绿,入门为更浅的绿色。
这种"灰度锁定 + 颜色等级"的设计在游戏中非常常见。未解锁的技能暗淡模糊,激发用户的解锁欲望;已解锁的技能根据等级显示不同颜色,提供成长的成就感。整个技能系统形成了清晰的成长路径,用户可以直观地看到自己的露营技能树。
8.5.2 打卡记录列表
Text('📍 露营打卡').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ left: 16, bottom: 8 })
ForEach(CHECKIN_LIST, (c: CheckinMeta) => {
Row() {
Stack() {
Column().width(40).height(40).borderRadius(10)
.backgroundColor(COLORS.earth)
Text(c.emoji).fontSize(24)
}
.width(40).height(40).margin({ right: 12 })
Column() {
Text(c.name).fontSize(13).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Medium)
Text(c.date + ' · ' + c.people + '人').fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
if (c.isDone) {
Text('已打卡').fontSize(10).fontColor(COLORS.success)
} else {
Text('待打卡').fontSize(10).fontColor(COLORS.warning).fontWeight(FontWeight.Bold)
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 10, bottom: 10 })
.backgroundColor(COLORS.cardBg)
.border({ width: { bottom: 1 }, color: COLORS.border })
})
打卡记录列表展示用户的露营历史记录。每条记录包含左侧图标、中间信息和右侧状态。
左侧是 40x40px 的大地色圆角图标,中间是营地名称和日期+人数,右侧是打卡状态。已打卡使用绿色文字,待打卡使用橙色粗体文字,突出显示即将到来的露营计划。
打卡功能是露营 App 的社交化元素——用户每完成一次露营就会增加一条打卡记录,积累的打卡记录既是回忆也是成就。配合个人数据中的"露营次数"和"露营天数"统计,形成了完整的用户成长体系。
九、弹窗组件详解
9.1 弹窗系统的统一架构
这款露营 App 包含三种类型的弹窗:新增营地、编辑装备、删除活动。虽然功能各异,但它们共享同一套弹窗架构:
Stack 堆叠布局
├── modalOverlay 遮罩层(点击关闭)
└── Column 内容卡片
├── Row 标题栏(标题 + 关闭按钮)
├── Divider 分隔线
├── Column 内容区(表单/确认文字)
└── Row 操作按钮(取消 + 确认/删除)
这种统一的架构有多重优势:
一致性体验:用户在不同功能中遇到的弹窗结构和交互方式都是一致的,降低了学习成本。
开发效率:开发者可以基于模板快速创建新的弹窗,不需要每次都从头设计。
维护成本低:如果需要调整弹窗的通用样式(如圆角、内边距),只需修改一个模板即可全局生效。
9.2 modalOverlay 的复用设计
modalOverlay 是所有弹窗共用的遮罩层构建函数。它被定义在每个页面组件内部(每个页面各有一份),但实现逻辑完全相同。
从代码复用的角度来看,如果将 modalOverlay 抽取为全局公共组件或工具函数会更高效。但在原型阶段,每个页面单独定义一份也有其合理性——减少跨组件的依赖,每个页面组件都是自包含的,便于独立开发和调试。
9.3 bindSheet 声明式弹窗
.bindSheet(this.showEditGearModal, this.editGearModal(), {
height: SheetSize.LARGE,
})
弹窗的显示使用 bindSheet 方法,这是 ArkTS 提供的底部抽屉组件 API。它采用声明式的绑定方式——将弹窗的显示状态与内容绑定到组件上,状态为 true 时弹窗滑出,状态为 false 时弹窗收起。
这种声明式的弹窗管理比命令式的 show/dismiss 调用更加直观和可控。开发者只需要关注状态的变化,具体的动画效果和交互细节由框架负责处理。
9.4 三种弹窗的业务场景对比
| 弹窗类型 | 业务场景 | 内容结构 | 按钮配置 | 宽度 |
|---|---|---|---|---|
| 新增营地 | 创建新营地 | 标题 + 2个输入框 | 取消+发布(主色) | 88% |
| 编辑装备 | 修改装备信息 | 标题 + 只读名 + 2个输入框 | 取消+保存(主色) | 88% |
| 删除活动 | 确认删除活动 | 标题 + 确认文字 | 取消+删除(危险色) | 80% |
三种弹窗分别对应"增、改、删"三种典型操作,覆盖了 CRUD 的主要场景。它们的设计遵循了一致的设计语言,同时根据内容多少和操作性质做了差异化调整——删除确认弹窗更窄、更聚焦,表单弹窗更宽、信息更多。
十、核心 UI 组件分析
10.1 渐变头卡组件
渐变头卡是这款 App 最具辨识度的 UI 元素之一,出现在每个页面的顶部。
设计特点:
- 135 度线性渐变,从浅到深或从一种颜色过渡到另一种
- 底部大圆角(20-24px),形成流畅的曲线
- 深色背景配白色文字,对比度高
- 大标题 + 副标题的信息层级
- 高度从 120px 到 180px 不等
页面差异:
- 首页头卡最高(180px),信息最丰富(状态+温度+营地+人数+进度)
- 营地/活动/装备页头卡中等高度,包含标题和统计信息
- 我的页头卡 160px,展示用户头像和等级信息
渐变头卡不仅提供了视觉冲击力,也为每个页面建立了独特的视觉标识——用户通过头卡的颜色和内容就能快速识别当前页面。
10.2 天气预报组件
天气预报是露营 App 的特色功能组件,采用水平滚动的卡片式布局。
设计亮点:
- emoji 天气图标直观可爱
- "宜露营/不宜"标签提供直接的业务建议
- 水平滚动节省垂直空间
- 绿色主题贯穿始终(绿色边框、绿色适宜标签)
这个组件体现了"场景化设计"的思维——不是简单地展示天气数据,而是从露营用户的角度出发,告诉用户天气对露营的影响。这种增值信息比原始数据更有价值。
10.3 装备清单组件
装备清单是露营 App 的核心功能组件,列表式布局配合丰富的状态视觉。
设计亮点:
- 图标透明度区分已备/待备状态
- 分类标签 + 重量 + NEW 标签的信息组合
- 状态与操作按钮的联动(已备→编辑按钮,待备→待备标签)
- 大地色背景与户外主题契合
装备清单的设计兼顾了实用性和美观性。用户可以快速浏览装备状态,识别新增装备,查看装备分类和重量信息,同时整体视觉风格统一且专业。
10.4 技能网格组件
技能网格是游戏化设计的代表组件,五列网格布局展示技能树。
设计亮点:
- 灰度+透明度表示未解锁状态
- 颜色等级区分掌握程度
- 小卡片大信息量
- 游戏化设计提升用户粘性
技能系统为 App 注入了成长和成就的元素,让单纯的工具类应用变得更有趣味性。用户在使用 App 的过程中,不仅管理了露营装备和行程,也在"升级"自己的露营技能。
十一、技术亮点总结表格
| 功能模块 | 技术实现方式 | 设计模式 | 代码量估计 | 亮点说明 |
|---|---|---|---|---|
| 颜色系统 | interface + const 常量 | 配置驱动 | 约 40 行 | 军绿+篝火橙主题,绿色文本贯穿,16色调色板 |
| 数据模型 | 9 个 interface | 类型优先 | 约 85 行 | 装备、天气、技能等特色数据模型,字段设计专业 |
| Tab 导航 | enum + @State + @Builder | 状态驱动 | 约 60 行 | 枚举驱动导航,配置数据分离,构建器复用 |
| 首页模块 | 渐变头卡 + 天气预报 + 柱状图 | 信息聚合 | 约 180 行 | 营位状态展示,天气宜露营标签,纯代码柱状图 |
| 营地列表 | Flex 双列卡片 + ForEach | 响应式网格 | 约 80 行 | 双列瀑布流,预订按钮直转,渐变卡片风格 |
| 装备管理 | 列表 + 编辑弹窗 + 状态区分 | CRUD 模式 | 约 140 行 | 透明度状态区分,分类标签,重量信息,NEW 标记 |
| 活动页面 | 双列卡片 + 删除弹窗 | 卡片式布局 | 约 110 行 | 活动时间展示,删除确认交互,热门标记 |
| 个人中心 | 头卡 + 数据 + 技能 + 打卡 | 成长体系 | 约 160 行 | 游戏化技能系统,打卡记录,四级数据统计 |
| 弹窗系统 | modalOverlay + bindSheet | 函数式组件 | 约 130 行 | 三种弹窗类型统一架构,声明式显示,底部抽屉 |
| Mock 数据 | 8 组模拟数据 | 数据驱动 | 约 140 行 | 装备数据专业,天气数据场景化,技能数据游戏化 |
安装DevEco Studio程序

选择目标安装目录:

设置环境变量,但是需要重启一下:

新建一个空白模板:

设置API为24的模板项目:
初始化项目,自动下载相关依赖:

完整代码:
// ============================================================
// 532.ets 露营户外 APP — CampingApp
// 主色调: 军绿 #33691E + 篝火橙 #FF8F00
// 布局: 营位状态+温度头卡 + 周打卡柱状图 + 营地横滑
// + 装备清单列表 + 营地活动双列 + 天气预报 + 我的
// 弹窗: 新增营地 / 编辑装备 / 删除活动 (全部 modalOverlay)
// ============================================================
interface CampingColorPalette {
primary: string
primaryLight: string
primaryDark: string
accent: string
accentLight: string
earth: 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 CampMeta {
name: string
emoji: string
location: string
price: number
rating: number
isHot: boolean
}
interface GearMeta {
name: string
emoji: string
category: string
weight: number
isDone: boolean
isNew: boolean
}
interface ActivityMeta {
name: string
emoji: string
time: string
desc: string
isHot: boolean
}
interface WeekCampMeta {
label: string
value: number
}
interface WeatherMeta {
day: string
emoji: string
temp: string
wind: string
isGood: boolean
}
interface CheckinMeta {
name: string
emoji: string
date: string
people: number
isDone: boolean
}
interface SkillMeta {
name: string
emoji: string
level: string
isDone: boolean
}
interface MenuMeta {
icon: string
name: string
sub: string
}
const COLORS: CampingColorPalette = {
primary: '#33691E',
primaryLight: '#689F38',
primaryDark: '#1B5E20',
accent: '#FF8F00',
accentLight: '#FFB74D',
earth: '#D7CCC8',
bg: '#F5F6F0',
cardBg: '#FFFFFF',
textPrimary: '#1B5E20',
textSecondary: '#558B2F',
textHint: '#AED581',
border: '#DCEDC8',
success: '#43A047',
warning: '#FB8C00',
danger: '#E53935',
white: '#FFFFFF'
}
const TAB_CONFIG: Record<string, TabMeta> = {
'HOME': { title: '首页', icon: '🏕️' },
'CAMP': { title: '营地', icon: '⛺' },
'GEAR': { title: '装备', icon: '🎒' },
'ACT': { title: '活动', icon: '🔥' },
'MINE': { title: '我的', icon: '👤' }
}
const CAMP_LIST: CampMeta[] = [
{ name: '星空营地', emoji: '🌌', location: '青海湖', price: 268, rating: 4.9, isHot: true },
{ name: '松林营地', emoji: '🌲', location: '莫干山', price: 198, rating: 4.7, isHot: true },
{ name: '湖畔营地', emoji: '🏞️', location: '千岛湖', price: 228, rating: 4.8, isHot: true },
{ name: '草原营地', emoji: '🌿', location: '呼伦贝尔', price: 318, rating: 4.6, isHot: false },
{ name: '沙漠营地', emoji: '🏜️', location: '敦煌', price: 388, rating: 4.5, isHot: true },
{ name: '雪山营地', emoji: '🏔️', location: '稻城', price: 458, rating: 4.9, isHot: false },
{ name: '溪谷营地', emoji: '💧', location: '桐庐', price: 168, rating: 4.4, isHot: false },
{ name: '竹林营地', emoji: '🎋', location: '安吉', price: 188, rating: 4.6, isHot: true },
{ name: '海岛营地', emoji: '🏝️', location: '涠洲岛', price: 298, rating: 4.7, isHot: false },
{ name: '山谷营地', emoji: '⛰️', location: '阳朔', price: 208, rating: 4.5, isHot: false }
]
const GEAR_LIST: GearMeta[] = [
{ name: '四季帐篷', emoji: '⛺', category: '住宿', weight: 3.2, isDone: true, isNew: false },
{ name: '羽绒睡袋', emoji: '🛌', category: '睡眠', weight: 1.5, isDone: true, isNew: false },
{ name: '防潮垫', emoji: '🟫', category: '睡眠', weight: 0.8, isDone: true, isNew: true },
{ name: '户外炉具', emoji: '🔥', category: '炊具', weight: 1.2, isDone: true, isNew: false },
{ name: '折叠桌椅', emoji: '🪑', category: '家具', weight: 2.5, isDone: true, isNew: false },
{ name: '头灯手电', emoji: '🔦', category: '照明', weight: 0.3, isDone: true, isNew: false },
{ name: '急救包', emoji: '🩹', category: '安全', weight: 0.5, isDone: true, isNew: true },
{ name: '登山杖', emoji: '🥢', category: '工具', weight: 0.6, isDone: false, isNew: false },
{ name: '保温水壶', emoji: '🫖', category: '炊具', weight: 0.4, isDone: false, isNew: false },
{ name: '户外刀具', emoji: '🗡️', category: '工具', weight: 0.2, isDone: false, isNew: true },
{ name: '防雨天幕', emoji: '🌈', category: '住宿', weight: 2.8, isDone: false, isNew: false },
{ name: '便携音箱', emoji: '🔊', category: '娱乐', weight: 0.8, isDone: false, isNew: true }
]
const ACTIVITY_LIST: ActivityMeta[] = [
{ name: '篝火晚会', emoji: '🔥', time: '19:30', desc: '星空下的派对', isHot: true },
{ name: '星空摄影', emoji: '📸', time: '21:00', desc: '银河拍摄教学', isHot: true },
{ name: '晨间徒步', emoji: '🥾', time: '06:00', desc: '日出观景徒步', isHot: false },
{ name: '皮划艇', emoji: '🛶', time: '10:00', desc: '湖上皮划艇', isHot: true },
{ name: '飞盘运动', emoji: '🥏', time: '15:00', desc: '草地飞盘比赛', isHot: false },
{ name: '钓鱼野炊', emoji: '🎣', time: '11:00', desc: '湖边钓鱼烹饪', isHot: false },
{ name: '观鸟活动', emoji: '🦅', time: '06:30', desc: '专业向导带队', isHot: false },
{ name: '露天电影', emoji: '🎬', time: '20:00', desc: '星空下看电影', isHot: true }
]
const WEEK_CAMP: WeekCampMeta[] = [
{ label: '周一', value: 0 },
{ label: '周二', value: 1 },
{ label: '周三', value: 0 },
{ label: '周四', value: 2 },
{ label: '周五', value: 5 },
{ label: '周六', value: 8 },
{ label: '周日', value: 6 }
]
const WEATHER_LIST: WeatherMeta[] = [
{ day: '今天', emoji: '☀️', temp: '26°', wind: '微风', isGood: true },
{ day: '明天', emoji: '⛅', temp: '24°', wind: '3级', isGood: true },
{ day: '后天', emoji: '🌧️', temp: '19°', wind: '4级', isGood: false },
{ day: '周五', emoji: '☁️', temp: '22°', wind: '2级', isGood: true },
{ day: '周六', emoji: '☀️', temp: '27°', wind: '微风', isGood: true },
{ day: '周日', emoji: '⛅', temp: '25°', wind: '3级', isGood: true }
]
const CHECKIN_LIST: CheckinMeta[] = [
{ name: '星空营地', emoji: '🌌', date: '08-15', people: 4, isDone: false },
{ name: '松林营地', emoji: '🌲', date: '07-20', people: 2, isDone: true },
{ name: '湖畔营地', emoji: '🏞️', date: '07-06', people: 6, isDone: true },
{ name: '竹林营地', emoji: '🎋', date: '06-22', people: 3, isDone: true },
{ name: '溪谷营地', emoji: '💧', date: '06-08', people: 5, isDone: true },
{ name: '草原营地', emoji: '🌿', date: '05-28', people: 8, isDone: true },
{ name: '海岛营地', emoji: '🏝️', date: '05-15', people: 4, isDone: true },
{ name: '山谷营地', emoji: '⛰️', date: '05-01', people: 3, isDone: true },
{ name: '沙漠营地', emoji: '🏜️', date: '04-18', people: 6, isDone: true },
{ name: '雪山营地', emoji: '🏔️', date: '04-05', people: 2, isDone: true }
]
const SKILL_LIST: SkillMeta[] = [
{ name: '帐篷搭建', emoji: '⛺', level: '精通', isDone: true },
{ name: '生火技巧', emoji: '🔥', level: '熟练', isDone: true },
{ name: '绳结技术', emoji: '🪢', level: '入门', isDone: true },
{ name: '野外导航', emoji: '🧭', level: '熟练', isDone: true },
{ name: '急救知识', emoji: '🩹', level: '入门', isDone: true },
{ name: '观星识别', emoji: '⭐', level: '入门', isDone: false },
{ name: '钓鱼技巧', emoji: '🎣', level: '熟练', isDone: true },
{ name: '户外烹饪', emoji: '🍳', level: '精通', isDone: true },
{ name: '皮划艇', emoji: '🛶', level: '入门', isDone: false },
{ name: '攀岩基础', emoji: '🧗', level: '入门', isDone: false }
]
const MENU_LIST: MenuMeta[] = [
{ icon: '📍', name: '打卡记录', sub: '10 次露营' },
{ icon: '⭐', name: '收藏营地', sub: '8 个营地' },
{ icon: '🏆', name: '营地徽章', sub: '6 枚徽章' },
{ icon: '👥', name: '露营好友', sub: '12 位好友' },
{ icon: '⚙️', name: '账号设置', sub: '个人信息' },
{ icon: '📞', name: '客服中心', sub: '7×24 在线' }
]
function getLevelColor(level: string): string {
if (level === '精通') {
return COLORS.accent
} else if (level === '熟练') {
return COLORS.primaryLight
}
return COLORS.textHint
}
enum CampingTab {
HOME,
CAMP,
GEAR,
ACT,
MINE
}
@Entry
@Component
struct CampingApp {
@State activeTab: CampingTab = CampingTab.HOME
@Builder contentArea() {
Column() {
if (this.activeTab === CampingTab.HOME) {
CampingHomeContent()
} else if (this.activeTab === CampingTab.CAMP) {
CampingCampContent()
} else if (this.activeTab === CampingTab.GEAR) {
CampingGearContent()
} else if (this.activeTab === CampingTab.ACT) {
CampingActivityContent()
} else if (this.activeTab === CampingTab.MINE) {
CampingMineContent()
}
}
.width('100%')
}
@Builder tabItem(icon: string, label: string, tab: CampingTab) {
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, CampingTab.HOME)
this.tabItem(TAB_CONFIG.CAMP.icon, TAB_CONFIG.CAMP.title, CampingTab.CAMP)
this.tabItem(TAB_CONFIG.GEAR.icon, TAB_CONFIG.GEAR.title, CampingTab.GEAR)
this.tabItem(TAB_CONFIG.ACT.icon, TAB_CONFIG.ACT.title, CampingTab.ACT)
this.tabItem(TAB_CONFIG.MINE.icon, TAB_CONFIG.MINE.title, CampingTab.MINE)
}
.width('100%')
.padding({ top: 8, bottom: 8 })
.backgroundColor(COLORS.white)
.shadow({ radius: 10, color: '#33691E26', offsetY: -2 })
}
.width('100%').height('100%')
.backgroundColor(COLORS.bg)
}
}
@Component
struct CampingHomeContent {
@State showAddCampModal: boolean = false
@State campName: string = ''
@State campLocation: string = ''
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.55)')
.onClick(onClose)
}
@Builder addCampModal() {
Stack() {
this.modalOverlay(() => { this.showAddCampModal = false })
Column() {
Row() {
Text('🏕️ 新增营地').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
Column().layoutWeight(1)
Text('✕').fontSize(18).fontColor(COLORS.textHint).onClick(() => { this.showAddCampModal = 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.earth).borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
.onChange((v: string) => { this.campName = v })
Text('营地位置').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 14, left: 20 })
TextInput({ placeholder: '如 青海湖' })
.placeholderColor(COLORS.textHint).fontSize(14).width('90%')
.backgroundColor(COLORS.earth).borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
.onChange((v: string) => { this.campLocation = v })
Row() {
Text('取消').fontSize(14).fontColor(COLORS.textSecondary)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.earth).borderRadius(8)
.onClick(() => { this.showAddCampModal = 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.showAddCampModal = 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() {
// 营位状态+温度头卡
Stack() {
Column()
.width('100%').height(180)
.linearGradient({ angle: 135, colors: [[COLORS.primary, 0.0], [COLORS.primaryDark, 1.0]] })
.borderRadius({ bottomLeft: 24, bottomRight: 24 })
Column() {
Row() {
Column() {
Text('🔥 篝火已点燃').fontSize(11).fontColor('#AED581')
Text('26°C').fontSize(42).fontWeight(FontWeight.Bold).fontColor(COLORS.white)
.margin({ top: 2 })
Text('星空营地 · 青海湖').fontSize(10).fontColor('#AED581').margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Stack() {
Column().width(72).height(72).borderRadius(36)
.backgroundColor('rgba(255,143,0,0.2)')
Column().width(50).height(50).borderRadius(25)
.backgroundColor(COLORS.accent)
Text('🔥').fontSize(34)
}
.width(72).height(72)
}
.width('100%')
.padding({ left: 20, right: 20, top: 30 })
Row() {
Text('👥 4人 · 🏕️ 2顶帐篷').fontSize(10).fontColor('#AED581')
Column().layoutWeight(1)
Text('Day 2 / 3').fontSize(10).fontColor(COLORS.accentLight)
.backgroundColor('rgba(255,255,255,0.15)')
.borderRadius(8).padding({ left: 6, right: 6, top: 2, bottom: 2 })
}
.width('100%')
.padding({ left: 20, right: 20, top: 12 })
}
}
.width('100%').height(180)
// 天气预报
Column() {
Text('🌤️ 营地天气预报').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).margin({ left: 16, top: 16 })
Scroll() {
Row() {
ForEach(WEATHER_LIST, (w: WeatherMeta) => {
Column() {
Text(w.day).fontSize(10).fontColor(COLORS.textSecondary)
Text(w.emoji).fontSize(28).margin({ top: 6 })
Text(w.temp).fontSize(12).fontColor(COLORS.textPrimary)
.fontWeight(FontWeight.Bold).margin({ top: 4 })
Text(w.wind).fontSize(8).fontColor(COLORS.textHint).margin({ top: 2 })
if (w.isGood) {
Text('宜露营').fontSize(7).fontColor(COLORS.success)
.backgroundColor(COLORS.border).borderRadius(4)
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
.margin({ top: 4 })
} else {
Text('不宜').fontSize(7).fontColor(COLORS.danger)
.backgroundColor('#FFCDD2').borderRadius(4)
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
.margin({ top: 4 })
}
}
.width(60)
.margin({ left: 4 })
.alignItems(HorizontalAlign.Center)
})
}
.padding({ left: 16, right: 16 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.margin({ top: 8 })
}
.width('100%')
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.margin({ left: 12, right: 12, top: 12 })
// 周打卡柱状图
Column() {
Text('📊 本周露营天数').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).margin({ left: 16, top: 16 })
Row() {
ForEach(WEEK_CAMP, (w: WeekCampMeta) => {
Column() {
Stack({ alignContent: Alignment.Bottom }) {
Column().width(22).height(85)
.backgroundColor(COLORS.earth)
.borderRadius(4)
Column().width(22).height(Number(w.value) * 9)
.linearGradient({ angle: 0, colors: [[COLORS.accent, 0.0], [COLORS.primary, 1.0]] })
.borderRadius(4)
}
.width(22).height(85)
Text(w.label).fontSize(9).fontColor(COLORS.textSecondary)
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 8 })
}
.width('100%')
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.margin({ left: 12, right: 12, top: 8 })
// 热门营地横滑
Row() {
Text('🔥 热门营地').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary).layoutWeight(1)
Text('查看全部 >').fontSize(11).fontColor(COLORS.textHint)
}
.width('100%')
.padding({ left: 16, right: 16, top: 16 })
Scroll() {
Row() {
ForEach(CAMP_LIST, (c: CampMeta) => {
Column() {
Stack() {
Column().width(76).height(76).borderRadius(14)
.linearGradient({ angle: 135, colors: [[COLORS.primaryLight, 0.0], [COLORS.accentLight, 1.0]] })
Text(c.emoji).fontSize(38)
if (c.isHot) {
Text('🔥').fontSize(14).position({ x: 52, y: -4 })
}
}
.width(76).height(76)
Text(c.name).fontSize(10).fontColor(COLORS.textPrimary)
.margin({ top: 6 }).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text(c.location).fontSize(9).fontColor(COLORS.textHint).margin({ top: 2 })
Row() {
Text('¥' + c.price).fontSize(11).fontColor(COLORS.accent)
.fontWeight(FontWeight.Bold)
Text(' ★' + c.rating).fontSize(9).fontColor(COLORS.warning)
}
.margin({ top: 2 })
}
.width(84)
.margin({ left: 8 })
.alignItems(HorizontalAlign.Center)
})
}
.padding({ left: 16, right: 16 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.margin({ top: 8, bottom: 8 })
// 新增按钮
Row() {
Text('+ 新增营地').fontSize(14).fontColor(COLORS.white)
.fontWeight(FontWeight.Bold)
.backgroundColor(COLORS.primary)
.borderRadius(24)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
}
.width('100%')
.justifyContent(FlexAlign.Center)
.margin({ top: 16, bottom: 20 })
.onClick(() => { this.showAddCampModal = true })
}
.width('100%')
}
.width('100%').height('100%')
.scrollBar(BarState.Off)
.bindSheet(this.showAddCampModal, this.addCampModal(), {
height: SheetSize.LARGE,
})
}
}
@Component
struct CampingCampContent {
build() {
Scroll() {
Column() {
// 营地列表头卡
Column() {
Row() {
Text('⛺').fontSize(28).margin({ right: 8 })
Column() {
Text('营地地图').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.white)
Text('10 个营地 · 8 个城市').fontSize(11).fontColor('#AED581').margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text('🗺️').fontSize(22).fontColor(COLORS.white)
}
.width('100%').padding({ left: 20, right: 20, top: 16, bottom: 16 })
}
.width('100%')
.linearGradient({ angle: 135, colors: [[COLORS.accent, 0.0], [COLORS.primary, 1.0]] })
.borderRadius({ bottomLeft: 20, bottomRight: 20 })
// 营地双列
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(CAMP_LIST, (c: CampMeta) => {
Column() {
Stack() {
Column().width('100%').height(70).borderRadius(12)
.linearGradient({ angle: 135, colors: [[COLORS.primaryLight, 0.0], [COLORS.accentLight, 1.0]] })
Text(c.emoji).fontSize(38)
if (c.isHot) {
Text('🔥').fontSize(14).position({ x: 96, y: -4 })
}
}
.width('100%').height(70)
Text(c.name).fontSize(12).fontColor(COLORS.textPrimary)
.fontWeight(FontWeight.Medium).margin({ top: 8 }).maxLines(1)
Row() {
Text('📍').fontSize(8)
Text(c.location).fontSize(9).fontColor(COLORS.textSecondary)
Text(' · ')
Row() {
Text('★').fontSize(8).fontColor(COLORS.warning)
Text(c.rating + '').fontSize(9).fontColor(COLORS.textSecondary)
}
}
.margin({ top: 2 })
Row() {
Text('¥' + c.price).fontSize(13).fontColor(COLORS.accent)
.fontWeight(FontWeight.Bold)
Text('/人').fontSize(9).fontColor(COLORS.textHint)
Text(' 预订').fontSize(11).fontColor(COLORS.white)
.backgroundColor(COLORS.primary).borderRadius(10)
.padding({ left: 10, right: 10, top: 3, bottom: 3 })
.margin({ left: 8 })
}
.margin({ top: 4 })
}
.width('48%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ bottom: 8 })
})
}
.width('92%')
.margin({ left: 16, right: 16, top: 16, bottom: 20 })
}
.width('100%')
}
.width('100%').height('100%')
.scrollBar(BarState.Off)
}
}
@Component
struct CampingGearContent {
@State showEditGearModal: boolean = false
@State selectedGear: 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 })
Text(this.selectedGear).fontSize(14).fontColor(COLORS.textPrimary)
.margin({ left: 20, top: 4 })
Text('重量(kg)').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 14, left: 20 })
TextInput({ placeholder: '如 3.2' })
.placeholderColor(COLORS.textHint).fontSize(14).width('90%')
.backgroundColor(COLORS.earth).borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
Text('备注').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 14, left: 20 })
TextInput({ placeholder: '输入备注信息' })
.placeholderColor(COLORS.textHint).fontSize(14).width('90%')
.backgroundColor(COLORS.earth).borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
Row() {
Text('取消').fontSize(14).fontColor(COLORS.textSecondary)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.earth).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() {
// 装备清单头卡
Stack() {
Column()
.width('100%').height(120)
.linearGradient({ angle: 135, colors: [[COLORS.primary, 0.0], [COLORS.accent, 1.0]] })
.borderRadius({ bottomLeft: 24, bottomRight: 24 })
Column() {
Text('🎒 装备清单').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.white)
Text('12 件装备 · 总重 12.8kg · 已备 7 件').fontSize(11).fontColor('#AED581').margin({ top: 4 })
}
.alignItems(HorizontalAlign.Center)
.padding({ top: 30 })
}
.width('100%').height(120)
// 装备列表
ForEach(GEAR_LIST, (g: GearMeta) => {
Row() {
Stack() {
Column().width(44).height(44).borderRadius(12)
.backgroundColor(COLORS.earth)
Text(g.emoji).fontSize(26)
.opacity(g.isDone ? 1 : 0.5)
}
.width(44).height(44).margin({ right: 12 })
Column() {
Text(g.name).fontSize(14).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Medium)
Row() {
Text(g.category).fontSize(10).fontColor(COLORS.primary)
.backgroundColor(COLORS.border).borderRadius(4)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
Text(' ' + g.weight + 'kg').fontSize(10).fontColor(COLORS.textSecondary)
if (g.isNew) {
Text(' NEW').fontSize(9).fontColor(COLORS.white)
.backgroundColor(COLORS.danger).borderRadius(4)
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
}
}
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
if (g.isDone) {
Text('✓').fontSize(16).fontColor(COLORS.success).margin({ right: 12 })
Text('编辑').fontSize(11).fontColor(COLORS.primary)
.border({ width: 1, color: COLORS.primary }).borderRadius(12)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.onClick(() => {
this.selectedGear = g.name
this.showEditGearModal = true
})
} else {
Text('待备').fontSize(10).fontColor(COLORS.warning)
.backgroundColor('#FFF3E0').borderRadius(8)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.onClick(() => {
this.selectedGear = g.name
this.showEditGearModal = true
})
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 10, bottom: 10 })
.backgroundColor(COLORS.cardBg)
.border({ width: { bottom: 1 }, color: COLORS.border })
})
}
.width('100%')
}
.width('100%').height('100%')
.scrollBar(BarState.Off)
.bindSheet(this.showEditGearModal, this.editGearModal(), {
height: SheetSize.LARGE,
})
}
}
@Component
struct CampingActivityContent {
@State showDeleteModal: boolean = false
@State selectedAct: string = ''
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.55)')
.onClick(onClose)
}
@Builder deleteActModal() {
Stack() {
this.modalOverlay(() => { this.showDeleteModal = false })
Column() {
Text('⚠️ 删除活动').fontSize(18).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ top: 24 })
Text('确定要删除「' + this.selectedAct + '」吗?')
.fontSize(13).fontColor(COLORS.textSecondary)
.textAlign(TextAlign.Center)
.margin({ left: 20, right: 20, top: 12 })
Row() {
Text('取消').fontSize(14).fontColor(COLORS.textSecondary)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.earth).borderRadius(8)
.onClick(() => { this.showDeleteModal = 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.showDeleteModal = false })
}
.margin({ left: 20, right: 20, top: 24, bottom: 24 })
}
.width('80%')
.backgroundColor(COLORS.cardBg).borderRadius(16)
}
.width('100%').height('100%')
}
build() {
Scroll() {
Column() {
// 活动头卡
Column() {
Row() {
Text('🔥').fontSize(28).margin({ right: 8 })
Column() {
Text('营地活动').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.white)
Text('8 项活动 · 今日 4 场').fontSize(11).fontColor('#AED581').margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
}
.width('100%').padding({ left: 20, right: 20, top: 16, bottom: 16 })
}
.width('100%')
.linearGradient({ angle: 135, colors: [[COLORS.accent, 0.0], [COLORS.primary, 1.0]] })
.borderRadius({ bottomLeft: 20, bottomRight: 20 })
// 活动双列
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(ACTIVITY_LIST, (a: ActivityMeta) => {
Column() {
Stack() {
Column().width('100%').height(56).borderRadius(12)
.linearGradient({ angle: 135, colors: [[COLORS.accentLight, 0.0], [COLORS.earth, 1.0]] })
Text(a.emoji).fontSize(32)
if (a.isHot) {
Text('🔥').fontSize(12).position({ x: 96, y: -4 })
}
}
.width('100%').height(56)
Text(a.name).fontSize(12).fontColor(COLORS.textPrimary)
.fontWeight(FontWeight.Medium).margin({ top: 8 })
Text(a.desc).fontSize(9).fontColor(COLORS.textHint).margin({ top: 2 })
Row() {
Text('⏰ ' + a.time).fontSize(10).fontColor(COLORS.textSecondary)
Column().layoutWeight(1)
Text('删除').fontSize(10).fontColor(COLORS.danger)
.onClick(() => {
this.selectedAct = a.name
this.showDeleteModal = true
})
}
.width('100%').margin({ top: 6 })
}
.width('48%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ bottom: 8 })
})
}
.width('92%')
.margin({ left: 16, right: 16, top: 16, bottom: 20 })
}
.width('100%')
}
.width('100%').height('100%')
.scrollBar(BarState.Off)
.bindSheet(this.showDeleteModal, this.deleteActModal(), {
height: SheetSize.LARGE,
})
}
}
@Component
struct CampingMineContent {
build() {
Scroll() {
Column() {
// 个人头卡
Stack() {
Column()
.width('100%').height(160)
.linearGradient({ angle: 135, colors: [[COLORS.primary, 0.0], [COLORS.primaryDark, 1.0]] })
.borderRadius({ bottomLeft: 24, bottomRight: 24 })
Column() {
Stack() {
Column().width(64).height(64).borderRadius(32)
.backgroundColor('rgba(255,255,255,0.25)')
Text('🏕️').fontSize(36)
}
.width(64).height(64)
Text('户外达人').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(COLORS.white).margin({ top: 8 })
Row() {
Text('Lv.8').fontSize(10).fontColor(COLORS.accentLight)
.backgroundColor('rgba(255,255,255,0.2)')
.borderRadius(8).padding({ left: 6, right: 6, top: 2, bottom: 2 })
Text(' · 露营 10 次').fontSize(10).fontColor('#AED581')
}
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Center)
.padding({ top: 24 })
}
.width('100%').height(160)
// 个人数据
Row() {
Column() {
Text('10').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.primary)
Text('露营次数').fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('38').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.accent)
Text('露营天数').fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('12').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.warning)
Text('装备数').fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('6').fontSize(20).fontWeight(FontWeight.Bold).fontColor(COLORS.danger)
Text('徽章数').fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
.width('100%')
.padding({ top: 16, bottom: 16 })
.backgroundColor(COLORS.cardBg)
.margin({ left: 12, right: 12, top: 12 })
.borderRadius(16)
// 露营技能
Text('🎯 露营技能').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ left: 16, top: 16, bottom: 8 })
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(SKILL_LIST, (s: SkillMeta) => {
Column() {
Stack() {
Column().width(44).height(44).borderRadius(12)
.backgroundColor(s.isDone ? COLORS.earth : '#F5F5F5')
Text(s.emoji).fontSize(26)
.opacity(s.isDone ? 1 : 0.3)
}
.width(44).height(44)
Text(s.name).fontSize(9).fontColor(COLORS.textPrimary).margin({ top: 6 })
Text(s.level).fontSize(8)
.fontColor(getLevelColor(s.level))
.margin({ top: 1 })
}
.width('19%')
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.margin({ bottom: 6 })
.alignItems(HorizontalAlign.Center)
})
}
.width('92%')
.margin({ left: 16, right: 16, bottom: 16 })
// 打卡记录
Text('📍 露营打卡').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ left: 16, bottom: 8 })
ForEach(CHECKIN_LIST, (c: CheckinMeta) => {
Row() {
Stack() {
Column().width(40).height(40).borderRadius(10)
.backgroundColor(COLORS.earth)
Text(c.emoji).fontSize(24)
}
.width(40).height(40).margin({ right: 12 })
Column() {
Text(c.name).fontSize(13).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Medium)
Text(c.date + ' · ' + c.people + '人').fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
if (c.isDone) {
Text('已打卡').fontSize(10).fontColor(COLORS.success)
} else {
Text('待打卡').fontSize(10).fontColor(COLORS.warning).fontWeight(FontWeight.Bold)
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 10, bottom: 10 })
.backgroundColor(COLORS.cardBg)
.border({ width: { bottom: 1 }, color: COLORS.border })
})
// 功能列表
Column() {
ForEach(MENU_LIST, (m: MenuMeta) => {
Row() {
Text(m.icon).fontSize(22).margin({ right: 12 })
Text(m.name).fontSize(14).fontColor(COLORS.textPrimary).layoutWeight(1)
Text(m.sub).fontSize(11).fontColor(COLORS.textHint)
Text(' >').fontSize(14).fontColor(COLORS.textHint)
}
.width('100%')
.padding({ left: 16, right: 16, top: 14, bottom: 14 })
.border({ width: { bottom: 1 }, color: COLORS.border })
})
}
.width('100%')
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.margin({ left: 12, right: 12, bottom: 20 })
}
.width('100%')
}
.width('100%').height('100%')
.scrollBar(BarState.Off)
}
}
十二、结尾总结

通过对这款露营户外 App 的全面深入分析,我们可以看到一个主题鲜明、功能完善、设计精良的移动端应用是如何通过 ArkTS 声明式开发范式构建起来的。从整体架构到组件细节,从数据模型到交互逻辑,每一处都体现了对户外露营主题的深入理解和对用户体验的用心打磨。
在架构层面,App 采用了经典的 Tab 导航 + 状态驱动页面切换的模式。单一 activeTab 状态变量控制全局导航,简洁高效。枚举 + 配置常量的双层设计实现了逻辑与展示的分离,@Builder 自定义构建器提升了代码复用率。整体架构清晰明了,层次分明,对于五个 Tab 的中等规模应用来说,这种方案既轻量又高效。
在视觉设计方面,军绿 + 篝火橙的配色方案是这款 App 的最大亮点之一。军绿色传递出户外、坚韧、专业的气质,篝火橙带来温暖、活力、热情的感觉。两种颜色的碰撞产生了强烈的视觉冲击力,又完美契合露营的主题。更值得称道的是,绿色不仅仅是主色调,还贯穿到了文本色、边框色、背景色等各个层面,形成了统一而独特的视觉语言。用户打开 App 的瞬间,就能感受到浓浓的户外氛围。
在功能设计方面,这款 App 展现了对露营场景的深入理解。天气预报不只是展示天气数据,而是给出了"宜露营/不宜"的直接建议;装备清单不仅列了装备名称,还包含分类、重量、状态等实用信息;技能系统为工具类应用注入了游戏化元素,提升了用户粘性。这些功能设计都不是凭空想象的,而是从露营爱好者的真实需求出发,解决了实际问题。
在组件实现方面,App 展示了丰富的 UI 构建技巧:渐变头卡营造视觉冲击力,纯代码柱状图实现数据可视化,水平滚动列表高效利用空间,双列卡片平衡信息密度,技能网格呈现游戏化成长体系。这些组件都是用 ArkTS 基础组件搭建而成的,没有依赖复杂的第三方库,但达到的视觉效果却相当专业。这充分展示了声明式 UI 开发的强大能力——只要设计得当,基础组件同样能构建出精美的界面。
在交互设计方面,三种类型的弹窗覆盖了新增、编辑、删除三种核心操作场景。统一的遮罩层、一致的卡片样式、相同的按钮布局,保证了交互体验的一致性。bindSheet 底部抽屉的使用也符合移动端的操作习惯。虽然没有复杂的转场动画,但核心交互流畅直观,满足了原型阶段的需求。
对于学习 ArkTS 开发的读者来说,这款 App 是一个非常好的实战案例。它涵盖了 @Component、@State、@Builder、@Entry、ForEach、条件渲染、事件绑定等核心概念,也展示了如何组织一个完整的应用项目。更重要的是,它演示了如何将一个具体的业务主题(露营)转化为设计语言和功能模块,这种"从业务到技术"的转化能力是开发者的核心竞争力。
更多推荐



所有评论(0)