HarmonyOS 6声明式范式的户外露营装备租赁与营地预订应用深度技术解析,ArkUI 提供的 Scroll、List、Grid、Swiper 等容器组件,以及 ForEach 渲染控制语句
一、技术背景:声明式 UI 与 HarmonyOS 生态的融合演进
在当今移动端跨平台开发领域,声明式 UI(Declarative UI)已经成为主流范式。与传统的命令式 UI 不同,声明式 UI 的核心思想是开发者只需描述界面"应该是什么样子",而无需关心"如何一步步构建界面"。这种范式转变源于前端框架如 React、Flutter、SwiftUI 的成功实践,它通过状态驱动的数据绑定机制,让 UI 成为状态的函数映射——当状态发生变化时,框架自动计算差异并更新对应的视图节点。HarmonyOS 的 ArkUI 框架正是这一思潮在国产操作系统生态中的集中体现,它采用了一套基于 TypeScript 扩展的 ArkTS 语言作为开发基础,融合了类型安全、装饰器系统和编译期优化等现代语言特性,为开发者提供了一套从语言到运行时的完整解决方案。
ArkTS 是华为为 HarmonyOS 应用开发量身定制的编程语言,它在 TypeScript 的基础上进行了深度定制与严格化。ArkTS 保留了 TypeScript 的静态类型系统,同时引入了 @Component、@Entry、@State、@Builder、@Observed 等装饰器,这些装饰器并非简单的语法糖,而是与 ArkUI 框架的编译管线深度集成的元编程机制。当编译器扫描到 @Component 装饰的 struct 时,会自动生成状态管理代理、依赖追踪图和差异比较代码;当检测到 @State 修饰的成员变量时,会为其注入 setter 拦截器,使得任何对该变量的赋值操作都能触发关联视图的重新渲染。这种编译期变换使得 ArkTS 既保留了声明式开发的简洁性,又能在运行时获得接近原生应用的性能表现。
HarmonyOS 生态作为华为面向全场景智慧生活打造的分布式操作系统,其应用开发框架 ArkUI 承载着"一次开发,多端部署"的核心愿景。在 HarmonyOS 的架构中,应用不仅运行在手机上,还能适配平板、智慧屏、车机、手表等多种终端形态。ArkUI 通过统一抽象的组件树模型和自适应布局能力,让同一份代码能在不同屏幕尺寸和交互模式下呈现合理的界面。对于户外露营这类需要展示大量图文信息、地理数据、装备库存的应用场景而言,ArkUI 提供的 Scroll、List、Grid、Swiper 等容器组件,以及 ForEach 渲染控制语句,能够高效地处理长列表与动态数据集,配合 @Observed 的可观察对象机制,可以构建出既美观又具备实时响应能力的复杂业务界面。
@Observed 机制是 ArkUI 状态管理体系的精髓之一,它与 @State 形成了层级化的响应式数据流。@State 装饰的变量是组件内部的私有状态,其变化只能驱动当前组件的重新渲染;而 @Observed 装饰的类则可以被多层组件共享,当其实例的属性发生变化时,所有引用该实例的 @State 数组、@Prop、@Link、@ObjectLink 都会感知到变化并触发更新。这种机制类似于 MobX 或 Vue 的响应式原理,但更深度地集成在编译器层面,避免了运行时反射的开销。在涉及列表项的局部更新、跨组件的数据共享(如购物车、收藏夹、预订状态)时,@Observed 能显著降低不必要的全量刷新,提升渲染性能。本应用中的 CampItem、CampGear、ActItem 等数据模型都采用了 @Observed 装饰,正是为了在弹窗编辑、列表删除等操作时实现精准的局部刷新。
从软件工程的角度看,现代户外露营行业正经历着从"线下门店租赁"向"线上预约+到店取货"的数字化转型。露营装备具有品类繁多(帐篷、睡袋、炉具、照明等数十个细分类目)、库存状态实时变化(在库、租出、维修)、使用场景分散(高山、湖畔、沙漠、森林)等特点,这对应用的数据建模和状态同步提出了较高要求。同时,营地预订业务涉及位置信息、价格策略、用户评分、活动报名等多维度信息,需要应用在 UI 层提供丰富的卡片展示、横滑浏览、瀑布流布局和弹窗交互。本应用以"户外暖橙 + 苔绿"为视觉主调,融合了底部主 Tab 导航、首页内嵌多内容 Tab、双列瀑布流、柱状图可视化、萤火虫闪烁特效等丰富交互形态,是一份兼具业务深度与技术广度的 ArkUI 实践范例。

二、色彩体系与界面常量定义:设计令牌的工程化实践

interface ColorPalette {
bg: string;
card: string;
deep: string;
orange: string;
orangeD: string;
orangeL: string;
moss: string;
mossL: string;
sky: string;
title: string;
sub: string;
text3: string;
gold: string;
red: string;
blue: string;
line: string;
tabOn: string;
mask: string;
chip: string;
}
const COLORS: ColorPalette = {
bg: '#F4F7F0',
card: '#FFFFFF',
deep: '#33402E',
orange: '#E8793E',
orangeD: '#C25A26',
orangeL: '#FDE9DD',
moss: '#6B8F5A',
mossL: '#E6EFE1',
sky: '#7FA8C8',
title: '#2E3A2A',
sub: '#77826E',
text3: '#B0BAa6',
gold: '#D9A441',
red: '#D9534F',
blue: '#5B8BB8',
line: '#E4EBDE',
tabOn: '#E8793E',
mask: 'rgba(46,58,42,0.55)',
chip: 'rgba(232,121,62,0.10)'
};
应用首先定义了一个 ColorPalette 接口,用于约束色彩配置对象的结构。这种"先定义类型,再定义常量"的写法是 ArkTS 强类型编程的典型实践:接口确保了所有色彩字段都有明确的类型声明(string),同时为后续的维护提供了自文档化的契约——任何使用 COLORS.xxx 的地方都会获得编译期的类型检查,避免因拼写错误导致的运行时 undefined 问题。接口中列举了 19 个色彩字段,覆盖了背景色(bg)、卡片色(card)、深色(deep)、主色及其明暗变体(orange/orangeD/orangeL)、辅助色及其变体(moss/mossL)、天空色(sky)、文字三级层次(title/sub/text3)、点缀色(gold/red/blue)、分割线色(line)、Tab 激活色(tabOn)、遮罩色(mask)和芯片色(chip)等全部界面需要的色彩角色。
COLORS 常量对象是整个应用的"设计令牌"(Design Token)集合。设计令牌是设计系统中的原子变量,它将视觉设计的决策(如"主色用 #E8793E")与代码实现解耦,使得设计师调整色彩方案时只需修改一处常量,而无需在数百处散落的硬编码色值中逐个替换。本应用选用 #E8793E(暖橙)作为主色,呼应露营场景中篝火与夕阳的色彩联想;选用 #6B8F5A(苔绿)作为辅助色,呼应山野森林的视觉意象;背景色 #F4F7F0 是一种带有轻微绿调的米白,比纯白更柔和,符合户外自然主题;文字色分三级——#2E3A2A(深墨绿,标题用)、#77826E(中灰绿,副文本用)、#B0BAa6(浅灰绿,弱化文本用),形成了清晰的视觉层级。值得注意的是 mask 使用了 rgba(46,58,42,0.55),这种半透明深色是模态遮罩的通用做法,既能让背景内容隐约可见,又能聚焦用户注意力到弹窗上。
从工程组织角度,将色彩集中定义在文件顶部、组件之外的模块级作用域,意味着所有 @Builder 方法都能直接引用 COLORS,而无需通过 props 逐层传递。这种"全局常量+组件内引用"的模式在中小型应用中非常高效,它避免了过多的属性透传带来的代码冗余。同时,由于 COLORS 是 const 常量而非 @State 状态,它不会触发响应式更新——色彩本身在运行时不会变化,因此无需纳入状态管理。如果未来需要支持"深色模式"切换,可以将 COLORS 改造为根据 @State theme 动态返回的函数,或使用 ArkUI 提供的 @AppStorage 全局状态配合资源文件实现,但当前应用聚焦于单一主题,这种简洁的常量方案是恰当的。
此外,色值命名采用了"语义化命名"而非"字面命名"的原则——例如不叫 color1、color2,而是 orange(橙)、moss(苔)、gold(金)、red(红),这种命名让代码本身具有可读性:看到 fontColor(COLORS.gold) 立刻知道这是评分星标的金色,看到 backgroundColor(COLORS.mossL) 立刻知道这是营地图标的浅绿底色。语义化命名是大型项目协作的基础,当多人维护同一份代码时,清晰的命名能大幅降低沟通成本和认知负担。
三、导航元数据与底部 Tab 常量

interface TabMeta {
icon: string;
label: string;
}
const BOTTOM_TABS: TabMeta[] = [
{ icon: '🏕️', label: '首页' },
{ icon: '⛺', label: '营地' },
{ icon: '🎒', label: '装备' },
{ icon: '👤', label: '我的' }
];
const HOME_TABS: TabMeta[] = [
{ icon: '⛺', label: '营地' },
{ icon: '🎒', label: '装备' },
{ icon: '🔥', label: '活动' },
{ icon: '🍳', label: '食谱' },
{ icon: '🎓', label: '技能' },
{ icon: '🌦️', label: '天气' },
{ icon: '🏆', label: '榜单' }
];
应用定义了 TabMeta 接口用于约束导航项的结构,每个导航项由 icon(图标 emoji)和 label(文字标签)两个字段组成。这种数据驱动的导航定义方式,相比在 Builder 中硬编码每个 Tab 的图标和文字,具有显著优势:数据与视图分离,新增或调整 Tab 只需修改常量数组,无需改动视图代码;同时 ForEach 渲染时能统一处理,代码更紧凑。
BOTTOM_TABS 定义了应用底部的 4 个主 Tab:首页、营地、装备、我的。这是经典的移动端"底部导航"模式,符合 iOS 与 Android 的设计规范,用户能在不同主功能区间快速切换。每个 Tab 选用一个语义化的 emoji 作为图标——🏕️(露营地)代表综合首页,⛺(帐篷)代表营地预订,🎒(背包)代表装备租赁,👤(人像)代表个人中心。使用 emoji 作为图标的好处是无需引入图片资源,应用体积小、加载快,且在不同设备上保持一致的视觉表现;缺点是无法精细控制颜色和动画,但对于本应用这种以信息密度为主、图标为辅的场景,emoji 方案是合理的折中。
HOME_TABS 定义了首页内的 7 个内容 Tab:营地、装备、活动、食谱、技能、天气、榜单。这是一种"Tab 嵌套 Tab"的信息架构设计——首页是用户的入口聚合页,其内部再细分为多个内容子模块。营地子 Tab 展示推荐营地的大卡横滑;装备子 Tab 展示装备的双列瀑布流;活动子 Tab 展示可报名的活动卡片;食谱子 Tab 展示营地菜谱的横条列表;技能子 Tab 展示教学课程;天气子 Tab 用柱状图展示未来天气;榜单子 Tab 展示装备销量排行。这 7 个子 Tab 覆盖了露营用户从"找营地"到"租装备"到"看天气"到"学技能"的完整决策链路,体现了产品对用户场景的深入理解。
从数据结构角度,TabMeta[] 数组使用对象字面量初始化,每个元素都符合 TabMeta 接口的形状约束。在 ArkTS 的严格模式下,这种字面量赋值会经过类型检查,确保每个对象都有 icon 和 label 两个字符串字段。数组本身的不可变性由 const 关键字保证——const 修饰引用类型时,保证的是引用绑定不可变(不能重新赋值整个数组),但数组内部元素在运行时仍可被修改(如 BOTTOM_TABS.push(...))。不过在本应用中,Tab 元数据在运行时不需要变化,因此不会被修改。如果将来需要支持动态配置 Tab(如根据用户权限显示不同 Tab),可以将这些常量改为 @State 状态,配合后端返回的配置动态渲染。
这两个 Tab 常量数组的另一个设计巧思在于:底部 Tab 与首页内容 Tab 之间存在功能重叠——首页的"营地"和"装备"子 Tab 与底部主 Tab 的"营地"和"装备"在业务上是同一类内容,但呈现形式不同。首页子 Tab 中的营地和装备是"精选推荐"式的卡片化展示,而底部主 Tab 的营地和装备是"全量列表"式的列表化展示。这种"聚合页推荐 + 详情页全量"的双层架构是内容型应用的标准做法,既能在首页给用户快速预览的高效入口,又能在主 Tab 中提供完整的可筛选、可操作的数据列表。
四、营地数据模型与初始数据

@Observed export class CampItem {
id: number = 0;
name: string = '';
icon: string = '';
area: string = '';
price: string = '';
score: number = 0;
tag: string = '';
constructor(id: number, name: string, icon: string, area: string, price: string, score: number, tag: string) {
this.id = id; this.name = name; this.icon = icon; this.area = area;
this.price = price; this.score = score; this.tag = tag;
}
}
const CAMP_INIT: CampItem[] = [
new CampItem(1, '云顶星空营地', '🌌', '海拔 2800m 高山草甸', '¥268/晚', 4.9, '🔥 星空'),
new CampItem(2, '溪谷听泉营地', '💧', '溪谷竹林畔', '¥198/晚', 4.8, '🔥 亲子'),
new CampItem(3, '松涛湖畔营地', '🌲', '高山湖畔松林', '¥228/晚', 4.7, '钓鱼'),
new CampItem(4, '麦田微风营地', '🌾', '平原麦田之间', '¥168/晚', 4.6, '性价比'),
new CampItem(5, '悬崖日出营地', '🌅', '悬崖之巅', '¥328/晚', 4.9, '🔥 日出'),
new CampItem(6, '森林氧吧营地', '🌳', '原始森林边缘', '¥218/晚', 4.7, '森林浴'),
new CampItem(7, '湖畔星火营地', '🔥', '火山湖岸', '¥268/晚', 4.8, '🔥 篝火'),
new CampItem(8, '沙漠绿洲营地', '🏜️', '绿洲边缘沙丘', '¥298/晚', 4.6, '沙漠'),
new CampItem(9, '野花谷营地', '🌸', '花海山谷', '¥188/晚', 4.7, '花季'),
new CampItem(10, '河畔牧场营地', '🐴', '牧场草甸', '¥178/晚', 4.5, '牧场'),
new CampItem(11, '雪线观星营地', '❄️', '雪线以下', '¥358/晚', 4.9, '🔥 观星'),
new CampItem(12, '竹海听风营地', '🎋', '竹海深处', '¥208/晚', 4.6, '幽静')
];
CampItem 类是营地业务实体的数据模型,使用 @Observed 装饰器修饰,表明其实例是可观察对象。@Observed 的作用是在类的每个属性上代理 getter/setter,当属性被赋新值时,会通知所有观察该实例的组件进行重新渲染。这一机制对于"编辑营地预订信息"这类场景至关重要:当用户在弹窗中修改了某个营地的 area(位置描述)字段后,关闭弹窗时列表中对应卡片的展示会自动更新,无需手动调用 setState 或重新构建整个数组。export 关键字使该类可被其他模块导入复用。
类的 7 个字段——id(唯一标识)、name(营地名称)、icon(emoji 图标)、area(位置描述)、price(价格)、score(评分)、tag(标签)——覆盖了一个营地卡片展示所需的全部信息。字段类型使用了 number = 0 和 string = '' 的"声明+默认值"写法,这是 ArkTS 的推荐实践:既声明了字段类型,又提供了默认值,避免在构造函数调用前出现 undefined 状态。构造函数接收 7 个位置参数并依次赋值给 this,这是一种简洁但略显冗长的写法——ArkTS 暂不支持 TypeScript 的"参数属性"(parameter properties,如 constructor(private id: number)),因此必须显式赋值。
CAMP_INIT 常量数组是营地的初始数据集,包含 12 个营地实例。这 12 个营地覆盖了多样的地理场景:高山(云顶星空营地 2800m)、溪谷(溪谷听泉营地)、湖畔(松涛湖畔营地)、平原(麦田微风营地)、悬崖(悬崖日出营地)、森林(森林氧吧营地)、火山湖(湖畔星火营地)、沙漠(沙漠绿洲营地)、花谷(野花谷营地)、牧场(河畔牧场营地)、雪线(雪线观星营地)、竹海(竹海听风营地),几乎囊括了户外露营爱好者可能涉足的所有典型地形。价格从 ¥168/晚 到 ¥358/晚 不等,评分集中在 4.5-4.9 之间,体现了"精选优质营地"的运营定位。
标签字段 tag 使用了 emoji 前缀(如 🔥 星空、🔥 亲子)来标识热门或特色属性,🔥 表示热门推荐。这种"emoji + 文字"的标签写法在视觉上醒目且节省空间,符合移动端小卡片的设计约束。从数据建模角度,将"是否热门"与"特色类别"合并到 tag 字段是一种简化的设计——更严格的建模会拆分为 isHot: boolean 和 category: string 两个字段,便于后端的筛选与统计;但本应用作为前端演示,采用合并字段简化了数据结构,同时在前端展示上仍能通过字符串匹配实现 🔥 标识的统一渲染。
这 12 条初始数据在应用启动时被赋值给 @State campList,作为可编辑的状态数据。在首页的营地子 Tab 中,campList.slice(0, 6) 取前 6 个用于大卡横滑,campList.slice(6, 12) 取后 6 个用于横条列表;在营地主 Tab 中,campList 全量展示并支持通过弹窗编辑。这种"同一数据集,多种视图"的设计正是 @Observed 与 @State 配合的价值所在:数据是单一真相源(single source of truth),所有视图都从同一数据派生,任何对数据的修改都会自动同步到所有视图。
五、装备数据模型与租赁库存

@Observed export class CampGear {
id: number = 0;
name: string = '';
icon: string = '';
price: string = '';
status: string = '';
tag: string = '';
sales: number = 0;
constructor(id: number, name: string, icon: string, price: string, status: string, tag: string, sales: number) {
this.id = id; this.name = name; this.icon = icon; this.price = price;
this.status = status; this.tag = tag; this.sales = sales;
}
}
const GEAR_INIT: CampGear[] = [
new CampGear(1, '双层帐篷 3-4 人', '⛺', '¥59/日', '在库', '🔥 热门', 320),
new CampGear(2, '充气睡垫', '🛏️', '¥19/日', '在库', '舒适', 280),
new CampGear(3, '露营灯', '💡', '¥9/日', '在库', '🔥 热门', 410),
new CampGear(4, '折叠桌椅套装', '🪑', '¥29/日', '在库', '野餐', 190),
new CampGear(5, '卡式炉', '🍳', '¥25/日', '在库', '炊具', 230),
new CampGear(6, '保温箱 20L', '🧊', '¥39/日', '在库', '冷藏', 160),
new CampGear(7, '天幕 4x4', '🛡️', '¥49/日', '在库', '🔥 热门', 350),
new CampGear(8, '睡袋 -5°C', '🧥', '¥35/日', '在库', '保暖', 240),
new CampGear(9, '登山杖', '🥾', '¥12/日', '在库', '徒步', 120),
new CampGear(10, '头灯', '🔦', '¥8/日', '在库', '照明', 310),
new CampGear(11, '便携水桶', '🪣', '¥6/日', '在库', '取水', 90),
new CampGear(12, '防潮垫', '🧺', '¥8/日', '在库', '基础', 380),
new CampGear(13, '炉头套装', '🔥', '¥22/日', '在库', '炊具', 140),
new CampGear(14, '户外电源 600W', '🔋', '¥89/日', '在库', '🔥 热门', 260),
new CampGear(15, '折叠椅', '🪑', '¥15/日', '在库', '野餐', 200),
new CampGear(16, '充气床', '🛌', '¥45/日', '在库', '舒适', 150)
];
CampGear 类是装备租赁业务的数据模型,同样使用 @Observed 装饰,包含 7 个字段:id、name、icon、price(日租金)、status(库存状态)、tag(分类标签)、sales(租赁次数)。与 CampItem 相比,多了 status 和 sales 两个字段——status 反映装备的实时库存状态(在库、租出、维修),是租赁业务的核心运营字段;sales 反映装备的累计租赁次数,用于榜单排序与热度展示。这两个字段的存在使得装备数据不仅是展示信息,更是业务运营的关键数据。
GEAR_INIT 常量数组包含 16 件装备实例,覆盖了露营的完整装备链:住宿类(双层帐篷、充气睡垫、睡袋、充气床、防潮垫)、炊具类(卡式炉、炉头套装)、冷藏类(保温箱)、照明类(露营灯、头灯)、家具类(折叠桌椅套装、折叠椅)、遮阳类(天幕)、徒步类(登山杖)、水电类(便携水桶、户外电源)。价格从 ¥6/日(便携水桶)到 ¥89/日(户外电源 600W)跨度较大,反映了不同装备的价值差异。所有装备初始状态均为"在库",但在实际运行中,用户可以通过弹窗将装备状态修改为"租出"或"维修",此时 statusColor 函数会根据状态返回不同的颜色——橙色(租出)、红色(维修)、绿色(在库),让用户一目了然地识别库存情况。
sales 字段的数值(如双层帐篷 320 次、露营灯 410 次、防潮垫 380 次)反映了装备的受欢迎程度。在首页的榜单子 Tab 中,gearList.slice(0, 10) 取前 10 件装备按 sales 排序展示(实际上初始数据已按 sales 降序排列,但榜单 Tab 直接取前 10),前三名用金色编号突出显示,体现了"销量榜单"的视觉层次。租赁次数也是装备补货与淘汰的决策依据——sales 持续走低的装备可能被下架,sales 高但库存紧张的装备需要补货,这种数据驱动的运营决策正是租赁业务数字化的价值所在。
值得注意的设计细节是,装备的 tag 字段使用了"功能分类"而非"热门标识"(虽然部分装备也带有 🔥 前缀表示热门)。这些分类标签(基础、舒适、炊具、冷藏、野餐、保暖、徒步、照明、取水、热门)在"新增装备"弹窗中被简化为 4 个可选分类:基础、炊具、照明、舒适。这种"展示分类丰富,但新增时分类简化"的设计是务实的产品决策——新增装备时让用户快速选择大类即可,不必要求精确分类;而初始数据由运营人员精心标注,用于丰富展示效果。
从数据规模看,16 件装备在双列瀑布流中分为左右两列,每列 8 件(slice(0, 8) 和 slice(8, 16)),恰好填满首屏。这种"分片渲染"策略既保证了视觉的瀑布流效果,又控制了单列渲染的节点数,避免一次性渲染过多节点带来的性能压力。当用户通过弹窗新增装备时,splice(0, 0, newCampGear) 将新装备插入到数组头部,左侧第一列的第一项会立即更新为新装备——这种"插入头部"的策略让新增结果立即可见,提升了操作的反馈感与用户信心。
六、活动、食谱、技能多业务数据建模

@Observed export class ActItem {
id: number = 0;
name: string = '';
icon: string = '';
date: string = '';
place: string = '';
price: string = '';
tag: string = '';
constructor(id: number, name: string, icon: string, date: string, place: string, price: string, tag: string) {
this.id = id; this.name = name; this.icon = icon; this.date = date;
this.place = place; this.price = price; this.tag = tag;
}
}
const ACT_INIT: ActItem[] = [
new ActItem(1, '星空摄影营', '🌌', '3月29日-30日', '云顶星空营地', '¥388', '🔥 热门'),
new ActItem(2, '亲子露营日', '👨👩👧', '4月4日', '溪谷听泉营地', '¥268', '🔥 热门'),
new ActItem(3, '篝火音乐会', '🔥', '4月5日', '湖畔星火营地', '¥158', '音乐'),
new ActItem(4, '山野徒步+野炊', '🥾', '4月11日', '森林氧吧营地', '¥228', '徒步'),
new ActItem(5, '观星露营课', '🔭', '4月12日', '雪线观星营地', '¥328', '课程'),
new ActItem(6, '钓鱼周末', '🎣', '4月18日', '松涛湖畔营地', '¥198', '钓鱼'),
new ActItem(7, '沙漠越野之夜', '🏜️', '4月25日', '沙漠绿洲营地', '¥468', '刺激'),
new ActItem(8, '花海野餐会', '🌸', '4月26日', '野花谷营地', '¥128', '轻松')
];
@Observed export class RecipeItem {
id: number = 0;
name: string = '';
icon: string = '';
time: string = '';
diff: string = '';
view: string = '';
constructor(id: number, name: string, icon: string, time: string, diff: string, view: string) {
this.id = id; this.name = name; this.icon = icon; this.time = time;
this.diff = diff; this.view = view;
}
}
const RECIPE_INIT: RecipeItem[] = [
new RecipeItem(1, '篝火烤棉花糖', '🍡', '10 分钟', '简单', '2.1w'),
new RecipeItem(2, '野炊香肠焖饭', '🍚', '30 分钟', '中等', '1.6w'),
new RecipeItem(3, '锡纸烤鱼', '🐟', '25 分钟', '中等', '1.8w'),
new RecipeItem(4, '营地热汤面', '🍜', '15 分钟', '简单', '1.2w'),
new RecipeItem(5, '烤红薯', '🍠', '40 分钟', '简单', '9800'),
new RecipeItem(6, '卡式炉小火锅', '🍲', '20 分钟', '简单', '1.4w'),
new RecipeItem(7, '营地手冲咖啡', '☕', '10 分钟', '简单', '2.3w'),
new RecipeItem(8, '烤披萨薄饼', '🍕', '35 分钟', '困难', '8800')
];
@Observed export class SkillItem {
id: number = 0;
name: string = '';
icon: string = '';
cat: string = '';
lessons: number = 0;
view: string = '';
constructor(id: number, name: string, icon: string, cat: string, lessons: number, view: string) {
this.id = id; this.name = name; this.icon = icon; this.cat = cat;
this.lessons = lessons; this.view = view;
}
}
const SKILL_INIT: SkillItem[] = [
new SkillItem(1, '新手搭帐篷 10 分钟速成', '⛺', '搭建', 4, '3.2w'),
new SkillItem(2, '野外生火技巧', '🔥', '生火', 3, '2.8w'),
new SkillItem(3, '绳结大全:8 种必学', '🪢', '绳结', 5, '2.4w'),
new SkillItem(4, '营火料理基础', '🍳', '料理', 6, '1.9w'),
new SkillItem(5, '方向辨别与地图阅读', '🧭', '导航', 4, '1.7w'),
new SkillItem(6, '露营安全守则', '🛡️', '安全', 3, '2.6w'),
new SkillItem(7, '户外急救包配置', '⛑️', '急救', 2, '2.1w'),
new SkillItem(8, '观星入门:星座识别', '🔭', '观星', 4, '3.0w')
];
应用的业务数据模型不止营地和装备两类,还包含活动(ActItem)、食谱(RecipeItem)、技能(SkillItem)三类,共同构成了"露营生态内容矩阵"。ActItem 模型有 7 个字段:id、name、icon、date(举办日期)、place(举办营地)、price、tag。它的特点是将活动与营地关联——place 字段引用了营地的名称(如"云顶星空营地"),形成了"营地-活动"的弱关联关系。这种弱关联通过字符串名称而非外键 ID 实现,简化了数据结构,但牺牲了引用完整性——如果营地改名,活动中的 place 字段不会自动同步。在本应用的前端演示场景下,这种简化是可接受的;在生产环境中,应使用 campId: number 作为外键,在渲染时通过 ID 查找营地名称。
ACT_INIT 包含 8 个活动实例,时间跨度从 3 月底到 4 月底,覆盖了星空摄影、亲子露营、篝火音乐、徒步野炊、观星课程、钓鱼、沙漠越野、花海野餐等多样主题。每个活动都关联一个具体营地,价格从 ¥128(花海野餐会)到 ¥468(沙漠越野之夜)不等,体现了不同主题活动的成本差异。活动数据存储在 @State actList 中,支持"取消报名"操作——点击删除图标会弹出确认框,确认后通过 actList.splice(idx, 1) 移除该活动。这种"软删除"(从列表中移除)而非"硬删除"(从数据库删除)的设计在前端演示中常见,实际生产中应配合后端的取消报名 API 与状态字段(如 status: '已取消')实现可追溯的取消流程。
RecipeItem 模型有 6 个字段:id、name、icon、time(烹饪时长)、diff(难度)、view(浏览量)。RECIPE_INIT 包含 8 道营地菜谱,从 10 分钟的篝火烤棉花糖到 40 分钟的烤红薯,难度分为简单、中等、困难三档。浏览量使用"万"为单位(如 2.1w 表示 2.1 万次浏览),这是中文内容平台的常见展示习惯。diffColor 函数根据难度返回不同颜色——绿色(简单)、橙色(中等)、红色(困难),让用户在浏览时能快速识别菜谱的难度梯度,选择适合自己水平的食谱。
SkillItem 模型有 6 个字段:id、name、icon、cat(技能分类)、lessons(课程节数)、view(学习人数)。SKILL_INIT 包含 8 个技能课程,覆盖搭建、生火、绳结、料理、导航、安全、急救、观星八大类。课程节数从 2 节到 6 节不等,反映了不同技能的学习曲线差异——急救包配置只需 2 节(配置清单教学),而营火料理基础需要 6 节(从选材到烹饪的完整流程)。学习人数 3.2w 等数值用于展示课程的热度,引导用户选择热门课程。这三类业务数据模型共同构成了"内容社区"的维度——营地和装备是"交易对象",活动、食谱、技能是"内容消费",后者为前者提供了除直接预订外的"种草"路径,提升用户粘性与转化率。
七、天气数据与纯函数工具集

interface BarData {
label: string;
value: number;
}
const WEATHER_BARS: BarData[] = [
{ label: '今天', value: 22 },
{ label: '明天', value: 24 },
{ label: '后天', value: 21 },
{ label: '周六', value: 26 },
{ label: '周日', value: 25 },
{ label: '周一', value: 19 },
{ label: '周二', value: 23 }
];
// ============ 全局纯函数 ============
function barColor(i: number): string {
if (i === 3 || i === 4) { return '#D9A441'; }
if (i % 2 === 1) { return '#6B8F5A'; }
return '#E8793E';
}
function statusColor(s: string): string {
if (s === '租出') { return '#E8793E'; }
if (s === '维修') { return '#D9534F'; }
return '#6B8F5A';
}
function diffColor(d: string): string {
if (d === '困难') { return '#D9534F'; }
if (d === '中等') { return '#E8793E'; }
return '#6B8F5A';
}
天气数据使用 BarData 接口建模,包含 label(日期标签)和 value(最高气温数值)两个字段。WEATHER_BARS 常量数组包含 7 天的气温数据,从 19°C 到 26°C 不等,反映了春秋季节露营的典型气温范围。这组数据在天气子 Tab 中以柱状图形式展示,每根柱子的高度为 value * 3 像素(如 22°C 对应 66px 高的柱子),这种"数值乘以系数"的简单映射方式适合数据范围已知且较小的小型柱状图;对于数据范围未知或波动大的场景,应采用"归一化"映射(如 (value - min) / (max - min) * maxHeight),避免极端值导致柱子过高或过矮。
三个全局纯函数 barColor、statusColor、diffColor 是应用中的"工具函数",它们接收数据值,返回对应的色彩字符串。纯函数的特性是:相同的输入永远产生相同的输出,且不产生副作用——这使得它们易于测试、易于组合、易于缓存。在 ArkUI 的渲染过程中,这些函数会在 ForEach 的每次迭代中被调用,根据当前数据项的属性动态决定 UI 元素的颜色。例如 statusColor(g.status) 在装备卡片的渲染中被调用,"在库"返回绿色、租出返回橙色、维修返回红色,实现了状态的可视化区分。
barColor 函数的实现颇具巧思:它根据柱子的索引 i 返回不同颜色——索引 3 和 4(周六、周日)返回金色 #D9A441,突出周末的"最佳露营日";奇数索引返回苔绿 #6B8F5A;偶数索引返回暖橙 #E8793E。这种基于索引的色彩分配让柱状图呈现出"金-绿-橙-绿-橙…“的视觉节奏,既区分了日期,又保持了色彩的整体协调。金色作为"高亮提示色”,将用户的注意力引向周末——这其实是产品逻辑的视觉化表达:“周末是露营的最佳时机”。
statusColor 和 diffColor 的实现模式相似——通过 if 条件判断返回不同的颜色值。这种"状态-色彩"的映射是 UI 中常见的"语义化着色"模式:颜色不再是任意的视觉装饰,而是承载了业务语义——红色代表"需注意/警示"(维修中、困难食谱),橙色代表"进行中/中等"(租出、中等难度),绿色代表"正常/简单"(在库、简单食谱)。这种语义化的色彩系统让用户无需阅读文字,仅凭颜色就能快速识别状态,是优秀信息设计(Information Design)的体现。
从代码组织角度,将这三个函数定义在组件外部(模块级作用域)而非组件内部,是因为它们不依赖组件的 this 上下文,是纯粹的数据变换函数。将它们外置使得组件代码更聚焦于"UI 描述",而数据变换逻辑集中管理,便于复用与测试。如果将来色彩映射规则需要根据主题动态调整(如深色模式下使用不同的色彩集),可以将这些函数改造为接收主题参数的高阶函数,或使用策略模式注入不同的色彩映射策略,但当前单一主题下,简洁的函数定义是恰当的。
八、主组件状态声明与生命周期管理
@Entry
@Component
struct Page830 {
@State mainTab: number = 0;
@State homeTab: number = 0;
@State glow: boolean = false;
@State addModal: boolean = false;
@State editModal: boolean = false;
@State delModal: boolean = false;
@State editIdx: number = 0;
@State addName: string = '';
@State addPrice: string = '';
@State addTag: string = '基础';
@State editDate: string = '';
@State editStatus: string = '在库';
@State gearList: CampGear[] = GEAR_INIT;
@State actList: ActItem[] = ACT_INIT;
@State campList: CampItem[] = CAMP_INIT;
timer: number = 0;
aboutToAppear() {
this.timer = setInterval(() => { this.glow = !this.glow; }, 720);
}
aboutToDisappear() {
clearInterval(this.timer);
}
Page830 是应用的根组件,使用 @Entry 和 @Component 双重装饰器修饰。@Entry 表示该组件是页面的入口组件,每个页面(Page)有且只有一个 @Entry 组件,它是组件树的根节点,ArkUI 框架会为其创建独立的渲染上下文与状态管理域。@Component 表示该 struct 是一个自定义组件,编译器会为其生成状态管理代理、build 方法的渲染管线、生命周期回调注入等基础设施。这两个装饰器组合是 HarmonyOS 页面开发的标准入口写法。
组件内部声明了 13 个 @State 状态变量和 1 个普通成员变量 timer。@State 装饰的变量是组件的响应式状态,其值的变化会触发依赖该状态的 UI 部分重新渲染。mainTab(当前主 Tab 索引,0-3)控制底部导航的激活状态与主内容区的切换;homeTab(当前首页子 Tab 索引,0-6)控制首页内 7 个内容子 Tab 的切换;glow(布尔值)控制萤火虫特效层的显示与隐藏。三个布尔状态 addModal、editModal、delModal 分别控制三个弹窗的显示——这种"一个弹窗一个布尔"的简洁状态模型在弹窗数量较少时清晰易维护;当弹窗数量增多(如 10+ 个)时,可考虑改为 activeModal: string 单状态管理,避免过多的布尔变量。
editIdx(编辑目标索引)、addName/addPrice/addTag(新增装备表单字段)、editDate/editStatus(编辑预订表单字段)是弹窗交互的临时状态——它们在弹窗打开时被初始化,在弹窗交互中被用户修改,在弹窗确认时被写入主数据。这种"临时状态+提交"的模式是表单交互的标准实践,它将"编辑中"的数据与"已确认"的数据分离,避免用户在编辑过程中误改主数据。如果用户取消弹窗,临时状态被丢弃,主数据不受影响。
三个数组状态 gearList、actList、campList 分别持有装备、活动、营地的运行时数据集,初始值来自 GEAR_INIT、ACT_INIT、CAMP_INIT 常量。需要注意的是,@State 对数组的响应式追踪是基于数组引用的——当调用 splice、push 等方法修改数组时,ArkUI 框架能感知到变化(因为 @State 对数组的常用方法做了代理拦截)。但如果直接通过索引赋值修改数组元素(如 this.gearList[0].name = '新名'),需要该元素的类是 @Observed 装饰的,才能触发局部更新——这正是 CampGear、ActItem、CampItem 都使用 @Observed 的原因。timer 成员变量没有 @State 装饰,因为它不需要触发 UI 更新,只是用于保存定时器 ID 以便清理。
aboutToAppear 和 aboutToDisappear 是组件的生命周期回调。aboutToAppear 在组件创建后、build 方法执行前调用,用于初始化状态、启动定时器、发起网络请求等副作用操作。本组件在 aboutToAppear 中调用 setInterval 创建了一个 720ms 间隔的定时器,反复切换 glow 状态——这驱动了"萤火虫闪烁"特效。720ms 的间隔略长于常见的 500ms,使得闪烁节奏更为舒缓,符合"萤火虫"缓慢闪烁的自然意象。aboutToDisappear 在组件销毁前调用,用于清理定时器、移除事件监听等——如果不清理定时器,组件销毁后定时器仍会运行,导致内存泄漏与对已销毁组件状态的非法访问。这种"创建-清理"的对称性是资源管理的黄金法则。
九、户外电商风格头部 Builder
@Builder
header() {
Column({ space: 10 }) {
Row({ space: 8 }) {
Text('🔍').fontSize(14)
Text('搜索营地 / 装备 / 活动').fontSize(12).fontColor(COLORS.sub)
}
.width('100%').padding({ left: 14, right: 14, top: 9, bottom: 9 })
.borderRadius(20).backgroundColor('#FFFFFF')
// 今日营地大卡
Row({ space: 12 }) {
Column() {
Text('⛺').fontSize(26)
}
.width(52).height(52).borderRadius(12).backgroundColor(COLORS.orangeL)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Column({ space: 3 }) {
Text('周末特惠 · 云顶星空营地').fontSize(13).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
Text('含装备套餐 · 两人同行 8 折').fontSize(9).fontColor('#F5D9A0')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column() {
Text('预订 ›').fontSize(9).fontColor(COLORS.orange)
}
.padding({ left: 10, right: 10, top: 6, bottom: 6 }).borderRadius(8)
.backgroundColor('#FFFFFF')
}
.width('100%').padding(12).borderRadius(16)
.linearGradient({ angle: 135, colors: [['#E8793E', 0], ['#C25A26', 1]] })
// 装备横滚
Scroll() {
Row({ space: 8 }) {
ForEach(GEAR_INIT, (g: CampGear) => {
Column({ space: 2 }) {
Text(g.icon).fontSize(18)
Text(g.name).fontSize(9).fontColor(COLORS.sub)
}
.width(60).padding({ top: 8, bottom: 6 }).borderRadius(10)
.backgroundColor('#FFFFFF').alignItems(HorizontalAlign.Center)
})
}
.padding({ right: 4 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
}
.width('100%').padding({ left: 12, right: 12, top: 10, bottom: 10 })
.backgroundColor(COLORS.bg)
}
header 是一个 @Builder 方法,用于构建首页的头部区域。@Builder 是 ArkUI 的"构建器"装饰器,它修饰的方法返回一段 UI 描述(组件树),可在 build 方法或其他 Builder 中通过 this.header() 调用复用。@Builder 与普通方法的区别在于:Builder 方法的参数和局部变量会被编译器特殊处理,使得其中的 UI 组件能正确参与到响应式更新中;而普通方法返回的组件树可能无法正确响应状态变化。将复杂的 UI 拆分为多个 Builder 方法,是管理大型组件代码组织的标准实践。
头部由三部分组成,从上到下垂直排列(Column({ space: 10 }) 提供了 10 的垂直间距)。第一部分是搜索栏,一个 Row 包含放大镜 emoji 和占位提示文字,整体样式为白底圆角胶囊形(borderRadius(20)),padding 较紧凑(左右 14,上下 9),营造出"轻量搜索栏"的视觉感受。搜索栏目前仅是视觉占位,未绑定 onClick,实际应用中可在此触发跳转到搜索页或展开搜索输入框。
第二部分是"今日营地大卡",这是头部的视觉焦点。它使用 linearGradient 实现了从 #E8793E(暖橙)到 #C25A26(深橙)的 135 度线性渐变背景,营造出温暖、活力的视觉调性,呼应露营篝火与夕阳的色彩联想。卡片内左侧是一个 52x52 的圆角方块(白底+橙色背景),内嵌帐篷 emoji;中间是标题(“周末特惠 · 云顶星空营地”,白色粗体)和副标题(“含装备套餐 · 两人同行 8 折”,浅金色),右侧是"预订 ›"按钮(白底橙字)。这种"图标+标题副标题+行动按钮"的三段式布局是营销卡片的标准结构,信息层次清晰,行动号召(Call to Action)突出。
第三部分是"装备横滚"区域,使用 Scroll 组件配合 scrollable(ScrollDirection.Horizontal) 实现水平滚动。内部是一个 Row,通过 ForEach(GEAR_INIT, ...) 渲染所有 16 件装备的小卡片——每个小卡片是 60 宽的 Column,包含装备 emoji(18 号字)和名称(9 号字,灰色)。横滚区域的 scrollBar(BarState.Off) 隐藏了滚动条,使得视觉更简洁,符合移动端"无滚动条横滑"的设计习惯。这种"头部装备快捷入口"的设计让用户无需进入装备 Tab 即可浏览装备全貌,提升了内容曝光率。
头部整体使用 COLORS.bg 作为背景色(浅绿调米白),与下方的 7 内容 Tab 横条无缝衔接。头部只在 mainTab === 0(首页)时显示,在其他主 Tab 中替换为简洁的标题栏——这种"首页头部丰富,子页头部简洁"的差异化设计体现了"首页是综合入口,子页是专注任务"的信息架构原则。头部没有动画(如代码注释所标注"无动画"),这与下文将分析的萤火虫特效形成了"静态背景+动态前景"的视觉分工,避免头部动画分散用户对核心内容的注意力。
十、首页内容 Tab 横条与内容切换
@Builder
homeTabBar() {
Scroll() {
Row({ space: 6 }) {
ForEach(HOME_TABS, (t: TabMeta, i: number) => {
Column({ space: 1 }) {
Text(t.icon).fontSize(14)
Text(t.label).fontSize(9)
}
.padding({ left: 8, right: 8, top: 5, bottom: 5 }).borderRadius(10)
.backgroundColor(this.homeTab === i ? COLORS.orange : '#FFFFFF')
.alignItems(HorizontalAlign.Center)
.onClick(() => { this.homeTab = i; })
})
}
.padding({ right: 4 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
}
@Builder
homeContent() {
Column({ space: 0 }) {
this.homeTabBar()
Scroll() {
Column({ space: 10 }) {
if (this.homeTab === 0) {
this.campTab()
} else if (this.homeTab === 1) {
this.gearTab()
} else if (this.homeTab === 2) {
this.actTab()
} else if (this.homeTab === 3) {
this.recipeTab()
} else if (this.homeTab === 4) {
this.skillTab()
} else if (this.homeTab === 5) {
this.weatherTab()
} else {
this.rankTab()
}
}
.width('100%')
}
.scrollBar(BarState.Off)
.align(Alignment.Top)
.layoutWeight(1)
.width('100%')
}
.width('100%').height('100%')
}
homeTabBar 是首页内容 Tab 的横条导航,使用 Scroll+Row+ForEach 的组合渲染 7 个子 Tab。每个 Tab 项是一个 Column,上方是 emoji 图标(14 号字),下方是文字标签(9 号字),整体 padding 紧凑(左右 8,上下 5),圆角 10。关键的状态切换逻辑在 backgroundColor 属性上——this.homeTab === i ? COLORS.orange : '#FFFFFF',当前激活的 Tab 使用橙色背景白字(文字默认色为深色,此处未显式设置,继承自父组件),未激活的 Tab 使用白色背景。这种"激活填充色"的视觉反馈让用户一眼能识别当前所在的内容区。
onClick 回调 () => { this.homeTab = i; } 是状态切换的核心——点击任意 Tab,将 homeTab 更新为该 Tab 的索引 i。由于 homeTab 是 @State 状态,其变化会触发 homeContent Builder 中 if-else 链的重新求值,进而渲染对应的内容 Builder。这种"状态驱动条件渲染"是声明式 UI 的精髓——开发者只需声明"homeTab 为 0 时渲染 campTab,为 1 时渲染 gearTab",框架负责在状态变化时销毁旧内容、创建新内容,无需手动调用 show/hide 或 addChild/removeChild。
homeContent Builder 是首页内容的容器,它先渲染 homeTabBar,然后渲染一个 Scroll 包裹的内容区。内容区内部使用 if-else if-else 链根据 homeTab 的值选择渲染 7 个内容 Builder 之一。Scroll 组件的 align(Alignment.Top) 确保内容从顶部对齐,layoutWeight(1) 让内容区占据剩余高度(头部和底部 Tab 之外的所有空间)。scrollBar(BarState.Off) 隐藏滚动条,保持视觉简洁。这种"Tab 切换+Scroll 内容区"是移动端 Tab 内容页的标准结构,用户能在不同内容区之间快速切换,每个内容区内部可独立滚动浏览。
值得注意的细节是 homeContent 的根 Column 使用 space: 0(无间距),而内部内容区 Column 使用 space: 10(10 的间距)。这种差异化的间距设置是因为 Tab 横条与内容区之间无需额外间距(视觉上紧凑衔接),而内容区内部的各个卡片之间需要 10 的间距来保证可读性。ArkUI 的 Column({ space: N }) 语法是声明式设置子元素间距的便捷方式,等价于在每个子元素上设置 margin({ bottom: N }),但代码更简洁。
从性能角度,if-else 条件渲染在 ArkUI 中会创建/销毁组件实例——当用户从营地 Tab 切换到装备 Tab 时,营地 Tab 的所有组件会被销毁,装备 Tab 的组件会被创建。这种"切换即重建"的模式适合内容区互不相关、切换不频繁的场景;如果内容区之间有共享状态或切换频繁,可考虑使用 Visibility.Hidden 隐藏而非销毁,或使用 Swiper 组件实现"切换时保留状态"的横向滑动切换。本应用的内容区各自独立,切换频率不高,if-else 重建模式是合适的。
十一、营地子 Tab:大卡横滑与列表展示
@Builder
campTab() {
Column({ space: 8 }) {
Scroll() {
Row({ space: 10 }) {
ForEach(this.campList.slice(0, 6), (c: CampItem) => {
Column({ space: 6 }) {
Column() {
Text(c.icon).fontSize(30)
}
.width('100%').height(84).borderRadius(12).backgroundColor(COLORS.mossL)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Row({ space: 4 }) {
Text(c.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
}
.width('100%')
Text(c.area).fontSize(8).fontColor(COLORS.sub)
Row({ space: 4 }) {
Text('★').fontSize(9).fontColor(COLORS.gold)
Text(c.score.toString()).fontSize(9).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
}
.width('100%')
Row({ space: 6 }) {
Text(c.price).fontSize(10).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text(c.tag).fontSize(8).fontColor(COLORS.red)
}
.width('100%')
}
.width(140).padding(10).borderRadius(14).backgroundColor(COLORS.card)
})
}
.padding({ right: 4 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
ForEach(this.campList.slice(6, 12), (c: CampItem) => {
Row({ space: 10 }) {
Column() {
Text(c.icon).fontSize(22)
}
.width(46).height(46).borderRadius(12).backgroundColor(COLORS.mossL)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Column({ space: 3 }) {
Row({ space: 6 }) {
Text(c.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(c.tag).fontSize(8).fontColor(COLORS.red)
}
Text(c.area).fontSize(9).fontColor(COLORS.sub)
Text('★ ' + c.score.toString() + ' · ' + c.price).fontSize(9).fontColor(COLORS.gold)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column() {
Text('预订').fontSize(9).fontColor('#FFFFFF')
}
.padding({ left: 12, right: 12, top: 7, bottom: 7 }).borderRadius(9)
.backgroundColor(COLORS.orange)
}
.width('100%').padding(10).borderRadius(12).backgroundColor(COLORS.card)
})
}
.width('100%')
}
campTab 是首页营地子 Tab 的内容 Builder,采用了"上下两段"的混合布局结构。上段是大卡横滑区域,使用 Scroll+Row+ForEach(this.campList.slice(0, 6), ...) 渲染前 6 个营地的大卡片。每张大卡固定宽度 140,内部结构为:顶部一个 84 高的图标区(浅绿底圆角,内嵌 30 号字的营地 emoji);下方依次是营地名称(12 号字粗体)、位置描述(8 号字灰色)、星级评分(金色星标+金色数字)、价格与标签的横向布局(价格在左橙色粗体,标签在右红色)。大卡的整体设计信息密度适中,视觉焦点突出——84 高的图标区占据了卡片的主要视觉空间,让用户首先被营地的视觉意象吸引,再阅读名称和价格等文字信息。
下段是横条列表区域,使用 ForEach(this.campList.slice(6, 12), ...) 渲染后 6 个营地的横向卡片。每张横条卡片采用"图标+信息+按钮"的三段式布局:左侧 46x46 的圆角图标区,中间是营地名称、标签、位置、评分与价格的纵向信息列(Column 使用 layoutWeight(1) 占据剩余宽度),右侧是"预订"按钮(橙底白字圆角)。横条卡片的设计更紧凑,适合在垂直列表中展示更多营地——与大卡相比,横条牺牲了视觉冲击力,换来了信息密度的提升。这种"上半部分大卡推荐,下半部分横条列表"的设计是内容型应用常见的"精选+长尾"双层结构。
ForEach 是 ArkUI 的核心渲染控制语句,它接收一个数组和一个回调函数,为数组中每个元素生成对应的组件。ForEach 的关键特性是"键值追踪"——默认使用元素的 key(如果有的话)或索引作为唯一标识,当数组变化时(新增、删除、移动),ForEach 会根据键值进行差异比较,只更新变化的部分,而非全量重建。这对于"删除活动"这类操作至关重要——actList.splice(idx, 1) 移除一项后,ForEach 只会移除对应的那张卡片,其他卡片保持不变,避免整个列表闪烁重建。
Column().layoutWeight(1) 是一个空的 Column,用作"弹性占位符"——它占据剩余的横向空间,将右侧元素推到行尾。这是 Flexbox 布局中 flex: 1 或 margin-left: auto 的等价实现,在没有 Spacer 组件时,空 Column+layoutWeight(1) 是实现"两端对齐"的标准技巧。在营地横条卡片的"价格+标签"行中,Column().layoutWeight(1) 将标签推到行尾,实现了"价格在左,标签在右"的视觉分布。
从数据切片角度,campList.slice(0, 6) 和 campList.slice(6, 12) 将 12 个营地分为两组——前 6 个用于大卡横滑(视觉推荐位),后 6 个用于横条列表(信息密度位)。这种分片是产品运营决策的体现:前 6 个是"精选推荐"(如云顶星空营地、溪谷听泉营地等高评分或热门营地),后 6 个是"更多选择"。实际生产中,前 6 个可能来自后端的"推荐算法"接口,后 6 个来自"全部营地"分页接口,前端只需按位置渲染即可。
十二、装备子 Tab:双列瀑布流与上架入口
@Builder
gearTab() {
Column({ space: 8 }) {
Row({ space: 8 }) {
Text('🎒 装备租赁').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Column() { Text('+ 上架装备').fontSize(10).fontColor('#FFFFFF') }
.padding({ left: 12, right: 12, top: 6, bottom: 6 }).borderRadius(8)
.backgroundColor(COLORS.orange)
.onClick(() => { this.addModal = true; })
}
.width('100%')
Row({ space: 8 }) {
Column({ space: 8 }) {
ForEach(this.gearList.slice(0, 8), (g: CampGear) => {
Column({ space: 5 }) {
Text(g.icon).fontSize(24)
Text(g.name).fontSize(10).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 4 }) {
Text(g.price).fontSize(9).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
Text('·').fontSize(9).fontColor(COLORS.text3)
Text(g.sales.toString() + ' 租').fontSize(8).fontColor(COLORS.sub)
}
Text(g.status).fontSize(8).fontColor(statusColor(g.status))
}
.width('100%').padding(9).borderRadius(12).backgroundColor(COLORS.card)
.alignItems(HorizontalAlign.Start)
})
}
.layoutWeight(1)
Column({ space: 8 }) {
ForEach(this.gearList.slice(8, 16), (g: CampGear) => {
Column({ space: 5 }) {
Text(g.icon).fontSize(24)
Text(g.name).fontSize(10).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 4 }) {
Text(g.price).fontSize(9).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
Text('·').fontSize(9).fontColor(COLORS.text3)
Text(g.sales.toString() + ' 租').fontSize(8).fontColor(COLORS.sub)
}
Text(g.status).fontSize(8).fontColor(statusColor(g.status))
}
.width('100%').padding(9).borderRadius(12).backgroundColor(COLORS.card)
.alignItems(HorizontalAlign.Start)
})
}
.layoutWeight(1)
}
.width('100%')
}
.width('100%')
}
gearTab 是首页装备子 Tab 的内容 Builder,采用了"双列瀑布流"布局。顶部是标题栏,左侧"🎒 装备租赁"标题,右侧"+ 上架装备"按钮(橙底白字),点击触发 this.addModal = true,打开新增装备弹窗。这个上架入口是装备列表的关键交互——用户(商家或管理员)可以在此新增装备到列表头部,新增的装备会立即出现在双列瀑布流的第一列第一项。
双列瀑布流的实现核心是一个 Row 包含两个 Column,每个 Column 使用 layoutWeight(1) 等分宽度。左列通过 ForEach(this.gearList.slice(0, 8), ...) 渲染前 8 件装备,右列通过 ForEach(this.gearList.slice(8, 16), ...) 渲染后 8 件装备。这种"手动分列"的瀑布流实现方式简单直接,但与真正的瀑布流(masonry layout)有所不同——真正的瀑布流会根据卡片高度动态排列,填满列间的空隙;而本实现是"固定分列",左右两列的卡片数量固定,可能因卡片高度差异导致两列底部不对齐。在本应用中,由于装备卡片的内部结构一致(图标+名称+价格行+状态),高度差异不大,固定分列的视觉效果可接受。
每个装备卡片的内部结构是:顶部 emoji 图标(24 号字),下方是装备名称(10 号字粗体,单行显示,溢出省略)、价格与租赁次数的横向行(价格橙色粗体,分隔点灰色,租赁次数灰色小字)、状态文字(8 号字,颜色由 statusColor 函数动态决定)。maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) 是文本截断的标准配置——装备名称限制为单行,超出部分以省略号显示。这种处理避免了长名称撑破卡片布局,同时保留了"有更多内容"的视觉提示(省略号)。
statusColor(g.status) 的调用体现了"函数式 UI"的实践——状态颜色不是硬编码的固定值,而是由纯函数根据状态值动态计算。当装备状态从"在库"变为"租出"时,状态文字的颜色会自动从绿色变为橙色,无需额外编写条件渲染逻辑。这种"数据驱动样式"的写法使得 UI 与业务逻辑解耦,代码更简洁、更易维护。
从交互角度,装备卡片目前没有绑定 onClick——用户无法点击装备卡片进入详情页。这是本应用作为"演示版"的简化处理,实际生产中应在此绑定点击事件,跳转到装备详情页(展示更多图片、规格、评价、租赁按钮等)。但"上架装备"按钮的 onClick 是完整实现的,它能触发弹窗、接收用户输入、将新装备插入到列表头部——这一完整的"新增"交互流程足以展示 ArkUI 状态管理与表单交互的能力。
十三、活动子 Tab:报名卡与删除确认
@Builder
actTab() {
Column({ space: 8 }) {
ForEach(this.actList, (a: ActItem, idx: number) => {
Row({ space: 10 }) {
Column() {
Text(a.icon).fontSize(22)
}
.width(46).height(46).borderRadius(14).backgroundColor(COLORS.orangeL)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Column({ space: 4 }) {
Row({ space: 6 }) {
Text(a.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(a.tag).fontSize(8).fontColor(COLORS.red)
}
Text(a.date + ' · ' + a.place).fontSize(9).fontColor(COLORS.sub)
Text(a.price).fontSize(10).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column({ space: 4 }) {
Text('报名').fontSize(9).fontColor('#FFFFFF')
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(8)
.backgroundColor(COLORS.orange)
Text('🗑️').fontSize(12)
.onClick(() => { this.editIdx = idx; this.delModal = true; })
}
.alignItems(HorizontalAlign.Center)
}
.width('100%').padding(10).borderRadius(12).backgroundColor(COLORS.card)
})
}
.width('100%')
}
actTab 是首页活动子 Tab 的内容 Builder,使用 ForEach 渲染 actList 中的所有活动卡片。注意 ForEach 的第二个参数是 (a: ActItem, idx: number),其中 idx 是元素索引——这个索引在删除操作中至关重要。每张活动卡片采用"图标+信息+操作"的三段式布局:左侧 46x46 的圆角图标区(浅橙底),中间是活动信息列(名称+标签、日期+营地、价格),右侧是操作列(报名按钮+删除图标)。
活动信息列的设计紧凑而清晰——名称(12 号字粗体)与标签(8 号字红色)横向排列,下方是日期与营地的组合文本(9 号字灰色,使用 · 分隔),再下方是价格(10 号字橙色粗体)。这种"名称+元信息+价格"的三行结构是活动卡片的标准信息层级,用户能在快速浏览中获取活动的核心要素。a.date + ' · ' + a.place 的字符串拼接是 ArkUI 中"动态文本"的常见写法——将多个数据字段拼接为一行展示,节省垂直空间。
操作列包含两个交互元素:上方是"报名"按钮(橙底白字圆角,目前未绑定 onClick,实际应跳转到报名流程),下方是删除 emoji 🗑️,绑定了 onClick(() => { this.editIdx = idx; this.delModal = true; })。点击删除图标时,先将当前活动的索引 idx 保存到 this.editIdx(这是"待删除目标"的记录),然后设置 this.delModal = true 打开确认弹窗。这种"先记录目标,再弹窗确认"的两步式交互是"危险操作确认"的标准模式——删除是不可逆操作(至少在用户感知上),必须通过二次确认避免误删。
editIdx 状态变量的复用值得注意——它在"编辑营地预订"和"删除活动报名"两个场景中都被用作"目标索引",但不会冲突,因为这两个操作不会同时发生(用户不会同时打开编辑弹窗和删除弹窗)。这种"状态复用"减少了状态变量数量,但也要求开发者清楚每个状态在不同场景下的语义。在更严格的代码组织中,可以使用 editCampIdx 和 delActIdx 两个独立变量,但本应用的简化处理在可读性上可接受。
ForEach 对 actList 的渲染是响应式的——当 delModal 确认后调用 this.actList.splice(this.editIdx, 1) 移除活动时,actList 的变化会触发 ForEach 的差异更新,被删除的活动卡片会从列表中消失,其他卡片保持不变(不会重建)。这种"精准删除"的渲染性能是 ForEach 键值追踪的价值体现——如果使用 if 条件渲染或手动 addChild/removeChild,可能需要重建整个列表,带来视觉闪烁与性能开销。
十四、食谱、技能、天气多内容子 Tab
@Builder
recipeTab() {
Column({ space: 8 }) {
ForEach(RECIPE_INIT, (r: RecipeItem) => {
Row({ space: 10 }) {
Column() {
Text(r.icon).fontSize(24)
}
.width(48).height(48).borderRadius(14).backgroundColor(COLORS.mossL)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Column({ space: 3 }) {
Text(r.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(r.time + ' · 难度 ' + r.diff).fontSize(9).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(r.view + ' 看').fontSize(9).fontColor(diffColor(r.diff))
Text('›').fontSize(13).fontColor(COLORS.text3)
}
.width('100%').padding(10).borderRadius(12).backgroundColor(COLORS.card)
})
}
.width('100%')
}
@Builder
skillTab() {
Column({ space: 8 }) {
ForEach(SKILL_INIT, (s: SkillItem) => {
Row({ space: 10 }) {
Text(s.icon).fontSize(18)
Column({ space: 3 }) {
Text(s.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(s.cat + ' · ' + s.lessons.toString() + ' 节课 · ' + s.view + ' 学习').fontSize(9).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column() {
Text('去学').fontSize(9).fontColor('#FFFFFF')
}
.padding({ left: 10, right: 10, top: 6, bottom: 6 }).borderRadius(8)
.backgroundColor(COLORS.moss)
}
.width('100%').padding(10).borderRadius(12).backgroundColor(COLORS.card)
})
}
.width('100%')
}
@Builder
weatherTab() {
Column({ space: 10 }) {
Column({ space: 8 }) {
Text('🌦️ 未来 7 天最高温(°C)').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Row({ space: 8 }) {
ForEach(WEATHER_BARS, (w: BarData, wi: number) => {
Column({ space: 4 }) {
Text(w.value.toString()).fontSize(8).fontColor(COLORS.sub)
Column().width(20).height(w.value * 3).borderRadius(6)
.backgroundColor(barColor(wi))
Text(w.label).fontSize(8).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
})
}
.width('100%').alignItems(VerticalAlign.Bottom)
}
.width('100%').padding(12).borderRadius(14).backgroundColor(COLORS.card)
Row({ space: 6 }) {
ForEach([['☀️', '晴', '23°C'], ['🌤️', '多云', '24°C'], ['🌧️', '小雨', '19°C']], (w: string[]) => {
Column({ space: 4 }) {
Text(w[0]).fontSize(20)
Text(w[1]).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(w[2]).fontSize(9).fontColor(COLORS.orange)
}
.layoutWeight(1).padding(12).borderRadius(12).backgroundColor(COLORS.card)
.alignItems(HorizontalAlign.Center)
})
}
.width('100%')
Row({ space: 8 }) {
ForEach([['今天', '露营指数', '88 适宜'], ['明天', '露营指数', '76 较宜'], ['周六', '露营指数', '92 极佳']], (w: string[]) => {
Column({ space: 3 }) {
Text(w[0]).fontSize(10).fontColor(COLORS.sub)
Text(w[1]).fontSize(9).fontColor(COLORS.text3)
Text(w[2]).fontSize(11).fontColor(COLORS.moss).fontWeight(FontWeight.Bold)
}
.layoutWeight(1).padding(10).borderRadius(12).backgroundColor(COLORS.card)
.alignItems(HorizontalAlign.Center)
})
}
.width('100%')
}
.width('100%')
}
recipeTab 是食谱子 Tab,使用 ForEach(RECIPE_INIT, ...) 渲染食谱横条卡片。每张卡片采用"图标+信息+浏览量+箭头"的四段式布局:左侧 48x48 的圆角图标区(浅绿底),中间是食谱名称与"时间·难度"的组合信息(使用 layoutWeight(1) 占据剩余宽度),右侧是浏览量(颜色由 diffColor 根据难度动态决定——简单食谱的浏览量为绿色,困难食谱为红色),最右是箭头 › 提示可点击进入详情。diffColor(r.diff) 的调用将食谱难度与浏览量的颜色关联——这种"难度越高,浏览量颜色越警示"的设计巧妙地引导用户关注高难度的热门食谱。
skillTab 是技能教学子 Tab,使用 ForEach(SKILL_INIT, ...) 渲染技能课程横条。每条横条采用"图标+信息+去学按钮"的三段式布局:左侧 emoji 图标(18 号字,无背景框),中间是技能名称与"分类·节数·学习人数"的组合信息,右侧是"去学"按钮(苔绿底白字圆角)。技能子 Tab 的按钮使用 COLORS.moss(苔绿)作为背景色,与装备子 Tab 的橙色按钮形成视觉区分——这种"不同内容用不同主色按钮"的设计让用户在浏览时能通过颜色识别当前所在的内容区。s.lessons.toString() 将数字节数转换为字符串,用于拼接展示文本。
weatherTab 是天气子 Tab,它是最复杂的内容 Builder,包含三个部分。第一部分是"未来 7 天最高温"柱状图,使用 ForEach(WEATHER_BARS, ...) 渲染 7 根柱子。每根柱子是一个 Column,从上到下是:温度数值(8 号字灰色)、柱体(Column 宽 20,高 w.value * 3,圆角 6,颜色由 barColor(wi) 决定)、日期标签(8 号字灰色)。柱体高度的计算 w.value * 3 是简单的线性映射——22°C 对应 66px 高的柱子,26°C 对应 78px。整个柱状图行使用 alignItems(VerticalAlign.Bottom) 让所有柱子底部对齐,这是柱状图的标准视觉对齐方式。
第二部分是"今日天气"三卡片,使用 ForEach 遍历一个二维数组 [['☀️', '晴', '23°C'], ['🌤️', '多云', '24°C'], ['🌧️', '小雨', '19°C']]。这种"内联数据+ForEach"的写法适合数据量小且固定的场景——无需定义独立的数据模型和常量,直接在 Builder 中声明数据并渲染。每个卡片展示天气图标、天气描述、温度,三卡片等分宽度(layoutWeight(1))。第三部分是"露营指数"三卡片,展示今天、明天、周六的露营适宜度评估(88 适宜、76 较宜、92 极佳),用苔绿色突出显示指数值,引导用户选择最佳露营日。
天气子 Tab 的设计体现了"数据可视化"在露营场景的价值——气温柱状图帮助用户判断着装,露营指数帮助用户选择出行日。这种"工具型内容"与营地预订、装备租赁等"交易型内容"形成了互补——用户在决策"去不去露营"时参考天气,在决策"去哪个营地"时参考营地列表,在决策"带什么装备"时参考装备租赁,构成了完整的露营决策链路。
十五、榜单子 Tab:销量排行展示
@Builder
rankTab() {
Column({ space: 8 }) {
ForEach(this.gearList.slice(0, 10), (g: CampGear, gi: number) => {
Row({ space: 10 }) {
Text((gi + 1).toString()).fontSize(14).fontColor(gi < 3 ? COLORS.gold : COLORS.text3).fontWeight(FontWeight.Bold)
.width(24)
Text(g.icon).fontSize(18)
Column({ space: 3 }) {
Text(g.name).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(g.price + ' · ' + g.tag).fontSize(8).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(g.sales.toString() + ' 次').fontSize(10).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
}
.width('100%').padding(10).borderRadius(12).backgroundColor(COLORS.card)
})
}
.width('100%')
}
rankTab 是首页榜单子 Tab 的内容 Builder,使用 ForEach(this.gearList.slice(0, 10), ...) 渲染装备销量榜前 10 名。每条榜单项采用"排名+图标+信息+销量"的四段式布局:最左侧是排名数字(14 号字,前 3 名金色 COLORS.gold,4-10 名浅灰 COLORS.text3,固定宽 24),紧接是装备 emoji 图标,中间是装备名称与"价格·标签"的组合信息(layoutWeight(1) 占据剩余宽度),最右是销量次数(10 号字橙色粗体)。
榜单的设计核心是"排名的视觉层次"——前 3 名使用金色数字突出显示,这是"排行榜"类界面的通用设计。金色作为"荣誉/优胜"的象征色,让用户一眼识别"前三甲"。排名数字使用 fontWeight(FontWeight.Bold) 加粗,进一步强化视觉权重。gi < 3 的条件判断基于索引 gi(0-9),前 3 名(索引 0、1、2)为金色,其余为灰色——这种"基于索引的条件样式"是榜单渲染的典型模式。
榜单数据来自 gearList.slice(0, 10),即装备列表的前 10 项。由于 GEAR_INIT 初始数据已按 sales 降序排列(410、380、350、320、310、280、260、240、230、200…),前 10 项恰好是销量前 10 名。这种"初始数据已排序"的简化处理避免了前端再次排序的开销,但也依赖数据的初始顺序——如果用户通过弹窗新增装备(sales 为 0),新装备会出现在列表头部但不在榜单中(因为 slice(0, 10) 只取前 10)。实际生产中,应使用 gearList.sort((a, b) => b.sales - a.sales).slice(0, 10) 或在后端返回已排序的数据。
榜单的"价格·标签"信息行使用了 g.price + ' · ' + g.tag 的字符串拼接,将价格(如 ¥59/日)与标签(如 🔥 热门)合并为一行展示,节省垂直空间。销量展示 g.sales.toString() + ' 次' 将数字转换为带"次"单位的字符串,让数值的含义明确。这种"数值+单位"的展示方式是中文内容平台的习惯,比单纯的数字更易读。
榜单子 Tab 的存在为装备租赁业务增添了"内容发现"的维度——用户不仅能在装备子 Tab 浏览全部装备,还能在榜单子 Tab 看到最受欢迎的装备,引导用户选择热门装备(通常意味着质量可靠、口碑良好)。这种"排行榜"设计是电商与内容平台常见的用户引导机制,能有效降低用户的决策成本。
十六、营地主 Tab:全量列表与编辑预订
@Builder
campMain() {
Column({ space: 10 }) {
Scroll() {
Column({ space: 10 }) {
Text('⛺ 全部营地(' + this.campList.length.toString() + ')').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
ForEach(this.campList, (c: CampItem, ci: number) => {
Row({ space: 10 }) {
Column() {
Text(c.icon).fontSize(22)
}
.width(46).height(46).borderRadius(12).backgroundColor(COLORS.mossL)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Column({ space: 3 }) {
Text(c.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(c.area).fontSize(9).fontColor(COLORS.sub)
Text('★ ' + c.score.toString() + ' · ' + c.price).fontSize(9).fontColor(COLORS.gold)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column({ space: 4 }) {
Text('✏️').fontSize(13)
.onClick(() => { this.editIdx = ci; this.editDate = c.area; this.editStatus = '已预订'; this.editModal = true; })
Text('预订').fontSize(9).fontColor('#FFFFFF')
.padding({ left: 10, right: 10, top: 4, bottom: 4 }).borderRadius(8)
.backgroundColor(COLORS.orange)
}
.alignItems(HorizontalAlign.Center)
}
.width('100%').padding(10).borderRadius(12).backgroundColor(COLORS.card)
})
}
.width('100%')
}
.scrollBar(BarState.Off)
.layoutWeight(1)
.width('100%')
}
.width('100%').height('100%').padding({ left: 12, right: 12, top: 8 })
}
campMain 是底部主 Tab"营地"的内容 Builder,展示全部营地的列表视图。与首页营地子 Tab 的"大卡+横条"精选展示不同,营地主 Tab 是"全量列表"展示,用户能浏览所有 12 个营地并进行编辑预订操作。顶部标题 ⛺ 全部营地(${this.campList.length.toString()}) 动态显示营地数量——当用户通过弹窗编辑营地后(虽然编辑仅修改 area 字段,数量不变),标题中的数字会自动更新。这种"动态计数"让用户对数据规模有直观感知。
营地列表项采用"图标+信息+操作"的三段式布局,与首页横条营地卡片结构类似,但增加了编辑功能。右侧操作列包含两个元素:上方是编辑 emoji ✏️,绑定了 onClick 打开编辑弹窗;下方是"预订"按钮(橙底白字圆角)。编辑 emoji 的 onClick 回调做了四件事——this.editIdx = ci(记录编辑目标索引)、this.editDate = c.area(将当前营地的位置描述填入弹窗的编辑字段)、this.editStatus = '已预订'(设置默认状态为"已预订")、this.editModal = true(打开弹窗)。这种"点击即填充"的设计让弹窗打开时就预填了当前营地的数据,用户只需修改需要变更的字段,提升了编辑效率。
ForEach(this.campList, ...) 渲染全部营地(不使用 slice 分片),第二个参数 (c: CampItem, ci: number) 中的 ci 是营地索引,用于 editIdx 的记录。当用户在弹窗中修改 editDate(位置描述)并点击"保存"时,回调 this.campList[this.editIdx].area = this.editDate 将修改写入对应营地的 area 字段。由于 CampItem 是 @Observed 类,area 属性的变化会触发列表中对应卡片的局部更新——用户能看到该营地的位置描述立即更新为弹窗中输入的新值,而其他营地卡片保持不变。这是 @Observed 局部更新的核心价值体现。
Scroll+Column({ space: 10 }) 的组合提供了垂直滚动能力——12 个营地卡片在普通手机屏幕上无法一屏显示,Scroll 让用户能上下滑动浏览全部营地。scrollBar(BarState.Off) 隐藏滚动条,layoutWeight(1) 让 Scroll 占据主内容区的全部高度。外层 Column 的 padding({ left: 12, right: 12, top: 8 }) 提供了左右 12、顶部 8 的内边距,让列表内容与屏幕边缘保持间距,符合移动端"内容不贴边"的设计规范。
营地主 Tab 与首页营地子 Tab 的区别体现了"同一数据,不同视图"的设计哲学——首页子 Tab 是"精选推荐"(前 6 大卡+后 6 横条),营地主 Tab 是"全量管理"(12 项列表+编辑入口)。两个视图共享 campList 数据源,任何一处对数据的修改都会同步到另一处。例如,用户在营地主 Tab 编辑了某个营地的位置描述,切换回首页营地子 Tab 时,该营地的位置描述也会是更新后的值——这是单一真相源(single source of truth)的响应式数据流的价值。
十七、装备主 Tab 与个人中心
@Builder
gearMain() {
Column({ space: 10 }) {
Scroll() {
Column({ space: 10 }) {
Text('🎒 全部装备(' + this.gearList.length.toString() + ')').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
ForEach(this.gearList, (g: CampGear) => {
Row({ space: 10 }) {
Text(g.icon).fontSize(18)
Column({ space: 2 }) {
Text(g.name).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(g.price + ' · ' + g.tag + ' · ' + g.sales.toString() + ' 次租赁').fontSize(9).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(g.status).fontSize(9).fontColor(statusColor(g.status))
}
.width('100%').padding(10).borderRadius(12).backgroundColor(COLORS.card)
})
}
.width('100%')
}
.scrollBar(BarState.Off)
.layoutWeight(1)
.width('100%')
}
.width('100%').height('100%').padding({ left: 12, right: 12, top: 8 })
}
@Builder
mineContent() {
Column({ space: 10 }) {
Scroll() {
Column({ space: 10 }) {
Row({ space: 10 }) {
Column() {
Text('🏕️').fontSize(26)
}
.width(56).height(56).borderRadius(28).backgroundColor(COLORS.orangeL)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Column({ space: 3 }) {
Text('山野拾荒者').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('营地主 Lv.9 · 户外达人').fontSize(9).fontColor(COLORS.orange)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column() {
Text('编辑').fontSize(9).fontColor(COLORS.orange)
}
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(8)
.backgroundColor(COLORS.chip)
}
.width('100%').padding(12).borderRadius(14).backgroundColor(COLORS.card)
Row({ space: 8 }) {
Column({ space: 3 }) {
Text('38').fontSize(18).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
Text('露营次数').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('12').fontSize(18).fontColor(COLORS.moss).fontWeight(FontWeight.Bold)
Text('营地足迹').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('9').fontSize(18).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
Text('收藏装备').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('4').fontSize(18).fontColor(COLORS.blue).fontWeight(FontWeight.Bold)
Text('活动徽章').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
.width('100%').padding(12).borderRadius(14).backgroundColor(COLORS.card)
ForEach([['📅', '我的预订', '2 个营地'], ['🎒', '租赁中', '3 件装备'], ['🔥', '活动报名', '4 场'], ['🍳', '收藏食谱', '8 道'], ['🏅', '成就墙', '山野开拓者']], (m: string[]) => {
Row({ space: 10 }) {
Text(m[0]).fontSize(15)
Text(m[1]).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text(m[2]).fontSize(9).fontColor(COLORS.text3)
Text('›').fontSize(13).fontColor(COLORS.text3)
}
.width('100%').padding(10).borderRadius(12).backgroundColor(COLORS.card)
})
}
.width('100%')
}
.scrollBar(BarState.Off)
.layoutWeight(1)
.width('100%')
}
.width('100%').height('100%').padding({ left: 12, right: 12, top: 8 })
}
gearMain 是底部主 Tab"装备"的内容 Builder,展示全部装备的列表视图。与首页装备子 Tab 的双列瀑布流不同,装备主 Tab 采用单列长列表,每项展示更完整的信息。每项装备的布局是"图标+信息+状态"的三段式:左侧 emoji 图标(18 号字),中间是装备名称与"价格·标签·租赁次数"的组合信息行,右侧是状态文字(颜色由 statusColor 动态决定)。信息行 g.price + ' · ' + g.tag + ' · ' + g.sales.toString() + ' 次租赁' 将三个字段拼接为一行,比瀑布流卡片的信息更完整——增加了"次租赁"的展示,让用户了解装备的受欢迎程度。
装备主 Tab 没有提供"上架装备"入口(该入口在首页装备子 Tab),也没有提供单项装备的编辑或删除操作——它定位为"全量浏览",用户在此查看所有装备的状态与详情,具体的增删操作在首页装备子 Tab 完成。这种"首页子 Tab 做操作,主 Tab 做浏览"的分工避免了功能重复,让两个入口各有侧重。当用户在首页子 Tab 新增装备后,切换到装备主 Tab,新装备会出现在列表头部(因为 splice(0, 0, ...) 插入到数组头部),两个视图的数据同步是自动的。
mineContent 是底部主 Tab"我的"的内容 Builder,即个人中心页。个人中心是用户身份与成就的展示页,采用三段式结构:用户信息卡、数据统计卡、功能入口列表。用户信息卡展示头像(🏕️ emoji 在 56x56 圆形浅橙底中)、昵称(“山野拾荒者”,14 号字粗体)、等级标签(“营地主 Lv.9 · 户外达人”,9 号字橙色)、编辑按钮(浅橙底橙字)。数据统计卡展示四个数据——露营次数(38,橙色)、营地足迹(12,苔绿)、收藏装备(9,金色)、活动徽章(4,蓝色),四个数据等分宽度,数字使用 18 号字粗体突出显示。
功能入口列表使用 ForEach 遍历一个二维数组,渲染 5 个功能入口:我的预订、租赁中、活动报名、收藏食谱、成就墙。每个入口采用"图标+名称+描述+箭头"的四段式布局:左侧 emoji 图标,中间是功能名称(粗体),右侧是描述文字(如"2 个营地",浅灰),最右是箭头 ›。中间使用 Column().layoutWeight(1) 作为弹性占位,将描述与箭头推到行尾。这种"图标+名称+描述+箭头"的列表项是设置页/个人中心的标准结构,用户能快速扫描各功能的概要信息。
个人中心的数据(38 次露营、12 个营地足迹等)目前是硬编码的静态值,未与 campList、gearList、actList 等状态关联。在实际生产中,这些数据应从后端用户画像接口获取,或由前端根据用户的预订记录、收藏记录动态计算。本应用作为前端演示,采用硬编码值简化了数据来源,但个人中心的结构与交互模式完整展示了"用户中心"的设计范式——头像信息、数据统计、功能入口三层结构是个人中心页的通用模板。
十八、底部导航栏与弹窗遮罩
@Builder
bottomBar() {
Row({ space: 4 }) {
ForEach(BOTTOM_TABS, (t: TabMeta, i: number) => {
Column({ space: 2 }) {
Text(t.icon).fontSize(18)
Text(t.label).fontSize(9)
.fontColor(this.mainTab === i ? COLORS.orange : COLORS.text3)
.fontWeight(this.mainTab === i ? FontWeight.Bold : FontWeight.Normal)
}
.layoutWeight(1).padding({ top: 6, bottom: 6 })
.alignItems(HorizontalAlign.Center)
.onClick(() => { this.mainTab = i; })
})
}
.width('100%').padding({ left: 8, right: 8, top: 4, bottom: 6 })
.backgroundColor('#FFFFFF')
.border({ width: { top: 1 }, color: COLORS.line })
}
@Builder
modalOverlay(onClose: () => void) {
Column() {
}
.width('100%').height('100%').backgroundColor(COLORS.mask)
.onClick(() => { onClose(); })
}
bottomBar 是底部 4 主 Tab 的导航栏 Builder,使用 ForEach(BOTTOM_TABS, ...) 渲染 4 个 Tab 项。每个 Tab 项是一个 Column,上方是 emoji 图标(18 号字),下方是文字标签(9 号字)。关键的状态切换逻辑体现在文字的 fontColor 和 fontWeight 上——this.mainTab === i ? COLORS.orange : COLORS.text3(激活橙色,未激活浅灰)、this.mainTab === i ? FontWeight.Bold : FontWeight.Normal(激活粗体,未激活常规)。这种"激活态视觉强化"让用户一眼能识别当前所在的主 Tab。
每个 Tab 项使用 layoutWeight(1) 等分宽度,4 个 Tab 平均占据底部栏的横向空间。onClick(() => { this.mainTab = i; }) 是状态切换的核心——点击任意 Tab,将 mainTab 更新为该 Tab 的索引。由于 mainTab 是 @State,其变化会触发 build 方法中 if-else 链的重新求值,主内容区切换为对应的主 Tab 内容。底部栏整体使用白色背景,顶部有 1 像素的浅色边框 border({ width: { top: 1 }, color: COLORS.line }),与主内容区视觉分隔。border 的 width: { top: 1 } 是 ArkUI 的"方向性边框"语法——只设置顶边框,避免四周边框带来的视觉冗余。
modalOverlay 是一个通用的弹窗遮罩 Builder,接收一个 onClose: () => void 回调函数作为参数。它渲染一个铺满全屏的空 Column,背景色为 COLORS.mask(半透明深色 rgba(46,58,42,0.55)),绑定 onClick 调用 onClose 回调。这种"遮罩点击关闭"是模态弹窗的标准交互——用户点击遮罩区域(弹窗外部)即可关闭弹窗,无需寻找关闭按钮。@Builder 方法接收函数参数是 ArkUI 的高级用法——它允许将回调逻辑参数化,使得 modalOverlay 能被三个不同的弹窗复用,每个弹窗传入自己的关闭回调(如 () => { this.addModal = false; })。
modalOverlay 的设计体现了"复用 Builder"的价值——三个弹窗(新增装备、编辑预订、删除活动)都需要遮罩,如果每个弹窗都重复编写遮罩代码,会产生大量冗余。通过将遮罩抽取为独立的 modalOverlay Builder,并接收 onClose 回调,三个弹窗都能复用同一遮罩实现,代码更简洁,维护更集中。如果将来需要调整遮罩的视觉(如加深透明度或添加模糊效果),只需修改 modalOverlay 一处即可。
从布局层级角度,底部导航栏位于主内容区之下,是固定在屏幕底部的"固定栏"。它不随主内容区滚动,始终可见,让用户在任何内容区都能切换主 Tab。这种"底部固定导航"是移动端应用的标准结构,符合拇指操作的 ergonomics——底部是拇指最容易触及的区域,将导航放在底部提升了单手操作的便利性。
十九、build 主布局与萤火虫特效
build() {
Column() {
// 头部
if (this.mainTab === 0) {
this.header()
} else {
Row({ space: 8 }) {
Text('🏕️').fontSize(14)
Text(this.mainTab === 1 ? '营地预订' : (this.mainTab === 2 ? '装备租赁' : '个人中心')).fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
}
.width('100%').padding({ left: 12, right: 12, top: 12, bottom: 10 })
.backgroundColor(COLORS.bg)
}
// 主内容区
if (this.mainTab === 0) {
this.homeContent()
} else if (this.mainTab === 1) {
this.campMain()
} else if (this.mainTab === 2) {
this.gearMain()
} else {
this.mineContent()
}
// 底部 tab
this.bottomBar()
// 萤火虫特效层
if (this.glow) {
Row({ space: 42 }) {
Text('✨').fontSize(9).opacity(0.5)
Text('·').fontSize(6).opacity(0.4)
Text('✨').fontSize(7).opacity(0.35)
Text('·').fontSize(10).opacity(0.45)
}
.width('100%').height(14).position({ x: 0, y: 96 }).zIndex(1)
}
// ===== 新增装备弹框 =====
if (this.addModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.addModal = false; })
Column({ space: 10 }) {
// ... 弹窗内容 ...
}
.width('82%').constraintSize({ maxHeight: '80%' }).padding(16)
.borderRadius(16).backgroundColor(COLORS.card)
}
.width('100%').height('100%').zIndex(999)
}
// ... 编辑弹窗、删除弹窗 ...
}
.width('100%').height('100%').backgroundColor(COLORS.bg)
}
build 方法是组件的渲染入口,ArkUI 框架在组件创建和状态更新时调用 build 方法,根据返回的组件树渲染界面。build 方法内部是一个 Column,从上到下包含四个部分:头部(条件渲染)、主内容区(条件渲染)、底部导航栏(恒定渲染)、萤火虫特效层(条件渲染),以及三个弹窗(条件渲染)。
头部的条件渲染逻辑是 if (this.mainTab === 0) { this.header() } else { ... 简洁标题栏 ... }——首页显示丰富的户外电商风格头部,其他主 Tab 显示简洁的标题栏(露营 emoji + 标题文字)。标题文字根据 mainTab 的值动态切换:“营地预订”(mainTab=1)、“装备租赁”(mainTab=2)、“个人中心”(mainTab===3)。这种"首页头部丰富,子页头部简洁"的差异化设计体现了信息层级的考量——首页是综合入口需要丰富的视觉吸引,子页是专注任务需要简洁的标题指引。
主内容区的条件渲染使用 if-else if-else 链,根据 mainTab 选择渲染 homeContent、campMain、gearMain 或 mineContent。这种"状态驱动条件渲染"是声明式 UI 的核心——开发者声明"mainTab 为 0 时渲染 homeContent,为 1 时渲染 campMain",框架负责在状态变化时销毁旧内容、创建新内容。底部导航栏 this.bottomBar() 恒定渲染,不随 mainTab 变化,确保用户在任何主 Tab 都能看到底部导航。
萤火虫特效层是本应用最具特色的视觉元素。它通过 if (this.glow) 条件渲染——当 glow 为 true 时显示,为 false 时隐藏。glow 状态由 aboutToAppear 中的 setInterval 每 720ms 切换一次,形成"闪烁"效果。特效层是一个 Row,包含 4 个 Text 元素:两个 ✨(萤火虫 emoji,9 号和 7 号字,透明度 0.5 和 0.35)和两个 ·(点号,6 号和 10 号字,透明度 0.4 和 0.45)。这种"大小不一、透明度不一"的元素组合模拟了萤火虫远近不一、明暗闪烁的自然意象。
position({ x: 0, y: 96 }) 将特效层绝对定位到 y=96 的位置(大约在头部下方),zIndex(1) 确保特效层在主内容之上但弹窗之下(弹窗的 zIndex 为 999)。特效层不覆盖头部(y=96 避开了头部区域),仅在内容区顶部闪烁,符合注释所述"不覆盖头部"的设计意图。height(14) 限制了特效层的高度,使其只是一条窄带,不干扰内容的浏览。
三个弹窗都使用 if 条件渲染——if (this.addModal)、if (this.editModal)、if (this.delModal)。每个弹窗是一个 Stack({ alignContent: Alignment.Center }),内层是遮罩 modalOverlay 和弹窗内容 Column。Stack 的居中对齐让弹窗内容在屏幕中央显示。zIndex(999) 确保弹窗在最顶层,覆盖所有其他内容。弹窗的宽度 82%(新增装备)、82%(编辑预订)、76%(删除确认)适中,既不会过窄导致内容拥挤,也不会过宽遮挡背景。constraintSize({ maxHeight: '80%' }) 限制弹窗最大高度,避免内容过多时超出屏幕。
二十、新增装备弹窗的完整实现
// ===== 新增装备弹框 =====
if (this.addModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.addModal = false; })
Column({ space: 10 }) {
Row({ space: 8 }) {
Text('🎒 上架装备').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('✕').fontSize(16).fontColor(COLORS.sub)
.onClick(() => { this.addModal = false; })
}
.width('100%')
Divider().color(COLORS.line)
Text('装备名称').fontSize(10).fontColor(COLORS.sub)
TextInput({ placeholder: '如 充气床', text: this.addName })
.onChange((v: string) => { this.addName = v; })
.fontSize(12).padding(10).borderRadius(8).backgroundColor(COLORS.bg)
.fontColor(COLORS.title)
Text('日租金').fontSize(10).fontColor(COLORS.sub)
TextInput({ placeholder: '如 ¥45/日', text: this.addPrice })
.onChange((v: string) => { this.addPrice = v; })
.fontSize(12).padding(10).borderRadius(8).backgroundColor(COLORS.bg)
.fontColor(COLORS.title)
Text('装备分类').fontSize(10).fontColor(COLORS.sub)
Row({ space: 6 }) {
ForEach(['基础', '炊具', '照明', '舒适'], (t: string) => {
Text(t).fontSize(10)
.fontColor(this.addTag === t ? '#FFFFFF' : COLORS.sub)
.backgroundColor(this.addTag === t ? COLORS.orange : COLORS.bg)
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(8)
.onClick(() => { this.addTag = t; })
})
}
.width('100%')
Row({ space: 10 }) {
Column() { Text('取消').fontSize(11).fontColor(COLORS.sub) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.bg).alignItems(HorizontalAlign.Center)
.onClick(() => { this.addModal = false; })
Column() { Text('上架').fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.orange).alignItems(HorizontalAlign.Center)
.onClick(() => {
this.gearList.splice(0, 0, new CampGear(this.gearList.length + 1, this.addName === '' ? '新装备' : this.addName, '🧰', this.addPrice === '' ? '¥20/日' : this.addPrice, '在库', this.addTag, 0));
this.addName = ''; this.addPrice = '';
this.addModal = false;
})
}
.width('100%')
}
.width('82%').constraintSize({ maxHeight: '80%' }).padding(16)
.borderRadius(16).backgroundColor(COLORS.card)
}
.width('100%').height('100%').zIndex(999)
}
新增装备弹窗是应用中最复杂的交互组件之一,它实现了完整的"表单输入-验证-提交"流程。弹窗的结构从上到下是:标题栏(标题+关闭按钮)、分割线、装备名称输入框、日租金输入框、装备分类选择器、操作按钮栏(取消+上架)。标题栏使用 Column().layoutWeight(1) 作为弹性占位,将关闭按钮 ✕ 推到行尾,点击关闭按钮调用 this.addModal = false 关闭弹窗。Divider().color(COLORS.line) 是 ArkUI 的分割线组件,用于视觉分隔标题栏与表单内容。
两个 TextInput 组件分别用于输入装备名称和日租金。TextInput({ placeholder: '如 充气床', text: this.addName }) 的写法值得注意——placeholder 是输入框为空时的占位提示,text 是输入框的当前值,绑定到 this.addName 状态。onChange((v: string) => { this.addName = v; }) 监听输入变化,将用户输入的值同步到状态。这种"状态绑定+onChange 同步"是 ArkUI 表单的双向数据流——状态变化更新输入框的 text,用户输入更新状态的值。backgroundColor(COLORS.bg) 让输入框有浅色背景,与白色弹窗形成视觉层次。
装备分类选择器使用 ForEach(['基础', '炊具', '照明', '舒适'], ...) 渲染 4 个可选分类标签。每个标签的 fontColor 和 backgroundColor 根据 this.addTag === t 条件切换——选中的分类为橙底白字,未选中为浅灰底灰字。点击标签调用 this.addTag = t 切换选中状态。这种"标签组单选"的交互比下拉选择更直观,用户能一眼看到所有选项,点击即可切换。4 个分类(基础、炊具、照明、舒适)是对装备属性的简化归类,覆盖了大多数装备的大类,避免了过多分类带来的选择负担。
操作按钮栏包含"取消"和"上架"两个按钮,等分宽度(layoutWeight(1))。取消按钮为浅灰底灰字,上架按钮为橙底白字粗体——这种"次要操作浅色,主要操作深色"的视觉对比引导用户选择主要操作(上架)。上架按钮的 onClick 回调是弹窗的核心逻辑:this.gearList.splice(0, 0, new CampGear(...)) 在数组头部插入新装备实例。新装备的 id 为 this.gearList.length + 1(基于当前列表长度生成新 ID),name 为 this.addName === '' ? '新装备' : this.addName(如果用户未输入名称,使用默认值"新装备"),icon 固定为 🧰(工具箱 emoji,表示新增装备),price 为 this.addPrice === '' ? '¥20/日' : this.addPrice(如果未输入价格,使用默认值),status 为"在库",tag 为 this.addTag,sales 为 0(新装备无租赁记录)。
splice(0, 0, ...) 的插入位置是头部——这样新装备会立即出现在装备列表的第一项,用户能在关闭弹窗后立即看到新增结果。这种"插入头部"的策略提升了操作的反馈感,让用户确认"上架成功"。插入后,this.addName = ''; this.addPrice = ''; 重置表单字段(addTag 保留默认值"基础",因为下次新增可能仍是基础分类),this.addModal = false; 关闭弹窗。整个流程一气呵成,状态变化触发 ForEach 的差异更新,新装备卡片在列表中渐入显示。
从数据流角度,新增装备弹窗展示了"临时状态-主状态"的协作模式——addName、addPrice、addTag 是临时状态,仅在弹窗交互期间存在;gearList 是主状态,承载装备的持久数据。用户在弹窗中修改临时状态,不会影响主状态;只有点击"上架"按钮,临时状态的值才会被写入主状态(通过 splice 插入新装备)。如果用户点击"取消"或关闭按钮,临时状态被丢弃,主状态不受影响。这种模式确保了编辑过程的"可撤销性"——用户可以随时放弃编辑,不会对已有数据造成破坏。
二十一、编辑营地预订弹窗
// ===== 编辑营地预订弹框 =====
if (this.editModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.editModal = false; })
Column({ space: 10 }) {
Row({ space: 8 }) {
Text('✏️ 编辑预订信息').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('✕').fontSize(16).fontColor(COLORS.sub)
.onClick(() => { this.editModal = false; })
}
.width('100%')
Divider().color(COLORS.line)
Text('营地位置').fontSize(10).fontColor(COLORS.sub)
TextInput({ placeholder: '修改位置描述', text: this.editDate })
.onChange((v: string) => { this.editDate = v; })
.fontSize(12).padding(10).borderRadius(8).backgroundColor(COLORS.bg)
.fontColor(COLORS.title)
Text('预订状态').fontSize(10).fontColor(COLORS.sub)
Row({ space: 6 }) {
ForEach(['已预订', '已取消', '已完成'], (s: string) => {
Text(s).fontSize(10)
.fontColor(this.editStatus === s ? '#FFFFFF' : COLORS.sub)
.backgroundColor(this.editStatus === s ? COLORS.orange : COLORS.bg)
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(8)
.onClick(() => { this.editStatus = s; })
})
}
.width('100%')
Row({ space: 10 }) {
Column() { Text('取消').fontSize(11).fontColor(COLORS.sub) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.bg).alignItems(HorizontalAlign.Center)
.onClick(() => { this.editModal = false; })
Column() { Text('保存').fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.orange).alignItems(HorizontalAlign.Center)
.onClick(() => {
if (this.editIdx < this.campList.length) {
this.campList[this.editIdx].area = this.editDate;
}
this.editModal = false;
})
}
.width('100%')
}
.width('82%').constraintSize({ maxHeight: '70%' }).padding(16)
.borderRadius(16).backgroundColor(COLORS.card)
}
.width('100%').height('100%').zIndex(999)
}
编辑营地预订弹窗用于修改营地的位置描述和预订状态。弹窗结构与新增装备弹窗类似——标题栏、分割线、表单字段、操作按钮栏。表单字段有两个:营地位置的 TextInput 和预订状态的标签组选择器。营地位置输入框的 text 绑定到 this.editDate,该状态在打开弹窗时已被预填为当前营地的 area 值(参见 campMain 中编辑按钮的 onClick:this.editDate = c.area)。这种"预填当前值"的设计让用户能在现有值的基础上修改,而非从零开始输入,提升了编辑效率。
预订状态选择器使用 ForEach(['已预订', '已取消', '已完成'], ...) 渲染 3 个状态标签。默认选中"已预订"(在 campMain 的编辑 onClick 中设置 this.editStatus = '已预订')。这种"三态选择"覆盖了营地预订的完整生命周期——已预订(用户预约但未入住)、已取消(用户取消预订)、已完成(用户已入住并退房)。然而,保存按钮的 onClick 回调 this.campList[this.editIdx].area = this.editDate; 只将 editDate(位置描述)写入营地数据,并未将 editStatus(预订状态)写入——这是一个简化处理,实际生产中应增加 this.campList[this.editIdx].status = this.editStatus;(但 CampItem 类目前没有 status 字段,需要扩展模型)。
if (this.editIdx < this.campList.length) 的边界检查是防御性编程的体现——确保 editIdx 不越界。虽然正常流程下 editIdx 不会越界(它是在点击编辑按钮时从 ci 索引赋值的),但在异步场景或数据并发修改的情况下,可能出现 editIdx 指向已被删除的元素。这种防御性检查避免了 undefined 访问导致的运行时错误,是健壮代码的标志。
this.campList[this.editIdx].area = this.editDate; 这行代码是 @Observed 机制发挥价值的关键时刻——直接修改营地实例的 area 属性。由于 CampItem 是 @Observed 装饰的类,area 属性的 setter 被代理,赋值操作会触发 ArkUI 框架的通知机制,所有引用该营地实例的组件(营地主 Tab 的列表卡片、首页营地子 Tab 的大卡和横条)都会局部更新,展示新的位置描述。这种"修改对象属性即触发局部更新"的能力,使得 @Observed 在处理列表项的局部编辑时尤为高效——无需替换整个数组或手动触发刷新,框架自动完成精准的 UI 更新。
编辑弹窗的 constraintSize({ maxHeight: '70%' }) 比新增装备弹窗的 80% 更低——这是因为编辑弹窗的表单字段更少(只有位置和状态两项),不需要那么高的空间。constraintSize 是 ArkUI 的"约束尺寸"API,它限制组件的最大和最小尺寸,避免内容过多时组件撑破屏幕,或内容过少时组件过小。这种弹性尺寸约束让弹窗在不同内容量下都能合理展示。
二十二、取消活动报名确认弹窗
// ===== 取消活动报名确认弹框 =====
if (this.delModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.delModal = false; })
Column({ space: 10 }) {
Text('🔥').fontSize(34)
Text('取消这条活动报名?').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('取消后名额将释放给其他玩家').fontSize(10).fontColor(COLORS.red)
Row({ space: 10 }) {
Column() { Text('保留').fontSize(11).fontColor(COLORS.sub) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.bg).alignItems(HorizontalAlign.Center)
.onClick(() => { this.delModal = false; })
Column() { Text('确认取消').fontSize(11).fontColor('#FFFFFF') }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.red).alignItems(HorizontalAlign.Center)
.onClick(() => {
this.actList.splice(this.editIdx, 1);
this.delModal = false;
})
}
.width('100%')
}
.width('76%').constraintSize({ maxHeight: '60%' }).padding(16)
.borderRadius(16).backgroundColor(COLORS.card)
.alignItems(HorizontalAlign.Center)
}
.width('100%').height('100%').zIndex(999)
}
取消活动报名确认弹窗是一个"确认对话框"(Confirm Dialog),用于在用户执行删除操作前进行二次确认。与前两个弹窗(新增装备、编辑预订)不同,确认对话框不包含表单输入,只有提示信息和两个操作按钮。弹窗的结构简洁明了:顶部是一个大的火焰 emoji 🔥(34 号字,视觉警示),中间是确认问题"取消这条活动报名?“(14 号字粗体),下方是补充说明"取消后名额将释放给其他玩家”(10 号字红色,强调后果)。
确认对话框的视觉设计与前两个弹窗有显著差异——它使用 width('76%') 比前两个弹窗的 82% 更窄,constraintSize({ maxHeight: '60%' }) 比前两个的 70%-80% 更矮,整体更紧凑。这种"确认对话框比表单弹窗更小"的设计符合移动端对话框的设计规范——确认对话框的信息量少,无需过大空间;紧凑的尺寸反而能聚焦用户的注意力到核心问题上。alignItems(HorizontalAlign.Center) 让弹窗内容水平居中,与表单弹窗的左对齐形成对比——居中对齐更适合简洁的确认信息,左对齐更适合表单字段的扫描。
操作按钮栏包含"保留"和"确认取消"两个按钮。保留按钮为浅灰底灰字,确认取消按钮为红底白字——红色作为"危险/警示"色,强调这是一个不可逆的破坏性操作。这种"次要操作浅色,危险操作红色"的视觉对比引导用户谨慎选择。保留按钮的 onClick 只是关闭弹窗 this.delModal = false,不执行任何数据操作。确认取消按钮的 onClick 执行 this.actList.splice(this.editIdx, 1); this.delModal = false;——先从 actList 中移除索引为 editIdx 的活动,再关闭弹窗。
this.actList.splice(this.editIdx, 1) 是删除操作的核心——splice(start, deleteCount) 从数组的 start 索引开始移除 deleteCount 个元素。这里从 editIdx 索引移除 1 个元素,即移除用户点击删除图标的那条活动。由于 actList 是 @State 状态数组,splice 方法被 ArkUI 框架代理,会触发 ForEach 的差异更新——被删除的活动卡片从列表中消失,其他卡片保持不变(不会重建)。这种"精准移除"的渲染性能是 ForEach 键值追踪的价值体现。
editIdx 状态在删除场景中的使用流程是:用户在活动子 Tab 点击某条活动的删除图标 🗑️,触发 this.editIdx = idx; this.delModal = true;——先记录待删除活动的索引,再打开确认弹窗。用户在弹窗中点击"确认取消",触发 this.actList.splice(this.editIdx, 1),根据记录的索引移除对应活动。这种"先记录目标,再确认执行"的两步式交互是"危险操作"的标准模式——避免了误删(点击删除图标立即删除,用户来不及反悔),给了用户二次确认的机会。
从产品角度,“取消报名"是露营活动运营中的常见操作——用户可能因行程冲突、天气变化、装备不足等原因取消已报名的活动。取消后释放的名额可以让其他用户报名,体现了"共享资源"的运营理念。确认弹窗的提示文案"取消后名额将释放给其他玩家"告知了取消的后果(名额释放),既是善意的提醒,也是一种"软约束”——让用户知道自己的取消会让其他人受益,可能在心理上降低取消意愿,提升活动的参与率。
二十三、应用整体数据流与生命周期流程图
上图完整展示了应用从启动到交互到销毁的全流程。应用启动后首先进入 aboutToAppear 生命周期,创建萤火虫定时器;然后 build 方法首次渲染,根据 mainTab 和 homeTab 的初始值(均为 0)渲染首页头部和营地子 Tab 内容;然后渲染底部导航栏;接着根据 glow 状态决定是否渲染萤火虫特效层。定时器每 720ms 切换 glow 状态,触发特效层的条件渲染(显示/隐藏),形成闪烁效果。
用户交互分为五类:点击底部 Tab 切换 mainTab、点击内容 Tab 切换 homeTab、点击上架装备打开新增弹窗、点击编辑营地打开编辑弹窗、点击删除活动打开确认弹窗。前两类是简单的状态切换,触发条件渲染更换内容区;后三类打开弹窗,在弹窗中完成表单输入或确认操作,操作结果写入主状态(gearList、campList、actList),通过 @Observed 机制触发 ForEach 的差异更新,实现局部 UI 刷新。组件销毁时进入 aboutToDisappear 生命周期,清理定时器,避免内存泄漏。
整个流程体现了声明式 UI 的核心特征:开发者声明"状态 X 对应 UI Y",框架负责在状态变化时自动更新 UI。开发者的主要工作是定义状态、编写 UI 描述、处理用户交互(在交互中更新状态),而无需关心 DOM 操作、差异计算、渲染调度等底层细节。这种"状态即 UI"的编程模型大幅降低了 UI 开发的心智负担,让开发者能聚焦于业务逻辑。
二十四、核心技术维度对比
| 技术维度 | 实现方式 | 设计特点 | 性能考量 |
|---|---|---|---|
| 状态管理 | @State 装饰组件内私有状态 | 状态变化自动触发依赖 UI 重新渲染 | 编译期生成 setter 代理,运行时低开销 |
| 跨组件数据共享 | @Observed 装饰 CampItem/CampGear/ActItem 等类 | 实例属性变化触发局部更新,避免全量刷新 | 代理 getter/setter,仅通知变化属性 |
| 列表渲染 | ForEach 配合数组 slice 分片 | 支持键值追踪,数组变化时差异更新 | 仅重建变化的卡片,避免全量重建 |
| 布局系统 | Row/Column + layoutWeight 弹性布局 | 声明式 Flex 布局,自适应空间分配 | 框架优化布局计算,避免多 pass 测量 |
| 滚动容器 | Scroll + scrollable(ScrollDirection.Horizontal) | 支持横向/纵向滚动,可隐藏滚动条 | 懒加载渲染可见区域,长列表性能优 |
| 条件渲染 | if-else 链根据 Tab 状态切换内容 | 状态驱动条件渲染,切换即重建 | 切换时销毁旧组件释放内存 |
| 弹窗实现 | if 条件 + Stack + modalOverlay 复用 | 遮罩+内容堆叠,遮罩点击关闭 | zIndex 层级管理,弹窗在最顶层 |
| 表单交互 | TextInput + onChange 双向绑定 | 临时状态暂存,确认时写入主状态 | 输入实时同步状态,无防抖开销 |
| 标签组单选 | ForEach 渲染选项+onClick 切换 | 激活态填充色,视觉对比突出 | 简单状态切换,渲染开销极低 |
| 数据插入 | Array.splice(0, 0, newItem) 头部插入 | 新增项立即可见,反馈感强 | ForEach 差异更新,仅新增一张卡片 |
| 数据删除 | Array.splice(idx, 1) 移除元素 | ForEach 键值追踪,精准移除 | 仅销毁被删除的卡片,其他不变 |
| 定时器特效 | setInterval 720ms 切换 glow 布尔 | if 条件渲染特效层,闪烁模拟 | 720ms 间隔适中,避免高频刷新 |
| 资源清理 | aboutToDisappear 中 clearInterval | 生命周期对称,防止内存泄漏 | 组件销毁后定时器不运行 |
| 色彩管理 | COLORS 常量对象+接口约束 | 设计令牌集中管理,语义化命名 | const 常量无响应式开销 |
| 函数式着色 | barColor/statusColor/diffColor 纯函数 | 数据驱动样式,UI 与逻辑解耦 | 纯函数无副作用,易于缓存 |
| 背景渐变 | linearGradient 135 度橙渐变 | 营销卡片视觉焦点,呼应篝火意象 | GPU 加速渲染,性能优 |
| 文本截断 | maxLines(1)+textOverflow(Ellipsis) | 长名称单行截断省略,避免布局撑破 | 截断计算开销低,布局稳定 |
| 弹性占位 | Column().layoutWeight(1) 空容器 | 两端对齐布局的标准技巧 | 空容器渲染开销极低 |
| 模态遮罩 | rgba 半透明深色背景 | 聚焦注意力,背景隐约可见 | 半透明合成由 GPU 加速 |
| 边界检查 | if (editIdx < campList.length) | 防御性编程,避免越界访问 | 单次比较,无性能影响 |
二十五、总结
本文以"山野"户外露营装备租赁与营地预订应用为案例,深度解析了基于 ArkTS 声明式 UI 范式的 HarmonyOS 应用开发实践。从色彩体系的设计令牌管理,到多业务数据模型的 @Observed 建模,再到头部、Tab、瀑布流、柱状图、弹窗等丰富 UI 组件的 Builder 实现,整份代码展示了一个兼具业务深度与技术广度的完整应用形态。应用以"户外暖橙+苔绿"为视觉主调,通过 4 主 Tab+7 内容子 Tab 的双层导航架构,组织了营地、装备、活动、食谱、技能、天气、榜单七大业务维度,构成了露营生态的完整内容矩阵。
从状态管理角度,应用采用了"@State 组件内状态+@Observed 可观察对象"的分层响应式数据流。@State 用于管理 Tab 切换、弹窗显隐、表单临时字段等组件私有状态;@Observed 用于 CampItem、CampGear、ActItem 等业务实体的跨组件共享,当实体属性变化时触发局部更新。这种分层设计既保证了组件内状态的简单高效,又支持了跨组件数据的精准同步。特别是"编辑营地预订"场景,直接修改 campList[editIdx].area 后,@Observed 机制自动通知所有引用该实体的卡片局部更新,无需替换整个数组或手动触发刷新,体现了响应式数据流的优雅。
从交互设计角度,应用实现了完整的"新增-编辑-删除"CRUD 流程。新增装备弹窗展示了表单输入与状态绑定的双向数据流;编辑预订弹窗展示了"预填当前值+修改+保存"的编辑模式;删除确认弹窗展示了"先记录目标+二次确认+执行删除"的危险操作防护。三个弹窗复用了 modalOverlay 遮罩 Builder,通过参数化 onClose 回调实现了复用与定制的平衡。弹窗的临时状态(addName、addPrice、editDate 等)与主状态(gearList、campList、actList)分离,确保了编辑过程的可撤销性——用户取消弹窗不会影响已有数据。
从视觉特效角度,应用最独特的元素是"萤火虫闪烁"特效。它通过 setInterval 每 720ms 切换 glow 布尔状态,驱动 if (this.glow) 条件渲染特效层的显示与隐藏,形成闪烁效果。特效层使用大小不一、透明度不一的 ✨ 和 · 元素组合,模拟了萤火虫远近不一、明暗闪烁的自然意象。position({ y: 96 }) 和 zIndex(1) 的定位策略确保特效层不覆盖头部、不遮挡弹窗,仅在内容区顶部闪烁。这种"定时器驱动条件渲染"的特效实现方式简单直接,适合小范围的闪烁效果;对于更复杂的动画(如位移、缩放、旋转),应使用 ArkUI 的 animateTo 或 animation 属性实现,获得更平滑的动画曲线。
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

设置API为24的模板项目:

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

完整代码:
// ============================================================
// 830 山野 · 露营装备租赁与营地预订(现代行业:户外露营 / 轻旅行)
// 头部样式:户外电商头部(搜索栏 + 今日营地大卡 + 装备横滚)无动画
// 布局风格:底部 4 主 tab(首页/营地/装备/我的),首页内嵌 7 内容 tab
// 营地=营地大卡横滑 / 装备=双列瀑布(可新增) / 活动=报名卡(可删除)
// 食谱=图文横条 / 技能=教学列表 / 天气=柱状图+时段 / 榜单=销量榜
// 弹框:新增装备 / 编辑营地预订 / 取消活动报名
// 特效:萤火虫闪烁(setInterval 驱动,不覆盖头部)
// 主题:户外暖橙 + 苔绿 亮底
// ============================================================
interface ColorPalette {
bg: string;
card: string;
deep: string;
orange: string;
orangeD: string;
orangeL: string;
moss: string;
mossL: string;
sky: string;
title: string;
sub: string;
text3: string;
gold: string;
red: string;
blue: string;
line: string;
tabOn: string;
mask: string;
chip: string;
}
const COLORS: ColorPalette = {
bg: '#F4F7F0',
card: '#FFFFFF',
deep: '#33402E',
orange: '#E8793E',
orangeD: '#C25A26',
orangeL: '#FDE9DD',
moss: '#6B8F5A',
mossL: '#E6EFE1',
sky: '#7FA8C8',
title: '#2E3A2A',
sub: '#77826E',
text3: '#B0BAa6',
gold: '#D9A441',
red: '#D9534F',
blue: '#5B8BB8',
line: '#E4EBDE',
tabOn: '#E8793E',
mask: 'rgba(46,58,42,0.55)',
chip: 'rgba(232,121,62,0.10)'
};
interface TabMeta {
icon: string;
label: string;
}
const BOTTOM_TABS: TabMeta[] = [
{ icon: '🏕️', label: '首页' },
{ icon: '⛺', label: '营地' },
{ icon: '🎒', label: '装备' },
{ icon: '👤', label: '我的' }
];
const HOME_TABS: TabMeta[] = [
{ icon: '⛺', label: '营地' },
{ icon: '🎒', label: '装备' },
{ icon: '🔥', label: '活动' },
{ icon: '🍳', label: '食谱' },
{ icon: '🎓', label: '技能' },
{ icon: '🌦️', label: '天气' },
{ icon: '🏆', label: '榜单' }
];
@Observed export class CampItem {
id: number = 0;
name: string = '';
icon: string = '';
area: string = '';
price: string = '';
score: number = 0;
tag: string = '';
constructor(id: number, name: string, icon: string, area: string, price: string, score: number, tag: string) {
this.id = id; this.name = name; this.icon = icon; this.area = area;
this.price = price; this.score = score; this.tag = tag;
}
}
const CAMP_INIT: CampItem[] = [
new CampItem(1, '云顶星空营地', '🌌', '海拔 2800m 高山草甸', '¥268/晚', 4.9, '🔥 星空'),
new CampItem(2, '溪谷听泉营地', '💧', '溪谷竹林畔', '¥198/晚', 4.8, '🔥 亲子'),
new CampItem(3, '松涛湖畔营地', '🌲', '高山湖畔松林', '¥228/晚', 4.7, '钓鱼'),
new CampItem(4, '麦田微风营地', '🌾', '平原麦田之间', '¥168/晚', 4.6, '性价比'),
new CampItem(5, '悬崖日出营地', '🌅', '悬崖之巅', '¥328/晚', 4.9, '🔥 日出'),
new CampItem(6, '森林氧吧营地', '🌳', '原始森林边缘', '¥218/晚', 4.7, '森林浴'),
new CampItem(7, '湖畔星火营地', '🔥', '火山湖岸', '¥268/晚', 4.8, '🔥 篝火'),
new CampItem(8, '沙漠绿洲营地', '🏜️', '绿洲边缘沙丘', '¥298/晚', 4.6, '沙漠'),
new CampItem(9, '野花谷营地', '🌸', '花海山谷', '¥188/晚', 4.7, '花季'),
new CampItem(10, '河畔牧场营地', '🐴', '牧场草甸', '¥178/晚', 4.5, '牧场'),
new CampItem(11, '雪线观星营地', '❄️', '雪线以下', '¥358/晚', 4.9, '🔥 观星'),
new CampItem(12, '竹海听风营地', '🎋', '竹海深处', '¥208/晚', 4.6, '幽静')
];
@Observed export class CampGear {
id: number = 0;
name: string = '';
icon: string = '';
price: string = '';
status: string = '';
tag: string = '';
sales: number = 0;
constructor(id: number, name: string, icon: string, price: string, status: string, tag: string, sales: number) {
this.id = id; this.name = name; this.icon = icon; this.price = price;
this.status = status; this.tag = tag; this.sales = sales;
}
}
const GEAR_INIT: CampGear[] = [
new CampGear(1, '双层帐篷 3-4 人', '⛺', '¥59/日', '在库', '🔥 热门', 320),
new CampGear(2, '充气睡垫', '🛏️', '¥19/日', '在库', '舒适', 280),
new CampGear(3, '露营灯', '💡', '¥9/日', '在库', '🔥 热门', 410),
new CampGear(4, '折叠桌椅套装', '🪑', '¥29/日', '在库', '野餐', 190),
new CampGear(5, '卡式炉', '🍳', '¥25/日', '在库', '炊具', 230),
new CampGear(6, '保温箱 20L', '🧊', '¥39/日', '在库', '冷藏', 160),
new CampGear(7, '天幕 4x4', '🛡️', '¥49/日', '在库', '🔥 热门', 350),
new CampGear(8, '睡袋 -5°C', '🧥', '¥35/日', '在库', '保暖', 240),
new CampGear(9, '登山杖', '🥾', '¥12/日', '在库', '徒步', 120),
new CampGear(10, '头灯', '🔦', '¥8/日', '在库', '照明', 310),
new CampGear(11, '便携水桶', '🪣', '¥6/日', '在库', '取水', 90),
new CampGear(12, '防潮垫', '🧺', '¥8/日', '在库', '基础', 380),
new CampGear(13, '炉头套装', '🔥', '¥22/日', '在库', '炊具', 140),
new CampGear(14, '户外电源 600W', '🔋', '¥89/日', '在库', '🔥 热门', 260),
new CampGear(15, '折叠椅', '🪑', '¥15/日', '在库', '野餐', 200),
new CampGear(16, '充气床', '🛌', '¥45/日', '在库', '舒适', 150)
];
@Observed export class ActItem {
id: number = 0;
name: string = '';
icon: string = '';
date: string = '';
place: string = '';
price: string = '';
tag: string = '';
constructor(id: number, name: string, icon: string, date: string, place: string, price: string, tag: string) {
this.id = id; this.name = name; this.icon = icon; this.date = date;
this.place = place; this.price = price; this.tag = tag;
}
}
const ACT_INIT: ActItem[] = [
new ActItem(1, '星空摄影营', '🌌', '3月29日-30日', '云顶星空营地', '¥388', '🔥 热门'),
new ActItem(2, '亲子露营日', '👨👩👧', '4月4日', '溪谷听泉营地', '¥268', '🔥 热门'),
new ActItem(3, '篝火音乐会', '🔥', '4月5日', '湖畔星火营地', '¥158', '音乐'),
new ActItem(4, '山野徒步+野炊', '🥾', '4月11日', '森林氧吧营地', '¥228', '徒步'),
new ActItem(5, '观星露营课', '🔭', '4月12日', '雪线观星营地', '¥328', '课程'),
new ActItem(6, '钓鱼周末', '🎣', '4月18日', '松涛湖畔营地', '¥198', '钓鱼'),
new ActItem(7, '沙漠越野之夜', '🏜️', '4月25日', '沙漠绿洲营地', '¥468', '刺激'),
new ActItem(8, '花海野餐会', '🌸', '4月26日', '野花谷营地', '¥128', '轻松')
];
@Observed export class RecipeItem {
id: number = 0;
name: string = '';
icon: string = '';
time: string = '';
diff: string = '';
view: string = '';
constructor(id: number, name: string, icon: string, time: string, diff: string, view: string) {
this.id = id; this.name = name; this.icon = icon; this.time = time;
this.diff = diff; this.view = view;
}
}
const RECIPE_INIT: RecipeItem[] = [
new RecipeItem(1, '篝火烤棉花糖', '🍡', '10 分钟', '简单', '2.1w'),
new RecipeItem(2, '野炊香肠焖饭', '🍚', '30 分钟', '中等', '1.6w'),
new RecipeItem(3, '锡纸烤鱼', '🐟', '25 分钟', '中等', '1.8w'),
new RecipeItem(4, '营地热汤面', '🍜', '15 分钟', '简单', '1.2w'),
new RecipeItem(5, '烤红薯', '🍠', '40 分钟', '简单', '9800'),
new RecipeItem(6, '卡式炉小火锅', '🍲', '20 分钟', '简单', '1.4w'),
new RecipeItem(7, '营地手冲咖啡', '☕', '10 分钟', '简单', '2.3w'),
new RecipeItem(8, '烤披萨薄饼', '🍕', '35 分钟', '困难', '8800')
];
@Observed export class SkillItem {
id: number = 0;
name: string = '';
icon: string = '';
cat: string = '';
lessons: number = 0;
view: string = '';
constructor(id: number, name: string, icon: string, cat: string, lessons: number, view: string) {
this.id = id; this.name = name; this.icon = icon; this.cat = cat;
this.lessons = lessons; this.view = view;
}
}
const SKILL_INIT: SkillItem[] = [
new SkillItem(1, '新手搭帐篷 10 分钟速成', '⛺', '搭建', 4, '3.2w'),
new SkillItem(2, '野外生火技巧', '🔥', '生火', 3, '2.8w'),
new SkillItem(3, '绳结大全:8 种必学', '🪢', '绳结', 5, '2.4w'),
new SkillItem(4, '营火料理基础', '🍳', '料理', 6, '1.9w'),
new SkillItem(5, '方向辨别与地图阅读', '🧭', '导航', 4, '1.7w'),
new SkillItem(6, '露营安全守则', '🛡️', '安全', 3, '2.6w'),
new SkillItem(7, '户外急救包配置', '⛑️', '急救', 2, '2.1w'),
new SkillItem(8, '观星入门:星座识别', '🔭', '观星', 4, '3.0w')
];
interface BarData {
label: string;
value: number;
}
const WEATHER_BARS: BarData[] = [
{ label: '今天', value: 22 },
{ label: '明天', value: 24 },
{ label: '后天', value: 21 },
{ label: '周六', value: 26 },
{ label: '周日', value: 25 },
{ label: '周一', value: 19 },
{ label: '周二', value: 23 }
];
// ============ 全局纯函数 ============
function barColor(i: number): string {
if (i === 3 || i === 4) { return '#D9A441'; }
if (i % 2 === 1) { return '#6B8F5A'; }
return '#E8793E';
}
function statusColor(s: string): string {
if (s === '租出') { return '#E8793E'; }
if (s === '维修') { return '#D9534F'; }
return '#6B8F5A';
}
function diffColor(d: string): string {
if (d === '困难') { return '#D9534F'; }
if (d === '中等') { return '#E8793E'; }
return '#6B8F5A';
}
// ============================================================
@Entry
@Component
struct Page830 {
@State mainTab: number = 0;
@State homeTab: number = 0;
@State glow: boolean = false;
@State addModal: boolean = false;
@State editModal: boolean = false;
@State delModal: boolean = false;
@State editIdx: number = 0;
@State addName: string = '';
@State addPrice: string = '';
@State addTag: string = '基础';
@State editDate: string = '';
@State editStatus: string = '在库';
@State gearList: CampGear[] = GEAR_INIT;
@State actList: ActItem[] = ACT_INIT;
@State campList: CampItem[] = CAMP_INIT;
timer: number = 0;
aboutToAppear() {
this.timer = setInterval(() => { this.glow = !this.glow; }, 720);
}
aboutToDisappear() {
clearInterval(this.timer);
}
// ============ 头部(户外电商风格,无动画) ============
@Builder
header() {
Column({ space: 10 }) {
Row({ space: 8 }) {
Text('🔍').fontSize(14)
Text('搜索营地 / 装备 / 活动').fontSize(12).fontColor(COLORS.sub)
}
.width('100%').padding({ left: 14, right: 14, top: 9, bottom: 9 })
.borderRadius(20).backgroundColor('#FFFFFF')
// 今日营地大卡
Row({ space: 12 }) {
Column() {
Text('⛺').fontSize(26)
}
.width(52).height(52).borderRadius(12).backgroundColor(COLORS.orangeL)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Column({ space: 3 }) {
Text('周末特惠 · 云顶星空营地').fontSize(13).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
Text('含装备套餐 · 两人同行 8 折').fontSize(9).fontColor('#F5D9A0')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column() {
Text('预订 ›').fontSize(9).fontColor(COLORS.orange)
}
.padding({ left: 10, right: 10, top: 6, bottom: 6 }).borderRadius(8)
.backgroundColor('#FFFFFF')
}
.width('100%').padding(12).borderRadius(16)
.linearGradient({ angle: 135, colors: [['#E8793E', 0], ['#C25A26', 1]] })
// 装备横滚
Scroll() {
Row({ space: 8 }) {
ForEach(GEAR_INIT, (g: CampGear) => {
Column({ space: 2 }) {
Text(g.icon).fontSize(18)
Text(g.name).fontSize(9).fontColor(COLORS.sub)
}
.width(60).padding({ top: 8, bottom: 6 }).borderRadius(10)
.backgroundColor('#FFFFFF').alignItems(HorizontalAlign.Center)
})
}
.padding({ right: 4 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
}
.width('100%').padding({ left: 12, right: 12, top: 10, bottom: 10 })
.backgroundColor(COLORS.bg)
}
// ============ 首页 7 内容 tab 横条 ============
@Builder
homeTabBar() {
Scroll() {
Row({ space: 6 }) {
ForEach(HOME_TABS, (t: TabMeta, i: number) => {
Column({ space: 1 }) {
Text(t.icon).fontSize(14)
Text(t.label).fontSize(9)
}
.padding({ left: 8, right: 8, top: 5, bottom: 5 }).borderRadius(10)
.backgroundColor(this.homeTab === i ? COLORS.orange : '#FFFFFF')
.alignItems(HorizontalAlign.Center)
.onClick(() => { this.homeTab = i; })
})
}
.padding({ right: 4 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
}
// ============ 首页内容切换 ============
@Builder
homeContent() {
Column({ space: 0 }) {
this.homeTabBar()
Scroll() {
Column({ space: 10 }) {
if (this.homeTab === 0) {
this.campTab()
} else if (this.homeTab === 1) {
this.gearTab()
} else if (this.homeTab === 2) {
this.actTab()
} else if (this.homeTab === 3) {
this.recipeTab()
} else if (this.homeTab === 4) {
this.skillTab()
} else if (this.homeTab === 5) {
this.weatherTab()
} else {
this.rankTab()
}
}
.width('100%')
}
.scrollBar(BarState.Off)
.align(Alignment.Top)
.layoutWeight(1)
.width('100%')
}
.width('100%').height('100%')
}
// ============ 营地:大卡横滑 ============
@Builder
campTab() {
Column({ space: 8 }) {
Scroll() {
Row({ space: 10 }) {
ForEach(this.campList.slice(0, 6), (c: CampItem) => {
Column({ space: 6 }) {
Column() {
Text(c.icon).fontSize(30)
}
.width('100%').height(84).borderRadius(12).backgroundColor(COLORS.mossL)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Row({ space: 4 }) {
Text(c.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
}
.width('100%')
Text(c.area).fontSize(8).fontColor(COLORS.sub)
Row({ space: 4 }) {
Text('★').fontSize(9).fontColor(COLORS.gold)
Text(c.score.toString()).fontSize(9).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
}
.width('100%')
Row({ space: 6 }) {
Text(c.price).fontSize(10).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text(c.tag).fontSize(8).fontColor(COLORS.red)
}
.width('100%')
}
.width(140).padding(10).borderRadius(14).backgroundColor(COLORS.card)
})
}
.padding({ right: 4 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
ForEach(this.campList.slice(6, 12), (c: CampItem) => {
Row({ space: 10 }) {
Column() {
Text(c.icon).fontSize(22)
}
.width(46).height(46).borderRadius(12).backgroundColor(COLORS.mossL)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Column({ space: 3 }) {
Row({ space: 6 }) {
Text(c.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(c.tag).fontSize(8).fontColor(COLORS.red)
}
Text(c.area).fontSize(9).fontColor(COLORS.sub)
Text('★ ' + c.score.toString() + ' · ' + c.price).fontSize(9).fontColor(COLORS.gold)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column() {
Text('预订').fontSize(9).fontColor('#FFFFFF')
}
.padding({ left: 12, right: 12, top: 7, bottom: 7 }).borderRadius(9)
.backgroundColor(COLORS.orange)
}
.width('100%').padding(10).borderRadius(12).backgroundColor(COLORS.card)
})
}
.width('100%')
}
// ============ 装备:双列瀑布(可新增) ============
@Builder
gearTab() {
Column({ space: 8 }) {
Row({ space: 8 }) {
Text('🎒 装备租赁').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Column() { Text('+ 上架装备').fontSize(10).fontColor('#FFFFFF') }
.padding({ left: 12, right: 12, top: 6, bottom: 6 }).borderRadius(8)
.backgroundColor(COLORS.orange)
.onClick(() => { this.addModal = true; })
}
.width('100%')
Row({ space: 8 }) {
Column({ space: 8 }) {
ForEach(this.gearList.slice(0, 8), (g: CampGear) => {
Column({ space: 5 }) {
Text(g.icon).fontSize(24)
Text(g.name).fontSize(10).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 4 }) {
Text(g.price).fontSize(9).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
Text('·').fontSize(9).fontColor(COLORS.text3)
Text(g.sales.toString() + ' 租').fontSize(8).fontColor(COLORS.sub)
}
Text(g.status).fontSize(8).fontColor(statusColor(g.status))
}
.width('100%').padding(9).borderRadius(12).backgroundColor(COLORS.card)
.alignItems(HorizontalAlign.Start)
})
}
.layoutWeight(1)
Column({ space: 8 }) {
ForEach(this.gearList.slice(8, 16), (g: CampGear) => {
Column({ space: 5 }) {
Text(g.icon).fontSize(24)
Text(g.name).fontSize(10).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 4 }) {
Text(g.price).fontSize(9).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
Text('·').fontSize(9).fontColor(COLORS.text3)
Text(g.sales.toString() + ' 租').fontSize(8).fontColor(COLORS.sub)
}
Text(g.status).fontSize(8).fontColor(statusColor(g.status))
}
.width('100%').padding(9).borderRadius(12).backgroundColor(COLORS.card)
.alignItems(HorizontalAlign.Start)
})
}
.layoutWeight(1)
}
.width('100%')
}
.width('100%')
}
// ============ 活动:报名卡(可删除) ============
@Builder
actTab() {
Column({ space: 8 }) {
ForEach(this.actList, (a: ActItem, idx: number) => {
Row({ space: 10 }) {
Column() {
Text(a.icon).fontSize(22)
}
.width(46).height(46).borderRadius(14).backgroundColor(COLORS.orangeL)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Column({ space: 4 }) {
Row({ space: 6 }) {
Text(a.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(a.tag).fontSize(8).fontColor(COLORS.red)
}
Text(a.date + ' · ' + a.place).fontSize(9).fontColor(COLORS.sub)
Text(a.price).fontSize(10).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column({ space: 4 }) {
Text('报名').fontSize(9).fontColor('#FFFFFF')
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(8)
.backgroundColor(COLORS.orange)
Text('🗑️').fontSize(12)
.onClick(() => { this.editIdx = idx; this.delModal = true; })
}
.alignItems(HorizontalAlign.Center)
}
.width('100%').padding(10).borderRadius(12).backgroundColor(COLORS.card)
})
}
.width('100%')
}
// ============ 食谱:图文横条 ============
@Builder
recipeTab() {
Column({ space: 8 }) {
ForEach(RECIPE_INIT, (r: RecipeItem) => {
Row({ space: 10 }) {
Column() {
Text(r.icon).fontSize(24)
}
.width(48).height(48).borderRadius(14).backgroundColor(COLORS.mossL)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Column({ space: 3 }) {
Text(r.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(r.time + ' · 难度 ' + r.diff).fontSize(9).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(r.view + ' 看').fontSize(9).fontColor(diffColor(r.diff))
Text('›').fontSize(13).fontColor(COLORS.text3)
}
.width('100%').padding(10).borderRadius(12).backgroundColor(COLORS.card)
})
}
.width('100%')
}
// ============ 技能:教学列表 ============
@Builder
skillTab() {
Column({ space: 8 }) {
ForEach(SKILL_INIT, (s: SkillItem) => {
Row({ space: 10 }) {
Text(s.icon).fontSize(18)
Column({ space: 3 }) {
Text(s.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(s.cat + ' · ' + s.lessons.toString() + ' 节课 · ' + s.view + ' 学习').fontSize(9).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column() {
Text('去学').fontSize(9).fontColor('#FFFFFF')
}
.padding({ left: 10, right: 10, top: 6, bottom: 6 }).borderRadius(8)
.backgroundColor(COLORS.moss)
}
.width('100%').padding(10).borderRadius(12).backgroundColor(COLORS.card)
})
}
.width('100%')
}
// ============ 天气:柱状图 + 时段 ============
@Builder
weatherTab() {
Column({ space: 10 }) {
Column({ space: 8 }) {
Text('🌦️ 未来 7 天最高温(°C)').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Row({ space: 8 }) {
ForEach(WEATHER_BARS, (w: BarData, wi: number) => {
Column({ space: 4 }) {
Text(w.value.toString()).fontSize(8).fontColor(COLORS.sub)
Column().width(20).height(w.value * 3).borderRadius(6)
.backgroundColor(barColor(wi))
Text(w.label).fontSize(8).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
})
}
.width('100%').alignItems(VerticalAlign.Bottom)
}
.width('100%').padding(12).borderRadius(14).backgroundColor(COLORS.card)
Row({ space: 6 }) {
ForEach([['☀️', '晴', '23°C'], ['🌤️', '多云', '24°C'], ['🌧️', '小雨', '19°C']], (w: string[]) => {
Column({ space: 4 }) {
Text(w[0]).fontSize(20)
Text(w[1]).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(w[2]).fontSize(9).fontColor(COLORS.orange)
}
.layoutWeight(1).padding(12).borderRadius(12).backgroundColor(COLORS.card)
.alignItems(HorizontalAlign.Center)
})
}
.width('100%')
Row({ space: 8 }) {
ForEach([['今天', '露营指数', '88 适宜'], ['明天', '露营指数', '76 较宜'], ['周六', '露营指数', '92 极佳']], (w: string[]) => {
Column({ space: 3 }) {
Text(w[0]).fontSize(10).fontColor(COLORS.sub)
Text(w[1]).fontSize(9).fontColor(COLORS.text3)
Text(w[2]).fontSize(11).fontColor(COLORS.moss).fontWeight(FontWeight.Bold)
}
.layoutWeight(1).padding(10).borderRadius(12).backgroundColor(COLORS.card)
.alignItems(HorizontalAlign.Center)
})
}
.width('100%')
}
.width('100%')
}
// ============ 榜单:销量榜 ============
@Builder
rankTab() {
Column({ space: 8 }) {
ForEach(this.gearList.slice(0, 10), (g: CampGear, gi: number) => {
Row({ space: 10 }) {
Text((gi + 1).toString()).fontSize(14).fontColor(gi < 3 ? COLORS.gold : COLORS.text3).fontWeight(FontWeight.Bold)
.width(24)
Text(g.icon).fontSize(18)
Column({ space: 3 }) {
Text(g.name).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(g.price + ' · ' + g.tag).fontSize(8).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(g.sales.toString() + ' 次').fontSize(10).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
}
.width('100%').padding(10).borderRadius(12).backgroundColor(COLORS.card)
})
}
.width('100%')
}
// ============ 营地主 tab:列表(可编辑预订) ============
@Builder
campMain() {
Column({ space: 10 }) {
Scroll() {
Column({ space: 10 }) {
Text('⛺ 全部营地(' + this.campList.length.toString() + ')').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
ForEach(this.campList, (c: CampItem, ci: number) => {
Row({ space: 10 }) {
Column() {
Text(c.icon).fontSize(22)
}
.width(46).height(46).borderRadius(12).backgroundColor(COLORS.mossL)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Column({ space: 3 }) {
Text(c.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(c.area).fontSize(9).fontColor(COLORS.sub)
Text('★ ' + c.score.toString() + ' · ' + c.price).fontSize(9).fontColor(COLORS.gold)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column({ space: 4 }) {
Text('✏️').fontSize(13)
.onClick(() => { this.editIdx = ci; this.editDate = c.area; this.editStatus = '已预订'; this.editModal = true; })
Text('预订').fontSize(9).fontColor('#FFFFFF')
.padding({ left: 10, right: 10, top: 4, bottom: 4 }).borderRadius(8)
.backgroundColor(COLORS.orange)
}
.alignItems(HorizontalAlign.Center)
}
.width('100%').padding(10).borderRadius(12).backgroundColor(COLORS.card)
})
}
.width('100%')
}
.scrollBar(BarState.Off)
.layoutWeight(1)
.width('100%')
}
.width('100%').height('100%').padding({ left: 12, right: 12, top: 8 })
}
// ============ 装备主 tab:全部装备 ============
@Builder
gearMain() {
Column({ space: 10 }) {
Scroll() {
Column({ space: 10 }) {
Text('🎒 全部装备(' + this.gearList.length.toString() + ')').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
ForEach(this.gearList, (g: CampGear) => {
Row({ space: 10 }) {
Text(g.icon).fontSize(18)
Column({ space: 2 }) {
Text(g.name).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(g.price + ' · ' + g.tag + ' · ' + g.sales.toString() + ' 次租赁').fontSize(9).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(g.status).fontSize(9).fontColor(statusColor(g.status))
}
.width('100%').padding(10).borderRadius(12).backgroundColor(COLORS.card)
})
}
.width('100%')
}
.scrollBar(BarState.Off)
.layoutWeight(1)
.width('100%')
}
.width('100%').height('100%').padding({ left: 12, right: 12, top: 8 })
}
// ============ 我的:个人中心 ============
@Builder
mineContent() {
Column({ space: 10 }) {
Scroll() {
Column({ space: 10 }) {
Row({ space: 10 }) {
Column() {
Text('🏕️').fontSize(26)
}
.width(56).height(56).borderRadius(28).backgroundColor(COLORS.orangeL)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Column({ space: 3 }) {
Text('山野拾荒者').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('营地主 Lv.9 · 户外达人').fontSize(9).fontColor(COLORS.orange)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column() {
Text('编辑').fontSize(9).fontColor(COLORS.orange)
}
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(8)
.backgroundColor(COLORS.chip)
}
.width('100%').padding(12).borderRadius(14).backgroundColor(COLORS.card)
Row({ space: 8 }) {
Column({ space: 3 }) {
Text('38').fontSize(18).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
Text('露营次数').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('12').fontSize(18).fontColor(COLORS.moss).fontWeight(FontWeight.Bold)
Text('营地足迹').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('9').fontSize(18).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
Text('收藏装备').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('4').fontSize(18).fontColor(COLORS.blue).fontWeight(FontWeight.Bold)
Text('活动徽章').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
.width('100%').padding(12).borderRadius(14).backgroundColor(COLORS.card)
ForEach([['📅', '我的预订', '2 个营地'], ['🎒', '租赁中', '3 件装备'], ['🔥', '活动报名', '4 场'], ['🍳', '收藏食谱', '8 道'], ['🏅', '成就墙', '山野开拓者']], (m: string[]) => {
Row({ space: 10 }) {
Text(m[0]).fontSize(15)
Text(m[1]).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text(m[2]).fontSize(9).fontColor(COLORS.text3)
Text('›').fontSize(13).fontColor(COLORS.text3)
}
.width('100%').padding(10).borderRadius(12).backgroundColor(COLORS.card)
})
}
.width('100%')
}
.scrollBar(BarState.Off)
.layoutWeight(1)
.width('100%')
}
.width('100%').height('100%').padding({ left: 12, right: 12, top: 8 })
}
// ============ 底部 4 主 tab ============
@Builder
bottomBar() {
Row({ space: 4 }) {
ForEach(BOTTOM_TABS, (t: TabMeta, i: number) => {
Column({ space: 2 }) {
Text(t.icon).fontSize(18)
Text(t.label).fontSize(9)
.fontColor(this.mainTab === i ? COLORS.orange : COLORS.text3)
.fontWeight(this.mainTab === i ? FontWeight.Bold : FontWeight.Normal)
}
.layoutWeight(1).padding({ top: 6, bottom: 6 })
.alignItems(HorizontalAlign.Center)
.onClick(() => { this.mainTab = i; })
})
}
.width('100%').padding({ left: 8, right: 8, top: 4, bottom: 6 })
.backgroundColor('#FFFFFF')
.border({ width: { top: 1 }, color: COLORS.line })
}
// ============ 弹框遮罩 ============
@Builder
modalOverlay(onClose: () => void) {
Column() {
}
.width('100%').height('100%').backgroundColor(COLORS.mask)
.onClick(() => { onClose(); })
}
build() {
Column() {
// 头部
if (this.mainTab === 0) {
this.header()
} else {
Row({ space: 8 }) {
Text('🏕️').fontSize(14)
Text(this.mainTab === 1 ? '营地预订' : (this.mainTab === 2 ? '装备租赁' : '个人中心')).fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
}
.width('100%').padding({ left: 12, right: 12, top: 12, bottom: 10 })
.backgroundColor(COLORS.bg)
}
// 主内容区
if (this.mainTab === 0) {
this.homeContent()
} else if (this.mainTab === 1) {
this.campMain()
} else if (this.mainTab === 2) {
this.gearMain()
} else {
this.mineContent()
}
// 底部 tab
this.bottomBar()
// 萤火虫特效层
if (this.glow) {
Row({ space: 42 }) {
Text('✨').fontSize(9).opacity(0.5)
Text('·').fontSize(6).opacity(0.4)
Text('✨').fontSize(7).opacity(0.35)
Text('·').fontSize(10).opacity(0.45)
}
.width('100%').height(14).position({ x: 0, y: 96 }).zIndex(1)
}
// ===== 新增装备弹框 =====
if (this.addModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.addModal = false; })
Column({ space: 10 }) {
Row({ space: 8 }) {
Text('🎒 上架装备').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('✕').fontSize(16).fontColor(COLORS.sub)
.onClick(() => { this.addModal = false; })
}
.width('100%')
Divider().color(COLORS.line)
Text('装备名称').fontSize(10).fontColor(COLORS.sub)
TextInput({ placeholder: '如 充气床', text: this.addName })
.onChange((v: string) => { this.addName = v; })
.fontSize(12).padding(10).borderRadius(8).backgroundColor(COLORS.bg)
.fontColor(COLORS.title)
Text('日租金').fontSize(10).fontColor(COLORS.sub)
TextInput({ placeholder: '如 ¥45/日', text: this.addPrice })
.onChange((v: string) => { this.addPrice = v; })
.fontSize(12).padding(10).borderRadius(8).backgroundColor(COLORS.bg)
.fontColor(COLORS.title)
Text('装备分类').fontSize(10).fontColor(COLORS.sub)
Row({ space: 6 }) {
ForEach(['基础', '炊具', '照明', '舒适'], (t: string) => {
Text(t).fontSize(10)
.fontColor(this.addTag === t ? '#FFFFFF' : COLORS.sub)
.backgroundColor(this.addTag === t ? COLORS.orange : COLORS.bg)
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(8)
.onClick(() => { this.addTag = t; })
})
}
.width('100%')
Row({ space: 10 }) {
Column() { Text('取消').fontSize(11).fontColor(COLORS.sub) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.bg).alignItems(HorizontalAlign.Center)
.onClick(() => { this.addModal = false; })
Column() { Text('上架').fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.orange).alignItems(HorizontalAlign.Center)
.onClick(() => {
this.gearList.splice(0, 0, new CampGear(this.gearList.length + 1, this.addName === '' ? '新装备' : this.addName, '🧰', this.addPrice === '' ? '¥20/日' : this.addPrice, '在库', this.addTag, 0));
this.addName = ''; this.addPrice = '';
this.addModal = false;
})
}
.width('100%')
}
.width('82%').constraintSize({ maxHeight: '80%' }).padding(16)
.borderRadius(16).backgroundColor(COLORS.card)
}
.width('100%').height('100%').zIndex(999)
}
// ===== 编辑营地预订弹框 =====
if (this.editModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.editModal = false; })
Column({ space: 10 }) {
Row({ space: 8 }) {
Text('✏️ 编辑预订信息').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('✕').fontSize(16).fontColor(COLORS.sub)
.onClick(() => { this.editModal = false; })
}
.width('100%')
Divider().color(COLORS.line)
Text('营地位置').fontSize(10).fontColor(COLORS.sub)
TextInput({ placeholder: '修改位置描述', text: this.editDate })
.onChange((v: string) => { this.editDate = v; })
.fontSize(12).padding(10).borderRadius(8).backgroundColor(COLORS.bg)
.fontColor(COLORS.title)
Text('预订状态').fontSize(10).fontColor(COLORS.sub)
Row({ space: 6 }) {
ForEach(['已预订', '已取消', '已完成'], (s: string) => {
Text(s).fontSize(10)
.fontColor(this.editStatus === s ? '#FFFFFF' : COLORS.sub)
.backgroundColor(this.editStatus === s ? COLORS.orange : COLORS.bg)
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(8)
.onClick(() => { this.editStatus = s; })
})
}
.width('100%')
Row({ space: 10 }) {
Column() { Text('取消').fontSize(11).fontColor(COLORS.sub) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.bg).alignItems(HorizontalAlign.Center)
.onClick(() => { this.editModal = false; })
Column() { Text('保存').fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.orange).alignItems(HorizontalAlign.Center)
.onClick(() => {
if (this.editIdx < this.campList.length) {
this.campList[this.editIdx].area = this.editDate;
}
this.editModal = false;
})
}
.width('100%')
}
.width('82%').constraintSize({ maxHeight: '70%' }).padding(16)
.borderRadius(16).backgroundColor(COLORS.card)
}
.width('100%').height('100%').zIndex(999)
}
// ===== 取消活动报名确认弹框 =====
if (this.delModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.delModal = false; })
Column({ space: 10 }) {
Text('🔥').fontSize(34)
Text('取消这条活动报名?').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('取消后名额将释放给其他玩家').fontSize(10).fontColor(COLORS.red)
Row({ space: 10 }) {
Column() { Text('保留').fontSize(11).fontColor(COLORS.sub) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.bg).alignItems(HorizontalAlign.Center)
.onClick(() => { this.delModal = false; })
Column() { Text('确认取消').fontSize(11).fontColor('#FFFFFF') }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.red).alignItems(HorizontalAlign.Center)
.onClick(() => {
this.actList.splice(this.editIdx, 1);
this.delModal = false;
})
}
.width('100%')
}
.width('76%').constraintSize({ maxHeight: '60%' }).padding(16)
.borderRadius(16).backgroundColor(COLORS.card)
.alignItems(HorizontalAlign.Center)
}
.width('100%').height('100%').zIndex(999)
}
}
.width('100%').height('100%').backgroundColor(COLORS.bg)
}
}
—
从工程组织角度,应用遵循了"数据-逻辑-视图"的分层结构。数据层包括 ColorPalette/TabMeta/BarData 等接口与 COLORS/BOTTOM_TABS/HOME_TABS/WEATHER_BARS 等常量,以及 CampItem/CampGear/ActItem/RecipeItem/SkillItem 等 @Observed 数据模型与对应的 INIT 初始数据;逻辑层包括 barColor/statusColor/diffColor 三个纯函数,负责数据到样式的映射;视图层包括 Page830 组件及其多个 @Builder 方法,分别构建头部、Tab 横条、7 个内容子 Tab、3 个主 Tab 内容、底部导航、3 个弹窗等 UI 区域。这种分层使得代码结构清晰,职责分明,便于后续的迭代与维护。
从产品视角看,这份代码不仅是一份技术演示,更是一个完整的产品设计实践。营地数据覆盖了 12 种典型地形,装备数据覆盖了 16 件核心装备,活动数据关联了 8 个主题活动,食谱与技能数据为用户提供了内容消费维度,天气与露营指数为用户提供了出行决策工具,榜单为用户提供了热门引导。这些业务数据的精心设计,配合"暖橙+苔绿"的户外主题色彩、萤火虫闪烁的自然意象特效、双列瀑布流与横滑大卡的视觉层次,共同营造了一个既实用又富有美感的露营生态应用。它展示了 ArkTS 在内容型电商应用中的开发能力,也为户外露营行业的数字化转型提供了可参考的前端实现范式。
数据层可从静态常量改为后端 API 动态获取,配合分页加载与下拉刷新;状态管理可引入 @AppStorage 全局状态实现跨页面共享(如用户登录态、购物车);视觉层可支持深色模式切换,通过主题色令牌的动态切换实现;交互层可为装备卡片绑定 onClick 跳转详情页,为营地卡片绑定地图导航;动画层可使用 animateTo 实现卡片入场动画、弹窗过渡动画,提升视觉精致度。这些扩展方向都建立在当前代码的架构基础上,得益于分层设计与响应式数据流,扩展时不会对现有代码造成大规模重构。这体现了良好架构设计的长期价值——初始的合理设计为后续的迭代演进预留了充足的空间。
更多推荐
所有评论(0)