基于 HarmonyOS API 24 与 ArkTS 构建露营搭子户外装备社区「野邻居」:从湖蓝篝火橙色彩体系到篝火跳动与星空闪烁特效的全栈实践
一、HarmonyOS 技术生态与 ArkTS 声明式开发范式
HarmonyOS 6.1.1 代表了华为在全场景智慧生活战略下的操作系统最新成果,其前端开发框架 ArkUI 在 HarmonyOS ArkTS API 24 的基础上提供了完整的声明式 UI 编程能力。与传统的命令式编程不同,声明式范式要求开发者描述「界面应该是什么样子」而非「如何一步步构建界面」,框架负责在数据变化时自动计算差异并更新视图。这种模式大幅减少了模板代码量,使开发者能够将注意力集中在业务逻辑和用户体验上。ArkTS 作为 TypeScript 的增强版本,在保留类型推断和接口约束等语言级优势的同时,通过 AOT 编译将声明式 UI 描述转化为高效的原生渲染指令。
在 HarmonyOS 6.1.1 的设备能力矩阵中,ArkTS 编译产物可以直接运行于手机、平板、智慧屏等多种终端设备,实现「一次开发、多端部署」。对于社区类应用而言,这意味着开发者只需维护一套代码库,即可覆盖用户在不同场景下的使用需求。ArkUI 框架内置的 linearGradient、borderRadius、position、rotate、scale 等样式属性,配合 @Builder 装饰器定义的可复用 UI 片段,能够轻松构建出视觉效果丰富、交互流畅的移动端界面。
露营搭子社区「野邻居」正是基于上述技术栈构建的一款垂直户外社交应用。随着精致露营(Glamping)文化在国内年轻群体中的流行,户外露营已从单一的活动形式演变为一种生活方式和社交场景。用户的核心需求包括:寻找志同道合的露营搭子、管理个人装备库、发现优质营地、学习露营干货攻略、规划观星行程以及闲置装备交易。本应用以「快手」短视频平台的沉浸式信息流为参照,将营地发现、装备库、搭子圈、攻略、观星预报、跳蚤市集六大功能模块有机融合,通过湖蓝与篝火橙的双色体系营造出自然户外的清新氛围。
在数据驱动层面,本应用严格遵循 ArkUI 的响应式状态管理理念。所有可变数据通过 @State 装饰器声明为组件内部状态,当数据源发生变化时框架自动触发依赖追踪和差异化渲染。对于数组内对象属性级别的变更,使用 @Observed 装饰器配合 splice 操作触发 ForEach 列表的精准刷新。这种细粒度的更新机制确保了在频繁的用户交互(如申请加入队伍、关注搭子、收藏攻略等)下,界面始终保持流畅且不产生多余的渲染开销。
特效动画方面,本应用实现了「篝火跳动」与「星星闪烁」两个特效层。通过 setInterval 定时器每 120 毫秒递增 fxTick 计数器,驱动篝火 emoji 的 scale 和 rotate 属性产生跳动摇摆效果,同时驱动星星 emoji 的 opacity 交替变化产生闪烁效果。这种基于数学取模运算的轻量动画方案,在 HarmonyOS 6.1.1 的渲染引擎下能够以极低的 CPU 开销实现持续的视觉动态,且通过 hitTestBehavior(HitTestMode.None) 确保不干扰用户操作。
二、湖蓝篝火橙色彩体系
本应用采用湖蓝 #0277BD 作为主色、篝火橙 #FF6F00 作为强调色,构建了一套完整的双主题色彩体系。湖蓝象征着湖水、天空和远方,传达出清新、自然、信赖的品牌调性;篝火橙象征着营火、温暖和活力,用于标注关键操作和价格信息,形成冷色与暖色的视觉张力。
interface ColorPalette {
primary: string;
primaryLight: string;
primaryDark: string;
accent: string;
accentLight: string;
bg: string;
cardBg: string;
textPrimary: string;
textSecondary: string;
textHint: string;
border: string;
success: string;
warning: string;
danger: string;
white: string;
gold: string;
}
const COLORS: ColorPalette = {
primary: '#0277BD',
primaryLight: '#E1F5FE',
primaryDark: '#01528A',
accent: '#FF6F00',
accentLight: '#FFF3E0',
bg: '#F2F8FB',
cardBg: '#FFFFFF',
textPrimary: '#12324A',
textSecondary: '#52708A',
textHint: '#A5BECE',
border: '#DDEBF3',
success: '#66BB6A',
warning: '#FFA726',
danger: '#EF5350',
white: '#FFFFFF',
gold: '#FFB300'
};
色彩接口 ColorPalette 定义了 16 个色阶属性,从主色三阶(primary/primaryLight/primaryDark)到强调色二阶(accent/accentLight),再到文字三阶(textPrimary/textSecondary/textHint)和语义四色(success/warning/danger/gold),构成了完整的色彩语义系统。背景色 #F2F8FB 是一种极淡的蓝白色调,为卡片白色背景提供了柔和的衬托。文字主色 #12324A 是一种深邃的蓝灰色,确保在白色背景上具有足够的对比度。金色 #FFB300 专用于评分星星和观星指数,与篝火橙形成暖色系的层次区分。
三、数据模型层设计
本应用围绕露营搭子的核心业务场景,定义了八个 @Observed 数据模型类,覆盖了结伴队伍、装备管理、营地笔记、搭子用户、攻略文章、观星预报、跳蚤市集和季节趋势八个维度。
3.1 结伴队伍与装备模型
@Observed
class TripItem {
id: number = 0;
title: string = '';
icon: string = '';
style: string = '';
place: string = '';
date: string = '';
need: number = 0;
got: number = 0;
host: string = '';
joined: number = 0;
constructor(id: number, title: string, icon: string, style: string,
place: string, date: string, need: number, got: number,
host: string, joined: number) {
this.id = id; this.title = title; this.icon = icon; this.style = style;
this.place = place; this.date = date; this.need = need; this.got = got;
this.host = host; this.joined = joined;
}
}
@Observed
class GearItem {
id: number = 0;
name: string = '';
icon: string = '';
cat: string = '';
brand: string = '';
weight: number = 0;
used: number = 0;
rent: number = 0;
price: number = 0;
renting: number = 0;
constructor(id: number, name: string, icon: string, cat: string,
brand: string, weight: number, used: number, rent: number,
price: number, renting: number) {
this.id = id; this.name = name; this.icon = icon; this.cat = cat;
this.brand = brand; this.weight = weight; this.used = used;
this.rent = rent; this.price = price; this.renting = renting;
}
}

TripItem 是结伴队伍的核心模型,title 存储队伍名称(如「周末去白河湾钓鱼露营」),style 标注露营类型(休闲露营/徒步重装/车载露营/风格露营等),place 记录目的地省市,date 标注出发时间。need 和 got 分别记录所需人数和已加入人数,host 是队长昵称,joined 标记当前用户是否已申请加入。
GearItem 是装备管理模型,cat 标注装备分类(庇护/睡眠/厨房/家具/照明/供电等),brand 记录品牌(牧高笛/黑冰/Therm-a-Rest/Fire Maple 等),weight 以克为单位记录重量,used 以百分比记录成色,rent 是日租金,price 是估值,renting 标记当前是否处于出租状态。这个模型同时承载了装备管理和装备租赁两个业务场景。
3.2 营地与搭子模型
@Observed
class CampItem {
id: number = 0;
name: string = '';
icon: string = '';
province: string = '';
terrain: string = '';
rating: number = 0;
meters: number = 0;
starred: number = 0;
constructor(id: number, name: string, icon: string, province: string,
terrain: string, rating: number, meters: number, starred: number) {
this.id = id; this.name = name; this.icon = icon; this.province = province;
this.terrain = terrain; this.rating = rating; this.meters = meters;
this.starred = starred;
}
}
@Observed
class MateItem {
id: number = 0;
name: string = '';
avatar: string = '';
city: string = '';
badge: string = '';
trips: number = 0;
followed: number = 0;
constructor(id: number, name: string, avatar: string, city: string,
badge: string, trips: number, followed: number) {
this.id = id; this.name = name; this.avatar = avatar; this.city = city;
this.badge = badge; this.trips = trips; this.followed = followed;
}
}

CampItem 描述营地笔记信息,province 标注省市位置,terrain 标注地形类型(河滩/高山/草原/沙漠/草甸/湖畔等),rating 是评分,starred 标记当前用户是否收藏了该营地。MateItem 描述搭子用户画像,badge 是技能徽章(如「百座山认证」「银河猎人」「溯溪达人」等),trips 记录出营次数,followed 标记当前用户是否已关注。
3.3 攻略与观星模型
@Observed
class GuideItem {
id: number = 0;
title: string = '';
author: string = '';
reads: number = 0;
saves: number = 0;
saved: number = 0;
constructor(id: number, title: string, author: string,
reads: number, saves: number, saved: number) {
this.id = id; this.title = title; this.author = author;
this.reads = reads; this.saves = saves; this.saved = saved;
}
}
@Observed
class SkyItem {
id: number = 0;
day: string = '';
icon: string = '';
temp: string = '';
wind: string = '';
seeing: number = 0;
night: number = 0;
constructor(id: number, day: string, icon: string, temp: string,
wind: string, seeing: number, night: number) {
this.id = id; this.day = day; this.icon = icon; this.temp = temp;
this.wind = wind; this.seeing = seeing; this.night = night;
}
}

GuideItem 是攻略文章模型,reads 记录阅读量,saves 记录收藏数,saved 标记当前用户是否已收藏。SkyItem 是观星预报模型,temp 记录温度范围,wind 记录风向风力,seeing 使用 1-5 整数表示大气视宁度(观星条件),night 是夜间可见度百分比。此外还有 MarketItem(跳蚤市集)和 SeasonItem(季节趋势)两个辅助模型,分别用于二手装备展示和年度出营统计。
四、静态数据种子与辅助函数
4.1 种子数据
应用预置了丰富真实的种子数据,以下是结伴队伍数据的片段:
const TRIP_LIST: TripItem[] = [
new TripItem(1, '周末去白河湾钓鱼露营', '🎣', '休闲露营', '北京·怀柔', '周六出发', 4, 3, '老钓客阿伦', 0),
new TripItem(2, '海坨山看日出云海', '🌄', '徒步重装', '河北·赤城', '两天一夜', 6, 4, '山野向导老周', 0),
new TripItem(3, '乌兰布统草原自驾扎营', '🌾', '车载露营', '内蒙古·赤峰', '五一假期', 5, 5, '自驾狂人七哥', 0),
new TripItem(4, '后河峡谷溯溪野炊', '🏞️', '轻装徒步', '北京·延庆', '周日单日', 8, 6, '溪谷领队小鹿', 0),
new TripItem(5, '翡翠岛沙漠星空营', '🏜️', '风格露营', '河北·昌黎', '周六出发', 4, 2, '星空摄影师老吴', 0),
// ...更多队伍
];

每条数据包含了完整的队伍名称、emoji 图标、露营风格、目的地省市、出发时段、需要人数、已加入人数、队长昵称。这些种子数据在组件初始化时直接赋值给 @State 数组,确保首帧渲染即可呈现丰富的内容。装备数据涵盖了帐篷、睡袋、充气垫、焚火台、卡式炉、折叠桌椅、天幕、营地灯、户外电源、咖啡手冲套装十件装备,每件都标注了品牌、重量、成色和租金。营地数据覆盖了北京怀柔到内蒙古丰宁的十个营地,涵盖河滩、高山、草原、沙漠、草甸等多种地形。
4.2 工具函数
function numText(n: number): string {
if (n >= 10000) {
return (n / 10000).toFixed(1) + 'w';
}
return n.toString();
}
function maxTrips(): number {
let m: number = 0;
for (let i: number = 0; i < SEASON_LIST.length; i++) {
if (SEASON_LIST[i].trips > m) {
m = SEASON_LIST[i].trips;
}
}
return m;
}
function barHeight(v: number): number {
return Math.round(v * 100 / maxTrips());
}
function seeingStars(s: number): string {
let out: string = '';
for (let i: number = 0; i < 5; i++) {
if (i < s) {
out += '★';
} else {
out += '☆';
}
}
return out;
}

numText 将万级数字格式化为简写形式,用于阅读量和播放量的展示。maxTrips 遍历季节趋势数组找出最大出营次数,作为柱状图高度的基准值。barHeight 根据当前值与最大值的比例计算柱状高度(四舍五入到整数)。seeingStars 根据观星指数生成 5 颗星的可视化字符串,用于观星预报页面中指数的直观展示。
五、组件状态管理与生命周期
@Entry
@Component
struct PageCampMate {
@State currentTab: number = 0;
@State bottomTab: number = 0;
@State tripList: TripItem[] = TRIP_LIST;
@State gearList: GearItem[] = GEAR_LIST;
@State campList: CampItem[] = CAMP_LIST;
@State mateList: MateItem[] = MATE_LIST;
@State guideList: GuideItem[] = GUIDE_LIST;
@State marketList: MarketItem[] = MARKET_LIST;
@State addOpen: boolean = false;
@State editOpen: boolean = false;
@State delOpen: boolean = false;
@State bizOpen: boolean = false;
@State addTitle: string = '';
@State addStyle: number = 0;
@State addTool: number = 0;
@State addNum: number = 4;
@State editCond: number = 2;
@State editRent: number = 45;
@State delIndex: number = 0;
@State bizDay: number = 0;
@State bizRent: number = 3;
@State fxTick: number = 0;
private timer: number = -1;

PageCampMate 组件声明了 21 个 @State 变量。其中 currentTab 和 bottomTab 控制页面切换,6 个数组持有各功能页面的数据源。4 个布尔值控制弹框显隐。addTitle、addStyle、addTool、addNum 是发布结伴弹框的表单状态。editCond、editRent 是编辑装备弹框的选择项。bizDay、bizRent 是出租装备弹框的参数。fxTick 是动画计数器。
aboutToAppear(): void {
this.timer = setInterval(() => {
this.fxTick += 1;
}, 120);
}
aboutToDisappear(): void {
if (this.timer >= 0) {
clearInterval(this.timer);
}
}

生命周期管理与其他应用保持一致:aboutToAppear 启动 120ms 间隔的定时器驱动动画,aboutToDisappear 清理定时器。
5.1 业务操作方法
doAdd(): void {
if (this.addTitle.length > 0) {
this.tripList.unshift(new TripItem(998, this.addTitle, '🏕️',
ADD_STYLES[this.addStyle].substring(3), '北京·怀柔',
'周六出发', this.addNum, 1, '我', 0));
}
this.addOpen = false;
}
doEdit(): void {
if (this.gearList.length > 0) {
this.gearList[0].rent = this.editRent;
this.gearList[0].used = this.editCond * 10 + 55;
this.gearList.splice(0, 1, this.gearList[0]);
}
this.editOpen = false;
}
doDel(): void {
if (this.delIndex >= 0 && this.delIndex < this.campList.length) {
this.campList.splice(this.delIndex, 1);
}
this.delOpen = false;
}
doBiz(): void {
if (this.gearList.length > 0) {
this.gearList[0].renting = 1;
this.gearList.splice(0, 1, this.gearList[0]);
}
this.bizOpen = false;
}
四个 do 方法分别处理:doAdd 将新建队伍插入数组头部(使用 unshift),doEdit 更新装备的租金和成色,doDel 删除指定索引的营地笔记,doBiz 将装备标记为出租中。每个操作完成后关闭对应弹框。splice(0, 1, item) 的惯用手法确保 @Observed 对象的属性变更能够被 ForEach 捕获并触发局部刷新。
六、篝火跳动与星星闪烁特效
@Builder
fxLayer() {
Column() {
Text('🔥')
.fontSize(22)
.scale({ x: this.fxTick % 2 === 0 ? 1.15 : 0.9, y: this.fxTick % 2 === 0 ? 1.1 : 0.92 })
.rotate({ angle: (this.fxTick % 4) * 6 - 9, centerX: '50%', centerY: '90%' })
.position({ x: 30, y: 610 })
Text('✨')
.fontSize(14)
.fontColor(COLORS.gold)
.opacity(this.fxTick % 2 === 0 ? 1 : 0.15)
.position({ x: 90, y: 80 })
Text('⭐')
.fontSize(12)
.opacity(this.fxTick % 2 === 1 ? 1 : 0.15)
.position({ x: 300, y: 110 })
Text('✨')
.fontSize(10)
.fontColor(COLORS.gold)
.opacity(this.fxTick % 3 === 0 ? 1 : 0.2)
.position({ x: 200, y: 60 })
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.None)
.position({ x: 0, y: 0 })
}

篝火跳动的实现精髓在于 scale 和 rotate 的协同变化。scale 的 X 轴在 1.15 和 0.9 之间交替,Y 轴在 1.1 和 0.92 之间交替,模拟了火焰在水平方向扩张收缩、垂直方向随之变化的物理特性。rotate 的角度在 -9 到 +9 度之间循环(基于 fxTick % 4 的四态周期),且旋转中心设在底部中央 centerY: '90%',完美模拟了篝火从底部向上摆动的真实物理感。
星星闪烁层使用了三颗星,分别位于不同位置。第一颗和第二颗通过 fxTick % 2 的奇偶性交替显隐(一个完全不透明时另一个几乎透明),产生了此起彼伏的闪烁节奏。第三颗使用 fxTick % 3 的三态周期,节奏更为缓慢,丰富了视觉层次。金色 COLORS.gold 赋予星星温暖的色调,与篝火的橙红色系形成暖色系的统一。hitTestBehavior(HitTestMode.None) 确保整个特效层穿透触摸事件。
七、渐变头部与营地木牌式导航
7.1 渐变头部
@Builder
header() {
Column() {
Row() {
Column() {
Text('🏕️ 野邻居')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('今晚观星指数 5 星 · 本周末 386 个结伴队伍出发')
.fontSize(11)
.fontColor('#B3E5FC')
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
Row() {
Text('🧭')
.fontSize(18)
.padding(8)
.backgroundColor('#33FFFFFF')
.borderRadius(18)
.onClick(() => { this.bizOpen = true; })
Text('🔔')
.fontSize(18)
.padding(8)
.backgroundColor('#33FFFFFF')
.borderRadius(18)
.margin({ left: 8 })
}
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ left: 16, right: 16, top: 14 })
Row() {
Text('🔍 搜营地 / 找搭子 / 租装备')
.fontSize(12)
.fontColor('#B0CDE0')
.padding({ left: 14, right: 14, top: 8, bottom: 8 })
.backgroundColor('#33FFFFFF')
.borderRadius(18)
.layoutWeight(1)
}
.width('100%')
.padding({ left: 16, right: 16, top: 12 })
}
.width('100%')
.linearGradient({
angle: 160,
colors: [['#01528A', 0], ['#0277BD', 0.6], ['#039BE5', 1]]
})
.borderRadius({ bottomLeft: 24, bottomRight: 24 })
.padding({ bottom: 14 })
}
头部使用 linearGradient 实现了从深湖蓝 #01528A 到主色 #0277BD 再到亮蓝 #039BE5 的三段渐变,角度 160 度,营造出湖水由深到浅的自然过渡感。头部左侧展示应用名称和实时运营数据(观星指数、出发队伍数),右侧设置导航和通知按钮。头部底部嵌入了一个搜索栏,使用半透明白色背景和圆角设计,提示文案「搜营地 / 找搭子 / 租装备」概括了应用三大核心功能。
7.2 营地木牌式导航
@Builder
subNav() {
Row() {
ForEach(['营地发现', '装备库', '搭子圈', '攻略', '观星预报', '跳蚤市集'],
(name: string, idx: number) => {
Column() {
Text(idx === this.currentTab ? '▲' : '△')
.fontSize(9)
.fontColor(idx === this.currentTab ? COLORS.accent : COLORS.textHint)
.opacity(idx === this.currentTab ? 1 : 0.4)
Column() {
Text(idx === 0 ? '🏞️' : (idx === 1 ? '🎒' : (idx === 2 ? '🧑🤝🧑'
: (idx === 3 ? '📖' : (idx === 4 ? '🔭' : '♻️')))))
.fontSize(16)
Text(name)
.fontSize(11)
.fontWeight(idx === this.currentTab ? FontWeight.Bold : FontWeight.Normal)
.fontColor(idx === this.currentTab ? COLORS.white : COLORS.textSecondary)
.margin({ top: 3 })
}
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.backgroundColor(idx === this.currentTab ? COLORS.primary : COLORS.cardBg)
.borderRadius(8)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.onClick(() => { this.currentTab = idx; })
}, (name: string) => 'nav' + name)
}
.width('100%')
.padding({ left: 10, right: 10, top: 10, bottom: 10 })
.backgroundColor(COLORS.bg)
}
子导航采用「单排六栏营地木牌式」布局,灵感来自露营地的木牌指示牌。每个标签上方有一个小三角标记(选中态实心三角 ▲ 使用篝火橙色,未选中态空心三角 △ 使用提示色),下方是图标和文字。选中态通过三角变实、图标上无变化但文字加粗变白、背景变湖蓝色四重差异来强调。六个标签通过 ForEach 动态生成,使用内联的三元运算符链为不同索引映射不同图标。
八、内容页面详解
8.1 营地发现页面
@Builder
pageCamp() {
Column() {
Row() {
Text('🔥 本周热门营地')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('切换城市')
.fontSize(11)
.fontColor(COLORS.primary)
.margin({ left: 'auto' })
}
.width('100%')
.padding({ left: 16, right: 16, top: 12 })
ForEach(this.campList, (c: CampItem) => {
Column() {
Row() {
Text(c.icon)
.fontSize(30)
.padding(16)
.backgroundColor(COLORS.primaryLight)
.borderRadius(16)
Column() {
Text(c.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.maxLines(1)
Row() {
Text('⭐ ' + c.rating.toString())
.fontSize(10)
.fontColor(COLORS.gold)
Text('📍 ' + c.province)
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ left: 8 })
Text(c.terrain)
.fontSize(9)
.fontColor(COLORS.primary)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.primaryLight)
.borderRadius(6)
.margin({ left: 8 })
}
.margin({ top: 4 })
Text('距您 ' + (c.id * 7 + 12).toString() + 'km · 可扎 200 帐')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
Text(c.starred === 1 ? '⭐' : '☆')
.fontSize(22)
.fontColor(c.starred === 1 ? COLORS.gold : COLORS.textHint)
.onClick(() => {
c.starred = c.starred === 1 ? 0 : 1;
this.campList.splice(0, 1, this.campList[0]);
})
}
.width('100%')
Row() {
Text('🗑️ 删除笔记')
.fontSize(10)
.fontColor(COLORS.danger)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor(COLORS.accentLight)
.borderRadius(8)
.onClick(() => {
this.delIndex = c.id - 1;
this.delOpen = true;
})
Text('🔥 有 ' + (c.id * 23 + 8).toString() + ' 个队伍正在约')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 'auto' })
}
.width('100%')
.margin({ top: 10 })
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ top: 10 })
}, (c: CampItem) => 'cp' + c.id.toString() + '_' + c.starred.toString())
营地卡片包含两行信息:第一行是营地图标、名称、评分、位置和地形标签,右侧是收藏按钮(点击切换收藏状态并通过 splice 触发刷新);第二行是删除笔记按钮和该营地关联的队伍数提示。ForEach 的键生成器包含 c.starred,确保收藏状态变化时对应卡片精准刷新。页面底部还嵌入了年度出营记录柱状图,通过 SEASON_LIST 数据和 barHeight 函数计算柱高,7 月柱子使用篝火橙突出夏季高峰。
8.2 装备库与搭子圈页面
装备库页面展示用户的装备列表,每件装备包含图标、名称、分类标签、品牌、重量、成色和日租金。右侧的出租按钮通过点击切换 renting 状态,已出租的装备显示绿色「已挂出」标签,未出租的显示湖蓝色「挂出租」按钮。页面头部右侧的出租统计可点击进入出租装备弹框。
搭子圈页面是结伴队伍信息流,每条队伍卡片展示队伍名称、风格标签、地点、日期和人数比。右侧的加入按钮在未满员时点击递增 got 并标记 joined 为已申请。页面底部嵌入装备大佬榜,展示搭子用户的头像、城市、出营次数、技能徽章和关注按钮。
8.3 观星预报页面
Column() {
Text('今夜观星指数')
.fontSize(12)
.fontColor(COLORS.textSecondary)
Text(seeingStars(5))
.fontSize(34)
.fontColor(COLORS.gold)
.margin({ top: 6 })
Text('银河肉眼可见 · 湿度42% · 月相残月')
.fontSize(11)
.fontColor(COLORS.textPrimary)
.margin({ top: 6 })
Text('推荐营地:海坨山谷 · 乌兰布统 · 翡翠岛')
.fontSize(10)
.fontColor(COLORS.primary)
.margin({ top: 8 })
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor(COLORS.primaryLight)
.borderRadius(10)
}
观星预报页面是本应用的特色功能模块。顶部展示今夜观星指数(5 星满分的金色大字),下方是气象参数和推荐营地。中部展示五日观星预报横向卡片列表,每张卡片包含日期、天气图标、温度、风力、观星指数星级和可见度百分比,底部有彩色指示条(绿色/橙色/红色对应不同观星条件)。底部展示银河季拍摄窗口的详细文字说明,包含银河核心升起时间、最佳拍摄高度和月光干扰情况。
九、弹框组件体系
9.1 发布结伴弹框
@Builder
modalBodyAdd() {
Column() {
Row() {
Text('🏕️ 发布结伴')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('✕')
.fontSize(15)
.fontColor(COLORS.textHint)
.margin({ left: 'auto' })
.onClick(() => { this.addOpen = false; })
}
.width('100%')
Column() {
Text('TRIP INVITATION · 结伴申请单')
.fontSize(9)
.fontColor(COLORS.primary)
.letterSpacing(2)
Text('找到你的野邻居')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.primaryLight)
.borderRadius(12)
.margin({ top: 12 })
发布结伴弹框以「结伴申请单」为视觉形式,顶部展示标题和关闭按钮,下方是浅蓝色卡片头部区域。后续依次展示队伍名称输入框、露营风格选择标签组(休闲露营/轻装徒步/车载露营/徒步重装/风格露营)、装备情况选择标签组(装备齐全/缺帐篷/缺睡袋/有车可拼)、招募人数步进器,底部是取消和发布结伴按钮。
9.2 其他弹框
编辑装备弹框(modalBodyEdit)以「装备信息卡」为形式,提供成色等级选择(全新/95新/9成新/8成新)、日租金步进器和定价参考建议。删除营地笔记弹框(modalBodyDel)以湖蓝警示卡呈现,展示待删除笔记的照片数、轨迹数和获赞数,提供再想想和确认删除操作。出租装备弹框(modalBodyBiz)以「出租单」为形式,提供租期套餐选择(周末2天/小长假3天/长线7天)、出租份数步进器、押金说明和预计收入计算。
四个弹框共享统一的 modalOverlay 遮罩层容器,通过四个布尔状态的或运算决定渲染。遮罩点击关闭所有弹框,弹框内容区设置 constraintSize({ maxHeight: '80%' }) 防止内容溢出。
十、底部导航与主构建函数
@Builder
bottomBar() {
Row() {
Column() {
Text('🏞️').fontSize(20)
Text('营地')
.fontSize(10)
.fontColor(this.bottomTab === 0 ? COLORS.primary : COLORS.textHint)
.margin({ top: 2 })
}
.layoutWeight(1)
.onClick(() => { this.bottomTab = 0; this.currentTab = 0; })
Column() {
Text('🧑🤝🧑').fontSize(20)
Text('搭子')
.fontSize(10)
.fontColor(this.bottomTab === 1 ? COLORS.primary : COLORS.textHint)
.margin({ top: 2 })
}
.layoutWeight(1)
.onClick(() => { this.bottomTab = 1; this.currentTab = 2; })
Column() {
Column() {
Text('➕')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
}
.width(48).height(48)
.justifyContent(FlexAlign.Center)
.backgroundColor(COLORS.accent)
.borderRadius(24)
.offset({ y: -14 })
.onClick(() => { this.addOpen = true; })
}
.layoutWeight(1)
Column() {
Text('📖').fontSize(20)
Text('攻略')
.fontSize(10)
.fontColor(this.bottomTab === 3 ? COLORS.primary : COLORS.textHint)
.margin({ top: 2 })
}
.layoutWeight(1)
.onClick(() => { this.bottomTab = 3; this.currentTab = 3; })
Column() {
Text('🎒').fontSize(20)
Text('我的营地')
.fontSize(10)
.fontColor(this.bottomTab === 4 ? COLORS.primary : COLORS.textHint)
.margin({ top: 2 })
}
.layoutWeight(1)
.onClick(() => { this.bottomTab = 4; this.currentTab = 1; })
}
.width('100%')
.padding({ top: 8, bottom: 8 })
.backgroundColor(COLORS.cardBg)
.borderRadius({ topLeft: 20, topRight: 20 })
}
底部导航采用五栏式布局,中间的发布按钮使用篝火橙背景和 offset({ y: -14 }) 上浮,形成凸出的 FAB。每个 tab 同时设置 bottomTab 和 currentTab,实现底部导航与内容区联动。最终 build 方法通过 Stack 将主内容层、特效层和弹框遮罩层叠加。
十一、组件架构与数据流 Mermaid 图
如上图所示,PageCampMate 主组件通过 @Builder 组织成头部、导航、内容区、特效层、弹框层、底部栏六大模块。数据流从 @State 数组单向向下流动到各内容页面,用户交互通过 splice 触发 @Observed 对象变更并向上回传到 UI 层。特效层由 fxTick 独立驱动,弹框层由四个布尔状态控制,各弹框表单状态独立管理。
十二、数据模型对比表
| 数据模型 | 核心字段 | 应用场景 | 交互行为 | 状态刷新机制 |
|---|---|---|---|---|
| TripItem | title, style, need, got, joined | 结伴队伍信息流 | 申请加入队伍 | splice 触发 ForEach 刷新 |
| GearItem | name, cat, brand, rent, renting | 装备库列表 | 挂出租/收回 | splice 触发出租状态刷新 |
| CampItem | name, province, terrain, starred | 营地发现列表 | 收藏/删除笔记 | splice 触发收藏状态刷新 |
| MateItem | name, badge, trips, followed | 装备大佬榜 | 关注/取消关注 | splice 触发关注状态刷新 |
| GuideItem | title, author, reads, saved | 攻略文章列表 | 收藏/取消收藏 | splice 触发收藏状态刷新 |
| SkyItem | day, temp, wind, seeing, night | 观星预报卡片 | 点击跳转观星页 | 静态数据无需刷新 |
| MarketItem | name, price, condition, wanted | 跳蚤市集列表 | 我想要/取消 | splice 触发想要状态刷新 |
| SeasonItem | month, trips, nights | 年度出营柱状图 | 无交互(展示型) | 静态数据驱动柱高计算 |
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

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

完整代码:
// 露营搭子·户外装备与营地打卡社区「野邻居」(快手app类)
// 风格:湖蓝+篝火橙;内容tab:6个单排营地木牌式(顶部小三角屋顶标记);底部5主tab
// 弹框:发布结伴(结伴卡)/ 编辑装备(装备卡)/ 删除营地笔记(湖蓝警示卡)/ 出租装备(出租单)
// 特效:篝火跳动 + 星星闪烁
interface ColorPalette {
primary: string;
primaryLight: string;
primaryDark: string;
accent: string;
accentLight: string;
bg: string;
cardBg: string;
textPrimary: string;
textSecondary: string;
textHint: string;
border: string;
success: string;
warning: string;
danger: string;
white: string;
gold: string;
}
const COLORS: ColorPalette = {
primary: '#0277BD',
primaryLight: '#E1F5FE',
primaryDark: '#01528A',
accent: '#FF6F00',
accentLight: '#FFF3E0',
bg: '#F2F8FB',
cardBg: '#FFFFFF',
textPrimary: '#12324A',
textSecondary: '#52708A',
textHint: '#A5BECE',
border: '#DDEBF3',
success: '#66BB6A',
warning: '#FFA726',
danger: '#EF5350',
white: '#FFFFFF',
gold: '#FFB300'
};
@Observed
class TripItem {
id: number = 0;
title: string = '';
icon: string = '';
style: string = '';
place: string = '';
date: string = '';
need: number = 0;
got: number = 0;
host: string = '';
joined: number = 0;
constructor(id: number, title: string, icon: string, style: string, place: string, date: string, need: number, got: number, host: string, joined: number) {
this.id = id; this.title = title; this.icon = icon; this.style = style; this.place = place;
this.date = date; this.need = need; this.got = got; this.host = host; this.joined = joined;
}
}
@Observed
class GearItem {
id: number = 0;
name: string = '';
icon: string = '';
cat: string = '';
brand: string = '';
weight: number = 0;
used: number = 0;
rent: number = 0;
price: number = 0;
renting: number = 0;
constructor(id: number, name: string, icon: string, cat: string, brand: string, weight: number, used: number, rent: number, price: number, renting: number) {
this.id = id; this.name = name; this.icon = icon; this.cat = cat; this.brand = brand;
this.weight = weight; this.used = used; this.rent = rent; this.price = price; this.renting = renting;
}
}
@Observed
class CampItem {
id: number = 0;
name: string = '';
icon: string = '';
province: string = '';
terrain: string = '';
rating: number = 0;
meters: number = 0;
starred: number = 0;
constructor(id: number, name: string, icon: string, province: string, terrain: string, rating: number, meters: number, starred: number) {
this.id = id; this.name = name; this.icon = icon; this.province = province; this.terrain = terrain;
this.rating = rating; this.meters = meters; this.starred = starred;
}
}
@Observed
class MateItem {
id: number = 0;
name: string = '';
avatar: string = '';
city: string = '';
badge: string = '';
trips: number = 0;
followed: number = 0;
constructor(id: number, name: string, avatar: string, city: string, badge: string, trips: number, followed: number) {
this.id = id; this.name = name; this.avatar = avatar; this.city = city; this.badge = badge;
this.trips = trips; this.followed = followed;
}
}
@Observed
class GuideItem {
id: number = 0;
title: string = '';
author: string = '';
reads: number = 0;
saves: number = 0;
saved: number = 0;
constructor(id: number, title: string, author: string, reads: number, saves: number, saved: number) {
this.id = id; this.title = title; this.author = author; this.reads = reads; this.saves = saves; this.saved = saved;
}
}
@Observed
class SkyItem {
id: number = 0;
day: string = '';
icon: string = '';
temp: string = '';
wind: string = '';
seeing: number = 0;
night: number = 0;
constructor(id: number, day: string, icon: string, temp: string, wind: string, seeing: number, night: number) {
this.id = id; this.day = day; this.icon = icon; this.temp = temp; this.wind = wind;
this.seeing = seeing; this.night = night;
}
}
@Observed
class MarketItem {
id: number = 0;
name: string = '';
icon: string = '';
price: number = 0;
oldPrice: number = 0;
seller: string = '';
condition: string = '';
wanted: number = 0;
constructor(id: number, name: string, icon: string, price: number, oldPrice: number, seller: string, condition: string, wanted: number) {
this.id = id; this.name = name; this.icon = icon; this.price = price; this.oldPrice = oldPrice;
this.seller = seller; this.condition = condition; this.wanted = wanted;
}
}
@Observed
class SeasonItem {
id: number = 0;
month: string = '';
trips: number = 0;
nights: number = 0;
constructor(id: number, month: string, trips: number, nights: number) {
this.id = id; this.month = month; this.trips = trips; this.nights = nights;
}
}
const TRIP_LIST: TripItem[] = [
new TripItem(1, '周末去白河湾钓鱼露营', '🎣', '休闲露营', '北京·怀柔', '周六出发', 4, 3, '老钓客阿伦', 0),
new TripItem(2, '海坨山看日出云海', '🌄', '徒步重装', '河北·赤城', '两天一夜', 6, 4, '山野向导老周', 0),
new TripItem(3, '乌兰布统草原自驾扎营', '🌾', '车载露营', '内蒙古·赤峰', '五一假期', 5, 5, '自驾狂人七哥', 0),
new TripItem(4, '后河峡谷溯溪野炊', '🏞️', '轻装徒步', '北京·延庆', '周日单日', 8, 6, '溪谷领队小鹿', 0),
new TripItem(5, '翡翠岛沙漠星空营', '🏜️', '风格露营', '河北·昌黎', '周六出发', 4, 2, '星空摄影师老吴', 0),
new TripItem(6, '崇礼桦皮岭高山草甸', '🌿', '徒步重装', '河北·张家口', '两天一夜', 6, 3, '草原牧民巴特', 0),
new TripItem(7, '雁栖湖骑行+湖畔过夜', '🚴', '休闲露营', '北京·怀柔', '周六出发', 4, 4, '骑行侠大鹏', 0),
new TripItem(8, '坝上草原骑马穿越', '🐎', '深度户外', '内蒙古·丰宁', '三天两夜', 6, 4, '马倌其其格', 0),
new TripItem(9, '灵山云雾茶田露营', '🍵', '风格露营', '北京·门头沟', '周日单日', 4, 2, '茶田主理人苏苏', 0),
new TripItem(10, '闪电湖落日烧烤局', '🌅', '休闲露营', '河北·沽源', '周六出发', 8, 7, '烧烤大师老铁', 0),
new TripItem(11, '小五台金莲花季冲顶', '🌸', '徒步重装', '河北·蔚县', '两天一夜', 6, 5, '野花向导阿花', 0),
new TripItem(12, '潮白河畔遛娃亲子营', '🧸', '亲子露营', '天津·宝坻', '周六出发', 5, 3, '超级奶爸强子', 0)
];
const GEAR_LIST: GearItem[] = [
new GearItem(1, '隧道帐篷·三人款', '⛺', '庇护', '牧高笛', 3800, 85, 68, 899, 0),
new GearItem(2, '羽绒睡袋·舒适-10℃', '🛌', '睡眠', '黑冰', 1200, 90, 45, 799, 0),
new GearItem(3, '充气垫·自充厚款', '🧽', '睡眠', 'Therm-a-Rest', 700, 80, 30, 459, 0),
new GearItem(4, '焚火台·折叠不锈钢', '🔥', '厨房', 'Fire Maple', 2100, 95, 35, 329, 0),
new GearItem(5, '卡式炉+烤盘套装', '🍳', '厨房', '岩谷', 1500, 88, 40, 388, 0),
new GearItem(6, '折叠桌椅·四人套', '🪑', '家具', 'KingCamp', 5600, 82, 55, 699, 0),
new GearItem(7, '天幕·六边形遮阳', '⛱️', '庇护', '挪客', 2800, 86, 48, 419, 0),
new GearItem(8, '营地灯·充气太阳能', '💡', '照明', 'MPOWERD', 300, 78, 25, 219, 0),
new GearItem(9, '户外电源·1度电', '🔋', '供电', 'EcoFlow', 9800, 92, 120, 3999, 0),
new GearItem(10, '咖啡手冲套装', '☕', '厨房', 'Hario', 800, 90, 32, 368, 0)
];
const CAMP_LIST: CampItem[] = [
new CampItem(1, '白河湾自然营地', '🏞️', '北京怀柔', '河滩', 4.8, 0, 0),
new CampItem(2, '海坨山谷星空公园', '🌄', '河北赤城', '高山', 4.9, 0, 0),
new CampItem(3, '乌兰布统公主湖营地', '🌾', '内蒙古赤峰', '草原', 4.7, 0, 0),
new CampItem(4, '翡翠岛沙漠营地', '🏜️', '河北昌黎', '沙漠', 4.6, 0, 0),
new CampItem(5, '崇礼桦皮岭草甸营位', '🌿', '河北张家口', '草甸', 4.5, 0, 0),
new CampItem(6, '闪电湖畔野奢营地', '🌅', '河北沽源', '湖畔', 4.8, 0, 0),
new CampItem(7, '小五台西金河口营地', '🌸', '河北蔚县', '山谷', 4.4, 0, 0),
new CampItem(8, '潮白河亲子营地', '🧸', '天津宝坻', '河畔', 4.6, 0, 0),
new CampItem(9, '雁栖湖露营岛', '🚣', '北京怀柔', '湖岛', 4.7, 0, 0),
new CampItem(10, '坝上草原牧云营地', '🐎', '内蒙古丰宁', '草原', 4.9, 0, 0)
];
const MATE_LIST: MateItem[] = [
new MateItem(1, '山野向导老周', '🧭', '北京', '百座山认证', 326, 0),
new MateItem(2, '星空摄影师老吴', '📷', '北京', '银河猎人', 208, 0),
new MateItem(3, '自驾狂人七哥', '🚙', '天津', '十万公里', 289, 0),
new MateItem(4, '溪谷领队小鹿', '🦌', '北京', '溯溪达人', 154, 0),
new MateItem(5, '烧烤大师老铁', '🍢', '河北', '篝火主理', 132, 0),
new MateItem(6, '超级奶爸强子', '👨👧', '天津', '亲子营地通', 98, 0),
new MateItem(7, '茶田主理人苏苏', '🍵', '北京', '风格露营家', 87, 0),
new MateItem(8, '草原牧民巴特', '🐎', '内蒙古', '马背向导', 76, 0),
new MateItem(9, '野花向导阿花', '🌸', '河北', '植物图鉴库', 65, 0),
new MateItem(10, '骑行侠大鹏', '🚴', '北京', '单日三百公里', 112, 0)
];
const GUIDE_LIST: GuideItem[] = [
new GuideItem(1, '新手露营清单:32件必备装备', '山野向导老周', 56000, 8900, 0),
new GuideItem(2, '雨天扎帐篷不漏水的7个细节', '溪谷领队小鹿', 38000, 6200, 0),
new GuideItem(3, '沙漠营地防沙防暑全攻略', '自驾狂人七哥', 45000, 7800, 0),
new GuideItem(4, '拍银河参数:手机也能出片', '星空摄影师老吴', 68000, 12000, 0),
new GuideItem(5, '营地牛排熟成指南', '烧烤大师老铁', 27000, 4100, 0),
new GuideItem(6, '带娃露营安全守则20条', '超级奶爸强子', 33000, 5600, 0),
new GuideItem(7, '高山草甸无痕露营法', '野花向导阿花', 21000, 3300, 0),
new GuideItem(8, '冬季露营睡袋温标怎么选', '山野向导老周', 49000, 8100, 0)
];
const SKY_LIST: SkyItem[] = [
new SkyItem(1, '今晚', '🌙', '12~21℃', '西北风2级', 5, 96),
new SkyItem(2, '明晚', '✨', '13~22℃', '微风', 5, 92),
new SkyItem(3, '周六', '⛅', '15~24℃', '南风3级', 3, 40),
new SkyItem(4, '周日', '🌧️', '11~18℃', '东风4级', 1, 10),
new SkyItem(5, '下周一', '🌤️', '12~20℃', '北风2级', 4, 75)
];
const MARKET_LIST: MarketItem[] = [
new MarketItem(1, '挪客云尚2帐篷', '⛺', 399, 599, '山雀', '95新', 0),
new MarketItem(2, '火枫一体炉头', '🔥', 119, 169, '溪谷小鹿', '99新', 0),
new MarketItem(3, '黑冰G700睡袋', '🛌', 528, 799, '雪线之上', '9成新', 0),
new MarketItem(4, 'EcoFlow快充线', '🔌', 39, 69, '电源党', '全新', 0),
new MarketItem(5, 'KingCamp月亮椅', '🪑', 89, 139, '老周装备库', '95新', 0),
new MarketItem(6, '牧高笛冷山2', '🏕️', 259, 399, '退坑老哥', '9成新', 0),
new MarketItem(7, '乐扣保温箱25L', '🧊', 79, 129, '烧烤老铁', '9成新', 0),
new MarketItem(8, 'snow peak杯', '🍶', 129, 199, '风格玩家', '99新', 0)
];
const SEASON_LIST: SeasonItem[] = [
new SeasonItem(1, '3月', 6, 8),
new SeasonItem(2, '4月', 11, 16),
new SeasonItem(3, '5月', 18, 27),
new SeasonItem(4, '6月', 21, 32),
new SeasonItem(5, '7月', 26, 41),
new SeasonItem(6, '8月', 23, 36)
];
const ADD_STYLES: string[] = ['🏕️ 休闲露营', '🎒 轻装徒步', '🚙 车载露营', '⛺ 徒步重装', '✨ 风格露营'];
const ADD_TOOLS: string[] = ['装备齐全', '缺帐篷', '缺睡袋', '有车可拼'];
const EDIT_CONDS: string[] = ['全新', '95新', '9成新', '8成新'];
const BIZ_DAYS: string[] = ['周末2天', '小长假3天', '长线7天'];
function numText(n: number): string {
if (n >= 10000) {
return (n / 10000).toFixed(1) + 'w';
}
return n.toString();
}
function maxTrips(): number {
let m: number = 0;
for (let i: number = 0; i < SEASON_LIST.length; i++) {
if (SEASON_LIST[i].trips > m) {
m = SEASON_LIST[i].trips;
}
}
return m;
}
function barHeight(v: number): number {
return Math.round(v * 100 / maxTrips());
}
function seeingStars(s: number): string {
let out: string = '';
for (let i: number = 0; i < 5; i++) {
if (i < s) {
out += '★';
} else {
out += '☆';
}
}
return out;
}
@Entry
@Component
struct PageCampMate {
@State currentTab: number = 0;
@State bottomTab: number = 0;
@State tripList: TripItem[] = TRIP_LIST;
@State gearList: GearItem[] = GEAR_LIST;
@State campList: CampItem[] = CAMP_LIST;
@State mateList: MateItem[] = MATE_LIST;
@State guideList: GuideItem[] = GUIDE_LIST;
@State marketList: MarketItem[] = MARKET_LIST;
@State addOpen: boolean = false;
@State editOpen: boolean = false;
@State delOpen: boolean = false;
@State bizOpen: boolean = false;
@State addTitle: string = '';
@State addStyle: number = 0;
@State addTool: number = 0;
@State addNum: number = 4;
@State editCond: number = 2;
@State editRent: number = 45;
@State delIndex: number = 0;
@State bizDay: number = 0;
@State bizRent: number = 3;
@State fxTick: number = 0;
private timer: number = -1;
aboutToAppear(): void {
this.timer = setInterval(() => {
this.fxTick += 1;
}, 120);
}
aboutToDisappear(): void {
if (this.timer >= 0) {
clearInterval(this.timer);
}
}
doAdd(): void {
if (this.addTitle.length > 0) {
this.tripList.unshift(new TripItem(998, this.addTitle, '🏕️', ADD_STYLES[this.addStyle].substring(3), '北京·怀柔', '周六出发', this.addNum, 1, '我', 0));
}
this.addOpen = false;
}
doEdit(): void {
if (this.gearList.length > 0) {
this.gearList[0].rent = this.editRent;
this.gearList[0].used = this.editCond * 10 + 55;
this.gearList.splice(0, 1, this.gearList[0]);
}
this.editOpen = false;
}
doDel(): void {
if (this.delIndex >= 0 && this.delIndex < this.campList.length) {
this.campList.splice(this.delIndex, 1);
}
this.delOpen = false;
}
doBiz(): void {
if (this.gearList.length > 0) {
this.gearList[0].renting = 1;
this.gearList.splice(0, 1, this.gearList[0]);
}
this.bizOpen = false;
}
incNum(): void {
if (this.addNum < 12) {
this.addNum += 1;
}
}
decNum(): void {
if (this.addNum > 2) {
this.addNum -= 1;
}
}
incRent(): void {
if (this.editRent < 300) {
this.editRent += 5;
}
}
decRent(): void {
if (this.editRent > 10) {
this.editRent -= 5;
}
}
@Builder
fxLayer() {
Column() {
Text('🔥')
.fontSize(22)
.scale({ x: this.fxTick % 2 === 0 ? 1.15 : 0.9, y: this.fxTick % 2 === 0 ? 1.1 : 0.92 })
.rotate({ angle: (this.fxTick % 4) * 6 - 9, centerX: '50%', centerY: '90%' })
.position({ x: 30, y: 610 })
Text('✨')
.fontSize(14)
.fontColor(COLORS.gold)
.opacity(this.fxTick % 2 === 0 ? 1 : 0.15)
.position({ x: 90, y: 80 })
Text('⭐')
.fontSize(12)
.opacity(this.fxTick % 2 === 1 ? 1 : 0.15)
.position({ x: 300, y: 110 })
Text('✨')
.fontSize(10)
.fontColor(COLORS.gold)
.opacity(this.fxTick % 3 === 0 ? 1 : 0.2)
.position({ x: 200, y: 60 })
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.None)
.position({ x: 0, y: 0 })
}
@Builder
header() {
Column() {
Row() {
Column() {
Text('🏕️ 野邻居')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('今晚观星指数 5 星 · 本周末 386 个结伴队伍出发')
.fontSize(11)
.fontColor('#B3E5FC')
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
Row() {
Text('🧭')
.fontSize(18)
.padding(8)
.backgroundColor('#33FFFFFF')
.borderRadius(18)
.onClick(() => {
this.bizOpen = true;
})
Text('🔔')
.fontSize(18)
.padding(8)
.backgroundColor('#33FFFFFF')
.borderRadius(18)
.margin({ left: 8 })
}
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ left: 16, right: 16, top: 14 })
Row() {
Text('🔍 搜营地 / 找搭子 / 租装备')
.fontSize(12)
.fontColor('#B0CDE0')
.padding({ left: 14, right: 14, top: 8, bottom: 8 })
.backgroundColor('#33FFFFFF')
.borderRadius(18)
.layoutWeight(1)
}
.width('100%')
.padding({ left: 16, right: 16, top: 12 })
}
.width('100%')
.linearGradient({
angle: 160,
colors: [['#01528A', 0], ['#0277BD', 0.6], ['#039BE5', 1]]
})
.borderRadius({ bottomLeft: 24, bottomRight: 24 })
.padding({ bottom: 14 })
}
@Builder
subNav() {
Row() {
ForEach(['营地发现', '装备库', '搭子圈', '攻略', '观星预报', '跳蚤市集'], (name: string, idx: number) => {
Column() {
Text(idx === this.currentTab ? '▲' : '△')
.fontSize(9)
.fontColor(idx === this.currentTab ? COLORS.accent : COLORS.textHint)
.opacity(idx === this.currentTab ? 1 : 0.4)
Column() {
Text(idx === 0 ? '🏞️' : (idx === 1 ? '🎒' : (idx === 2 ? '🧑🤝🧑' : (idx === 3 ? '📖' : (idx === 4 ? '🔭' : '♻️')))))
.fontSize(16)
Text(name)
.fontSize(11)
.fontWeight(idx === this.currentTab ? FontWeight.Bold : FontWeight.Normal)
.fontColor(idx === this.currentTab ? COLORS.white : COLORS.textSecondary)
.margin({ top: 3 })
}
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.backgroundColor(idx === this.currentTab ? COLORS.primary : COLORS.cardBg)
.borderRadius(8)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.onClick(() => {
this.currentTab = idx;
})
}, (name: string) => 'nav' + name)
}
.width('100%')
.padding({ left: 10, right: 10, top: 10, bottom: 10 })
.backgroundColor(COLORS.bg)
}
@Builder
pageCamp() {
Column() {
Row() {
Text('🔥 本周热门营地')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('切换城市')
.fontSize(11)
.fontColor(COLORS.primary)
.margin({ left: 'auto' })
}
.width('100%')
.padding({ left: 16, right: 16, top: 12 })
ForEach(this.campList, (c: CampItem) => {
Column() {
Row() {
Text(c.icon)
.fontSize(30)
.padding(16)
.backgroundColor(COLORS.primaryLight)
.borderRadius(16)
Column() {
Text(c.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.maxLines(1)
Row() {
Text('⭐ ' + c.rating.toString())
.fontSize(10)
.fontColor(COLORS.gold)
Text('📍 ' + c.province)
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ left: 8 })
Text(c.terrain)
.fontSize(9)
.fontColor(COLORS.primary)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.primaryLight)
.borderRadius(6)
.margin({ left: 8 })
}
.margin({ top: 4 })
Text('距您 ' + (c.id * 7 + 12).toString() + 'km · 可扎 200 帐')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
Text(c.starred === 1 ? '⭐' : '☆')
.fontSize(22)
.fontColor(c.starred === 1 ? COLORS.gold : COLORS.textHint)
.onClick(() => {
c.starred = c.starred === 1 ? 0 : 1;
this.campList.splice(0, 1, this.campList[0]);
})
}
.width('100%')
Row() {
Text('🗑️ 删除笔记')
.fontSize(10)
.fontColor(COLORS.danger)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor(COLORS.accentLight)
.borderRadius(8)
.onClick(() => {
this.delIndex = c.id - 1;
this.delOpen = true;
})
Text('🔥 有 ' + (c.id * 23 + 8).toString() + ' 个队伍正在约')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 'auto' })
}
.width('100%')
.margin({ top: 10 })
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ top: 10 })
}, (c: CampItem) => 'cp' + c.id.toString() + '_' + c.starred.toString())
Column() {
Text('📊 我的年度出营记录')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Row() {
ForEach(SEASON_LIST, (s: SeasonItem) => {
Column() {
Text(s.trips.toString() + '次')
.fontSize(8)
.fontColor(COLORS.primary)
Column()
.width(18)
.height(barHeight(s.trips))
.backgroundColor(s.month === '7月' ? COLORS.accent : COLORS.primary)
.borderRadius(4)
.margin({ top: 3 })
Text(s.month)
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}, (s: SeasonItem) => 'sn' + s.id.toString())
}
.width('100%')
.margin({ top: 10 })
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ top: 14 })
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding({ left: 12, right: 12, bottom: 16 })
}
@Builder
pageGear() {
Column() {
Row() {
Text('🎒 装备库')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('共 10 件 · 出租中 2')
.fontSize(10)
.fontColor(COLORS.textHint)
.margin({ left: 'auto' })
.onClick(() => {
this.bizOpen = true;
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 12 })
ForEach(this.gearList, (g: GearItem) => {
Column() {
Row() {
Text(g.icon)
.fontSize(26)
.padding(12)
.backgroundColor(COLORS.primaryLight)
.borderRadius(12)
Column() {
Text(g.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.maxLines(1)
Row() {
Text(g.cat)
.fontSize(9)
.fontColor(COLORS.primary)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.primaryLight)
.borderRadius(6)
Text(g.brand)
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ left: 6 })
Text(g.renting === 1 ? '· 出租中' : '· 自用')
.fontSize(9)
.fontColor(g.renting === 1 ? COLORS.accent : COLORS.textHint)
.margin({ left: 6 })
}
.margin({ top: 4 })
Text('重量 ' + (g.weight / 1000).toFixed(1) + 'kg · 成色 ' + g.used.toString() + '%')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
Column() {
Text('¥' + g.rent.toString())
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
Text('/天出租')
.fontSize(8)
.fontColor(COLORS.textHint)
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
Row() {
Text('估值 ¥' + g.price.toString())
.fontSize(9)
.fontColor(COLORS.textSecondary)
Text(g.renting === 1 ? '✅ 已挂出' : '挂出租')
.fontSize(10)
.fontColor(g.renting === 1 ? COLORS.success : COLORS.white)
.padding({ left: 12, right: 12, top: 4, bottom: 4 })
.backgroundColor(g.renting === 1 ? '#E8F5E9' : COLORS.primary)
.borderRadius(10)
.margin({ left: 'auto' })
.onClick(() => {
g.renting = g.renting === 1 ? 0 : 1;
this.gearList.splice(0, 1, this.gearList[0]);
})
}
.width('100%')
.margin({ top: 10 })
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ top: 10 })
}, (g: GearItem) => 'gr' + g.id.toString() + '_' + g.renting.toString())
}
.width('100%')
.padding({ left: 12, right: 12, bottom: 16 })
}
@Builder
pageMate() {
Column() {
Row() {
Text('🧑🤝🧑 结伴队伍')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('实名认证率 98%')
.fontSize(10)
.fontColor(COLORS.success)
.margin({ left: 'auto' })
}
.width('100%')
.padding({ left: 16, right: 16, top: 12 })
ForEach(this.tripList, (t: TripItem) => {
Column() {
Row() {
Text(t.icon)
.fontSize(24)
.padding(11)
.backgroundColor(COLORS.accentLight)
.borderRadius(12)
Column() {
Text(t.title)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.maxLines(1)
Row() {
Text(t.style)
.fontSize(9)
.fontColor(COLORS.accent)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.accentLight)
.borderRadius(6)
Text('📍 ' + t.place + ' · ' + t.date)
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 6 })
}
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
Column() {
Text(t.got.toString() + '/' + t.need.toString())
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(t.need - t.got === 0 ? COLORS.success : COLORS.primary)
Text(t.need - t.got === 0 ? '已满员' : '还缺' + (t.need - t.got).toString() + '人')
.fontSize(8)
.fontColor(t.need - t.got === 0 ? COLORS.success : COLORS.textHint)
.margin({ top: 2 })
}
}
.width('100%')
Row() {
Text('👨 队长 ' + t.host)
.fontSize(9)
.fontColor(COLORS.textSecondary)
Text(t.joined === 1 ? '✅ 已申请' : '申请加入')
.fontSize(10)
.fontColor(t.joined === 1 ? COLORS.success : COLORS.white)
.padding({ left: 12, right: 12, top: 4, bottom: 4 })
.backgroundColor(t.joined === 1 ? '#E8F5E9' : COLORS.accent)
.borderRadius(10)
.margin({ left: 'auto' })
.onClick(() => {
if (t.got < t.need) {
t.got += 1;
t.joined = 1;
this.tripList.splice(0, 1, this.tripList[0]);
}
})
}
.width('100%')
.margin({ top: 10 })
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ top: 10 })
}, (t: TripItem) => 'tp' + t.id.toString() + '_' + t.got.toString() + '_' + t.joined.toString())
Column() {
Text('🏅 装备大佬榜')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
ForEach(this.mateList, (m: MateItem) => {
Row() {
Text(m.avatar)
.fontSize(20)
.padding(5)
.backgroundColor(COLORS.primaryLight)
.borderRadius(16)
Column() {
Text(m.name)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('📍 ' + m.city + ' · ' + m.trips.toString() + ' 次出营')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 8 })
Text(m.badge)
.fontSize(8)
.fontColor(COLORS.gold)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor('#FFF8E1')
.borderRadius(6)
Text(m.followed === 1 ? '✓' : '+')
.fontSize(13)
.fontColor(m.followed === 1 ? COLORS.success : COLORS.white)
.padding({ left: 10, right: 10, top: 3, bottom: 3 })
.backgroundColor(m.followed === 1 ? '#E8F5E9' : COLORS.primary)
.borderRadius(12)
.margin({ left: 8 })
.onClick(() => {
m.followed = m.followed === 1 ? 0 : 1;
this.mateList.splice(0, 1, this.mateList[0]);
})
}
.width('100%')
.margin({ top: 8 })
}, (m: MateItem) => 'mt' + m.id.toString() + '_' + m.followed.toString())
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.margin({ top: 14 })
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding({ left: 12, right: 12, bottom: 16 })
}
@Builder
pageGuide() {
Column() {
Row() {
Text('📖 露营干货攻略')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('本周更新 32 篇')
.fontSize(10)
.fontColor(COLORS.textHint)
.margin({ left: 'auto' })
}
.width('100%')
.padding({ left: 16, right: 16, top: 12 })
ForEach(this.guideList, (g: GuideItem) => {
Row() {
Column() {
Text('TOP' + g.id.toString())
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(g.id <= 3 ? COLORS.accent : COLORS.textHint)
Text('GUIDE')
.fontSize(7)
.fontColor(COLORS.textHint)
.letterSpacing(1)
.margin({ top: 2 })
}
.width(48)
.height(48)
.justifyContent(FlexAlign.Center)
.backgroundColor(g.id <= 3 ? COLORS.accentLight : COLORS.bg)
.borderRadius(10)
Column() {
Text(g.title)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.maxLines(2)
Row() {
Text('✍️ ' + g.author)
.fontSize(9)
.fontColor(COLORS.textSecondary)
Text('👁 ' + numText(g.reads))
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 10 })
}
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
Column() {
Text(g.saved === 1 ? '⭐' : '☆')
.fontSize(20)
.fontColor(g.saved === 1 ? COLORS.gold : COLORS.textHint)
.onClick(() => {
g.saved = g.saved === 1 ? 0 : 1;
this.guideList.splice(0, 1, this.guideList[0]);
})
Text(numText(g.saves))
.fontSize(8)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.margin({ top: 8 })
}, (g: GuideItem) => 'gd' + g.id.toString() + '_' + g.saved.toString())
}
.width('100%')
.padding({ left: 12, right: 12, bottom: 16 })
}
@Builder
pageSky() {
Column() {
Row() {
Text('🔭 观星预报')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('数据来自天文台')
.fontSize(10)
.fontColor(COLORS.textHint)
.margin({ left: 'auto' })
}
.width('100%')
.padding({ left: 16, right: 16, top: 12 })
Column() {
Text('今夜观星指数')
.fontSize(12)
.fontColor(COLORS.textSecondary)
Text(seeingStars(5))
.fontSize(34)
.fontColor(COLORS.gold)
.margin({ top: 6 })
Text('银河肉眼可见 · 湿度42% · 月相残月')
.fontSize(11)
.fontColor(COLORS.textPrimary)
.margin({ top: 6 })
Text('推荐营地:海坨山谷 · 乌兰布统 · 翡翠岛')
.fontSize(10)
.fontColor(COLORS.primary)
.margin({ top: 8 })
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor(COLORS.primaryLight)
.borderRadius(10)
}
.width('100%')
.padding(18)
.alignItems(HorizontalAlign.Center)
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.margin({ top: 12 })
Row() {
ForEach(SKY_LIST, (s: SkyItem) => {
Column() {
Text(s.icon)
.fontSize(22)
Text(s.day)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ top: 4 })
Text(s.temp)
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 2 })
Text(s.wind)
.fontSize(8)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
Text(seeingStars(s.seeing))
.fontSize(9)
.fontColor(s.seeing >= 4 ? COLORS.gold : COLORS.textHint)
.margin({ top: 4 })
Column()
.width(30)
.height(4)
.borderRadius(2)
.backgroundColor(s.seeing >= 4 ? COLORS.success : (s.seeing >= 3 ? COLORS.warning : COLORS.danger))
.margin({ top: 5 })
Text('可见度 ' + s.night.toString() + '%')
.fontSize(8)
.fontColor(COLORS.textHint)
.margin({ top: 4 })
}
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.margin({ left: 4, right: 4 })
.onClick(() => {
this.currentTab = 4;
})
}, (s: SkyItem) => 'sk' + s.id.toString())
}
.width('100%')
.margin({ top: 12 })
Column() {
Text('🌌 银河季拍摄窗口')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('当前银河季最佳拍摄期为 4 月至 9 月,今晚 22:40 银河核心从东南方升起,凌晨 2 点到达最佳高度,无月光干扰,是拍摄银河拱桥的好时机。')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 6 })
.lineHeight(18)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.margin({ top: 14 })
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding({ left: 12, right: 12, bottom: 16 })
}
@Builder
pageMarket() {
Column() {
Row() {
Text('♻️ 装备跳蚤市集')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('闲置换钱')
.fontSize(10)
.fontColor(COLORS.accent)
.margin({ left: 'auto' })
.onClick(() => {
this.editOpen = true;
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 12 })
Row() {
ForEach(this.marketList.slice(0, 4), (m: MarketItem) => {
Column() {
Text(m.icon)
.fontSize(34)
.padding({ left: 24, right: 24, top: 16, bottom: 16 })
.backgroundColor(COLORS.bg)
.borderRadius(12)
Text(m.name)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.maxLines(1)
.margin({ top: 6 })
Text(m.condition)
.fontSize(9)
.fontColor(COLORS.primary)
.margin({ top: 2 })
Row() {
Text('¥' + m.price.toString())
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
Text('¥' + m.oldPrice.toString())
.fontSize(9)
.fontColor(COLORS.textHint)
.decoration({ type: TextDecorationType.LineThrough })
.margin({ left: 5 })
}
.margin({ top: 4 })
Text(m.wanted === 1 ? '✅ 已想要' : '我想要')
.fontSize(9)
.fontColor(m.wanted === 1 ? COLORS.success : COLORS.white)
.padding({ left: 10, right: 10, top: 3, bottom: 3 })
.backgroundColor(m.wanted === 1 ? '#E8F5E9' : COLORS.primary)
.borderRadius(8)
.margin({ top: 5 })
.onClick(() => {
m.wanted = m.wanted === 1 ? 0 : 1;
this.marketList.splice(0, 1, this.marketList[0]);
})
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.margin({ left: 3, right: 3 })
}, (m: MarketItem) => 'mk1' + m.id.toString() + '_' + m.wanted.toString())
}
.width('100%')
.margin({ top: 10 })
ForEach(this.marketList.slice(4), (m: MarketItem) => {
Row() {
Text(m.icon)
.fontSize(24)
.padding(11)
.backgroundColor(COLORS.bg)
.borderRadius(12)
Column() {
Text(m.name)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.maxLines(1)
Text('卖家:' + m.seller + ' · ' + m.condition)
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
Column() {
Text('¥' + m.price.toString())
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
Text(m.wanted === 1 ? '✅ 已想要' : '想要')
.fontSize(9)
.fontColor(m.wanted === 1 ? COLORS.success : COLORS.primary)
.padding({ left: 10, right: 10, top: 3, bottom: 3 })
.backgroundColor(m.wanted === 1 ? '#E8F5E9' : COLORS.primaryLight)
.borderRadius(8)
.margin({ top: 4 })
.onClick(() => {
m.wanted = m.wanted === 1 ? 0 : 1;
this.marketList.splice(0, 1, this.marketList[0]);
})
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.margin({ top: 8 })
}, (m: MarketItem) => 'mk2' + m.id.toString() + '_' + m.wanted.toString())
}
.width('100%')
.padding({ left: 12, right: 12, bottom: 16 })
}
@Builder
modalBodyAdd() {
Column() {
Row() {
Text('🏕️ 发布结伴')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('✕')
.fontSize(15)
.fontColor(COLORS.textHint)
.margin({ left: 'auto' })
.onClick(() => {
this.addOpen = false;
})
}
.width('100%')
Column() {
Text('TRIP INVITATION · 结伴申请单')
.fontSize(9)
.fontColor(COLORS.primary)
.letterSpacing(2)
Text('找到你的野邻居')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.primaryLight)
.borderRadius(12)
.margin({ top: 12 })
Column() {
Text('队伍名称')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
TextInput({ placeholder: '如:周末去白河湾钓鱼露营' })
.fontSize(12)
.placeholderFont({ size: 11 })
.placeholderColor(COLORS.textHint)
.padding(10)
.backgroundColor(COLORS.bg)
.borderRadius(10)
.margin({ top: 6 })
.onChange((v: string) => {
this.addTitle = v;
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.margin({ top: 14 })
Column() {
Text('露营风格')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Row() {
ForEach(ADD_STYLES, (s: string, i: number) => {
Text(s)
.fontSize(11)
.fontColor(this.addStyle === i ? COLORS.white : COLORS.textSecondary)
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.backgroundColor(this.addStyle === i ? COLORS.primary : COLORS.bg)
.borderRadius(10)
.margin({ right: 6, bottom: 6 })
.onClick(() => {
this.addStyle = i;
})
}, (s: string) => 'as' + s)
}
.margin({ top: 6 })
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.margin({ top: 14 })
Column() {
Text('装备情况')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Row() {
ForEach(ADD_TOOLS, (t: string, i: number) => {
Text(t)
.fontSize(11)
.fontColor(this.addTool === i ? COLORS.white : COLORS.textSecondary)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(this.addTool === i ? COLORS.accent : COLORS.bg)
.borderRadius(10)
.margin({ right: 6 })
.onClick(() => {
this.addTool = i;
})
}, (t: string) => 'at' + t)
}
.margin({ top: 6 })
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.margin({ top: 14 })
Column() {
Text('招募人数')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Row() {
Text('-')
.fontSize(16)
.fontColor(COLORS.textSecondary)
.padding({ left: 16, right: 16, top: 6, bottom: 6 })
.backgroundColor(COLORS.bg)
.borderRadius(10)
.onClick(() => {
this.decNum();
})
Text(this.addNum.toString() + ' 人')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
.padding({ left: 20, right: 20 })
Text('+')
.fontSize(16)
.fontColor(COLORS.textSecondary)
.padding({ left: 16, right: 16, top: 6, bottom: 6 })
.backgroundColor(COLORS.bg)
.borderRadius(10)
.onClick(() => {
this.incNum();
})
}
.margin({ top: 8 })
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.margin({ top: 14 })
Row() {
Text('取消')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.padding({ left: 18, right: 18, top: 10, bottom: 10 })
.backgroundColor(COLORS.bg)
.borderRadius(20)
.onClick(() => {
this.addOpen = false;
})
Text('发布结伴')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 22, right: 22, top: 10, bottom: 10 })
.backgroundColor(COLORS.primary)
.borderRadius(20)
.margin({ left: 12 })
.onClick(() => {
this.doAdd();
})
}
.width('100%')
.justifyContent(FlexAlign.End)
.margin({ top: 18 })
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.cardBg)
.borderRadius(20)
.constraintSize({ maxHeight: '80%' })
}
@Builder
modalBodyEdit() {
Column() {
Row() {
Text('🛠️ 编辑装备')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('✕')
.fontSize(15)
.fontColor(COLORS.textHint)
.margin({ left: 'auto' })
.onClick(() => {
this.editOpen = false;
})
}
.width('100%')
Column() {
Text('GEAR CARD · 装备信息卡')
.fontSize(9)
.fontColor(COLORS.primary)
.letterSpacing(2)
Text('⛺ 隧道帐篷·三人款')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ top: 4 })
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.primaryLight)
.borderRadius(12)
.margin({ top: 12 })
Column() {
Text('成色等级')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Row() {
ForEach(EDIT_CONDS, (c: string, i: number) => {
Text(c)
.fontSize(11)
.fontColor(this.editCond === i ? COLORS.white : COLORS.textSecondary)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.backgroundColor(this.editCond === i ? COLORS.primary : COLORS.bg)
.borderRadius(10)
.margin({ right: 8 })
.onClick(() => {
this.editCond = i;
})
}, (c: string) => 'ec' + c)
}
.margin({ top: 8 })
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.margin({ top: 14 })
Column() {
Text('日租金(元/天)')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Row() {
Text('-')
.fontSize(16)
.fontColor(COLORS.textSecondary)
.padding({ left: 16, right: 16, top: 6, bottom: 6 })
.backgroundColor(COLORS.bg)
.borderRadius(10)
.onClick(() => {
this.decRent();
})
Text('¥' + this.editRent.toString())
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
.padding({ left: 20, right: 20 })
Text('+')
.fontSize(16)
.fontColor(COLORS.textSecondary)
.padding({ left: 16, right: 16, top: 6, bottom: 6 })
.backgroundColor(COLORS.bg)
.borderRadius(10)
.onClick(() => {
this.incRent();
})
}
.margin({ top: 8 })
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.margin({ top: 14 })
Column() {
Text('💡 定价参考')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('同类帐篷平均日租 ¥62,最高 ¥75。建议新装备定价不超过估值 8%/天,旺季可上浮 20%。')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 6 })
.lineHeight(16)
}
.width('100%')
.padding(12)
.backgroundColor('#FFF8E1')
.borderRadius(12)
.margin({ top: 14 })
.alignItems(HorizontalAlign.Start)
Row() {
Text('取消')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.padding({ left: 18, right: 18, top: 10, bottom: 10 })
.backgroundColor(COLORS.bg)
.borderRadius(20)
.onClick(() => {
this.editOpen = false;
})
Text('保存装备')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 22, right: 22, top: 10, bottom: 10 })
.backgroundColor(COLORS.primary)
.borderRadius(20)
.margin({ left: 12 })
.onClick(() => {
this.doEdit();
})
}
.width('100%')
.justifyContent(FlexAlign.End)
.margin({ top: 18 })
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.cardBg)
.borderRadius(20)
.constraintSize({ maxHeight: '80%' })
}
@Builder
modalBodyDel() {
Column() {
Text('🗑️ 删除营地笔记')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.danger)
Column() {
Text('⚠️ NOTE DELETE · 笔记删除确认')
.fontSize(9)
.fontColor(COLORS.danger)
.letterSpacing(2)
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.accentLight)
.borderRadius(10)
.margin({ top: 14 })
Text('即将删除「白河湾自然营地」的打卡笔记,包含 23 张照片和 1 条轨迹记录。删除后相册和攻略中的引用将一并移除,此操作不可恢复。')
.fontSize(12)
.fontColor(COLORS.textSecondary)
.margin({ top: 14 })
.lineHeight(20)
Row() {
Column() {
Text('23')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('照片')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.bg)
.borderRadius(10)
Column() {
Text('1')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('轨迹')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.bg)
.borderRadius(10)
.margin({ left: 8 })
Column() {
Text('386')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
Text('获赞')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.bg)
.borderRadius(10)
.margin({ left: 8 })
}
.width('100%')
.margin({ top: 14 })
Row() {
Text('再想想')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.padding({ left: 18, right: 18, top: 10, bottom: 10 })
.backgroundColor(COLORS.bg)
.borderRadius(20)
.onClick(() => {
this.delOpen = false;
})
Text('确认删除')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 22, right: 22, top: 10, bottom: 10 })
.backgroundColor(COLORS.danger)
.borderRadius(20)
.margin({ left: 12 })
.onClick(() => {
this.doDel();
})
}
.width('100%')
.justifyContent(FlexAlign.End)
.margin({ top: 18 })
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.cardBg)
.borderRadius(20)
.constraintSize({ maxHeight: '80%' })
}
@Builder
modalBodyBiz() {
Column() {
Row() {
Text('📤 出租装备')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('✕')
.fontSize(15)
.fontColor(COLORS.textHint)
.margin({ left: 'auto' })
.onClick(() => {
this.bizOpen = false;
})
}
.width('100%')
Column() {
Text('RENTAL SLIP · 出租单')
.fontSize(9)
.fontColor(COLORS.primary)
.letterSpacing(2)
Text('⛺ 隧道帐篷·三人款 · 95新')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ top: 4 })
Text('已完成 38 次出租 · 租客好评率 99%')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.primaryLight)
.borderRadius(12)
.margin({ top: 12 })
Column() {
Text('租期套餐')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Row() {
ForEach(BIZ_DAYS, (d: string, i: number) => {
Text(d)
.fontSize(11)
.fontColor(this.bizDay === i ? COLORS.white : COLORS.textSecondary)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(this.bizDay === i ? COLORS.accent : COLORS.bg)
.borderRadius(10)
.margin({ right: 8 })
.onClick(() => {
this.bizDay = i;
})
}, (d: string) => 'bd' + d)
}
.margin({ top: 8 })
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.margin({ top: 14 })
Column() {
Text('出租份数(件)')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Row() {
Text('-')
.fontSize(16)
.fontColor(COLORS.textSecondary)
.padding({ left: 16, right: 16, top: 6, bottom: 6 })
.backgroundColor(COLORS.bg)
.borderRadius(10)
.onClick(() => {
if (this.bizRent > 1) {
this.bizRent -= 1;
}
})
Text(this.bizRent.toString() + ' 件')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
.padding({ left: 20, right: 20 })
Text('+')
.fontSize(16)
.fontColor(COLORS.textSecondary)
.padding({ left: 16, right: 16, top: 6, bottom: 6 })
.backgroundColor(COLORS.bg)
.borderRadius(10)
.onClick(() => {
if (this.bizRent < 10) {
this.bizRent += 1;
}
})
}
.margin({ top: 8 })
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.margin({ top: 14 })
Row() {
Text('押金 ¥300(归还后退回)')
.fontSize(10)
.fontColor(COLORS.textSecondary)
Text('预计收入 ¥' + ((this.bizDay + 1) * 2 * 68 * this.bizRent).toString())
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
.margin({ left: 'auto' })
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.accentLight)
.borderRadius(10)
.margin({ top: 14 })
Row() {
Text('取消')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.padding({ left: 18, right: 18, top: 10, bottom: 10 })
.backgroundColor(COLORS.bg)
.borderRadius(20)
.onClick(() => {
this.bizOpen = false;
})
Text('挂出租')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 22, right: 22, top: 10, bottom: 10 })
.backgroundColor(COLORS.primary)
.borderRadius(20)
.margin({ left: 12 })
.onClick(() => {
this.doBiz();
})
}
.width('100%')
.justifyContent(FlexAlign.End)
.margin({ top: 18 })
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.cardBg)
.borderRadius(20)
.constraintSize({ maxHeight: '80%' })
}
@Builder
modalOverlay() {
Stack({ alignContent: Alignment.Bottom }) {
Column()
.width('100%')
.height('100%')
.backgroundColor('#66000000')
.onClick(() => {
this.addOpen = false;
this.editOpen = false;
this.delOpen = false;
this.bizOpen = false;
})
Column() {
if (this.addOpen) {
this.modalBodyAdd()
}
if (this.editOpen) {
this.modalBodyEdit()
}
if (this.delOpen) {
this.modalBodyDel()
}
if (this.bizOpen) {
this.modalBodyBiz()
}
}
.width('92%')
.margin({ bottom: 20 })
}
.width('100%')
.height('100%')
}
@Builder
bottomBar() {
Row() {
Column() {
Text('🏞️')
.fontSize(20)
Text('营地')
.fontSize(10)
.fontColor(this.bottomTab === 0 ? COLORS.primary : COLORS.textHint)
.margin({ top: 2 })
}
.layoutWeight(1)
.onClick(() => {
this.bottomTab = 0;
this.currentTab = 0;
})
Column() {
Text('🧑🤝🧑')
.fontSize(20)
Text('搭子')
.fontSize(10)
.fontColor(this.bottomTab === 1 ? COLORS.primary : COLORS.textHint)
.margin({ top: 2 })
}
.layoutWeight(1)
.onClick(() => {
this.bottomTab = 1;
this.currentTab = 2;
})
Column() {
Column() {
Text('➕')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
}
.width(48)
.height(48)
.justifyContent(FlexAlign.Center)
.backgroundColor(COLORS.accent)
.borderRadius(24)
.offset({ y: -14 })
.onClick(() => {
this.addOpen = true;
})
}
.layoutWeight(1)
Column() {
Text('📖')
.fontSize(20)
Text('攻略')
.fontSize(10)
.fontColor(this.bottomTab === 3 ? COLORS.primary : COLORS.textHint)
.margin({ top: 2 })
}
.layoutWeight(1)
.onClick(() => {
this.bottomTab = 3;
this.currentTab = 3;
})
Column() {
Text('🎒')
.fontSize(20)
Text('我的营地')
.fontSize(10)
.fontColor(this.bottomTab === 4 ? COLORS.primary : COLORS.textHint)
.margin({ top: 2 })
}
.layoutWeight(1)
.onClick(() => {
this.bottomTab = 4;
this.currentTab = 1;
})
}
.width('100%')
.padding({ top: 8, bottom: 8 })
.backgroundColor(COLORS.cardBg)
.borderRadius({ topLeft: 20, topRight: 20 })
}
@Builder
mainContent() {
Column() {
this.header()
this.subNav()
Scroll() {
Column() {
if (this.currentTab === 0) {
this.pageCamp()
}
if (this.currentTab === 1) {
this.pageGear()
}
if (this.currentTab === 2) {
this.pageMate()
}
if (this.currentTab === 3) {
this.pageGuide()
}
if (this.currentTab === 4) {
this.pageSky()
}
if (this.currentTab === 5) {
this.pageMarket()
}
}
.width('100%')
}
.layoutWeight(1)
.scrollBar(BarState.Off)
}
.width('100%')
.height('100%')
}
build() {
Stack() {
this.mainContent()
this.fxLayer()
if (this.addOpen || this.editOpen || this.delOpen || this.bizOpen) {
this.modalOverlay()
}
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
}
}

十三、总结
本文全面解析了基于 HarmonyOS API 24 和 ArkTS 构建露营搭子社区「野邻居」的完整实现方案。从湖蓝篝火橙的双色体系定义,到 8 个 @Observed 数据模型类的精细化设计,再到 6 个内容页面和 4 个弹框组件的逐行代码分析,覆盖了声明式 UI 开发的全部核心环节。湖蓝与篝火橙的色彩搭配不仅仅是视觉层面的审美选择,更是对露营场景中「湖水天空」与「营火温暖」两种核心意象的视觉提炼,在渐变头部和类型标签中形成了冷色与暖色的和谐对话。
特效动画层面,篝火跳动的实现展现了 ArkUI 样式属性的精细控制能力。scale 的 X/Y 轴非对称变化模拟了火焰的物理形变,rotate 以底部为旋转中心实现摆动,三颗星星通过不同周期的取模运算产生异步闪烁。这套方案仅依赖一个 setInterval 和几个数学表达式,在 HarmonyOS 6.1.1 的渲染引擎下以极低开销实现了持续的视觉动态。hitTestBehavior(HitTestMode.None) 保证了特效层与用户交互的完美隔离,使得动画运行期间用户仍能流畅操作页面。
状态管理方面,@State + @Observed + splice(0, 1, item) 的组合模式在多个交互场景中反复验证了其可靠性。无论是营地收藏、装备出租、队伍加入、搭子关注、攻略收藏还是市集想要,都通过同一套模式实现了精准的局部刷新。每个 ForEach 的键生成器都包含了可能变化的属性值,这是触发精准 DOM 更新的关键。这种统一的刷新策略大幅降低了代码的复杂度,使得开发者无需为不同的交互场景编写不同的刷新逻辑。
观星预报作为本应用的特色功能模块,展示了垂直社区应用如何通过专业化内容建立竞争壁垒。观星指数、大气视宁度、银河季拍摄窗口等专业参数的展示,不仅满足了硬核露营用户的信息需求,更通过推荐营地与观星条件的关联推荐,实现了内容到交易的闭环转化。这种「专业内容驱动社区活跃、社区活跃驱动装备租赁和营地发现」的商业模型,是垂直社区应用可持续运营的关键。
更多推荐


所有评论(0)