一、HarmonyOS 技术生态与 ArkTS 声明式开发范式

HarmonyOS 6.1.1 代表了华为在全场景智慧生活战略下的操作系统最新成果,其前端开发框架 ArkUI 在 HarmonyOS ArkTS API 24 的基础上提供了完整的声明式 UI 编程能力。与传统的命令式编程不同,声明式范式要求开发者描述「界面应该是什么样子」而非「如何一步步构建界面」,框架负责在数据变化时自动计算差异并更新视图。这种模式大幅减少了模板代码量,使开发者能够将注意力集中在业务逻辑和用户体验上。ArkTS 作为 TypeScript 的增强版本,在保留类型推断和接口约束等语言级优势的同时,通过 AOT 编译将声明式 UI 描述转化为高效的原生渲染指令。

在 HarmonyOS 6.1.1 的设备能力矩阵中,ArkTS 编译产物可以直接运行于手机、平板、智慧屏等多种终端设备,实现「一次开发、多端部署」。对于社区类应用而言,这意味着开发者只需维护一套代码库,即可覆盖用户在不同场景下的使用需求。ArkUI 框架内置的 linearGradientborderRadiuspositionrotatescale 等样式属性,配合 @Builder 装饰器定义的可复用 UI 片段,能够轻松构建出视觉效果丰富、交互流畅的移动端界面。

露营搭子社区「野邻居」正是基于上述技术栈构建的一款垂直户外社交应用。随着精致露营(Glamping)文化在国内年轻群体中的流行,户外露营已从单一的活动形式演变为一种生活方式和社交场景。用户的核心需求包括:寻找志同道合的露营搭子、管理个人装备库、发现优质营地、学习露营干货攻略、规划观星行程以及闲置装备交易。本应用以「快手」短视频平台的沉浸式信息流为参照,将营地发现、装备库、搭子圈、攻略、观星预报、跳蚤市集六大功能模块有机融合,通过湖蓝与篝火橙的双色体系营造出自然户外的清新氛围。

在数据驱动层面,本应用严格遵循 ArkUI 的响应式状态管理理念。所有可变数据通过 @State 装饰器声明为组件内部状态,当数据源发生变化时框架自动触发依赖追踪和差异化渲染。对于数组内对象属性级别的变更,使用 @Observed 装饰器配合 splice 操作触发 ForEach 列表的精准刷新。这种细粒度的更新机制确保了在频繁的用户交互(如申请加入队伍、关注搭子、收藏攻略等)下,界面始终保持流畅且不产生多余的渲染开销。

特效动画方面,本应用实现了「篝火跳动」与「星星闪烁」两个特效层。通过 setInterval 定时器每 120 毫秒递增 fxTick 计数器,驱动篝火 emoji 的 scalerotate 属性产生跳动摇摆效果,同时驱动星星 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 标注出发时间。needgot 分别记录所需人数和已加入人数,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 变量。其中 currentTabbottomTab 控制页面切换,6 个数组持有各功能页面的数据源。4 个布尔值控制弹框显隐。addTitleaddStyleaddTooladdNum 是发布结伴弹框的表单状态。editCondeditRent 是编辑装备弹框的选择项。bizDaybizRent 是出租装备弹框的参数。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 })
  }

在这里插入图片描述

篝火跳动的实现精髓在于 scalerotate 的协同变化。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 同时设置 bottomTabcurrentTab,实现底部导航与内容区联动。最终 build 方法通过 Stack 将主内容层、特效层和弹框遮罩层叠加。

十一、组件架构与数据流 Mermaid 图

currentTab 切换

fxTick 驱动

fxTick 驱动

bizOpen

bizOpen

delOpen

addOpen

addOpen

campList @State

gearList @State

tripList @State

mateList @State

guideList @State

SKY_LIST 静态

marketList @State

PageCampMate 主组件

header 渐变头部

subNav 木牌式导航

Scroll 可滚动内容区

fxLayer 特效动画层

modalOverlay 弹框遮罩层

bottomBar 底部导航栏

pageCamp 营地发现

pageGear 装备库

pageMate 搭子圈

pageGuide 攻略

pageSky 观星预报

pageMarket 跳蚤市集

modalBodyAdd 发布结伴

modalBodyEdit 编辑装备

modalBodyDel 删除笔记

modalBodyBiz 出租装备

🔥 篝火跳动

✨ 星星闪烁

CampItem 数据

GearItem 数据

TripItem 数据

MateItem 数据

GuideItem 数据

SkyItem 数据

MarketItem 数据

如上图所示,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 更新的关键。这种统一的刷新策略大幅降低了代码的复杂度,使得开发者无需为不同的交互场景编写不同的刷新逻辑。

观星预报作为本应用的特色功能模块,展示了垂直社区应用如何通过专业化内容建立竞争壁垒。观星指数、大气视宁度、银河季拍摄窗口等专业参数的展示,不仅满足了硬核露营用户的信息需求,更通过推荐营地与观星条件的关联推荐,实现了内容到交易的闭环转化。这种「专业内容驱动社区活跃、社区活跃驱动装备租赁和营地发现」的商业模型,是垂直社区应用可持续运营的关键。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐