基于HarmonyOS ArkTS API 24每个组件都是独立的 @Component struct,内部管理自己的局部状态和弹框逻辑
一、引言:鸿蒙原生应用与 ArkTS 声明式开发范式
HarmonyOS 6.1.1 作为华为全场景智慧操作系统的最新演进版本,其应用开发框架已经趋于成熟与稳定。在 HarmonyOS ArkTS API 24 的支撑下,开发者可以使用类型安全、静态检查严格的 ArkTS 语言,配合声明式 UI 语法,快速构建出性能优异、体验流畅的跨设备原生应用。ArkTS 在 TypeScript 的基础上做了进一步约束与增强,去除了动态类型特性,引入了 @State、@Prop、@Builder、@Observed 等装饰器,使得状态驱动的 UI 渲染更加高效可预测。本文将以一个名为"星野营地 CAMP HAUL"的露营装备运输与营地预订平台为例,完整剖析如何基于 HarmonyOS ArkTS API 24 构建一个包含七个 Tab 页面、四个弹框交互、萤火虫粒子动画的复杂业务应用,从数据建模到组件拆分、从状态管理到动画驱动,逐一深入解读每一行核心代码的设计意图与实现细节。

星野营地 CAMP HAUL 是一个面向户外露营爱好者的综合服务平台,其业务场景覆盖了露营装备管理(帐篷、天幕、桌椅、炊具、照明)、营地浏览与预订、装备运输调度、露营攻略阅读、营地天气预报、露搭社区互动以及个人中心等七大功能模块。整个应用采用户外大地色系作为视觉主题,以松林绿作为主色调,搭配篝火橙作为强调色,再以沙米色作为背景底色,营造出温暖而贴近自然的户外氛围。这种色彩策略不仅传递了品牌调性,更通过色彩的语义化映射——例如用松林绿表示"在家"状态、用天空蓝表示"在营地"状态、用篝火橙表示"在途"状态——让用户可以在信息密集的列表中快速识别关键状态,体现了色彩即信息的交互设计理念。
从架构角度来看,这个应用采用了典型的"单入口多组件"结构。顶层是一个标记了 @Entry 和 @Component 的 CampHaulApp 结构体作为应用入口,它负责管理全局状态(当前激活的 Tab、搜索关键词、露营风格筛选、萤火虫粒子数组),并通过条件渲染将不同的内容组件装配到主内容区。七个业务页面分别是 GearContent(装备页)、SiteContent(营地页)、ShipContent(运输页)、GuideContent(攻略页)、WeatherContent(天气页)、PostContent(社区页)和 ProfileContent(我的页),每个组件都是独立的 @Component struct,内部管理自己的局部状态和弹框逻辑。这种"外壳统一、内聚自治"的架构既保证了导航体验的一致性,又让每个功能模块可以独立开发和维护,符合 ArkTS 组件化开发的最佳实践。
在数据层面,应用为每个业务实体定义了 interface 接口(如 GearModel、SiteModel、ShipOrderModel 等)和 @Observed 修饰的可观察类(如 GearItem、SiteItem、ShipOrderItem 等)。@Observed 装饰器使得类的实例可以被 ArkTS 的状态观测系统追踪,当实例的属性发生变化时,依赖这些数据的 UI 组件会自动重新渲染。同时,应用大量使用了 Record<string, Meta> 类型的配置映射表(如 GEAR_STATUS_CONFIG、GEAR_CATEGORY_CONFIG、SHIP_STATUS_CONFIG、VEHICLE_CONFIG),将状态、类别、车型等枚举值与对应的标签、图标、颜色、背景色等视觉元数据集中管理,实现了数据与展示的解耦。这种配置驱动的模式让后续新增类别或调整配色时,只需修改配置表而无需改动渲染逻辑,大幅降低了维护成本。
二、应用整体架构与导航流程
上图展示了星野营地应用从入口组件到各业务页面的完整导航架构。CampHaulApp 作为 @Entry 入口组件,通过 build 方法将四个 @Builder 组合在一起:headerBuilder 提供全局搜索和风格筛选,contentArea 通过条件判断切换七个业务组件,tabBarBuilder 以两排布局呈现七个 Tab 入口,fireflyLayer 则作为全屏覆盖层提供萤火虫动画氛围。这种分层叠加的结构通过 Stack 容器实现,主内容层在下,粒子层在上但设置了 hitTestBehavior(HitTestMode.None) 不拦截触摸事件,确保用户交互不受动画层干扰。
三、逐段代码深度解析
3.1 主题色常量体系
const COLOR_BG: string = '#F7F4EF'
const COLOR_CARD: string = '#FFFFFF'
const COLOR_PRIMARY: string = '#4A7C59'
const COLOR_PRIMARY_LIGHT: string = '#8FBF9F'
const COLOR_ACCENT: string = '#C97B3D'
const COLOR_ACCENT_LIGHT: string = '#F0C8A0'
const COLOR_SKY: string = '#4A90A4'
const COLOR_GOLD: string = '#D4A843'
const COLOR_TEXT_MAIN: string = '#2C3A2E'
const COLOR_TEXT_SUB: string = '#7A8B7D'
const COLOR_TEXT_HINT: string = '#AFC2B2'
const COLOR_BORDER: string = '#DDE5DC'
const COLOR_SUCCESS: string = '#4A7C59'
const COLOR_WARNING: string = '#D4A843'
const COLOR_DANGER: string = '#C0392B'

应用在文件顶部定义了一套完整的主题色常量体系,这是整个视觉设计的基石。COLOR_BG 取值 #F7F4EF 是一种温暖的沙米色,模拟沙滩与干燥土地的色调,作为页面底色让用户感受到户外的温暖。COLOR_PRIMARY 取值 #4A7C59 是松林绿,象征常青的松树林,被用于主按钮、选中态、标题文字等需要强调品牌色的位置。COLOR_ACCENT 取值 #C97B3D 是篝火橙,用于价格、强调标签和操作引导,呼应篝火与夕阳的视觉记忆。COLOR_SKY 取值 #4A90A4 是天空蓝,COLOR_GOLD 取值 #D4A843 是金麦黄,分别用于星级评分和积分展示。文字色分为主文本、次文本和提示三级,从 #2C3A2E 深墨绿到 #AFC2B2 浅灰绿,形成清晰的信息层级。这套常量通过统一命名贯穿全文件,任何颜色调整只需在此处修改,体现了配置即设计的原则。
3.2 数据模型与 @Observed 可观察类
interface GearModel {
id: number
name: string
category: string
icon: string
weight: number
status: string
price: number
notes: string
}
@Observed
class GearItem implements GearModel {
id: number = 0
name: string = ''
category: string = '帐篷'
icon: string = '⛺'
weight: number = 0
status: string = '在家'
price: number = 0
notes: string = ''
constructor(id: number, name: string, category: string, icon: string,
weight: number, status: string, price: number, notes: string) {
this.id = id
this.name = name
this.category = category
this.icon = icon
this.weight = weight
this.status = status
this.price = price
this.notes = notes
}
}

这里以装备模型为例展示了应用数据建模的标准模式。首先定义一个 GearModel interface 声明所有字段的类型,包括 id、name、category、icon、weight、status、price、notes 共八个字段,每个字段都有明确的类型标注,确保编译期类型安全。随后用 @Observed 装饰的 GearItem 类 implements 该接口,@Observed 的作用是让 ArkTS 的响应式系统能够追踪实例属性的变化,当属性被重新赋值时,绑定到该属性的 UI 会自动刷新。类中所有字段都设置了默认值,这样即使不通过构造函数赋值也能拥有合理的初始状态。构造函数接收全部参数并逐一赋值给 this 上的对应属性,保证了对象创建的完整性。这种"接口定义契约、@Observed 类实现可观察、构造函数保证完整初始化"的三段式模式,在 SiteItem、ShipOrderItem、WeatherDayItem、GuideItem、PostItem、FireflyItem 等所有模型中重复使用,形成了统一的建模规范。
3.3 配置驱动的视觉元数据映射
interface GearStatusMeta {
label: string
icon: string
color: string
bg: string
}
const GEAR_STATUS_CONFIG: Record<string, GearStatusMeta> = {
'在家': { label: '在家', icon: '🏠', color: '#4A7C59', bg: '#EAF2EC' },
'在营地': { label: '在营地', icon: '🏕️', color: '#4A90A4', bg: '#E6F0F3' },
'在途': { label: '在途', icon: '🚚', color: '#C97B3D', bg: '#F8EDE2' }
}
const GEAR_CATEGORY_CONFIG: Record<string, GearCategoryMeta> = {
'帐篷': { label: '帐篷', icon: '⛺', color: '#4A7C59', bg: '#EAF2EC' },
'天幕': { label: '天幕', icon: '⛱️', color: '#4A90A4', bg: '#E6F0F3' },
'桌椅': { label: '桌椅', icon: '🪑', color: '#C97B3D', bg: '#F8EDE2' },
'炊具': { label: '炊具', icon: '🍳', color: '#C0392B', bg: '#F8E8E6' },
'照明': { label: '照明', icon: '💡', color: '#D4A843', bg: '#F9F2DE' }
}
const VEHICLE_CONFIG: Record<string, VehicleMeta> = {
'面包车': { label: '面包车', icon: '🚐', capacity: '3m³ · 800kg', loadPct: 65, color: '#4A7C59', desc: '适合2-3人轻装露营' },
'皮卡': { label: '皮卡', icon: '🛻', capacity: '开放货台 · 1200kg', loadPct: 40, color: '#C97B3D', desc: '适合大型天幕+桌椅' },
'厢货': { label: '厢货', icon: '🚛', capacity: '6m³ · 2000kg', loadPct: 82, color: '#4A90A4', desc: '适合团队/团建装备' }
}

应用通过 Record<string, Meta> 类型将枚举字符串与视觉元数据绑定,实现配置驱动的渲染策略。以 GEAR_STATUS_CONFIG 为例,"在家"状态映射到松林绿文字加浅绿背景和房屋图标,"在营地"映射到天空蓝加浅蓝背景和营地图标,"在途"映射到篝火橙加浅橙背景和卡车图标。这种设计让渲染代码中只需通过 GEAR_STATUS_CONFIG[g.status]?.color 即可获取对应颜色,无需在 if-else 中硬编码。GEAR_CATEGORY_CONFIG 为五个装备类别分别配置了图标和配色,VEHICLE_CONFIG 则为三种车型配置了容量、负载百分比、颜色和描述文案,后者还包含 loadPct 字段用于在 UI 中渲染进度条,实现了数据可视化与配置的统一。SHIPE_STATUS_CONFIG 同理为五种运输状态(待取件、运输中、已送达、已完成、已取消)映射了图标和色彩语义,让用户可以通过颜色快速判断订单进展。
3.4 辅助纯函数与数据统计
function getGearCountByStatus(status: string): number {
let count: number = 0
for (let i = 0; i < mockGears.length; i++) {
if (mockGears[i].status === status) {
count++
}
}
return count
}
function getGearCountByCategory(cat: string): number {
let count: number = 0
for (let i = 0; i < mockGears.length; i++) {
if (mockGears[i].category === cat) {
count++
}
}
return count
}
function getGearWeightSum(): number {
let sum: number = 0
for (let i = 0; i < mockGears.length; i++) {
sum += mockGears[i].weight
}
return sum
}
function getStarText(rating: number): string {
const full: number = Math.round(rating)
let stars: string = ''
for (let i = 0; i < 5; i++) {
if (i < full) {
stars += '★'
} else {
stars += '☆'
}
}
return stars
}
function formatPrice(price: number): string {
return '¥' + price.toString()
}
function formatViews(views: number): string {
return (views / 1000).toFixed(1) + 'k'
}

应用在全局作用域定义了一批纯函数用于数据统计和格式化。getGearCountByStatus 遍历 mockGears 数组统计指定状态的装备数量,getGearCountByCategory 统计指定类别的数量,getGearWeightSum 累加所有装备的重量。这三个函数在装备页的统计栏和类别分布图中被频繁调用,确保数据展示与底层数据的一致性。getStarText 将数值评分转换为五星字符串,通过 Math.round 取整后用循环拼接实心星和空心星,在营地卡片中直观展示评分。formatPrice 在数值前加货币符号,formatViews 将阅读量除以 1000 并保留一位小数加 k 后缀,例如 52310 变为 52.3k,让大数字在紧凑的列表卡片中更易阅读。这些纯函数不依赖任何组件状态,可以在任何地方安全调用,是数据处理层的核心工具集。
3.5 萤火虫粒子初始化函数
function initFireflies(): FireflyItem[] {
const list: FireflyItem[] = []
for (let i = 0; i < 12; i++) {
list.push(new FireflyItem(
4 + (i * 7.7) % 92,
6 + (i * 13.3) % 88,
0.3 + (i % 5) * 0.15,
8 + (i % 4) * 3,
(i % 2 === 0) ? 0.6 : -0.6
))
}
return list
}

initFireflies 函数负责创建 12 个萤火虫粒子作为初始数据。每个粒子的位置通过数学公式生成:x 坐标用 4 加上 i 乘 7.7 对 92 取模,y 坐标用 6 加上 i 乘 13.3 对 88 取模,这种取模运算确保粒子初始分布在屏幕的百分比坐标范围内且不重叠。透明度从 0.3 起步,每轮循环增加 0.15 形成层次感。字体大小从 8 起步,每隔四个粒子增加 3,模拟远近不同的萤火虫。drift(漂移方向)根据索引奇偶性取 0.6 或 -0.6,让一半粒子向右飘移、一半向左飘移,增强自然感。这个函数体现了用简单数学公式生成丰富视觉效果的编程艺术,避免了纯随机带来的初始分布不均问题。
3.6 Tab 枚举与入口组件状态定义
enum CampTab {
GEAR = 0,
SITE = 1,
SHIP = 2,
GUIDE = 3,
WEATHER = 4,
POST = 5,
PROFILE = 6
}
@Entry
@Component
struct CampHaulApp {
@State activeTab: CampTab = CampTab.GEAR
@State searchKeyword: string = ''
@State selectedScene: string = '全部'
@State fireflies: FireflyItem[] = initFireflies()
private timerId: number = -1
}

应用使用 enum CampTab 定义了七个 Tab 的枚举值,从 GEAR 等于 0 到 PROFILE 等于 6,这种数值枚举便于在条件渲染中做相等判断。CampHaulApp 作为 @Entry 入口组件,通过 @State 装饰器管理四个全局响应式状态:activeTab 记录当前激活的页面(默认装备页),searchKeyword 存储搜索框输入,selectedScene 记录当前选中的露营风格筛选(默认全部),fireflies 存储萤火虫粒子数组(通过 initFireflies 初始化)。@State 的核心作用是当这些变量的值发生变化时,ArkTS 框架会自动触发 build 方法的重新执行,让 UI 与状态保持同步。private timerId 用于保存定时器 ID 以便在组件销毁时清理,默认值 -1 表示尚未启动定时器。这种将全局状态集中在入口组件的设计,配合后续通过条件渲染将状态传递给子组件的方式,构成了应用的状态管理骨架。
3.7 生命周期与定时器驱动的萤火虫动画
aboutToAppear() {
this.timerId = setInterval(() => {
this.updateFireflies()
}, 280)
}
aboutToDisappear() {
if (this.timerId !== -1) {
clearInterval(this.timerId)
this.timerId = -1
}
}
updateFireflies() {
const next: FireflyItem[] = []
for (let i = 0; i < this.fireflies.length; i++) {
const f: FireflyItem = this.fireflies[i]
let nx: number = f.x + f.drift
if (nx > 96) {
nx = 2
}
if (nx < 2) {
nx = 96
}
let ny: number = f.y + (Math.random() - 0.5) * 3
if (ny > 92) {
ny = 90
}
if (ny < 4) {
ny = 6
}
next.push(new FireflyItem(nx, ny, 0.2 + Math.random() * 0.8, f.size, f.drift))
}
this.fireflies = next
}

aboutToAppear 是 ArkTS 组件的生命周期回调,在组件创建后、build 执行前调用,这里用于启动一个每 280 毫秒触发一次的 setInterval 定时器,将返回的 timer ID 保存到 this.timerId。aboutToDisappear 在组件销毁前调用,检查 timerId 是否有效后调用 clearInterval 清除定时器并将 ID 重置为 -1,防止内存泄漏和无效回调。updateFireflies 是动画的核心逻辑:遍历当前粒子数组,为每个粒子计算新的 x 坐标(原值加漂移量,超出 96 则回绕到 2,低于 2 则回绕到 96,实现循环穿越效果)和新的 y 坐标(原值加上一个 -1.5 到 1.5 的随机偏移,并限制在 4 到 92 的范围内防止飘出屏幕)。透明度重新随机为 0.2 到 1.0 之间,模拟萤火虫闪烁。最终用新数组替换 this.fireflies 触发 @State 的重新渲染,让萤火虫层每 280 毫秒更新一次位置和亮度,营造出夜晚萤火虫自然飘动的氛围效果。
3.8 头部构建器:Logo、搜索与风格筛选
@Builder headerBuilder() {
Column() {
Row() {
Column() {
Text('⛺').fontSize(24)
}
.width(40).height(40)
.backgroundColor(COLOR_PRIMARY)
.borderRadius(12)
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.Center)
.margin({ left: 14 })
Column() {
Text('星野营地').fontSize(17).fontWeight(FontWeight.Bold).fontColor(COLOR_PRIMARY)
Text('CAMP HAUL · 装备运输+营地预订').fontSize(8).fontColor(COLOR_TEXT_SUB).margin({ top: 1 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 8 })
Row() {
Text('🔍').fontSize(13).margin({ left: 8 })
TextInput({ placeholder: '搜索营地/装备' })
.placeholderColor(COLOR_TEXT_HINT)
.fontSize(12)
.layoutWeight(1)
.backgroundColor(COLOR_BG)
.borderRadius(16)
.onChange((v: string) => {
this.searchKeyword = v
})
}
.layoutWeight(1)
.height(36)
.backgroundColor(COLOR_BG)
.borderRadius(18)
.margin({ left: 10 })
.border({ width: 1, color: COLOR_BORDER })
}
.width('100%')
.height(56)
.alignItems(VerticalAlign.Center)
Scroll() {
Row() {
ForEach(SCENE_FILTERS, (scene: string) => {
if (this.selectedScene === scene) {
Text(scene)
.fontSize(11)
.fontColor(COLOR_CARD)
.backgroundColor(COLOR_PRIMARY)
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.borderRadius(14)
.margin({ left: 4, right: 4 })
} else {
Text(scene)
.fontSize(11)
.fontColor(COLOR_TEXT_SUB)
.backgroundColor(COLOR_BG)
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.borderRadius(14)
.margin({ left: 4, right: 4 })
.border({ width: 1, color: COLOR_BORDER })
.onClick(() => {
this.selectedScene = scene
})
}
})
}
.padding({ left: 10, right: 10 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.height(34)
.margin({ top: 2 })
}
.width('100%')
.backgroundColor(COLOR_CARD)
.padding({ bottom: 8 })
}

headerBuilder 是一个 @Builder 修饰的构建器函数,用于声明可复用的 UI 片段。它分为两行:第一行是横向 Row,包含一个带松林绿背景和圆角的帐篷图标 Logo、一个品牌名称和副标题的双行 Column、一个包含搜索图标和 TextInput 的搜索框(通过 layoutWeight 自适应宽度,onChange 回调将输入值同步到 searchKeyword 状态)、以及一个带红色未读数徽章的消息铃铛。第二行是一个横向可滚动的 Scroll 容器,内部用 ForEach 遍历 SCENE_FILTERS 数组(全部、精致露营地、野奢、星空、湖畔、山地、森林),对每个风格渲染一个药丸标签。选中态使用松林绿背景和白色文字,未选中态使用沙米色背景和灰色文字加边框,点击未选中标签时更新 selectedScene 状态触发重渲染。这种药丸筛选是移动端常见的交互模式,横向滚动支持更多选项而不占用垂直空间。
3.9 内容区条件渲染与 Tab 栏布局
@Builder contentArea() {
Column() {
if (this.activeTab === CampTab.GEAR) {
GearContent()
} else if (this.activeTab === CampTab.SITE) {
SiteContent()
} else if (this.activeTab === CampTab.SHIP) {
ShipContent()
} else if (this.activeTab === CampTab.GUIDE) {
GuideContent()
} else if (this.activeTab === CampTab.WEATHER) {
WeatherContent()
} else if (this.activeTab === CampTab.POST) {
PostContent()
} else {
ProfileContent()
}
}
.layoutWeight(1)
}
@Builder tabItem(icon: string, label: string, tab: CampTab) {
Column() {
Text(icon)
.fontSize(19)
.opacity(this.activeTab === tab ? 1.0 : 0.45)
Text(label)
.fontSize(9)
.fontColor(this.activeTab === tab ? COLOR_PRIMARY : COLOR_TEXT_SUB)
.fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 1 })
if (this.activeTab === tab) {
Column()
.width(16)
.height(3)
.backgroundColor(COLOR_PRIMARY)
.borderRadius(2)
.margin({ top: 2 })
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 4, bottom: 2 })
.onClick(() => {
this.activeTab = tab
})
}

contentArea 通过 if-else 链根据 activeTab 的值条件渲染对应的业务组件,layoutWeight(1) 让内容区占据 header 和 tabBar 之间的全部剩余空间。tabItem 是单个 Tab 项的构建器,接收图标、标签和枚举值三个参数,内部用三元表达式判断当前 Tab 是否激活:激活时图标不透明度为 1.0、标签文字为松林绿加粗,并在下方显示一个 16vp 宽、3vp 高的松林绿指示条;未激活时图标半透明、标签为灰色常规字重。点击事件将 activeTab 设置为传入的 tab 值,触发整个组件重新渲染,选中的 Tab 获得高亮指示条,内容区同步切换到对应组件。这种用条件渲染替代路由跳转的方式,让七个页面共享同一组件树实例,切换时无页面重建开销,体验流畅。
3.10 萤火虫粒子层与主构建方法
@Builder fireflyLayer() {
Stack() {
ForEach(this.fireflies, (f: FireflyItem) => {
Text('✦')
.fontSize(f.size)
.fontColor('#D8E870')
.opacity(f.opacity)
.position({ x: f.x.toString() + '%', y: f.y.toString() + '%' })
})
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.None)
}
build() {
Stack() {
Column() {
this.headerBuilder()
this.contentArea()
this.tabBarBuilder()
}
.width('100%')
.height('100%')
this.fireflyLayer()
}
.width('100%')
.height('100%')
.backgroundColor(COLOR_BG)
}

fireflyLayer 用 Stack 容器叠加所有萤火虫粒子,每个粒子是一个 ‘✦’ 字符,字体大小取自粒子的 size 属性(8 到 17vp 之间),颜色统一为 #D8E870 暖黄色模拟萤火虫发光,透明度取自粒子的 opacity 属性,位置通过 position 方法以百分比坐标定位。关键是设置了 hitTestBehavior(HitTestMode.None),这让粒子层不参与触摸命中测试,所有触摸事件穿透到下层的主内容区,用户不会因为粒子覆盖而无法操作界面。主 build 方法用 Stack 将主内容层(Column 包含 header、content、tabBar)和萤火虫层叠加在一起,主内容层在下占满全屏,萤火虫层在上覆盖全屏但透明且不拦截事件。整个 Stack 背景设为 COLOR_BG 沙米色,奠定了应用的底色基调。这种分层叠加架构在保持视觉丰富度的同时确保了交互的可靠性。
3.11 装备页统计栏与类别分布图
build() {
Stack() {
Column() {
Row() {
Column() {
Text(mockGears.length.toString())
.fontSize(19)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_TEXT_MAIN)
Text('总装备').fontSize(9).fontColor(COLOR_TEXT_SUB)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column().width(1).height(28).backgroundColor(COLOR_BORDER)
Column() {
Text(getGearCountByStatus('在家').toString())
.fontSize(19)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_PRIMARY)
Text('在家').fontSize(9).fontColor(COLOR_TEXT_SUB)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLOR_CARD)
Scroll() {
Column() {
Column() {
Text('📦 装备类别分布 · 总重' + getGearWeightSum().toFixed(1) + 'kg')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_TEXT_MAIN)
.width('100%')
.padding({ left: 14, top: 12, bottom: 8 })
Row() {
ForEach(GEAR_CATEGORIES, (cat: string) => {
Column() {
Text(GEAR_CATEGORY_CONFIG[cat]?.icon ?? '')
.fontSize(17)
Text(getGearCountByCategory(cat).toString())
.fontSize(19)
.fontWeight(FontWeight.Bold)
.fontColor(GEAR_CATEGORY_CONFIG[cat]?.color ?? COLOR_PRIMARY)
.margin({ top: 2 })
Text(cat)
.fontSize(9)
.fontColor(COLOR_TEXT_SUB)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 8, bottom: 8 })
.backgroundColor(GEAR_CATEGORY_CONFIG[cat]?.bg ?? COLOR_BG)
.borderRadius(10)
.margin({ left: 3, right: 3 })
})
}
.padding({ left: 10, right: 10, bottom: 12 })
}
.width('100%')
.backgroundColor(COLOR_CARD)
.borderRadius(12)
.margin({ left: 12, right: 12, top: 10 })
.border({ width: 1, color: COLOR_BORDER })
}
.padding({ bottom: 20 })
}
.layoutWeight(1)
.scrollBar(BarState.Off)
}
.width('100%')
.height('100%')
if (this.showEditModal) {
this.editGearModal()
}
}
.width('100%')
.height('100%')
}

装备页的 build 方法展示了典型的页面布局结构。顶部是一个四列统计 Row,分别展示总装备数、在家数量、在营地数量和在途数量,每列之间用 1vp 宽的灰色分隔线隔开,数字使用 19vp 加粗字体,标签使用 9vp 灰色小字,颜色根据状态语义取值(在家用松林绿、在营地用天空蓝、在途用篝火橙)。统计栏下方是可滚动内容区,首先是一个类别分布卡片:标题用 getGearWeightSum 获取总重量并拼接,然后用 ForEach 遍历 GEAR_CATEGORIES 五个类别,每个类别渲染一个带对应背景色的小方块,内含图标、数量(通过 getGearCountByCategory 统计)、类别名,数量文字使用类别对应的颜色。这种统计加可视化的组合让用户一眼掌握装备全局分布。整个页面用 Stack 包裹,当 showEditModal 为 true 时叠加编辑弹框,实现模态交互。弹框通过 zIndex(999) 确保浮于最上层,遮罩层用半透明深绿色覆盖全屏并绑定点击关闭事件。
3.12 装备编辑弹框:表单交互与状态收集
@Builder editGearModal() {
Column() {
this.modalOverlay(() => {
this.showEditModal = false
})
Column() {
Row() {
Text('✏️ 编辑装备信息')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_TEXT_MAIN)
Column().layoutWeight(1)
Text('✕')
.fontSize(17)
.fontColor(COLOR_TEXT_HINT)
.onClick(() => {
this.showEditModal = false
})
}
.width('100%')
.padding({ left: 20, right: 20, top: 16, bottom: 10 })
Divider().color(COLOR_BORDER)
Scroll() {
Column() {
Text('装备名称')
.fontSize(12)
.fontColor(COLOR_TEXT_SUB)
.width('100%')
.margin({ top: 10 })
TextInput({ placeholder: this.editingGear?.name ?? '' })
.placeholderColor(COLOR_TEXT_HINT)
.fontSize(13)
.width('100%')
.backgroundColor(COLOR_BG)
.borderRadius(10)
.margin({ top: 4 })
.onChange((v: string) => {
this.editName = v
})
Row() {
ForEach(GEAR_STATUSES, (st: string) => {
if (this.editStatus === st) {
Text((GEAR_STATUS_CONFIG[st]?.icon ?? '') + ' ' + st)
.fontSize(11)
.fontColor(COLOR_CARD)
.backgroundColor(GEAR_STATUS_CONFIG[st]?.color ?? COLOR_PRIMARY)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.borderRadius(12)
} else {
Text((GEAR_STATUS_CONFIG[st]?.icon ?? '') + ' ' + st)
.fontSize(11)
.fontColor(GEAR_STATUS_CONFIG[st]?.color ?? COLOR_TEXT_SUB)
.backgroundColor(COLOR_BG)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.borderRadius(12)
.border({ width: 1, color: COLOR_BORDER })
.onClick(() => {
this.editStatus = st
})
}
})
}
.margin({ top: 4 })
}
.padding({ left: 20, right: 20, bottom: 14 })
}
.layoutWeight(1)
Row() {
Text('取消')
.fontSize(14)
.fontColor(COLOR_TEXT_SUB)
.backgroundColor(COLOR_BG)
.borderRadius(18)
.padding({ left: 26, right: 26, top: 9, bottom: 9 })
.border({ width: 1, color: COLOR_BORDER })
.onClick(() => {
this.showEditModal = false
})
Text('保存')
.fontSize(14)
.fontColor(COLOR_CARD)
.backgroundColor(COLOR_PRIMARY)
.borderRadius(18)
.padding({ left: 26, right: 26, top: 9, bottom: 9 })
.margin({ left: 12 })
.onClick(() => {
this.showEditModal = false
})
}
.width('100%')
.justifyContent(FlexAlign.Center)
.padding({ top: 12, bottom: 16 })
}
.width('85%')
.height('60%')
.backgroundColor(COLOR_CARD)
.borderRadius(16)
.position({ x: '7.5%', y: '18%' })
}
.width('100%')
.height('100%')
.position({ x: 0, y: 0 })
.zIndex(999)
}
editGearModal 是装备页的第一个弹框,采用卡片式布局,宽度 85%、高度 60%,通过 position 定位在屏幕上方 18% 处居中。弹框首先渲染 modalOverlay 遮罩层(全屏半透明深绿色,点击关闭),然后在 Column 中从上到下依次排列:标题行(编辑装备信息加关闭按钮)、分割线、可滚动表单区、底部按钮行。表单区内包含装备名称 TextInput、类别选择药丸(遍历 GEAR_CATEGORIES,选中态为松林绿背景)、重量和价格的并排输入框、状态选择药丸(遍历 GEAR_STATUSES,选中态使用各状态对应的语义色)、备注 TextArea。每个输入控件的 onChange 回调将值同步到对应的 @State 变量(editName、editCategory、editWeight、editPrice、editStatus、editNotes),完成表单数据的收集。底部是取消和保存两个按钮,取消使用沙米色背景加边框,保存使用松林绿背景加白色文字,两者都通过设置 showEditModal 为 false 关闭弹框。editingGear 通过可选链(?.)提供 placeholder 默认值,确保未选择装备时不会崩溃。
3.13 营地预订弹框:表单式交互与费用计算
@Builder siteBookingModal() {
Column() {
this.modalOverlay(() => {
this.showBookingModal = false
})
Column() {
Row() {
Text('🏕️ 营地预订')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_CARD)
Column().layoutWeight(1)
Text('✕')
.fontSize(16)
.fontColor('#EAF2EC')
.onClick(() => {
this.showBookingModal = false
})
}
.width('100%')
.padding({ left: 18, right: 18, top: 14, bottom: 12 })
.backgroundColor(COLOR_PRIMARY)
Scroll() {
Column() {
Row() {
Column() {
Text('⛺')
.fontSize(34)
}
.width(86)
.height(72)
.linearGradient({
angle: 120,
colors: [[this.selectedSite?.color ?? COLOR_PRIMARY, 0], [COLOR_ACCENT_LIGHT, 1]]
})
.borderRadius(12)
Column() {
Text(this.selectedSite?.name ?? '')
Text('📍 ' + (this.selectedSite?.location ?? ''))
Row() {
Text(getStarText(this.selectedSite?.rating ?? 4.5))
Text((this.selectedSite?.rating ?? 4.5).toString())
Text('可容纳' + (this.selectedSite?.capacity ?? 4) + '人')
}
}
.layoutWeight(1)
Column() {
Text(formatPrice(this.selectedSite?.price ?? 300))
Text('/晚')
}
}
Row() {
Text('−')
.fontSize(20)
.width(38)
.height(38)
.borderRadius(19)
.onClick(() => {
if (this.bookingPeople > 1) {
this.bookingPeople--
}
})
Text(this.bookingPeople.toString() + ' 人')
Text('+')
.onClick(() => {
if (this.bookingPeople < (this.selectedSite?.capacity ?? 4)) {
this.bookingPeople++
}
})
}
Row() {
Text('营地费用')
Text(formatPrice(this.selectedSite?.price ?? 300))
Text('装备运输(到营地)')
Text('¥99')
Text('合计')
Text(formatPrice((this.selectedSite?.price ?? 300) + 99))
}
.backgroundColor(COLOR_BG)
.borderRadius(10)
Text('🚀 立即预订 · 装备同步运输')
.backgroundColor(COLOR_PRIMARY)
.onClick(() => {
this.showBookingModal = false
})
}
}
.layoutWeight(1)
}
.width('92%')
.height('70%')
.backgroundColor(COLOR_CARD)
.borderRadius(16)
.position({ x: '4%', y: '14%' })
}
.zIndex(999)
}
营地预订弹框是应用中最复杂的表单交互之一,宽度 92%、高度 70%,采用松林绿标题栏加白色表单区的设计。标题栏使用 COLOR_PRIMARY 背景配白色文字,关闭按钮用浅绿色确保在深色背景上可读。表单可滚动区首先展示营地信息卡:左侧是用 linearGradient 渐变(从营地自身颜色到浅橙色)填充的图标色块,右侧是营地名称、地址、星级评分(通过 getStarText 转换)、可容纳人数和价格。接着是日期选择药丸(今晚、明晚、周末)、人数步进器(减号和加号按钮,减号在 bookingPeople 大于 1 时递减,加号在小于营地容量时递增,实现边界约束)、帐篷方案选择(自带、租赁、豪华)。费用明细区展示营地费用和装备运输费 99 元,合计通过运行时计算 (selectedSite?.price ?? 300) + 99 得出,体现了 ArkTS 表达式直接在 UI 中参与计算的能力。底部立即预订按钮点击后关闭弹框,在实际应用中这里应触发订单提交流程。整个弹框通过 selectedSite 的可选链访问属性并设置默认值,保证了在未选择营地时的安全渲染。
3.14 运输预约弹框:多选装备与车型选择
toggleShipGear(id: number) {
const next: number[] = []
let found: boolean = false
for (let i = 0; i < this.shipGearIds.length; i++) {
if (this.shipGearIds[i] === id) {
found = true
} else {
next.push(this.shipGearIds[i])
}
}
if (!found) {
next.push(id)
}
this.shipGearIds = next
}
@Builder shipBookingModal() {
Column() {
this.modalOverlay(() => {
this.showShipModal = false
})
Column() {
Row() {
Text('⛺ 预约装备运输')
.fontColor(COLOR_CARD)
.backgroundColor(COLOR_PRIMARY)
}
Scroll() {
Column() {
Text('选择装备(已选' + this.shipGearIds.length + '件)')
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(mockGears, (g: GearItem) => {
if (this.shipGearIds.indexOf(g.id) >= 0) {
Text(g.icon + ' ' + g.name)
.backgroundColor(COLOR_PRIMARY)
.onClick(() => {
this.toggleShipGear(g.id)
})
} else {
Text(g.icon + ' ' + g.name)
.backgroundColor(COLOR_BG)
.border({ width: 1, color: COLOR_BORDER })
.onClick(() => {
this.toggleShipGear(g.id)
})
}
})
}
TextInput({ placeholder: '如:杭州 · 滨江仓库' })
.onChange((v: string) => {
this.formPickup = v
})
Row() {
ForEach(VEHICLE_TYPES, (v: string) => {
if (this.formVehicle === v) {
Text((VEHICLE_CONFIG[v]?.icon ?? '') + ' ' + v)
.backgroundColor(COLOR_ACCENT)
} else {
Text((VEHICLE_CONFIG[v]?.icon ?? '') + ' ' + v)
.onClick(() => {
this.formVehicle = v
})
}
})
}
Row() {
ForEach(SPECIAL_REQS, (r: string) => {
if ((r === '上门取' && this.reqPickup) || (r === '安装' && this.reqInstall)
|| (r === '加急' && this.reqRush)) {
Text('✓ ' + r)
.backgroundColor('#EAF2EC')
.onClick(() => {
if (r === '上门取') {
this.reqPickup = false
} else if (r === '安装') {
this.reqInstall = false
} else {
this.reqRush = false
}
})
}
})
}
}
}
Row() {
Text('取消')
Text('确认预约')
.backgroundColor(COLOR_PRIMARY)
}
}
.width('90%')
.height('75%')
.position({ x: '5%', y: '12%' })
}
.zIndex(999)
}
运输预约弹框是应用中表单最丰富的弹框,宽度 90%、高度 75%。其核心特色是装备多选交互:toggleShipGear 方法遍历当前已选 ID 数组,如果找到目标 ID 则跳过(实现取消选中),否则将其加入新数组(实现选中),最终用新数组替换 shipGearIds 触发重渲染。UI 上用 Flex 容器配合 FlexWrap.Wrap 实现自动换行的药丸列表,已选装备显示为松林绿背景,未选显示为沙米色加边框,点击切换状态。出发地、目的营地、运输日期三个 TextInput 分别收集到 formPickup、formDest、formDate 状态。车型选择遍历 VEHICLE_TYPES(面包车、皮卡、厢货),选中态使用篝火橙背景。特殊要求使用三个独立的布尔状态(reqPickup、reqInstall、reqRush)控制多选切换,选中时显示对勾前缀和松林绿边框。底部备注 TextArea 收集 formNotes。这种一个弹框内集成多选、单选、文本输入、多状态切换的复杂表单,充分展示了 ArkTS 声明式 UI 在处理复杂交互时的表达力。
3.15 天气页:预警卡、观星指数与温度柱状图
@Builder weatherDayCard(w: WeatherDayItem) {
Column() {
Text(w.date)
.fontSize(10)
.fontColor(COLOR_TEXT_SUB)
Text(w.icon)
.fontSize(26)
.margin({ top: 6 })
Text(w.tempMin + '° ~ ' + w.tempMax + '°')
.fontSize(11)
.fontWeight(FontWeight.Medium)
.fontColor(COLOR_TEXT_MAIN)
.margin({ top: 6 })
Text(w.wind)
.fontSize(9)
.fontColor(COLOR_TEXT_HINT)
.margin({ top: 3 })
Row() {
Text('✦' + w.starIndex)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_GOLD)
}
.backgroundColor('#F9F2DE')
.borderRadius(9)
.padding({ left: 7, right: 7, top: 2, bottom: 2 })
.margin({ top: 5 })
}
.width(88)
.backgroundColor(COLOR_CARD)
.borderRadius(12)
.padding({ top: 10, bottom: 10 })
.border({ width: 1, color: COLOR_BORDER })
}
天气页是应用中数据可视化最丰富的页面。weatherDayCard 构建器渲染单日天气卡片,包含日期、天气图标、温度区间、风力信息和观星指数(用金色星星加数字表示,背景为浅金色药丸)。在页面主体中,首先是一个橙色预警卡,用篝火橙边框和浅橙背景突出周五夜间的阵风预警信息。接着是观星指数大数字展示,用 44vp 的超大金色字体显示 5.0,旁边搭配月亮图标和新月月相说明。七日天气预报通过横向滚动 Scroll 展示七个 weatherDayCard。最具特色的是温度走势柱状图:用 ForEach 遍历 mockWeather,每天渲染一个 Column,内含温度数值、一个用 linearGradient 从金色到橙色渐变填充的柱子(高度通过 tempBarHeight 函数根据最高温计算)、日期缩写。tempBarHeight 的实现是 ((tempMax - 20) * 9 + 24).toFixed(0) + ‘vp’,将温度差映射为像素高度,温度越高柱子越高,直观展示一周温度趋势。这种纯 ArkTS 声明式实现的数据可视化,无需引入图表库即可达到良好的信息表达效果。
3.16 社区页与个人中心页
@Builder postCardBuilder(p: PostItem) {
Column() {
Row() {
Column() {
Text(p.avatar)
.fontSize(22)
}
.width(40)
.height(40)
.backgroundColor(COLOR_BG)
.borderRadius(20)
Column() {
Text(p.username)
.fontSize(13)
.fontWeight(FontWeight.Bold)
Text('🏕️ ' + p.campTag)
.fontSize(9)
.fontColor(COLOR_SKY)
}
.layoutWeight(1)
Text('关注')
.fontColor(COLOR_PRIMARY)
.backgroundColor('#EAF2EC')
.onClick(() => {
console.info('关注用户:' + p.username)
})
}
Text(p.content)
.fontSize(12)
.lineHeight(18)
.margin({ top: 8 })
Row() {
Column() {
Text('🌄')
.fontSize(28)
}
.layoutWeight(2)
.height(110)
.linearGradient({
angle: 135,
colors: [[p.color, 0], [COLOR_ACCENT_LIGHT, 1]]
})
Column() {
Text('🔥')
.fontSize(20)
}
.layoutWeight(1)
.height(110)
.backgroundColor(COLOR_BG)
}
Row() {
Text('❤️ ' + p.likes)
Text('💬 ' + p.comments)
Column().layoutWeight(1)
Text('刚刚')
}
}
.backgroundColor(COLOR_CARD)
.borderRadius(12)
.onClick(() => {
console.info('打开帖子详情:' + p.username)
})
}
社区页的 postCardBuilder 渲染社交信息流卡片,包含用户头像、用户名、营地标签、关注按钮、帖子正文、图片色块组合(一个大的渐变色块配两个小色块,使用 layoutWeight 控制宽度比例为 2:1:1)、点赞和评论数。每个帖子的图片色块用 linearGradient 从帖子自身颜色渐变到浅橙色,让信息流在无真实图片的情况下也具备视觉层次。个人中心页包含资料卡(用渐变背景展示头像、用户名、户外等级和成就标签)、2x2 统计网格(总营地、总运输、装备数、积分四个大数字卡)、装备状态小结(用三段进度条按在家、在营地、在途的比例展示装备分布,宽度通过 getGearCountByStatus 取值拼接百分比字符串实现)和设置列表(装备、常用营地、优惠券、客服、设置五项,用 Divider 分隔)。底部版本号文字居中显示,完成整个应用的收尾。
四、弹框交互与状态流转
上图展示了四个弹框的状态触发与流转关系。每个弹框都遵循"点击触发 showXxxModal 为 true、弹框内交互收集状态、保存或取消时设为 false"的模式。modalOverlay 遮罩层统一使用 rgba(44,58,46,0.55) 半透明深绿色,点击遮罩也关闭弹框。弹框通过 position 百分比定位实现居中或偏上布局,zIndex(999) 保证浮于内容之上。运输弹框的多选交互是一个闭环:toggleShipGear 修改 shipGearIds、触发 Flex 内 ForEach 重新渲染、已选与未选的视觉态实时更新,用户可以在弹框内反复增减装备直到满意再确认。
五、关键设计模式对比
| 设计维度 | 实现方式 | 优势 | 适用场景 |
|---|---|---|---|
| 状态管理 | @State 装饰器 + @Observed 类 | 自动驱动 UI 刷新,无需手动操作 DOM | 组件内响应式状态 |
| 全局配色 | 顶层 const 常量 | 统一管理,修改成本极低 | 全应用视觉一致性 |
| 视觉元数据 | Record 映射配置 | 数据与展示解耦,扩展只改配置 | 状态/类别等多枚举场景 |
| 可复用 UI | @Builder 构建器 | 提取公共片段,减少重复代码 | 头部、Tab 项、卡片模板 |
| 组件拆分 | @Component struct | 内聚自治,独立维护 | 七个业务页面 |
| 条件渲染 | if-else 链 + @State | 无路由开销,切换流畅 | Tab 页面切换 |
| 模态弹框 | Stack 叠 + zIndex + 布尔开关 | 灵活控制弹框显隐 | 编辑、预订、取消等表单 |
| 数据统计 | 纯函数遍历 mock 数据 | 无副作用,可安全复用 | 统计栏、分布图 |
| 动画驱动 | setInterval + 数组替换 | 轻量实现持续动画 | 萤火虫粒子效果 |
| 数据可视化 | linearGradient + 高度计算 | 无需图表库,纯声明式 | 温度柱状图、进度条 |
| 安全访问 | 可选链 ?. 加默认值 | 防止 null/undefined 崩溃 | 弹框中引用选中对象 |
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

设置API为24的模板项目:

初始化项目,自动下载相关依赖:

完整代码:
// ============================================================================
// 星野营地 CAMP HAUL — 露营装备运输 + 营地预订平台
// 场景:露营装备(帐篷/天幕/桌椅)运输到营地 + 营地预订 + 露搭社区
// 主题:户外大地色系 · 松林绿 × 篝火橙 × 沙米色
// 页面:7个Tab(装备/营地/运输/攻略/天气/社区/我的) + 4个弹框 + 萤火虫粒子
// ============================================================================
// ============ 主题色常量 ============
const COLOR_BG: string = '#F7F4EF'
const COLOR_CARD: string = '#FFFFFF'
const COLOR_PRIMARY: string = '#4A7C59'
const COLOR_PRIMARY_LIGHT: string = '#8FBF9F'
const COLOR_ACCENT: string = '#C97B3D'
const COLOR_ACCENT_LIGHT: string = '#F0C8A0'
const COLOR_SKY: string = '#4A90A4'
const COLOR_GOLD: string = '#D4A843'
const COLOR_TEXT_MAIN: string = '#2C3A2E'
const COLOR_TEXT_SUB: string = '#7A8B7D'
const COLOR_TEXT_HINT: string = '#AFC2B2'
const COLOR_BORDER: string = '#DDE5DC'
const COLOR_SUCCESS: string = '#4A7C59'
const COLOR_WARNING: string = '#D4A843'
const COLOR_DANGER: string = '#C0392B'
// ============ 数据模型:装备 ============
interface GearModel {
id: number
name: string
category: string
icon: string
weight: number
status: string
price: number
notes: string
}
@Observed
class GearItem implements GearModel {
id: number = 0
name: string = ''
category: string = '帐篷'
icon: string = '⛺'
weight: number = 0
status: string = '在家'
price: number = 0
notes: string = ''
constructor(id: number, name: string, category: string, icon: string, weight: number, status: string, price: number, notes: string) {
this.id = id
this.name = name
this.category = category
this.icon = icon
this.weight = weight
this.status = status
this.price = price
this.notes = notes
}
}
// ============ 数据模型:营地 ============
interface SiteModel {
id: number
name: string
location: string
rating: number
price: number
capacity: number
tags: string
color: string
}
@Observed
class SiteItem implements SiteModel {
id: number = 0
name: string = ''
location: string = ''
rating: number = 4.5
price: number = 300
capacity: number = 4
tags: string = ''
color: string = '#4A7C59'
constructor(id: number, name: string, location: string, rating: number, price: number, capacity: number, tags: string, color: string) {
this.id = id
this.name = name
this.location = location
this.rating = rating
this.price = price
this.capacity = capacity
this.tags = tags
this.color = color
}
}
// ============ 数据模型:运输订单 ============
interface ShipOrderModel {
id: number
orderNo: string
pickup: string
destination: string
gearCount: number
price: number
status: string
date: string
}
@Observed
class ShipOrderItem implements ShipOrderModel {
id: number = 0
orderNo: string = ''
pickup: string = ''
destination: string = ''
gearCount: number = 0
price: number = 0
status: string = '待取件'
date: string = ''
constructor(id: number, orderNo: string, pickup: string, destination: string, gearCount: number, price: number, status: string, date: string) {
this.id = id
this.orderNo = orderNo
this.pickup = pickup
this.destination = destination
this.gearCount = gearCount
this.price = price
this.status = status
this.date = date
}
}
// ============ 数据模型:天气 ============
interface WeatherDayModel {
id: number
date: string
icon: string
tempMin: number
tempMax: number
wind: string
starIndex: number
}
@Observed
class WeatherDayItem implements WeatherDayModel {
id: number = 0
date: string = ''
icon: string = '☀️'
tempMin: number = 15
tempMax: number = 25
wind: string = '微风'
starIndex: number = 3
constructor(id: number, date: string, icon: string, tempMin: number, tempMax: number, wind: string, starIndex: number) {
this.id = id
this.date = date
this.icon = icon
this.tempMin = tempMin
this.tempMax = tempMax
this.wind = wind
this.starIndex = starIndex
}
}
// ============ 数据模型:攻略 ============
interface GuideModel {
id: number
title: string
author: string
views: number
likes: number
tag: string
color: string
}
@Observed
class GuideItem implements GuideModel {
id: number = 0
title: string = ''
author: string = ''
views: number = 0
likes: number = 0
tag: string = '新手'
color: string = '#4A7C59'
constructor(id: number, title: string, author: string, views: number, likes: number, tag: string, color: string) {
this.id = id
this.title = title
this.author = author
this.views = views
this.likes = likes
this.tag = tag
this.color = color
}
}
// ============ 数据模型:社区帖子 ============
interface PostModel {
id: number
avatar: string
username: string
campTag: string
content: string
likes: number
comments: number
color: string
}
@Observed
class PostItem implements PostModel {
id: number = 0
avatar: string = '🧑'
username: string = ''
campTag: string = ''
content: string = ''
likes: number = 0
comments: number = 0
color: string = '#4A90A4'
constructor(id: number, avatar: string, username: string, campTag: string, content: string, likes: number, comments: number, color: string) {
this.id = id
this.avatar = avatar
this.username = username
this.campTag = campTag
this.content = content
this.likes = likes
this.comments = comments
this.color = color
}
}
// ============ 数据模型:萤火虫粒子 ============
interface FireflyModel {
x: number
y: number
opacity: number
size: number
drift: number
}
@Observed
class FireflyItem implements FireflyModel {
x: number = 10
y: number = 10
opacity: number = 0.6
size: number = 10
drift: number = 0.5
constructor(x: number, y: number, opacity: number, size: number, drift: number) {
this.x = x
this.y = y
this.opacity = opacity
this.size = size
this.drift = drift
}
}
// ============ 配置:装备状态 ============
interface GearStatusMeta {
label: string
icon: string
color: string
bg: string
}
const GEAR_STATUS_CONFIG: Record<string, GearStatusMeta> = {
'在家': { label: '在家', icon: '🏠', color: '#4A7C59', bg: '#EAF2EC' },
'在营地': { label: '在营地', icon: '🏕️', color: '#4A90A4', bg: '#E6F0F3' },
'在途': { label: '在途', icon: '🚚', color: '#C97B3D', bg: '#F8EDE2' }
}
// ============ 配置:装备类别 ============
interface GearCategoryMeta {
label: string
icon: string
color: string
bg: string
}
const GEAR_CATEGORY_CONFIG: Record<string, GearCategoryMeta> = {
'帐篷': { label: '帐篷', icon: '⛺', color: '#4A7C59', bg: '#EAF2EC' },
'天幕': { label: '天幕', icon: '⛱️', color: '#4A90A4', bg: '#E6F0F3' },
'桌椅': { label: '桌椅', icon: '🪑', color: '#C97B3D', bg: '#F8EDE2' },
'炊具': { label: '炊具', icon: '🍳', color: '#C0392B', bg: '#F8E8E6' },
'照明': { label: '照明', icon: '💡', color: '#D4A843', bg: '#F9F2DE' }
}
// ============ 配置:运输状态 ============
interface ShipStatusMeta {
label: string
icon: string
color: string
bg: string
}
const SHIP_STATUS_CONFIG: Record<string, ShipStatusMeta> = {
'待取件': { label: '待取件', icon: '📦', color: '#D4A843', bg: '#F9F2DE' },
'运输中': { label: '运输中', icon: '🚚', color: '#4A90A4', bg: '#E6F0F3' },
'已送达': { label: '已送达', icon: '🏕️', color: '#4A7C59', bg: '#EAF2EC' },
'已完成': { label: '已完成', icon: '✅', color: '#7A8B7D', bg: '#F0F3F0' },
'已取消': { label: '已取消', icon: '🚫', color: '#C0392B', bg: '#F8E8E6' }
}
// ============ 配置:车型容量 ============
interface VehicleMeta {
label: string
icon: string
capacity: string
loadPct: number
color: string
desc: string
}
const VEHICLE_CONFIG: Record<string, VehicleMeta> = {
'面包车': { label: '面包车', icon: '🚐', capacity: '3m³ · 800kg', loadPct: 65, color: '#4A7C59', desc: '适合2-3人轻装露营' },
'皮卡': { label: '皮卡', icon: '🛻', capacity: '开放货台 · 1200kg', loadPct: 40, color: '#C97B3D', desc: '适合大型天幕+桌椅' },
'厢货': { label: '厢货', icon: '🚛', capacity: '6m³ · 2000kg', loadPct: 82, color: '#4A90A4', desc: '适合团队/团建装备' }
}
// ============ 静态数据:10件装备 ============
const mockGears: GearItem[] = [
new GearItem(1, '云隙隧道帐篷', '帐篷', '⛺', 4.2, '在家', 1299, '双层防雨,可睡4人'),
new GearItem(2, '沙丘六边形天幕', '天幕', '⛱️', 2.8, '在家', 699, '240x240黑胶涂银'),
new GearItem(3, '蜂鸟折叠桌+月影椅', '桌椅', '🪑', 7.5, '在营地', 899, '一套桌四把椅'),
new GearItem(4, '铸铁煎锅焚火架', '炊具', '🍳', 5.1, '在家', 459, '明火煎牛排神器'),
new GearItem(5, '星火一体炉头', '炊具', '🔥', 0.9, '在家', 329, '防风功率2500W'),
new GearItem(6, '松果氛围灯串', '照明', '💡', 0.6, '在营地', 159, '暖光8米铜线'),
new GearItem(7, '萤火虫营地灯', '照明', '🔦', 0.4, '在途', 269, '磁吸充电三档调光'),
new GearItem(8, '荒原羽绒睡袋', '帐篷', '🛌', 1.9, '在营地', 799, '舒适温标-5℃'),
new GearItem(9, '山脊蛋卷桌', '桌椅', '🪵', 3.6, '在家', 399, '铝合金120cm'),
new GearItem(10, '篝火折叠烤架', '炊具', '🍢', 4.4, '在途', 549, '不锈钢可调高度')
]
// ============ 静态数据:8个营地 ============
const mockSites: SiteItem[] = [
new SiteItem(1, '云顶星野营地', '浙江 · 莫干山', 4.9, 488, 4, '观星|云海|宠物友好', '#4A7C59'),
new SiteItem(2, '洱海湖畔营地', '云南 · 大理', 4.8, 398, 6, '湖畔|日出|垂钓', '#4A90A4'),
new SiteItem(3, '松涛森林营地', '四川 · 彭州', 4.7, 328, 4, '森林|溪流|亲子', '#2C3A2E'),
new SiteItem(4, '雪山观星营地', '云南 · 丽江', 4.9, 568, 2, '雪山|观星|野奢', '#4A90A4'),
new SiteItem(5, '溪谷野奢营地', '浙江 · 安吉', 4.6, 888, 2, '野奢|温泉|私汤', '#C97B3D'),
new SiteItem(6, '稻田亲子营地', '广西 · 阳朔', 4.5, 268, 8, '亲子|稻田|骑行', '#D4A843'),
new SiteItem(7, '峡谷风营地', '湖北 · 恩施', 4.4, 298, 5, '峡谷|溯溪|篝火', '#7A8B7D'),
new SiteItem(8, '湖心岛野营地', '江苏 · 千岛湖', 4.7, 358, 3, '湖岛|桨板|静谧', '#4A90A4')
]
// ============ 静态数据:6条运输记录 ============
const mockShipOrders: ShipOrderItem[] = [
new ShipOrderItem(1, 'CH2026082001', '杭州 · 滨江仓库', '莫干山云顶营地', 12, 286, '已送达', '2026-08-20'),
new ShipOrderItem(2, 'CH2026082102', '上海 · 静安公寓', '安吉溪谷营地', 8, 358, '运输中', '2026-08-23'),
new ShipOrderItem(3, 'CH2026082103', '杭州 · 城西车库', '千岛湖湖心岛营地', 15, 420, '运输中', '2026-08-23'),
new ShipOrderItem(4, 'CH2026081904', '上海 · 徐汇工作室', '大理洱海营地', 6, 680, '已完成', '2026-08-19'),
new ShipOrderItem(5, 'CH2026082405', '杭州 · 未来科技城', '彭州松涛营地', 22, 560, '待取件', '2026-08-25'),
new ShipOrderItem(6, 'CH2026081706', '南京 · 江宁仓库', '阳朔稻田营地', 9, 512, '已取消', '2026-08-17')
]
// ============ 静态数据:7天天气 ============
const mockWeather: WeatherDayItem[] = [
new WeatherDayItem(1, '今天 08/23', '☀️', 19, 31, '东南风2级', 5),
new WeatherDayItem(2, '周日 08/24', '⛅', 18, 29, '东风3级', 4),
new WeatherDayItem(3, '周一 08/25', '🌦️', 17, 26, '东北风3级', 2),
new WeatherDayItem(4, '周二 08/26', '🌧️', 16, 23, '北风4级', 1),
new WeatherDayItem(5, '周三 08/27', '⛅', 17, 27, '西北风2级', 4),
new WeatherDayItem(6, '周四 08/28', '🌙', 18, 30, '微风1级', 5),
new WeatherDayItem(7, '周五 08/29', '☀️', 19, 32, '南风2级', 4)
]
// ============ 静态数据:8篇攻略 ============
const mockGuides: GuideItem[] = [
new GuideItem(1, '新手第一次露营装备清单(避坑版)', '松果爸爸', 52310, 4820, '新手', '#4A7C59'),
new GuideItem(2, '天幕怎么搭才不会被风吹跑', '风一样的阿岚', 38206, 3512, '装备', '#4A90A4'),
new GuideItem(3, '营地晚餐:铸铁锅三道硬菜', '篝火厨师长', 29844, 4216, '食谱', '#C97B3D'),
new GuideItem(4, '带娃露营全流程:从打包到入睡', '稻田妈妈', 45120, 5108, '亲子', '#D4A843'),
new GuideItem(5, '银河怎么拍:手机也能出大片', '追星星的鹿', 61230, 7842, '摄影', '#2C3A2E'),
new GuideItem(6, '帐篷选购指南:隧道帐vs屋脊帐', '装备库老王', 27018, 2315, '装备', '#4A7C59'),
new GuideItem(7, '露营第一晚孩子哭了怎么办', '山雀一家', 19876, 1876, '亲子', '#D4A843'),
new GuideItem(8, '营地早餐15分钟出餐食谱', '晨雾咖啡师', 22450, 2960, '食谱', '#C97B3D')
]
// ============ 静态数据:6条社区帖子 ============
const mockPosts: PostItem[] = [
new PostItem(1, '🧔', '松果爸爸', '莫干山 · 云顶星野营地', '第一次带全家来云顶,晚上银河肉眼可见,孩子兴奋到不想睡,氛围灯串一挂幸福感拉满!', 328, 56, '#4A7C59'),
new PostItem(2, '👩', '追星星的鹿', '丽江 · 雪山观星营地', '蹲了三晚终于等到银河爆发的窗口期,凌晨两点的雪山星空,快门按到手软。', 512, 88, '#4A90A4'),
new PostItem(3, '👨', '篝火厨师长', '恩施 · 峡谷风营地', '峡谷里的风就是天然冰箱,铸铁锅炖了一锅腊排骨,隔壁营地的小孩都馋哭了。', 276, 41, '#C97B3D'),
new PostItem(4, '👧', '稻田妈妈', '阳朔 · 稻田亲子营地', '稻子快熟了,金色海洋里搭帐篷,娃在田埂上追蜻蜓,这就是童年的样子吧。', 433, 72, '#D4A843'),
new PostItem(5, '🧑', '风一样的阿岚', '安吉 · 溪谷野奢营地', '私汤+山泉+篝火三件套,周五下班出发,周日满血复活,打工人回血圣地。', 369, 63, '#7A8B7D'),
new PostItem(6, '👦', '装备库老王', '千岛湖 · 湖心岛营地', '桨板划到湖中央看了场日落,装备是划过去的,营地是划出来的,硬核露营。', 291, 47, '#2C3A2E')
]
// ============ 常量列表 ============
const SCENE_FILTERS: string[] = ['全部', '精致露营地', '野奢', '星空', '湖畔', '山地', '森林']
const GEAR_CATEGORIES: string[] = ['帐篷', '天幕', '桌椅', '炊具', '照明']
const GEAR_STATUSES: string[] = ['在家', '在营地', '在途']
const SITE_SORTS: string[] = ['推荐', '评分', '价格', '距离']
const VEHICLE_TYPES: string[] = ['面包车', '皮卡', '厢货']
const GUIDE_CATEGORIES: string[] = ['新手', '装备', '食谱', '亲子', '摄影']
const SPECIAL_REQS: string[] = ['上门取', '安装', '加急']
const BOOKING_DATES: string[] = ['今晚', '明晚', '周末']
const TENT_TYPES: string[] = ['自带', '租赁', '豪华']
// ============ 辅助纯函数 ============
function getGearCountByStatus(status: string): number {
let count: number = 0
for (let i = 0; i < mockGears.length; i++) {
if (mockGears[i].status === status) {
count++
}
}
return count
}
function getGearCountByCategory(cat: string): number {
let count: number = 0
for (let i = 0; i < mockGears.length; i++) {
if (mockGears[i].category === cat) {
count++
}
}
return count
}
function getGearWeightSum(): number {
let sum: number = 0
for (let i = 0; i < mockGears.length; i++) {
sum += mockGears[i].weight
}
return sum
}
function getStarText(rating: number): string {
const full: number = Math.round(rating)
let stars: string = ''
for (let i = 0; i < 5; i++) {
if (i < full) {
stars += '★'
} else {
stars += '☆'
}
}
return stars
}
function getStarLabel(starIndex: number): string {
if (starIndex >= 5) {
return '极佳'
} else if (starIndex >= 4) {
return '优秀'
} else if (starIndex >= 3) {
return '一般'
} else {
return '不佳'
}
}
function tempBarHeight(tempMax: number): string {
return ((tempMax - 20) * 9 + 24).toFixed(0) + 'vp'
}
function splitTags(tags: string): string[] {
return tags.split('|')
}
function formatPrice(price: number): string {
return '¥' + price.toString()
}
function formatViews(views: number): string {
return (views / 1000).toFixed(1) + 'k'
}
function initFireflies(): FireflyItem[] {
const list: FireflyItem[] = []
for (let i = 0; i < 12; i++) {
list.push(new FireflyItem(4 + (i * 7.7) % 92, 6 + (i * 13.3) % 88, 0.3 + (i % 5) * 0.15, 8 + (i % 4) * 3, (i % 2 === 0) ? 0.6 : -0.6))
}
return list
}
// ============ Tab 枚举 ============
enum CampTab {
GEAR = 0,
SITE = 1,
SHIP = 2,
GUIDE = 3,
WEATHER = 4,
POST = 5,
PROFILE = 6
}
// ============ 入口主页面 ============
@Entry
@Component
struct CampHaulApp {
@State activeTab: CampTab = CampTab.GEAR
@State searchKeyword: string = ''
@State selectedScene: string = '全部'
@State fireflies: FireflyItem[] = initFireflies()
private timerId: number = -1
aboutToAppear() {
this.timerId = setInterval(() => {
this.updateFireflies()
}, 280)
}
aboutToDisappear() {
if (this.timerId !== -1) {
clearInterval(this.timerId)
this.timerId = -1
}
}
// 萤火虫每280ms随机漂移,模拟夜晚萤火虫
updateFireflies() {
const next: FireflyItem[] = []
for (let i = 0; i < this.fireflies.length; i++) {
const f: FireflyItem = this.fireflies[i]
let nx: number = f.x + f.drift
if (nx > 96) {
nx = 2
}
if (nx < 2) {
nx = 96
}
let ny: number = f.y + (Math.random() - 0.5) * 3
if (ny > 92) {
ny = 90
}
if (ny < 4) {
ny = 6
}
next.push(new FireflyItem(nx, ny, 0.2 + Math.random() * 0.8, f.size, f.drift))
}
this.fireflies = next
}
// ========== 头部:Logo + 搜索 + 消息(无动画) ==========
@Builder headerBuilder() {
Column() {
Row() {
Column() {
Text('⛺').fontSize(24)
}
.width(40).height(40)
.backgroundColor(COLOR_PRIMARY)
.borderRadius(12)
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.Center)
.margin({ left: 14 })
Column() {
Text('星野营地').fontSize(17).fontWeight(FontWeight.Bold).fontColor(COLOR_PRIMARY)
Text('CAMP HAUL · 装备运输+营地预订').fontSize(8).fontColor(COLOR_TEXT_SUB).margin({ top: 1 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 8 })
Row() {
Text('🔍').fontSize(13).margin({ left: 8 })
TextInput({ placeholder: '搜索营地/装备' })
.placeholderColor(COLOR_TEXT_HINT)
.fontSize(12)
.layoutWeight(1)
.backgroundColor(COLOR_BG)
.borderRadius(16)
.margin({ left: 4, right: 4 })
.padding({ left: 4, right: 4 })
.height(34)
.onChange((v: string) => {
this.searchKeyword = v
})
}
.layoutWeight(1)
.height(36)
.backgroundColor(COLOR_BG)
.borderRadius(18)
.margin({ left: 10 })
.border({ width: 1, color: COLOR_BORDER })
Stack() {
Text('🔔').fontSize(20)
Text('2')
.fontSize(8).fontColor(COLOR_CARD)
.backgroundColor(COLOR_DANGER)
.width(14).height(14).borderRadius(7)
.textAlign(TextAlign.Center)
.position({ x: 24, y: -2 })
}
.width(34).height(34)
.margin({ left: 8, right: 14 })
.onClick(() => {
console.info('打开消息中心')
})
}
.width('100%')
.height(56)
.alignItems(VerticalAlign.Center)
// 第二行:露营风格药丸横向滚动
Scroll() {
Row() {
ForEach(SCENE_FILTERS, (scene: string) => {
if (this.selectedScene === scene) {
Text(scene)
.fontSize(11)
.fontColor(COLOR_CARD)
.backgroundColor(COLOR_PRIMARY)
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.borderRadius(14)
.margin({ left: 4, right: 4 })
} else {
Text(scene)
.fontSize(11)
.fontColor(COLOR_TEXT_SUB)
.backgroundColor(COLOR_BG)
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.borderRadius(14)
.margin({ left: 4, right: 4 })
.border({ width: 1, color: COLOR_BORDER })
.onClick(() => {
this.selectedScene = scene
})
}
})
}
.padding({ left: 10, right: 10 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.height(34)
.margin({ top: 2 })
}
.width('100%')
.backgroundColor(COLOR_CARD)
.padding({ bottom: 8 })
}
// ========== Tab 内容区 ==========
@Builder contentArea() {
Column() {
if (this.activeTab === CampTab.GEAR) {
GearContent()
} else if (this.activeTab === CampTab.SITE) {
SiteContent()
} else if (this.activeTab === CampTab.SHIP) {
ShipContent()
} else if (this.activeTab === CampTab.GUIDE) {
GuideContent()
} else if (this.activeTab === CampTab.WEATHER) {
WeatherContent()
} else if (this.activeTab === CampTab.POST) {
PostContent()
} else {
ProfileContent()
}
}
.layoutWeight(1)
}
// ========== 单个Tab项 ==========
@Builder tabItem(icon: string, label: string, tab: CampTab) {
Column() {
Text(icon)
.fontSize(19)
.opacity(this.activeTab === tab ? 1.0 : 0.45)
Text(label)
.fontSize(9)
.fontColor(this.activeTab === tab ? COLOR_PRIMARY : COLOR_TEXT_SUB)
.fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 1 })
if (this.activeTab === tab) {
Column()
.width(16)
.height(3)
.backgroundColor(COLOR_PRIMARY)
.borderRadius(2)
.margin({ top: 2 })
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 4, bottom: 2 })
.onClick(() => {
this.activeTab = tab
})
}
// ========== 多排Tab栏:上排4个 + 下排3个 ==========
@Builder tabBarBuilder() {
Column() {
Row() {
this.tabItem('⛺', '装备', CampTab.GEAR)
this.tabItem('🏕️', '营地', CampTab.SITE)
this.tabItem('🚛', '运输', CampTab.SHIP)
this.tabItem('🗺️', '攻略', CampTab.GUIDE)
}
.width('100%')
Row() {
this.tabItem('🌤️', '天气', CampTab.WEATHER)
this.tabItem('📷', '社区', CampTab.POST)
this.tabItem('👤', '我的', CampTab.PROFILE)
}
.width('100%')
}
.width('100%')
.backgroundColor(COLOR_CARD)
.padding({ top: 2, bottom: 4 })
.shadow({ radius: 6, color: '#144A7C59', offsetY: -2 })
}
// ========== 全屏萤火虫粒子层(不阻挡点击) ==========
@Builder fireflyLayer() {
Stack() {
ForEach(this.fireflies, (f: FireflyItem) => {
Text('✦')
.fontSize(f.size)
.fontColor('#D8E870')
.opacity(f.opacity)
.position({ x: f.x.toString() + '%', y: f.y.toString() + '%' })
})
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.None)
}
build() {
Stack() {
// 主内容层
Column() {
this.headerBuilder()
this.contentArea()
this.tabBarBuilder()
}
.width('100%')
.height('100%')
// 夜晚萤火虫粒子层
this.fireflyLayer()
}
.width('100%')
.height('100%')
.backgroundColor(COLOR_BG)
}
}
// ============ Tab1 装备页 ============
@Component
struct GearContent {
@State showEditModal: boolean = false
@State editingGear: GearItem | null = null
@State editName: string = ''
@State editCategory: string = '帐篷'
@State editWeight: string = ''
@State editPrice: string = ''
@State editStatus: string = '在家'
@State editNotes: string = ''
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%')
.height('100%')
.backgroundColor('rgba(44,58,46,0.55)')
.onClick(onClose)
}
// ========== 弹框2:编辑装备信息(卡片式 85%宽 60%高) ==========
@Builder editGearModal() {
Column() {
this.modalOverlay(() => {
this.showEditModal = false
})
Column() {
Row() {
Text('✏️ 编辑装备信息')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_TEXT_MAIN)
Column().layoutWeight(1)
Text('✕')
.fontSize(17)
.fontColor(COLOR_TEXT_HINT)
.onClick(() => {
this.showEditModal = false
})
}
.width('100%')
.padding({ left: 20, right: 20, top: 16, bottom: 10 })
Divider().color(COLOR_BORDER)
Scroll() {
Column() {
Text('装备名称')
.fontSize(12)
.fontColor(COLOR_TEXT_SUB)
.width('100%')
.margin({ top: 10 })
TextInput({ placeholder: this.editingGear?.name ?? '' })
.placeholderColor(COLOR_TEXT_HINT)
.fontSize(13)
.width('100%')
.backgroundColor(COLOR_BG)
.borderRadius(10)
.margin({ top: 4 })
.onChange((v: string) => {
this.editName = v
})
Text('类别')
.fontSize(12)
.fontColor(COLOR_TEXT_SUB)
.width('100%')
.margin({ top: 12 })
Row() {
ForEach(GEAR_CATEGORIES, (cat: string) => {
if (this.editCategory === cat) {
Text((GEAR_CATEGORY_CONFIG[cat]?.icon ?? '') + ' ' + cat)
.fontSize(11)
.fontColor(COLOR_CARD)
.backgroundColor(COLOR_PRIMARY)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.borderRadius(12)
.margin({ left: 3, right: 3 })
} else {
Text((GEAR_CATEGORY_CONFIG[cat]?.icon ?? '') + ' ' + cat)
.fontSize(11)
.fontColor(COLOR_TEXT_SUB)
.backgroundColor(COLOR_BG)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.borderRadius(12)
.margin({ left: 3, right: 3 })
.border({ width: 1, color: COLOR_BORDER })
.onClick(() => {
this.editCategory = cat
})
}
})
}
.margin({ top: 4 })
Row() {
Column() {
Text('重量(kg)')
.fontSize(12)
.fontColor(COLOR_TEXT_SUB)
.width('100%')
TextInput({ placeholder: this.editingGear?.weight.toString() ?? '' })
.placeholderColor(COLOR_TEXT_HINT)
.fontSize(13)
.width('100%')
.backgroundColor(COLOR_BG)
.borderRadius(10)
.margin({ top: 4 })
.onChange((v: string) => {
this.editWeight = v
})
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Column() {
Text('购入价格(¥)')
.fontSize(12)
.fontColor(COLOR_TEXT_SUB)
.width('100%')
TextInput({ placeholder: this.editingGear?.price.toString() ?? '' })
.placeholderColor(COLOR_TEXT_HINT)
.fontSize(13)
.width('100%')
.backgroundColor(COLOR_BG)
.borderRadius(10)
.margin({ top: 4 })
.onChange((v: string) => {
this.editPrice = v
})
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
}
.width('100%')
.margin({ top: 12 })
Text('当前状态')
.fontSize(12)
.fontColor(COLOR_TEXT_SUB)
.width('100%')
.margin({ top: 12 })
Row() {
ForEach(GEAR_STATUSES, (st: string) => {
if (this.editStatus === st) {
Text((GEAR_STATUS_CONFIG[st]?.icon ?? '') + ' ' + st)
.fontSize(11)
.fontColor(COLOR_CARD)
.backgroundColor(GEAR_STATUS_CONFIG[st]?.color ?? COLOR_PRIMARY)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.borderRadius(12)
.margin({ left: 3, right: 3 })
} else {
Text((GEAR_STATUS_CONFIG[st]?.icon ?? '') + ' ' + st)
.fontSize(11)
.fontColor(GEAR_STATUS_CONFIG[st]?.color ?? COLOR_TEXT_SUB)
.backgroundColor(COLOR_BG)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.borderRadius(12)
.margin({ left: 3, right: 3 })
.border({ width: 1, color: COLOR_BORDER })
.onClick(() => {
this.editStatus = st
})
}
})
}
.margin({ top: 4 })
Text('备注')
.fontSize(12)
.fontColor(COLOR_TEXT_SUB)
.width('100%')
.margin({ top: 12 })
TextArea({ placeholder: this.editingGear?.notes ?? '' })
.placeholderColor(COLOR_TEXT_HINT)
.fontSize(12)
.width('100%')
.constraintSize({ maxHeight: 80 })
.backgroundColor(COLOR_BG)
.borderRadius(10)
.margin({ top: 4 })
.onChange((v: string) => {
this.editNotes = v
})
}
.padding({ left: 20, right: 20, bottom: 14 })
}
.layoutWeight(1)
Row() {
Text('取消')
.fontSize(14)
.fontColor(COLOR_TEXT_SUB)
.backgroundColor(COLOR_BG)
.borderRadius(18)
.padding({ left: 26, right: 26, top: 9, bottom: 9 })
.border({ width: 1, color: COLOR_BORDER })
.onClick(() => {
this.showEditModal = false
})
Text('保存')
.fontSize(14)
.fontColor(COLOR_CARD)
.backgroundColor(COLOR_PRIMARY)
.borderRadius(18)
.padding({ left: 26, right: 26, top: 9, bottom: 9 })
.margin({ left: 12 })
.onClick(() => {
this.showEditModal = false
})
}
.width('100%')
.justifyContent(FlexAlign.Center)
.padding({ top: 12, bottom: 16 })
}
.width('85%')
.height('60%')
.backgroundColor(COLOR_CARD)
.borderRadius(16)
.position({ x: '7.5%', y: '18%' })
}
.width('100%')
.height('100%')
.position({ x: 0, y: 0 })
.zIndex(999)
}
// ========== 装备列表项 ==========
@Builder gearItemBuilder(g: GearItem) {
Column() {
Row() {
Column() {
Text(g.icon)
.fontSize(24)
}
.width(46)
.height(46)
.backgroundColor(GEAR_CATEGORY_CONFIG[g.category]?.bg ?? COLOR_BG)
.borderRadius(12)
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.Center)
Column() {
Text(g.name)
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor(COLOR_TEXT_MAIN)
Row() {
Text((GEAR_CATEGORY_CONFIG[g.category]?.label ?? g.category) + ' · ' + g.weight + 'kg')
.fontSize(10)
.fontColor(COLOR_TEXT_SUB)
Text(formatPrice(g.price))
.fontSize(10)
.fontColor(COLOR_ACCENT)
.margin({ left: 8 })
}
.margin({ top: 3 })
Text(g.notes)
.fontSize(9)
.fontColor(COLOR_TEXT_HINT)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 10 })
Column() {
Text((GEAR_STATUS_CONFIG[g.status]?.icon ?? '') + ' ' + g.status)
.fontSize(10)
.fontColor(GEAR_STATUS_CONFIG[g.status]?.color ?? COLOR_TEXT_SUB)
.backgroundColor(GEAR_STATUS_CONFIG[g.status]?.bg ?? COLOR_BG)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(10)
Text('编辑')
.fontSize(10)
.fontColor(COLOR_PRIMARY)
.margin({ top: 6 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.alignItems(VerticalAlign.Center)
.padding({ top: 10, bottom: 10, left: 12, right: 12 })
}
.width('100%')
.backgroundColor(COLOR_CARD)
.borderRadius(12)
.margin({ left: 12, right: 12, top: 6 })
.border({ width: 1, color: COLOR_BORDER })
.onClick(() => {
this.editingGear = g
this.editCategory = g.category
this.editStatus = g.status
this.showEditModal = true
})
}
build() {
Stack() {
Column() {
// 顶部统计条
Row() {
Column() {
Text(mockGears.length.toString())
.fontSize(19)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_TEXT_MAIN)
Text('总装备').fontSize(9).fontColor(COLOR_TEXT_SUB)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column()
.width(1)
.height(28)
.backgroundColor(COLOR_BORDER)
Column() {
Text(getGearCountByStatus('在家').toString())
.fontSize(19)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_PRIMARY)
Text('在家').fontSize(9).fontColor(COLOR_TEXT_SUB)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column()
.width(1)
.height(28)
.backgroundColor(COLOR_BORDER)
Column() {
Text(getGearCountByStatus('在营地').toString())
.fontSize(19)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_SKY)
Text('在营地').fontSize(9).fontColor(COLOR_TEXT_SUB)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column()
.width(1)
.height(28)
.backgroundColor(COLOR_BORDER)
Column() {
Text(getGearCountByStatus('在途').toString())
.fontSize(19)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_ACCENT)
Text('在途').fontSize(9).fontColor(COLOR_TEXT_SUB)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLOR_CARD)
Scroll() {
Column() {
// 类别分布统计卡(5格图表)
Column() {
Text('📦 装备类别分布 · 总重' + getGearWeightSum().toFixed(1) + 'kg')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_TEXT_MAIN)
.width('100%')
.padding({ left: 14, top: 12, bottom: 8 })
Row() {
ForEach(GEAR_CATEGORIES, (cat: string) => {
Column() {
Text(GEAR_CATEGORY_CONFIG[cat]?.icon ?? '')
.fontSize(17)
Text(getGearCountByCategory(cat).toString())
.fontSize(19)
.fontWeight(FontWeight.Bold)
.fontColor(GEAR_CATEGORY_CONFIG[cat]?.color ?? COLOR_PRIMARY)
.margin({ top: 2 })
Text(cat)
.fontSize(9)
.fontColor(COLOR_TEXT_SUB)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 8, bottom: 8 })
.backgroundColor(GEAR_CATEGORY_CONFIG[cat]?.bg ?? COLOR_BG)
.borderRadius(10)
.margin({ left: 3, right: 3 })
})
}
.padding({ left: 10, right: 10, bottom: 12 })
}
.width('100%')
.backgroundColor(COLOR_CARD)
.borderRadius(12)
.margin({ left: 12, right: 12, top: 10 })
.border({ width: 1, color: COLOR_BORDER })
Text('我的装备清单')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_TEXT_MAIN)
.width('100%')
.padding({ left: 18, top: 14, bottom: 4 })
// 10件装备
ForEach(mockGears, (g: GearItem) => {
this.gearItemBuilder(g)
})
}
.padding({ bottom: 20 })
}
.layoutWeight(1)
.scrollBar(BarState.Off)
}
.width('100%')
.height('100%')
if (this.showEditModal) {
this.editGearModal()
}
}
.width('100%')
.height('100%')
}
}
// ============ Tab2 营地页 ============
@Component
struct SiteContent {
@State siteSort: string = '推荐'
@State showCancelModal: boolean = false
@State showBookingModal: boolean = false
@State selectedSite: SiteItem | null = null
@State bookingDateType: string = '今晚'
@State bookingPeople: number = 2
@State bookingTent: string = '自带'
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%')
.height('100%')
.backgroundColor('rgba(44,58,46,0.55)')
.onClick(onClose)
}
// ========== 弹框3:取消营地预订(警示式 80%宽 居中) ==========
@Builder cancelBookingModal() {
Column() {
this.modalOverlay(() => {
this.showCancelModal = false
})
Column() {
Text('⚠️')
.fontSize(46)
.margin({ top: 22 })
Text('确认取消此预订?')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_TEXT_MAIN)
.margin({ top: 6 })
Text('取消费用规则:出发前24小时免费取消')
.fontSize(11)
.fontColor(COLOR_WARNING)
.margin({ top: 4 })
Column() {
Row() {
Text('营地')
.fontSize(12)
.fontColor(COLOR_TEXT_SUB)
Text('云顶星野营地(浙江·莫干山)')
.fontSize(12)
.fontColor(COLOR_TEXT_MAIN)
.layoutWeight(1)
.textAlign(TextAlign.End)
}
.width('100%')
.padding({ top: 6 })
Row() {
Text('日期')
.fontSize(12)
.fontColor(COLOR_TEXT_SUB)
Text('2026-08-28 ~ 2026-08-29 · 1晚')
.fontSize(12)
.fontColor(COLOR_TEXT_MAIN)
.layoutWeight(1)
.textAlign(TextAlign.End)
}
.width('100%')
.padding({ top: 6 })
Row() {
Text('金额')
.fontSize(12)
.fontColor(COLOR_TEXT_SUB)
Text('¥488(含装备运输券¥50)')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_DANGER)
.layoutWeight(1)
.textAlign(TextAlign.End)
}
.width('100%')
.padding({ top: 6, bottom: 6 })
}
.width('100%')
.backgroundColor('#FBF1E8')
.borderRadius(10)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.margin({ top: 14 })
Row() {
Text('取消')
.fontSize(14)
.fontColor(COLOR_TEXT_SUB)
.backgroundColor(COLOR_BG)
.borderRadius(18)
.padding({ left: 28, right: 28, top: 10, bottom: 10 })
.border({ width: 1, color: COLOR_BORDER })
.onClick(() => {
this.showCancelModal = false
})
Text('确认取消')
.fontSize(14)
.fontColor(COLOR_CARD)
.backgroundColor(COLOR_DANGER)
.borderRadius(18)
.padding({ left: 28, right: 28, top: 10, bottom: 10 })
.margin({ left: 12 })
.onClick(() => {
this.showCancelModal = false
})
}
.width('100%')
.justifyContent(FlexAlign.Center)
.padding({ top: 18, bottom: 22 })
}
.width('80%')
.backgroundColor(COLOR_CARD)
.borderRadius(16)
.alignItems(HorizontalAlign.Center)
.position({ x: '10%', y: '34%' })
}
.width('100%')
.height('100%')
.position({ x: 0, y: 0 })
.zIndex(999)
}
// ========== 弹框4:营地预订详情(表单式 92%宽 70%高 可滚动) ==========
@Builder siteBookingModal() {
Column() {
this.modalOverlay(() => {
this.showBookingModal = false
})
Column() {
Row() {
Text('🏕️ 营地预订')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_CARD)
Column().layoutWeight(1)
Text('✕')
.fontSize(16)
.fontColor('#EAF2EC')
.onClick(() => {
this.showBookingModal = false
})
}
.width('100%')
.padding({ left: 18, right: 18, top: 14, bottom: 12 })
.backgroundColor(COLOR_PRIMARY)
Scroll() {
Column() {
// 营地封面色块 + 信息
Row() {
Column() {
Text('⛺')
.fontSize(34)
}
.width(86)
.height(72)
.linearGradient({
angle: 120,
colors: [[this.selectedSite?.color ?? COLOR_PRIMARY, 0], [COLOR_ACCENT_LIGHT, 1]]
})
.borderRadius(12)
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.Center)
Column() {
Text(this.selectedSite?.name ?? '')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_TEXT_MAIN)
Text('📍 ' + (this.selectedSite?.location ?? ''))
.fontSize(11)
.fontColor(COLOR_TEXT_SUB)
.margin({ top: 3 })
Row() {
Text(getStarText(this.selectedSite?.rating ?? 4.5))
.fontSize(11)
.fontColor(COLOR_GOLD)
Text((this.selectedSite?.rating ?? 4.5).toString())
.fontSize(10)
.fontColor(COLOR_TEXT_SUB)
.margin({ left: 4 })
Text('可容纳' + (this.selectedSite?.capacity ?? 4) + '人')
.fontSize(10)
.fontColor(COLOR_TEXT_SUB)
.margin({ left: 8 })
}
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 12 })
Column() {
Text(formatPrice(this.selectedSite?.price ?? 300))
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_ACCENT)
Text('/晚')
.fontSize(9)
.fontColor(COLOR_TEXT_SUB)
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding({ left: 16, right: 16, top: 14 })
.alignItems(VerticalAlign.Center)
// 日期选择药丸
Text('入住日期')
.fontSize(12)
.fontColor(COLOR_TEXT_SUB)
.width('100%')
.margin({ top: 14, left: 16 })
Row() {
ForEach(BOOKING_DATES, (d: string) => {
if (this.bookingDateType === d) {
Text(d)
.fontSize(12)
.fontColor(COLOR_CARD)
.backgroundColor(COLOR_PRIMARY)
.padding({ left: 18, right: 18, top: 7, bottom: 7 })
.borderRadius(14)
.margin({ left: 4, right: 4 })
} else {
Text(d)
.fontSize(12)
.fontColor(COLOR_TEXT_SUB)
.backgroundColor(COLOR_BG)
.padding({ left: 18, right: 18, top: 7, bottom: 7 })
.borderRadius(14)
.margin({ left: 4, right: 4 })
.border({ width: 1, color: COLOR_BORDER })
.onClick(() => {
this.bookingDateType = d
})
}
})
}
.margin({ left: 12, top: 4 })
// 人数选择
Text('入住人数(最多' + (this.selectedSite?.capacity ?? 4) + '人)')
.fontSize(12)
.fontColor(COLOR_TEXT_SUB)
.width('100%')
.margin({ top: 14, left: 16 })
Row() {
Text('−')
.fontSize(20)
.fontColor(COLOR_PRIMARY)
.width(38)
.height(38)
.borderRadius(19)
.backgroundColor(COLOR_BG)
.textAlign(TextAlign.Center)
.border({ width: 1, color: COLOR_BORDER })
.onClick(() => {
if (this.bookingPeople > 1) {
this.bookingPeople--
}
})
Text(this.bookingPeople.toString() + ' 人')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_TEXT_MAIN)
.margin({ left: 20, right: 20 })
Text('+')
.fontSize(20)
.fontColor(COLOR_PRIMARY)
.width(38)
.height(38)
.borderRadius(19)
.backgroundColor(COLOR_BG)
.textAlign(TextAlign.Center)
.border({ width: 1, color: COLOR_BORDER })
.onClick(() => {
if (this.bookingPeople < (this.selectedSite?.capacity ?? 4)) {
this.bookingPeople++
}
})
}
.margin({ top: 6 })
// 帐篷类型药丸
Text('帐篷方案')
.fontSize(12)
.fontColor(COLOR_TEXT_SUB)
.width('100%')
.margin({ top: 14, left: 16 })
Row() {
ForEach(TENT_TYPES, (t: string) => {
if (this.bookingTent === t) {
Text(t)
.fontSize(12)
.fontColor(COLOR_CARD)
.backgroundColor(COLOR_ACCENT)
.padding({ left: 16, right: 16, top: 7, bottom: 7 })
.borderRadius(14)
.margin({ left: 4, right: 4 })
} else {
Text(t)
.fontSize(12)
.fontColor(COLOR_TEXT_SUB)
.backgroundColor(COLOR_BG)
.padding({ left: 16, right: 16, top: 7, bottom: 7 })
.borderRadius(14)
.margin({ left: 4, right: 4 })
.border({ width: 1, color: COLOR_BORDER })
.onClick(() => {
this.bookingTent = t
})
}
})
}
.margin({ left: 12, top: 4 })
// 费用明细
Column() {
Row() {
Text('营地费用')
.fontSize(12)
.fontColor(COLOR_TEXT_SUB)
Text(formatPrice(this.selectedSite?.price ?? 300))
.fontSize(12)
.fontColor(COLOR_TEXT_MAIN)
.layoutWeight(1)
.textAlign(TextAlign.End)
}
.width('100%')
.padding({ top: 6 })
Row() {
Text('装备运输(到营地)')
.fontSize(12)
.fontColor(COLOR_TEXT_SUB)
Text('¥99')
.fontSize(12)
.fontColor(COLOR_TEXT_MAIN)
.layoutWeight(1)
.textAlign(TextAlign.End)
}
.width('100%')
.padding({ top: 6 })
Divider().color(COLOR_BORDER).margin({ top: 8, bottom: 4 })
Row() {
Text('合计')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_TEXT_MAIN)
Text(formatPrice((this.selectedSite?.price ?? 300) + 99))
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_ACCENT)
.layoutWeight(1)
.textAlign(TextAlign.End)
}
.width('100%')
.padding({ top: 4, bottom: 6 })
}
.width('100%')
.backgroundColor(COLOR_BG)
.borderRadius(10)
.padding({ left: 14, right: 14 })
.margin({ top: 16, left: 16, right: 16 })
// 立即预订按钮
Text('🚀 立即预订 · 装备同步运输')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_CARD)
.width('100%')
.textAlign(TextAlign.Center)
.backgroundColor(COLOR_PRIMARY)
.borderRadius(22)
.padding({ top: 12, bottom: 12 })
.margin({ top: 18, left: 16, right: 16, bottom: 18 })
.onClick(() => {
this.showBookingModal = false
})
}
}
.layoutWeight(1)
.scrollBar(BarState.Off)
}
.width('92%')
.height('70%')
.backgroundColor(COLOR_CARD)
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
.position({ x: '4%', y: '14%' })
}
.width('100%')
.height('100%')
.position({ x: 0, y: 0 })
.zIndex(999)
}
// ========== 营地卡片 ==========
@Builder siteCardBuilder(s: SiteItem) {
Column() {
Row() {
Column() {
Text('⛰️')
.fontSize(30)
}
.width(84)
.height(84)
.linearGradient({
angle: 135,
colors: [[s.color, 0], [COLOR_ACCENT_LIGHT, 1]]
})
.borderRadius(12)
Column() {
Text(s.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_TEXT_MAIN)
Text('📍 ' + s.location)
.fontSize(10)
.fontColor(COLOR_TEXT_SUB)
.margin({ top: 2 })
Row() {
Text(getStarText(s.rating))
.fontSize(11)
.fontColor(COLOR_GOLD)
Text(s.rating.toString())
.fontSize(10)
.fontColor(COLOR_TEXT_SUB)
.margin({ left: 3 })
Text('👥 ' + s.capacity + '人')
.fontSize(10)
.fontColor(COLOR_TEXT_SUB)
.margin({ left: 8 })
}
.margin({ top: 3 })
Row() {
ForEach(splitTags(s.tags), (tag: string) => {
Text('#' + tag)
.fontSize(9)
.fontColor(COLOR_SKY)
.backgroundColor('#E6F0F3')
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(8)
.margin({ right: 4 })
})
}
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 12 })
}
.width('100%')
.alignItems(VerticalAlign.Center)
Row() {
Column() {
Text(formatPrice(s.price))
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_ACCENT)
Text('/晚 · 含场地费')
.fontSize(8)
.fontColor(COLOR_TEXT_HINT)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('预订')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_CARD)
.backgroundColor(COLOR_PRIMARY)
.borderRadius(16)
.padding({ left: 26, right: 26, top: 8, bottom: 8 })
.onClick(() => {
this.selectedSite = s
this.showBookingModal = true
})
}
.width('100%')
.margin({ top: 10 })
}
.width('100%')
.backgroundColor(COLOR_CARD)
.borderRadius(12)
.padding(12)
.margin({ left: 12, right: 12, top: 8 })
.border({ width: 1, color: COLOR_BORDER })
}
build() {
Stack() {
Column() {
// 排序药丸
Row() {
ForEach(SITE_SORTS, (s: string) => {
if (this.siteSort === s) {
Text(s)
.fontSize(11)
.fontColor(COLOR_CARD)
.backgroundColor(COLOR_ACCENT)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.borderRadius(13)
.margin({ left: 4, right: 4 })
} else {
Text(s)
.fontSize(11)
.fontColor(COLOR_TEXT_SUB)
.backgroundColor(COLOR_CARD)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.borderRadius(13)
.margin({ left: 4, right: 4 })
.border({ width: 1, color: COLOR_BORDER })
.onClick(() => {
this.siteSort = s
})
}
})
}
.width('100%')
.padding({ left: 12, right: 12, top: 10, bottom: 6 })
Scroll() {
Column() {
// 当前预订条目(可取消)
Row() {
Column() {
Text('📌 当前预订')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_TEXT_MAIN)
Text('云顶星野营地 · 08/28入住 · 1晚')
.fontSize(10)
.fontColor(COLOR_TEXT_SUB)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text('取消预订')
.fontSize(11)
.fontColor(COLOR_DANGER)
.backgroundColor('#F8E8E6')
.borderRadius(12)
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.onClick(() => {
this.showCancelModal = true
})
}
.width('100%')
.backgroundColor(COLOR_CARD)
.borderRadius(12)
.padding({ left: 14, right: 14, top: 10, bottom: 10 })
.margin({ left: 12, right: 12, top: 4 })
.border({ width: 1, color: COLOR_BORDER })
// 8个营地卡片
ForEach(mockSites, (s: SiteItem) => {
this.siteCardBuilder(s)
})
}
.padding({ bottom: 20 })
}
.layoutWeight(1)
.scrollBar(BarState.Off)
}
.width('100%')
.height('100%')
if (this.showCancelModal) {
this.cancelBookingModal()
}
if (this.showBookingModal) {
this.siteBookingModal()
}
}
.width('100%')
.height('100%')
}
}
// ============ Tab3 运输页 ============
@Component
struct ShipContent {
@State showShipModal: boolean = false
@State shipGearIds: number[] = [1, 2, 3]
@State formPickup: string = ''
@State formDest: string = ''
@State formDate: string = ''
@State formVehicle: string = '面包车'
@State reqPickup: boolean = true
@State reqInstall: boolean = false
@State reqRush: boolean = false
@State formNotes: string = ''
// 多选装备切换
toggleShipGear(id: number) {
const next: number[] = []
let found: boolean = false
for (let i = 0; i < this.shipGearIds.length; i++) {
if (this.shipGearIds[i] === id) {
found = true
} else {
next.push(this.shipGearIds[i])
}
}
if (!found) {
next.push(id)
}
this.shipGearIds = next
}
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%')
.height('100%')
.backgroundColor('rgba(44,58,46,0.55)')
.onClick(onClose)
}
// ========== 弹框1:预约装备运输(大地色表单式 90%宽 75%高 绿色标题栏) ==========
@Builder shipBookingModal() {
Column() {
this.modalOverlay(() => {
this.showShipModal = false
})
Column() {
// 绿色标题栏
Row() {
Text('⛺ 预约装备运输')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_CARD)
Column().layoutWeight(1)
Text('✕')
.fontSize(16)
.fontColor('#EAF2EC')
.onClick(() => {
this.showShipModal = false
})
}
.width('100%')
.padding({ left: 20, right: 20, top: 16, bottom: 14 })
.backgroundColor(COLOR_PRIMARY)
Scroll() {
Column() {
Text('选择装备(已选' + this.shipGearIds.length + '件)')
.fontSize(12)
.fontColor(COLOR_TEXT_SUB)
.width('100%')
.margin({ top: 12 })
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(mockGears, (g: GearItem) => {
if (this.shipGearIds.indexOf(g.id) >= 0) {
Text(g.icon + ' ' + g.name)
.fontSize(10)
.fontColor(COLOR_CARD)
.backgroundColor(COLOR_PRIMARY)
.padding({ left: 8, right: 8, top: 5, bottom: 5 })
.borderRadius(12)
.margin({ left: 3, right: 3, top: 3, bottom: 3 })
.onClick(() => {
this.toggleShipGear(g.id)
})
} else {
Text(g.icon + ' ' + g.name)
.fontSize(10)
.fontColor(COLOR_TEXT_SUB)
.backgroundColor(COLOR_BG)
.padding({ left: 8, right: 8, top: 5, bottom: 5 })
.borderRadius(12)
.margin({ left: 3, right: 3, top: 3, bottom: 3 })
.border({ width: 1, color: COLOR_BORDER })
.onClick(() => {
this.toggleShipGear(g.id)
})
}
})
}
.width('100%')
.margin({ top: 4 })
Text('出发地')
.fontSize(12)
.fontColor(COLOR_TEXT_SUB)
.width('100%')
.margin({ top: 12 })
TextInput({ placeholder: '如:杭州 · 滨江仓库' })
.placeholderColor(COLOR_TEXT_HINT)
.fontSize(13)
.width('100%')
.backgroundColor(COLOR_BG)
.borderRadius(10)
.margin({ top: 4 })
.onChange((v: string) => {
this.formPickup = v
})
Text('目的营地')
.fontSize(12)
.fontColor(COLOR_TEXT_SUB)
.width('100%')
.margin({ top: 10 })
TextInput({ placeholder: '如:莫干山云顶星野营地' })
.placeholderColor(COLOR_TEXT_HINT)
.fontSize(13)
.width('100%')
.backgroundColor(COLOR_BG)
.borderRadius(10)
.margin({ top: 4 })
.onChange((v: string) => {
this.formDest = v
})
Text('运输日期')
.fontSize(12)
.fontColor(COLOR_TEXT_SUB)
.width('100%')
.margin({ top: 10 })
TextInput({ placeholder: '如:2026-08-25' })
.placeholderColor(COLOR_TEXT_HINT)
.fontSize(13)
.width('100%')
.backgroundColor(COLOR_BG)
.borderRadius(10)
.margin({ top: 4 })
.onChange((v: string) => {
this.formDate = v
})
Text('车型')
.fontSize(12)
.fontColor(COLOR_TEXT_SUB)
.width('100%')
.margin({ top: 10 })
Row() {
ForEach(VEHICLE_TYPES, (v: string) => {
if (this.formVehicle === v) {
Text((VEHICLE_CONFIG[v]?.icon ?? '') + ' ' + v)
.fontSize(11)
.fontColor(COLOR_CARD)
.backgroundColor(COLOR_ACCENT)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(12)
.margin({ left: 3, right: 3 })
} else {
Text((VEHICLE_CONFIG[v]?.icon ?? '') + ' ' + v)
.fontSize(11)
.fontColor(COLOR_TEXT_SUB)
.backgroundColor(COLOR_BG)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(12)
.margin({ left: 3, right: 3 })
.border({ width: 1, color: COLOR_BORDER })
.onClick(() => {
this.formVehicle = v
})
}
})
}
.margin({ top: 4 })
Text('特殊要求')
.fontSize(12)
.fontColor(COLOR_TEXT_SUB)
.width('100%')
.margin({ top: 10 })
Row() {
ForEach(SPECIAL_REQS, (r: string) => {
if ((r === '上门取' && this.reqPickup) || (r === '安装' && this.reqInstall) || (r === '加急' && this.reqRush)) {
Text('✓ ' + r)
.fontSize(11)
.fontColor(COLOR_PRIMARY)
.backgroundColor('#EAF2EC')
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.borderRadius(12)
.margin({ left: 3, right: 3 })
.border({ width: 1, color: COLOR_PRIMARY })
.onClick(() => {
if (r === '上门取') {
this.reqPickup = false
} else if (r === '安装') {
this.reqInstall = false
} else {
this.reqRush = false
}
})
} else {
Text(r)
.fontSize(11)
.fontColor(COLOR_TEXT_SUB)
.backgroundColor(COLOR_BG)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.borderRadius(12)
.margin({ left: 3, right: 3 })
.border({ width: 1, color: COLOR_BORDER })
.onClick(() => {
if (r === '上门取') {
this.reqPickup = true
} else if (r === '安装') {
this.reqInstall = true
} else {
this.reqRush = true
}
})
}
})
}
.margin({ top: 4 })
Text('备注')
.fontSize(12)
.fontColor(COLOR_TEXT_SUB)
.width('100%')
.margin({ top: 10 })
TextArea({ placeholder: '司机师傅请注意:天幕杆易碎,请轻放...' })
.placeholderColor(COLOR_TEXT_HINT)
.fontSize(12)
.width('100%')
.constraintSize({ maxHeight: 70 })
.backgroundColor(COLOR_BG)
.borderRadius(10)
.margin({ top: 4, bottom: 14 })
.onChange((v: string) => {
this.formNotes = v
})
}
.padding({ left: 20, right: 20 })
}
.layoutWeight(1)
.scrollBar(BarState.Off)
// 底部按钮
Row() {
Text('取消')
.fontSize(14)
.fontColor(COLOR_TEXT_SUB)
.backgroundColor(COLOR_BG)
.borderRadius(18)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.border({ width: 1, color: COLOR_BORDER })
.onClick(() => {
this.showShipModal = false
})
Text('确认预约')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_CARD)
.backgroundColor(COLOR_PRIMARY)
.borderRadius(18)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.margin({ left: 12 })
.onClick(() => {
this.showShipModal = false
})
}
.width('100%')
.justifyContent(FlexAlign.Center)
.padding({ top: 12, bottom: 16 })
}
.width('90%')
.height('75%')
.backgroundColor(COLOR_CARD)
.borderRadius(16)
.position({ x: '5%', y: '12%' })
}
.width('100%')
.height('100%')
.position({ x: 0, y: 0 })
.zIndex(999)
}
// ========== 运输记录项 ==========
@Builder shipOrderBuilder(o: ShipOrderItem) {
Column() {
Row() {
Column() {
Text(SHIP_STATUS_CONFIG[o.status]?.icon ?? '📦')
.fontSize(20)
}
.width(40)
.height(40)
.backgroundColor(SHIP_STATUS_CONFIG[o.status]?.bg ?? COLOR_BG)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.Center)
Column() {
Text(o.pickup + ' → ' + o.destination)
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor(COLOR_TEXT_MAIN)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row() {
Text(o.orderNo)
.fontSize(9)
.fontColor(COLOR_TEXT_HINT)
Text(o.date)
.fontSize(9)
.fontColor(COLOR_TEXT_HINT)
.margin({ left: 8 })
Text('装备' + o.gearCount + '件')
.fontSize(9)
.fontColor(COLOR_TEXT_SUB)
.margin({ left: 8 })
}
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 10 })
Column() {
Text(formatPrice(o.price))
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_ACCENT)
Text(SHIP_STATUS_CONFIG[o.status]?.label ?? o.status)
.fontSize(9)
.fontColor(SHIP_STATUS_CONFIG[o.status]?.color ?? COLOR_TEXT_SUB)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.alignItems(VerticalAlign.Center)
.padding({ top: 10, bottom: 10, left: 12, right: 12 })
}
.width('100%')
.backgroundColor(COLOR_CARD)
.borderRadius(12)
.margin({ left: 12, right: 12, top: 6 })
.border({ width: 1, color: COLOR_BORDER })
}
build() {
Stack() {
Column() {
Scroll() {
Column() {
// 车辆容量可视化(3种车型进度条)
Column() {
Text('🚛 车辆容量 · 近期负载')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_TEXT_MAIN)
.width('100%')
.padding({ left: 14, top: 12, bottom: 6 })
ForEach(VEHICLE_TYPES, (v: string) => {
Column() {
Row() {
Text((VEHICLE_CONFIG[v]?.icon ?? '') + ' ' + v)
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor(COLOR_TEXT_MAIN)
Column().layoutWeight(1)
Text((VEHICLE_CONFIG[v]?.capacity ?? ''))
.fontSize(9)
.fontColor(COLOR_TEXT_HINT)
Text((VEHICLE_CONFIG[v]?.loadPct ?? 0).toString() + '%')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(VEHICLE_CONFIG[v]?.color ?? COLOR_PRIMARY)
.margin({ left: 8 })
}
.width('100%')
Row() {
Column()
.width((VEHICLE_CONFIG[v]?.loadPct ?? 0).toString() + '%')
.height(8)
.backgroundColor(VEHICLE_CONFIG[v]?.color ?? COLOR_PRIMARY)
.borderRadius(4)
Column().layoutWeight(1)
}
.width('100%')
.height(8)
.backgroundColor(COLOR_BG)
.borderRadius(4)
.margin({ top: 6 })
Text(VEHICLE_CONFIG[v]?.desc ?? '')
.fontSize(9)
.fontColor(COLOR_TEXT_HINT)
.margin({ top: 3, bottom: 8 })
}
.width('100%')
.padding({ left: 14, right: 14 })
})
}
.width('100%')
.backgroundColor(COLOR_CARD)
.borderRadius(12)
.margin({ left: 12, right: 12, top: 10 })
.border({ width: 1, color: COLOR_BORDER })
// 运输预约表单卡
Column() {
Text('📝 快速预约运输')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_TEXT_MAIN)
.width('100%')
.padding({ left: 14, top: 12, bottom: 6 })
Row() {
Text('装备')
.fontSize(11)
.fontColor(COLOR_TEXT_SUB)
Text('已选' + this.shipGearIds.length + '件 / 共' + mockGears.length + '件')
.fontSize(11)
.fontColor(COLOR_PRIMARY)
.layoutWeight(1)
.textAlign(TextAlign.End)
}
.width('100%')
.padding({ left: 14, right: 14 })
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(mockGears, (g: GearItem) => {
if (this.shipGearIds.indexOf(g.id) >= 0) {
Text(g.icon + ' ' + g.name)
.fontSize(9)
.fontColor(COLOR_CARD)
.backgroundColor(COLOR_PRIMARY)
.padding({ left: 7, right: 7, top: 4, bottom: 4 })
.borderRadius(10)
.margin({ left: 3, right: 3, top: 3, bottom: 3 })
.onClick(() => {
this.toggleShipGear(g.id)
})
} else {
Text(g.icon + ' ' + g.name)
.fontSize(9)
.fontColor(COLOR_TEXT_SUB)
.backgroundColor(COLOR_BG)
.padding({ left: 7, right: 7, top: 4, bottom: 4 })
.borderRadius(10)
.margin({ left: 3, right: 3, top: 3, bottom: 3 })
.border({ width: 1, color: COLOR_BORDER })
.onClick(() => {
this.toggleShipGear(g.id)
})
}
})
}
.width('100%')
.padding({ left: 12, right: 12, top: 6 })
Row() {
Text('📍 出发地').fontSize(11).fontColor(COLOR_TEXT_SUB).margin({ left: 14 })
TextInput({ placeholder: '小区/仓库地址' })
.placeholderColor(COLOR_TEXT_HINT)
.fontSize(11)
.layoutWeight(1)
.backgroundColor(COLOR_BG)
.borderRadius(8)
.margin({ left: 8, right: 8 })
.onChange((v: string) => {
this.formPickup = v
})
}
.width('100%')
.margin({ top: 8 })
Row() {
Text('🏕️ 目的营地').fontSize(11).fontColor(COLOR_TEXT_SUB).margin({ left: 14 })
TextInput({ placeholder: '营地名称' })
.placeholderColor(COLOR_TEXT_HINT)
.fontSize(11)
.layoutWeight(1)
.backgroundColor(COLOR_BG)
.borderRadius(8)
.margin({ left: 8, right: 8 })
.onChange((v: string) => {
this.formDest = v
})
}
.width('100%')
.margin({ top: 6 })
Row() {
Text('📅 日期').fontSize(11).fontColor(COLOR_TEXT_SUB).margin({ left: 14 })
TextInput({ placeholder: '2026-08-25' })
.placeholderColor(COLOR_TEXT_HINT)
.fontSize(11)
.layoutWeight(1)
.backgroundColor(COLOR_BG)
.borderRadius(8)
.margin({ left: 8, right: 8 })
.onChange((v: string) => {
this.formDate = v
})
}
.width('100%')
.margin({ top: 6 })
Row() {
ForEach(VEHICLE_TYPES, (v: string) => {
if (this.formVehicle === v) {
Text((VEHICLE_CONFIG[v]?.icon ?? '') + ' ' + v)
.fontSize(10)
.fontColor(COLOR_CARD)
.backgroundColor(COLOR_ACCENT)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.borderRadius(12)
.margin({ left: 4, right: 4 })
} else {
Text((VEHICLE_CONFIG[v]?.icon ?? '') + ' ' + v)
.fontSize(10)
.fontColor(COLOR_TEXT_SUB)
.backgroundColor(COLOR_BG)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.borderRadius(12)
.margin({ left: 4, right: 4 })
.border({ width: 1, color: COLOR_BORDER })
.onClick(() => {
this.formVehicle = v
})
}
})
}
.width('100%')
.margin({ top: 8 })
Text('🚀 发起预约 · 查看完整表单')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_CARD)
.width('100%')
.textAlign(TextAlign.Center)
.backgroundColor(COLOR_PRIMARY)
.borderRadius(18)
.padding({ top: 10, bottom: 10 })
.margin({ top: 10, left: 14, right: 14, bottom: 14 })
.onClick(() => {
this.showShipModal = true
})
}
.width('100%')
.backgroundColor(COLOR_CARD)
.borderRadius(12)
.margin({ left: 12, right: 12, top: 10 })
.border({ width: 1, color: COLOR_BORDER })
// 运输记录
Text('🧾 运输记录')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_TEXT_MAIN)
.width('100%')
.padding({ left: 18, top: 14, bottom: 4 })
ForEach(mockShipOrders, (o: ShipOrderItem) => {
this.shipOrderBuilder(o)
})
}
.padding({ bottom: 20 })
}
.layoutWeight(1)
.scrollBar(BarState.Off)
}
.width('100%')
.height('100%')
if (this.showShipModal) {
this.shipBookingModal()
}
}
.width('100%')
.height('100%')
}
}
// ============ Tab4 攻略页 ============
@Component
struct GuideContent {
@State guideCat: string = '新手'
@Builder guideCardBuilder(g: GuideItem) {
Row() {
Column() {
Text('🗺️')
.fontSize(26)
}
.width(72)
.height(72)
.linearGradient({
angle: 135,
colors: [[g.color, 0], [COLOR_ACCENT_LIGHT, 1]]
})
.borderRadius(12)
Column() {
Text(g.title)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_TEXT_MAIN)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text('@' + g.author)
.fontSize(10)
.fontColor(COLOR_TEXT_SUB)
.margin({ top: 4 })
Row() {
Text('#' + g.tag)
.fontSize(9)
.fontColor(g.color)
.backgroundColor(COLOR_BG)
.padding({ left: 7, right: 7, top: 2, bottom: 2 })
.borderRadius(8)
.border({ width: 1, color: COLOR_BORDER })
Text('👁 ' + formatViews(g.views))
.fontSize(9)
.fontColor(COLOR_TEXT_HINT)
.margin({ left: 8 })
Text('❤️ ' + formatViews(g.likes))
.fontSize(9)
.fontColor(COLOR_TEXT_HINT)
.margin({ left: 8 })
}
.margin({ top: 5 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 12 })
}
.width('100%')
.backgroundColor(COLOR_CARD)
.borderRadius(12)
.padding(12)
.margin({ left: 12, right: 12, top: 8 })
.alignItems(VerticalAlign.Center)
.border({ width: 1, color: COLOR_BORDER })
.onClick(() => {
console.info('打开攻略:' + g.title)
})
}
build() {
Column() {
// 分类药丸
Scroll() {
Row() {
ForEach(GUIDE_CATEGORIES, (cat: string) => {
if (this.guideCat === cat) {
Text(cat)
.fontSize(11)
.fontColor(COLOR_CARD)
.backgroundColor(COLOR_PRIMARY)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.borderRadius(13)
.margin({ left: 4, right: 4 })
} else {
Text(cat)
.fontSize(11)
.fontColor(COLOR_TEXT_SUB)
.backgroundColor(COLOR_CARD)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.borderRadius(13)
.margin({ left: 4, right: 4 })
.border({ width: 1, color: COLOR_BORDER })
.onClick(() => {
this.guideCat = cat
})
}
})
}
.padding({ left: 8, right: 8 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.height(36)
.width('100%')
.margin({ top: 8 })
// 攻略列表
Scroll() {
Column() {
ForEach(mockGuides, (g: GuideItem) => {
this.guideCardBuilder(g)
})
}
.padding({ bottom: 20 })
}
.layoutWeight(1)
.scrollBar(BarState.Off)
}
.width('100%')
.height('100%')
}
}
// ============ Tab5 天气页 ============
@Component
struct WeatherContent {
@Builder weatherDayCard(w: WeatherDayItem) {
Column() {
Text(w.date)
.fontSize(10)
.fontColor(COLOR_TEXT_SUB)
Text(w.icon)
.fontSize(26)
.margin({ top: 6 })
Text(w.tempMin + '° ~ ' + w.tempMax + '°')
.fontSize(11)
.fontWeight(FontWeight.Medium)
.fontColor(COLOR_TEXT_MAIN)
.margin({ top: 6 })
Text(w.wind)
.fontSize(9)
.fontColor(COLOR_TEXT_HINT)
.margin({ top: 3 })
Row() {
Text('✦' + w.starIndex)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_GOLD)
}
.backgroundColor('#F9F2DE')
.borderRadius(9)
.padding({ left: 7, right: 7, top: 2, bottom: 2 })
.margin({ top: 5 })
}
.width(88)
.alignItems(HorizontalAlign.Center)
.backgroundColor(COLOR_CARD)
.borderRadius(12)
.padding({ top: 10, bottom: 10 })
.margin({ left: 4, right: 4 })
.border({ width: 1, color: COLOR_BORDER })
}
build() {
Column() {
// 顶部:当前营地位置
Row() {
Text('📍').fontSize(14)
Text('当前关注:莫干山 · 云顶星野营地')
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor(COLOR_TEXT_MAIN)
.margin({ left: 4 })
Column().layoutWeight(1)
Text('切换')
.fontSize(11)
.fontColor(COLOR_PRIMARY)
.backgroundColor('#EAF2EC')
.borderRadius(11)
.padding({ left: 12, right: 12, top: 4, bottom: 4 })
.onClick(() => {
console.info('切换营地位置')
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 10, bottom: 6 })
Scroll() {
Column() {
// 恶劣天气预警卡(橙色)
Row() {
Text('⚠️')
.fontSize(22)
Column() {
Text('周五夜间阵风预警')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_ACCENT)
Text('08/29 晚峡谷风力可达7级,请提前加固天幕并收起轻质装备,篝火活动可能临时取消。')
.fontSize(10)
.fontColor(COLOR_TEXT_MAIN)
.margin({ top: 3 })
.lineHeight(14)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 10 })
}
.width('100%')
.backgroundColor('#FBEEDC')
.borderRadius(12)
.padding(14)
.margin({ left: 12, right: 12, top: 6 })
.alignItems(VerticalAlign.Center)
.border({ width: 1, color: COLOR_ACCENT_LIGHT })
// 观星指数大数字
Row() {
Column() {
Text('今日观星指数')
.fontSize(11)
.fontColor(COLOR_TEXT_SUB)
Row() {
Text('✦')
.fontSize(28)
.fontColor(COLOR_GOLD)
Text('5.0')
.fontSize(44)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_GOLD)
.margin({ left: 6 })
}
.alignItems(VerticalAlign.Bottom)
Text('极佳 · 无月光干扰,肉眼可见银河')
.fontSize(10)
.fontColor(COLOR_TEXT_MAIN)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column() {
Text('🌙')
.fontSize(30)
Text('月相:新月')
.fontSize(9)
.fontColor(COLOR_TEXT_SUB)
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.backgroundColor(COLOR_CARD)
.borderRadius(12)
.padding({ left: 18, right: 18, top: 14, bottom: 14 })
.margin({ left: 12, right: 12, top: 10 })
.alignItems(VerticalAlign.Center)
.border({ width: 1, color: COLOR_BORDER })
// 7日天气预报横向滚动
Text('7日天气预报')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_TEXT_MAIN)
.width('100%')
.padding({ left: 18, top: 14, bottom: 6 })
Scroll() {
Row() {
ForEach(mockWeather, (w: WeatherDayItem) => {
this.weatherDayCard(w)
})
}
.padding({ left: 8, right: 8 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.height(150)
.width('100%')
// 7日温度走势柱状图
Column() {
Text('🌡️ 7日最高温走势')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_TEXT_MAIN)
.width('100%')
.padding({ left: 14, top: 12, bottom: 8 })
Row() {
ForEach(mockWeather, (w: WeatherDayItem) => {
Column() {
Text(w.tempMax + '°')
.fontSize(9)
.fontColor(COLOR_ACCENT)
.margin({ bottom: 3 })
Column()
.width(24)
.height(tempBarHeight(w.tempMax))
.linearGradient({
angle: 180,
colors: [[COLOR_GOLD, 0], [COLOR_ACCENT, 1]]
})
.borderRadius({ topLeft: 4, topRight: 4 })
Text(w.date.substring(3, 5))
.fontSize(8)
.fontColor(COLOR_TEXT_HINT)
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.End)
})
}
.width('100%')
.padding({ left: 10, right: 10, bottom: 12 })
.alignItems(VerticalAlign.Bottom)
.height(130)
}
.width('100%')
.backgroundColor(COLOR_CARD)
.borderRadius(12)
.margin({ left: 12, right: 12, top: 10 })
.border({ width: 1, color: COLOR_BORDER })
// 出行建议
Column() {
Text('🎒 出行建议')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_TEXT_MAIN)
.width('100%')
.padding({ left: 14, top: 12, bottom: 6 })
Text('· 周一有阵雨,天幕建议延至周二搭建\n· 昼夜温差12℃,睡袋选舒适温标10℃以下\n· 周四微风之夜适合篝火与观星')
.fontSize(11)
.fontColor(COLOR_TEXT_MAIN)
.lineHeight(18)
.padding({ left: 14, right: 14, bottom: 14 })
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.backgroundColor(COLOR_CARD)
.borderRadius(12)
.margin({ left: 12, right: 12, top: 10 })
.border({ width: 1, color: COLOR_BORDER })
}
.padding({ bottom: 20 })
}
.layoutWeight(1)
.scrollBar(BarState.Off)
}
.width('100%')
.height('100%')
}
}
// ============ Tab6 社区页 ============
@Component
struct PostContent {
@Builder postCardBuilder(p: PostItem) {
Column() {
// 头像 + 用户名 + 营地标签
Row() {
Column() {
Text(p.avatar)
.fontSize(22)
}
.width(40)
.height(40)
.backgroundColor(COLOR_BG)
.borderRadius(20)
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.Center)
.border({ width: 1, color: COLOR_BORDER })
Column() {
Text(p.username)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_TEXT_MAIN)
Text('🏕️ ' + p.campTag)
.fontSize(9)
.fontColor(COLOR_SKY)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 10 })
Text('关注')
.fontSize(10)
.fontColor(COLOR_PRIMARY)
.backgroundColor('#EAF2EC')
.borderRadius(11)
.padding({ left: 12, right: 12, top: 4, bottom: 4 })
.onClick(() => {
console.info('关注用户:' + p.username)
})
}
.width('100%')
Text(p.content)
.fontSize(12)
.fontColor(COLOR_TEXT_MAIN)
.lineHeight(18)
.margin({ top: 8 })
// 图片色块
Row() {
Column() {
Text('🌄')
.fontSize(28)
}
.layoutWeight(2)
.height(110)
.linearGradient({
angle: 135,
colors: [[p.color, 0], [COLOR_ACCENT_LIGHT, 1]]
})
.borderRadius(10)
Column() {
Text('🔥')
.fontSize(20)
}
.layoutWeight(1)
.height(110)
.backgroundColor(COLOR_BG)
.borderRadius(10)
.margin({ left: 6 })
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.Center)
Column() {
Text('⭐')
.fontSize(20)
}
.layoutWeight(1)
.height(110)
.backgroundColor('#F9F2DE')
.borderRadius(10)
.margin({ left: 6 })
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.Center)
}
.width('100%')
.margin({ top: 8 })
// 点赞评论
Row() {
Text('❤️ ' + p.likes)
.fontSize(11)
.fontColor(COLOR_TEXT_SUB)
Text('💬 ' + p.comments)
.fontSize(11)
.fontColor(COLOR_TEXT_SUB)
.margin({ left: 16 })
Column().layoutWeight(1)
Text('刚刚')
.fontSize(9)
.fontColor(COLOR_TEXT_HINT)
}
.width('100%')
.margin({ top: 8 })
}
.width('100%')
.backgroundColor(COLOR_CARD)
.borderRadius(12)
.padding(12)
.margin({ left: 12, right: 12, top: 8 })
.border({ width: 1, color: COLOR_BORDER })
.onClick(() => {
console.info('打开帖子详情:' + p.username)
})
}
build() {
Column() {
// 顶部:标题 + 分享按钮
Row() {
Column() {
Text('📷 露搭社区')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_TEXT_MAIN)
Text('今晚,把家搬到山野里')
.fontSize(9)
.fontColor(COLOR_TEXT_HINT)
.margin({ top: 1 })
}
.alignItems(HorizontalAlign.Start)
Column().layoutWeight(1)
Text('✨ 分享')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_CARD)
.backgroundColor(COLOR_ACCENT)
.borderRadius(15)
.padding({ left: 18, right: 18, top: 7, bottom: 7 })
.onClick(() => {
console.info('分享我的露营故事')
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 10, bottom: 6 })
.alignItems(VerticalAlign.Center)
Scroll() {
Column() {
ForEach(mockPosts, (p: PostItem) => {
this.postCardBuilder(p)
})
}
.padding({ bottom: 20 })
}
.layoutWeight(1)
.scrollBar(BarState.Off)
}
.width('100%')
.height('100%')
}
}
// ============ Tab7 我的页 ============
@Component
struct ProfileContent {
@Builder profileStatCell(icon: string, value: string, label: string, color: string) {
Column() {
Text(icon)
.fontSize(18)
Text(value)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(color)
.margin({ top: 3 })
Text(label)
.fontSize(9)
.fontColor(COLOR_TEXT_SUB)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 14, bottom: 14 })
.backgroundColor(COLOR_CARD)
.borderRadius(12)
.border({ width: 1, color: COLOR_BORDER })
}
@Builder settingRow(icon: string, label: string, hint: string) {
Row() {
Text(icon)
.fontSize(17)
Text(label)
.fontSize(13)
.fontColor(COLOR_TEXT_MAIN)
.layoutWeight(1)
.margin({ left: 12 })
Text(hint)
.fontSize(10)
.fontColor(COLOR_TEXT_HINT)
.margin({ right: 6 })
Text('>')
.fontSize(13)
.fontColor(COLOR_TEXT_HINT)
}
.width('100%')
.padding({ top: 12, bottom: 12, left: 16, right: 16 })
.onClick(() => {
console.info('打开设置项:' + label)
})
}
build() {
Column() {
Scroll() {
Column() {
// 资料卡
Column() {
Row() {
Column() {
Text('🧑🌾')
.fontSize(36)
}
.width(64)
.height(64)
.backgroundColor('#EAF2EC')
.borderRadius(32)
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.Center)
.border({ width: 2, color: COLOR_PRIMARY_LIGHT })
Column() {
Text('松果爸爸')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_TEXT_MAIN)
Text('户外等级:LV6 荒野老手 · 杭州')
.fontSize(10)
.fontColor(COLOR_TEXT_SUB)
.margin({ top: 3 })
Row() {
Text('🏕️ 露营 32 次')
.fontSize(9)
.fontColor(COLOR_PRIMARY)
.backgroundColor('#EAF2EC')
.padding({ left: 7, right: 7, top: 2, bottom: 2 })
.borderRadius(8)
Text('🌙 89 天')
.fontSize(9)
.fontColor(COLOR_GOLD)
.backgroundColor('#F9F2DE')
.padding({ left: 7, right: 7, top: 2, bottom: 2 })
.borderRadius(8)
.margin({ left: 6 })
}
.margin({ top: 5 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 14 })
}
.width('100%')
.alignItems(VerticalAlign.Center)
.padding({ left: 16, right: 16, top: 18, bottom: 18 })
}
.width('100%')
.linearGradient({
angle: 135,
colors: [[COLOR_CARD, 0], ['#EAF2EC', 1]]
})
.borderRadius(14)
.margin({ left: 12, right: 12, top: 12 })
.border({ width: 1, color: COLOR_BORDER })
// 统计网格 2x2(大数字)
Column() {
Row() {
this.profileStatCell('⛰️', '27', '总营地', COLOR_PRIMARY)
this.profileStatCell('🚛', '18', '总运输', COLOR_SKY)
}
.width('100%')
.padding({ left: 6, right: 6 })
Row() {
this.profileStatCell('⛺', '10', '装备数', COLOR_ACCENT)
this.profileStatCell('✦', '3260', '积分', COLOR_GOLD)
}
.width('100%')
.padding({ left: 6, right: 6, top: 8 })
}
.width('100%')
.margin({ top: 10 })
// 装备状态小结
Column() {
Text('📦 装备状态')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLOR_TEXT_MAIN)
.width('100%')
.padding({ left: 16, top: 12, bottom: 8 })
Row() {
Column() {
Row() {
Column()
.width('100%')
.height(10)
.backgroundColor(COLOR_PRIMARY)
.borderRadius(3)
}
.width(getGearCountByStatus('在家').toString() + '0%')
.height(10)
Text('在家 ' + getGearCountByStatus('在家') + '件')
.fontSize(9)
.fontColor(COLOR_TEXT_SUB)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column() {
Row() {
Column()
.width('100%')
.height(10)
.backgroundColor(COLOR_SKY)
.borderRadius(3)
}
.width(getGearCountByStatus('在营地').toString() + '0%')
.height(10)
Text('在营地 ' + getGearCountByStatus('在营地') + '件')
.fontSize(9)
.fontColor(COLOR_TEXT_SUB)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 12 })
Column() {
Row() {
Column()
.width('100%')
.height(10)
.backgroundColor(COLOR_ACCENT)
.borderRadius(3)
}
.width(getGearCountByStatus('在途').toString() + '0%')
.height(10)
Text('在途 ' + getGearCountByStatus('在途') + '件')
.fontSize(9)
.fontColor(COLOR_TEXT_SUB)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 12 })
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 14 })
}
.width('100%')
.backgroundColor(COLOR_CARD)
.borderRadius(12)
.margin({ left: 12, right: 12, top: 10 })
.border({ width: 1, color: COLOR_BORDER })
// 设置列表
Column() {
this.settingRow('🎒', '我的装备', '10件')
Divider().color(COLOR_BORDER)
this.settingRow('📍', '常用营地', '5个')
Divider().color(COLOR_BORDER)
this.settingRow('🎟️', '优惠券', '3张可用')
Divider().color(COLOR_BORDER)
this.settingRow('🎧', '客服', '24h在线')
Divider().color(COLOR_BORDER)
this.settingRow('⚙️', '设置', '')
}
.width('100%')
.backgroundColor(COLOR_CARD)
.borderRadius(12)
.margin({ left: 12, right: 12, top: 10 })
.border({ width: 1, color: COLOR_BORDER })
Text('v1.0 · 星野营地 CAMP HAUL · 把家搬进山野')
.fontSize(9)
.fontColor(COLOR_TEXT_HINT)
.alignSelf(ItemAlign.Center)
.margin({ top: 18, bottom: 16 })
}
.padding({ bottom: 10 })
}
.layoutWeight(1)
.scrollBar(BarState.Off)
}
.width('100%')
.height('100%')
}
}
六、总结
通过对星野营地 CAMP HAUL 这份完整源码的逐段剖析,我们可以清晰地看到基于 HarmonyOS ArkTS API 24 构建复杂业务应用的设计脉络。整个应用以 @Entry 入口组件为壳,通过 @State 管理全局导航状态,以条件渲染装配七个 @Component 业务页面,每个页面内部用 @State 管理局部交互状态和弹框开关,形成了层次分明的状态管理架构。@Observed 修饰的数据类让模型变更能够自动驱动 UI 更新,@Builder 提取的公共片段减少了重复代码,Record 配置映射将枚举值与视觉元数据绑定实现了数据与展示的解耦。这些 ArkTS 语言级特性的组合使用,使得三千余行代码能够支撑起七个功能完整的业务页面和四个复杂表单弹框,代码密度高且结构清晰。

在视觉设计层面,应用通过一套沙米色、松林绿、篝火橙、天空蓝、金麦黄构成的大地色系常量,贯穿了所有页面的背景、按钮、标签、图标色块,建立了统一的户外品牌调性。色彩不仅用于装饰,更承担了状态语义化的职责:在家用绿、在营地用蓝、在途用橙,让用户在信息密集的列表中通过色彩快速识别关键状态。linearGradient 渐变在营地封面、攻略卡片、社区图片色块中反复使用,以两种颜色的渐变模拟户外光影,在没有真实图片资源的情况下营造了丰富的视觉层次。温度柱状图、车辆负载进度条、装备状态分布条等数据可视化,全部用 ArkTS 原生组件和计算表达式实现,无需引入第三方图表库,体现了声明式 UI 的表达力。
在交互层面,四个弹框覆盖了编辑、预订、取消、运输预约等核心业务流程,每个弹框都采用遮罩加内容卡的两层结构,通过布尔状态控制显隐,内部集成文本输入、药丸单选、多选切换、步进器等多种交互控件。运输弹框的 toggleShipGear 多选逻辑、营地弹框的人数步进器边界约束、装备弹框的表单状态收集,都展示了 ArkTS 处理复杂表单交互的能力。萤火虫粒子层通过 setInterval 定时驱动数组替换实现持续动画,配合 hitTestBehavior(HitTestMode.None) 确保不干扰用户操作,是轻量动画实现的典范。这些交互细节的累积,让应用在功能完整的同时具备了良好的操作体验。
展望未来,这份代码作为 HarmonyOS 6.1.1 原生应用开发的参考范例,其设计模式具有良好的可扩展性。当前使用 mock 静态数据,若接入后端接口只需替换数据源即可,因为纯函数和 @Observed 模型已经与渲染逻辑解耦。配置驱动的映射表让新增装备类别、营地标签、车型只需修改配置对象。@Builder 构建器和 @Component 的拆分方式让新增第八个 Tab 或新的弹框交互可以独立开发。对于希望在鸿蒙生态中构建复杂业务应用的开发者而言,星野营地的代码结构提供了一个从数据建模、状态管理、组件拆分到视觉设计、交互实现的完整实践模板,值得深入研读和借鉴。
更多推荐





所有评论(0)