回顾移动端UI开发的十年历程,从Android XML布局+Activity的命令式范式,到React/Flutter的声明式革命,再到HarmonyOS ArkTS的原生声明式UI框架,每一次范式跃迁都重新定义了"状态与视图的关系"。本文以一款云露营夜话应用为标本,在技术演进的坐标系中审视其导航设计、弹窗系统和状态管理的每一个选择。

这款应用用松林绿#2E7D32、篝火橙#FF8F00与帆布米#FFF8E1构建了一个云端围炉社区:篝火夜话直播、营地导航、装备清单管理、话题行程和领队匹配。在它的底部火苗导航、五弹窗系统和生火六步流程背后,我们能清晰地看到从"开发者手动操控DOM/View树"到"框架根据状态自动推导UI"的完整演进轨迹。

演进史视角的价值在于:它让我们理解每一个技术选择不是孤立的决策,而是历史进程中的一个节点。当我们看到bindSheet($$this.showNightSheet, ...)时,我们应该想到它替代了Android中BottomSheetDialogFragment的繁样板代码;当我们看到@State gears: Gear204[]时,我们应该想到它替代了React中useState+useEffect的手动同步逻辑。每一次简化背后,都是框架对开发者认知负担的一次减轻。


引言:移动端UI开发的三个时代

在这里插入图片描述

要理解HarmonyOS ArkTS的设计哲学,我们需要回到移动端UI开发的起点。第一个时代是命令式+XML布局时代(2008-2015),以Android为代表。开发者用XML定义静态布局,在Java/Kotlin代码中通过findViewById获取View引用,再手动设置属性和事件监听器。这种模式的核心问题是"状态与视图脱节"——当数据变化时,开发者必须手动找到对应的View并更新它,漏掉一个就是bug。

第二个时代是声明式+虚拟DOM时代(2015-2020),以React和Flutter为代表。开发者用JSX/Dart描述"UI应该长什么样",框架负责将状态变化映射为UI更新。React引入了虚拟DOM和Reconciliation算法,Flutter则用自己的渲染引擎直接绘制。这个时代解决了"状态-视图同步"的问题,但引入了新的复杂度——React的useEffect依赖数组、Flutter的setState粒度控制,都需要开发者深入理解框架机制。

第三个时代是原生声明式UI时代(2020至今),以SwiftUI和HarmonyOS ArkTS为代表。这些框架将声明式范式与原生平台深度集成——不再需要虚拟DOM中间层,框架直接管理原生组件树的创建和更新。HarmonyOS ArkTS在这个演进中走得更远:它通过@State/@Prop/@Link等装饰器在编译期生成状态追踪代码,而非运行时 diff,实现了"编译期优化+运行时高效"的双重优势。

我们要审视的这款云露营应用,正是在第三个时代诞生的产物。它的每一行代码都承载着前两个时代积累的经验和教训。让我们在演进史的坐标系中,逐一分析它的核心技术选择。


一、底部导航的演化:从TabHost到火苗造型导航

在这里插入图片描述

底部导航是移动应用最基础的交互组件,它的演化最能体现UI范式的变迁。让我们先看这款应用当前的火苗导航实现。

// ---------- 底部「火苗」tab ----------
@Builder
tabBar204() {
  Column({ space: 4 }) {
    Row({ space: 5 }) {
      ForEach(this.tabs204, (t: string, i: number) => {
        Column({ space: 3 }) {
          Text(this.tabIcons204[i]).fontSize(16)
          Text(t)
            .fontSize(10)
            .fontColor(this.tabIndex1 === i ? '#FFFFFF' : '#5D4037')
            .fontWeight(this.tabIndex1 === i ? FontWeight.Bold : FontWeight.Normal)
          Text('')
            .width(this.tabIndex1 === i ? 18 : 8)
            .height(3)
            .borderRadius(2)
            .backgroundColor(this.tabIndex1 === i ? '#FFF8E1' : '#D7CCC8')
        }
        .justifyContent(FlexAlign.Center)
        .padding({ left: 10, right: 10, top: 9, bottom: 8 })
        .borderRadius({ topLeft: 24, topRight: 8, bottomLeft: 8, bottomRight: 24 })
        .backgroundColor(this.tabIndex1 === i ? '#FF8F00' : '#F5EFE6')
        .shadow({
          radius: this.tabIndex1 === i ? 10 : 0,
          color: '#66FF8F00',
          offsetY: 3
        })
        .scale({ x: this.tabIndex1 === i ? 1.08 : 1, y: this.tabIndex1 === i ? 1.08 : 1 })
        .animation({ duration: 180 })
        .onClick(() => {
          this.tabIndex1 = i
        })
      }, (t: string) => t)
    }
    .width('100%')
    .justifyContent(FlexAlign.SpaceEvenly)
  }
  .backgroundColor('#FFFFFF')
  .shadow({ radius: 12, color: '#142E7D32', offsetY: -4 })
}

在Android命令式时代,实现一个类似的底部导航需要:定义XML布局文件(BottomNavigationView或自定义LinearLayout),在Activity中通过findViewById获取引用,设置OnNavigationItemSelectedListener回调,在回调中手动切换Fragment事务(FragmentTransaction.replace())。选中态的颜色和动画需要在res/color/目录下定义selector XML,缩放动画需要在res/anim/目录下定义XML动画文件。整个过程涉及至少4个文件、20+行样板代码。

在React时代,同样需要:定义JSX组件、使用useState管理activeTab、通过条件渲染切换内容区、用CSS-in-JS或Tailwind定义选中态样式。虽然代码更简洁,但状态管理需要开发者手动维护,且虚拟DOM的Reconciliation在Tab切换时可能产生不必要的重渲染。

在HarmonyOS ArkTS中,这段导航代码将布局定义、状态绑定、动画声明和事件处理全部统一在了一个@Builder方法中。关键的技术进步体现在三个方面。第一,ForEach的声明式渲染替代了命令式的循环创建View——开发者只需描述"数据到UI的映射关系",框架自动处理列表项的创建、复用和回收。第二,this.tabIndex1 === i的条件表达式直接内联在属性值中,框架自动追踪tabIndex1的变化并只更新受影响的属性——无需手动调用notifyDataSetChanged()setState()。第三,.animation({ duration: 180 })是属性级动画声明,框架自动在属性变化时插入补间动画——无需创建Animator对象或设置AnimationListener

特别值得关注的是.borderRadius({ topLeft: 24, topRight: 8, bottomLeft: 8, bottomRight: 24 })这行代码。它创建了不对称的圆角——左上24像素、右上8像素——模拟了火焰从左下向右上舔舐的不规则形态。在Android XML时代,实现不对称圆角需要定义自定义Shape drawable或使用CardView+代码动态设置OutlineProvider。在ArkTS中,它只是一个属性值。这种"复杂视觉效果通过简单属性表达"的能力,是声明式UI框架的核心优势之一。


二、弹窗系统的演化:从DialogFragment到bindSheet

在这里插入图片描述

弹窗是UI开发中另一个充满历史包袱的区域。让我们审视这款应用的弹窗绑定方式。

build() {
  Column() {
    this.header204()
    Column() {
      if (this.tabIndex1 === 0) {
        LiveTab204({ ... })
      } else if (this.tabIndex1 === 1) {
        CampTab204({ ... })
      }
      // ...
    }
    this.tabBar204()
  }
  .bindSheet($$this.showNightSheet, this.nightSheet204(), {
    height: 620,
    dragBar: true,
    showClose: false,
    backgroundColor: '#FFFFFF'
  })
  .bindSheet($$this.showGearSheet, this.gearSheet204(), {
    height: 600,
    dragBar: true,
    showClose: false,
    backgroundColor: '#FFFFFF'
  })
  .bindContentCover($$this.showDelDialog, this.delDialog204(), {})
  .bindContentCover($$this.showDetailDialog, this.detailDialog204(), {})
}

在Android命令式时代,底部抽屉需要使用BottomSheetDialogFragment——你需要创建一个Fragment子类,实现onCreateView返回布局,在Activity中通过FragmentManager管理其生命周期。Fragment的生命周期有onAttach、onCreate、onCreateView、onViewCreated、onStart、onResume等十余个回调,弹窗的显示和隐藏涉及复杂的生命周期管理。居中弹框则需要AlertDialog.Builder或自定义DialogFragment,同样需要处理生命周期和窗口管理。一个包含5个弹窗的Activity,其Fragment管理代码可能超过200行。

在React Native时代,弹窗需要使用第三方库如react-native-modalreact-native-bottom-sheet。这些库通常需要手动管理弹窗的可见性状态(visible prop),在关闭时处理动画完成回调,还要处理Android和iOS的平台差异。弹窗的内容需要在JSX中条件渲染({visible && <Modal>...</Modal>}),关闭时手动设置setVisible(false)

HarmonyOS ArkTS的弹窗系统代表了演进的最新阶段。bindSheetbindContentCover是组件级方法——它们被直接链式调用在根组件上,将弹窗的显示状态与@State变量双向绑定。$$this.showNightSheet中的$$语法是ArkTS特有的双向绑定标记,它意味着:当showNightSheet变为true时弹窗自动打开,当用户下滑关闭弹窗时showNightSheet自动变为false。开发者无需编写任何弹窗生命周期管理代码。

这里的技术进步在于双向绑定的自动化。在传统范式中,弹窗的显示和隐藏是两个独立操作——"打开"需要设置visible=true并手动触发动画,"关闭"需要监听动画完成回调再设置visible=false。在ArkTS中,$$语法将这两个操作合并为一个原子操作——状态和UI永远同步,不存在"状态说关了但弹窗还在显示"的不一致状态。

弹窗的内容通过@Builder方法定义,如this.nightSheet204()@Builder是ArkTS的UI构建器装饰器,它允许开发者将UI片段封装为可复用的方法——类似于React中的函数组件,但在编译期就被解析优化,没有运行时函数调用的开销。每个弹窗的配置(height、dragBar、showClose、backgroundColor)通过对象参数传入,提供了足够的定制能力而无需子类化。


三、状态管理的演化:从手动同步到@State自动追踪

在这里插入图片描述

状态管理是UI开发永恒的核心命题。让我们审视这款应用的装备清单管理,看看状态管理是如何演进的。

@State gears: Gear204[] = [
  { id: 1, name: '三人隧道帐', cat: '睡眠系统', qty: 1, urgent: false, packed: true },
  { id: 2, name: '羽绒睡袋 (-10℃)', cat: '睡眠系统', qty: 2, urgent: false, packed: true },
  { id: 3, name: '充气防潮垫', cat: '睡眠系统', qty: 2, urgent: true, packed: false },
  { id: 4, name: '焚火台 + 柴刀', cat: '炊事用具', qty: 1, urgent: false, packed: true },
  { id: 5, name: '钛合金锅具套装', cat: '炊事用具', qty: 1, urgent: false, packed: false },
  // ...
]

// 编辑装备保存
.onClick(() => {
  this.gears = this.gears.map((g: Gear204, i: number) => i === this.editIndex ? {
    id: g.id,
    name: this.editName === '' ? g.name : this.editName,
    cat: g.cat,
    qty: this.editQty,
    urgent: g.urgent,
    packed: this.editPacked
  } : g)
  this.showEditSheet = false
})

// 删除装备
.onClick(() => {
  this.gears = this.gears.filter((g: Gear204, i: number) => i !== this.delIndex)
  this.showDelDialog = false
})

在Android命令式时代,列表数据管理需要RecyclerView+Adapter模式。数据变化时,开发者需要调用adapter.notifyDataSetChanged()(全量刷新)或adapter.notifyItemRemoved(position)(增量刷新)来通知视图更新。如果用错刷新方法,会导致列表闪烁或索引错乱。修改单条数据需要adapter.notifyItemChanged(position),但如果position计算错误,就会更新错误的条目。数据与视图的同步完全依赖开发者的手动管理。

在React时代,useState管理数组状态需要遵循不可变更新原则——setGears(prev => prev.map((g, i) => i === editIndex ? {...g, name: newName} : g))。这与ArkTS的模式非常相似,但React的更新是在运行时通过虚拟DOM diff实现的,而ArkTS是在编译期通过@State装饰器生成的追踪代码实现的。React还需要处理useEffect的依赖数组和useMemo的缓存优化,心智负担更重。

HarmonyOS ArkTS的@State装饰器代表了状态管理的最新演进阶段。当开发者写this.gears = this.gears.map(...)时,ArkTS框架在编译期就为gears数组生成了变化追踪代码。新的数组引用赋值给gears后,框架自动检测到引用变化,触发依赖gears的所有UI组件重新渲染——无需手动调用任何刷新方法。@Prop装饰器在子组件中接收父组件传递的数据,实现单向数据流——父组件数据变化时子组件自动更新,但子组件不能反向修改。

这里最关键的演进在于追踪的自动化程度。Android需要开发者手动调用notifyXxx()方法,React需要开发者理解虚拟DOM diff机制并遵守不可变更新规则,而ArkTS在编译期就完成了状态追踪代码的生成和注入。开发者只需关注"数据应该怎么变",框架负责"UI应该怎么更新"。这种关注点分离是声明式UI的核心价值。


四、图表渲染的演化:从MPAndroidChart到纯布局柱图

在这里插入图片描述

移动端图表渲染经历了从第三方库依赖到原生实现的演进。让我们审视这款应用的温度柱图。

// 月度夜间气温柱图
Column({ space: 8 }) {
  Text('月度夜间气温(℃)').fontSize(12).fontWeight(FontWeight.Bold).fontColor('#3E2723')
  Row({ space: 8 }) {
    ForEach(this.tempLogs, (t: TempLog204) => {
      Column({ space: 4 }) {
        Text(t.temp + '').fontSize(9).fontColor('#2E7D32')
        Text('')
          .width(14)
          .height(t.temp * 4)
          .borderRadius(4)
          .linearGradient({ angle: 180, colors: [['#A5D6A7', 0], ['#2E7D32', 1]] })
        Text(t.month).fontSize(8).fontColor('#78909C')
      }
    }, (t: TempLog204) => t.month)
  }
  .alignItems(VerticalAlign.Bottom)
}

在Android命令式时代,实现柱图通常需要引入MPAndroidChart或AChartEngine等第三方库。这些库虽然功能强大,但引入了额外的APK体积(约2-5MB),需要学习其API(DataSet、ChartEntry、XAxisFormatter等),还要处理库的版本兼容性问题。如果只需一个简单的柱图,这种"杀鸡用牛刀"的依赖引入是过度工程。

在React时代,可以使用react-native-charts-wrapper(封装了原生图表库)或纯JS实现(如d3-shape+SVG)。前者有跨平台问题,后者在性能上可能不如原生实现。SVG渲染在移动端始终存在性能瓶颈——大量DOM节点会导致页面卡顿。

HarmonyOS ArkTS选择了一条不同的路线——用纯布局组件实现简单图表。这段温度柱图的实现完全基于ColumnTextRow三个基础组件。每根柱子是一个Text('')组件,高度通过height(t.temp * 4)计算——将温度值乘以4得到像素高度(12度对应48像素,26度对应104像素)。柱子使用linearGradient从浅绿#A5D6A7渐变到深绿#2E7D32,配合alignItems(VerticalAlign.Bottom)实现底部对齐。

这种"纯布局柱图"的演进策略代表了一种设计哲学:对于简单图表,不引入第三方依赖。它的优势是零额外体积、无需学习新API、渲染性能等同原生组件。劣势是功能有限——没有tooltip、没有动画、没有交互高亮。但对于"展示月度温度"这种静态数据可视化场景,纯布局方案是最优选择。这体现了从"什么都用库"到"按需选库"的工程成熟度演进。


五、生火六步流程的演化:从ViewPager到ForEach步骤清单

在这里插入图片描述

交互式步骤清单是教学类应用的常见组件。让我们审视这款应用的"生火六步"流程实现。

interface FireStep204 {
  id: number
  title: string
  tip: string
  done: boolean
}

@State fireSteps: FireStep204[] = [
  { id: 1, title: '选位与防火圈', tip: '离帐篷 5 米 · 清理枯叶 · 石块围圈', done: true },
  { id: 2, title: '引火物打底', tip: '松果 · 干苔藓 · 桦树皮做火绒', done: true },
  { id: 3, title: '搭井字形柴堆', tip: '细柴在内 · 粗柴在外 · 留通风口', done: true },
  { id: 4, title: '点火与控火', tip: '顺风点 · 小火养 · 逐步添柴', done: false },
  { id: 5, title: '围炉就位', tip: '添一把迷迭香 · 分发烤棉花糖', done: false },
  { id: 6, title: '熄火与无痕', tip: '浇透水 · 搅拌灰烬 · 恢复地面', done: false }
]

// 步骤渲染
ForEach(this.fireSteps, (s: FireStep204, i: number) => {
  Row({ space: 10 }) {
    Column() {
      Text(s.done ? '✓' : (i + 1) + '')
        .fontSize(12)
        .fontColor('#FFFFFF')
        .textAlign(TextAlign.Center)
        .width(26)
        .height(26)
        .borderRadius(13)
        .backgroundColor(s.done ? '#FF8F00' : '#BCAAA4')
      if (i < this.fireSteps.length - 1) {
        Text('')
          .width(2)
          .layoutWeight(1)
          .backgroundColor('#D7CCC8')
          .margin({ top: 2, bottom: 2 })
      }
    }
    Column({ space: 3 }) {
      Row({ space: 8 }) {
        Text(s.title).fontSize(12).fontWeight(FontWeight.Bold).fontColor(s.done ? '#EF6C00' : '#3E2723')
        Text(s.done ? '已完成' : '待进行').fontSize(9).fontColor(s.done ? '#EF6C00' : '#8D6E63')
      }
      Text(s.tip).fontSize(10).fontColor('#90A4AE')
    }
    .layoutWeight(1)
    .padding({ top: 2, bottom: 8 })
    .onClick(() => {
      this.onStep(i)
    })
  }
}, (s: FireStep204) => s.id.toString())

在Android命令式时代,步骤清单通常用ViewPager+Fragment实现(横向滑动)或RecyclerView+自定义Adapter实现(纵向列表)。后者需要定义ViewHolder、处理item的点击事件、在Adapter中维护done状态(通常用SparseBooleanArray),状态变化时调用notifyItemChanged(position)。步骤之间的连接线需要在Adapter的onBindViewHolder中根据position计算是否显示——这种"视图细节混入数据绑定逻辑"的模式是命令式UI的典型痛点。

在React时代,步骤清单的实现方式与ArkTS非常相似——map渲染列表项、条件渲染连接线、onClick切换状态。但React的状态更新需要使用setFireSteps(prev => prev.map(...))这种函数式更新,而ArkTS直接赋值this.fireSteps = this.fireSteps.map(...)。两者在语法层面差异不大,但ArkTS的@State在编译期生成的追踪代码比React的运行时Reconciliation更高效。

这段代码中的连接线实现值得特别关注。if (i < this.fireSteps.length - 1)条件判断确保最后一个步骤不显示下方的连接线——这是时间线设计的标准模式。连接线使用layoutWeight(1)自适应高度,在步骤之间填充剩余空间。颜色#D7CCC8是浅棕色,与帆布米的主题色系一致。完成态的序号背景色是篝火橙#FF8F00,未完成态是灰棕色#BCAAA4。文字颜色也随状态切换——完成态用#EF6C00(深橙色),未完成态用#3E2723(深棕色)。这种多属性联动的状态表达在ArkTS中通过内联三元表达式实现,简洁而直观。


六、标签选择器的演化:从RadioGroup到Flex弹性换行

在这里插入图片描述

这款应用的预约弹窗中有多个标签选择器(夜话主题、营地形制),使用了Flex弹性布局实现自动换行。这代表了标签选择器的演进方向。

// 夜话主题标签
const campThemeTags204: string[] = ['篝火夜话', '星空观测', '森林茶席', '手冲咖啡', '露天影院']

Column({ space: 8 }) {
  Text('夜话主题').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#3E2723')
  Flex({ wrap: FlexWrap.Wrap }) {
    ForEach(campThemeTags204, (tag: string, i: number) => {
      Text(tag)
        .fontSize(11)
        .fontColor(this.nightTheme === i ? '#FFFFFF' : '#5D4037')
        .padding({ left: 12, right: 12, top: 6, bottom: 6 })
        .borderRadius(16)
        .backgroundColor(this.nightTheme === i ? '#2E7D32' : '#EFEBE9')
        .margin(4)
        .onClick(() => {
          this.nightTheme = i
        })
    }, (tag: string) => tag)
  }
}

在Android命令式时代,标签选择器通常用RadioGroup+RadioButton实现(单选)或CheckBox实现(多选)。但RadioGroup是垂直或水平排列的LinearLayout,不支持自动换行——标签多了就会溢出屏幕。如果需要换行,需要使用第三方库如FlexboxLayout,或者自定义ViewGroup重写onLayout方法手动计算子View的位置。后者涉及复杂的测量和布局算法,对开发者的要求很高。

在React时代,可以使用CSS的flex-wrap: wrap属性轻松实现换行。但React Native的Flexbox实现与Web CSS有细微差异(如flexShrink默认值为0而非1),开发者需要跨平台调试。

HarmonyOS ArkTS的Flex({ wrap: FlexWrap.Wrap })提供了原生的弹性换行能力。ForEach渲染的标签组件会在容器宽度不足时自动换行,margin(4)为每个标签提供间距。选中态用松林绿#2E7D32填充、白色文字;未选中态用浅棕色#EFEBE9背景、深棕色文字。选中态的切换通过this.nightTheme === i条件表达式实现,框架自动追踪nightTheme的变化并只更新受影响的标签样式。

这种"单选标签组"在传统开发中需要大量样板代码——维护选中索引、手动取消前一个选中项的样式、设置新选中项的样式。在ArkTS中,整个选择逻辑被简化为一个状态变量和一个条件表达式。框架负责"根据状态自动推导样式",开发者只需声明"选中时是什么样、未选中时是什么样",无需手动管理样式切换的命令式操作。


核心架构演进对照流程图

渲染错误: Mermaid 渲染失败: Parse error on line 17: ...1[ArkTS声明式描述UI] --> C2[@State编译期生成追踪代码] -----------------------^ Expecting 'AMP', 'COLON', 'PIPE', 'TESTSTR', 'DOWN', 'DEFAULT', 'NUM', 'COMMA', 'NODE_STRING', 'BRKT', 'MINUS', 'MULT', 'UNICODE_TEXT', got 'LINK_ID'

七、头部渐变与电商大促条的演化

这款应用的头部设计融合了品牌展示和促销引导两个功能,其技术实现也体现了演进痕迹。

// ---------- 头部(电商出游季风,无动画) ----------
@Builder
header204() {
  Column({ space: 12 }) {
    Row({ space: 10 }) {
      Column({ space: 4 }) {
        Text('篝语 · 云露营夜').fontSize(19).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
        Text('营地篝火夜话直播 · 星空围炉连麦').fontSize(11).fontColor('#DCEDC8')
      }
      .alignItems(HorizontalAlign.Start)
      Text('').layoutWeight(1)
      Column({ space: 2 }) {
        Text('🔥').fontSize(20)
        Text('986').fontSize(12).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
        Text('围炉在线').fontSize(9).fontColor('#DCEDC8')
      }
    }

    Row({ space: 10 }) {
      Column().width(4).height(34).borderRadius(2).backgroundColor('#FFF8E1')
      Column({ space: 3 }) {
        Text('出游季 · 装备焕新周').fontSize(13).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
        Text('帐篷睡袋满 599 减 80 · 赠星空投影灯').fontSize(10).fontColor('#FFE0B2')
      }
      Text('').layoutWeight(1)
      Column() {
        Text('去逛逛 →').fontSize(11).fontColor('#1B5E20').fontWeight(FontWeight.Bold)
      }
      .padding({ left: 12, right: 12, top: 7, bottom: 7 })
      .borderRadius(14)
      .backgroundColor('#FFF8E1')
      .onClick(() => {
        this.tabIndex1 = 2
      })
    }
    .backgroundColor('#1B5E20')
  }
  .linearGradient({ angle: 140, colors: [['#43A047', 0], ['#2E7D32', 1]] })
}

在Android命令式时代,渐变背景需要在res/drawable/目录下定义XML的<gradient>元素,然后通过android:background="@drawable/xxx"引用。修改渐变需要编辑XML文件,无法在运行时动态调整。如果需要在代码中动态创建渐变,需要使用GradientDrawable类——代码冗长且不易维护。

在React时代,渐变需要使用react-native-linear-gradient第三方库(因为React Native的View组件原生不支持渐变背景)。这引入了原生模块依赖,增加了构建复杂度和包体积。

在ArkTS中,linearGradient是组件的直接属性——linearGradient({ angle: 140, colors: [['#43A047', 0], ['#2E7D32', 1]] })。140度角表示渐变方向从左上到右下,颜色从#43A047(中绿)渐变到#2E7D32(深绿),模拟了松林从近处到远处的色彩变化。整个渐变定义是一行代码,无需XML文件、无需第三方库、无需GradientDrawable

头部中的促销条设计也值得关注。左侧的Column().width(4).height(34).backgroundColor('#FFF8E1')是一个4像素宽的竖条,用帆布米色强调了促销信息。这种"竖条+标题+描述+行动按钮"的组合是电商场景的经典设计模式,从淘宝到拼多多都有类似布局。在ArkTS中,这个布局通过Row+layoutWeight实现自适应排列,比传统方案的权重分配更直观。


八、装备分类统计的演化:从SQL聚合到函数式统计

装备库页面有一个按分类统计的堆叠条,它的数据计算方式体现了从数据库聚合到函数式计算的演进。

function gearCatCounts204(gears: Gear204[]): GearCount204[] {
  const counts: GearCount204[] = []
  for (let i = 0; i < gearCatTags204.length; i++) {
    let n: number = 0
    for (let j = 0; j < gears.length; j++) {
      if (gears[j].cat === gearCatTags204[i]) {
        n++
      }
    }
    counts.push({ label: gearCatTags204[i], count: n, color: ['#2E7D32', '#FF8F00', '#FBC02D', '#00838F', '#E53935'][i] })
  }
  return counts
}

// 堆叠条渲染
Row() {
  ForEach(gearCatCounts204(this.gears), (t: GearCount204) => {
    Column() {}
    .layoutWeight(t.count > 0 ? t.count : 1)
    .height(14)
    .backgroundColor(t.color)
  }, (t: GearCount204) => t.label)
}

在传统后端驱动的前端时代,统计数据通常由后端SQL聚合查询返回——SELECT cat, COUNT(*) FROM gears GROUP BY cat。前端只需展示结果,不负责计算。但当应用转为纯前端架构(无后端API)时,统计逻辑必须在前端完成。

在React时代,这种统计通常用useMemo缓存计算结果:const counts = useMemo(() => gearCatTags.map(tag => ({...tag, count: gears.filter(g => g.cat === tag).length})), [gears])useMemo的依赖数组管理是React的心智负担之一——忘记添加依赖会导致缓存过期,添加不必要的依赖会导致过度重计算。

在ArkTS中,这个统计函数在build()方法中被直接调用gearCatCounts204(this.gears)。由于@State gears的任何变化都会触发build()重新执行,统计结果会自动更新——无需useMemo缓存,无需手动管理依赖。框架的状态追踪机制已经保证了"数据变则UI变"的自动同步。当然,每次build都重新计算统计有一定性能开销,但在当前数据规模(10条装备、5个分类)下完全可接受。

堆叠条的layoutWeight(t.count > 0 ? t.count : 1)设计值得注意。当某类装备数量为0时,使用1作为fallback权重,确保该段仍有最小宽度。这种"零值不消失"的设计比传统的"零值隐藏"更友好——它让用户知道"这个分类存在但当前没有装备",而不是"这个分类不存在"。


传统方案与HarmonyOS方案对比分析表

技术维度 传统命令式方案(Android) 声明式虚拟DOM方案(React) HarmonyOS ArkTS方案 演进进步点
底部导航 XML布局+Fragment事务+selector JSX+useState+条件渲染 ForEach+属性内联条件+animation 零样板代码,动画内联声明
弹窗管理 DialogFragment+FragmentManager 第三方Modal库+visible prop bindSheet+$$双向绑定 生命周期全自动化
状态管理 手动notifyDataSetChanged useState+useMemo+依赖数组 @State编译期生成追踪 编译期优化,零运行时diff
图表渲染 MPAndroidChart第三方库 d3-shape+SVG或原生库 纯布局组件+缩放因子 零依赖,原生性能
标签换行 FlexboxLayout或自定义ViewGroup CSS flex-wrap Flex({wrap:FlexWrap.Wrap}) 原生支持,一行配置
渐变背景 XML drawable或GradientDrawable 第三方react-native-linear-gradient linearGradient属性 零依赖,运行时可调
步骤清单 RecyclerView+ViewHolder+SparseBooleanArray map渲染+useState ForEach+@State+条件渲染 状态与UI自动同步
数据统计 后端SQL聚合或前端循环 useMemo缓存+filter/count build中直接调用函数 无需缓存管理
数组更新 手动adapter.notifyItemXxx 不可变更新+setGears 不可变更新+@State赋值 框架自动检测引用变化
组件复用 include/merge XML标签 函数组件+props @Builder+@Component+@Prop 编译期优化,无运行时开销

安装DevEco Studio程序

在这里插入图片描述
选择目标安装目录:

在这里插入图片描述
设置环境变量,但是需要重启一下:

在这里插入图片描述
新建一个空白模板:

在这里插入图片描述
设置API为24的模板项目:
在这里插入图片描述
初始化项目,自动下载相关依赖:

在这里插入图片描述


完整代码:

// 场景:线上露营围炉房(腾讯会议类)
// 配色:松林绿 #2E7D32 × 篝火橙 #FF8F00 × 帆布米 #FFF8E1
// Tab:底部「火苗」导航(不对称圆角火苗造型 topLeft 24 topRight 8 + 选中篝火橙渐变 + 微放大)

// ================= 数据接口 =================

interface CampNight204 {
  day: string
  people: number
}

interface Gear204 {
  id: number
  name: string
  cat: string
  qty: number
  urgent: boolean
  packed: boolean
}

interface Camp204 {
  id: number
  name: string
  terrain: string
  altitude: number
  score: number
  dist: number
  state: string
  heat: number
}

interface Topic204 {
  id: number
  name: string
  host: string
  joins: number
  state: string
}

interface Leader204 {
  id: number
  name: string
  city: string
  years: number
  online: boolean
  heat: number
}

interface Barrage204 {
  id: number
  text: string
}

interface FireStep204 {
  id: number
  title: string
  tip: string
  done: boolean
}

interface GearCount204 {
  label: string
  count: number
  color: string
}

interface TempLog204 {
  month: string
  temp: number
}

// ================= 全局标签 =================

const campThemeTags204: string[] = ['篝火夜话', '星空观测', '森林茶席', '手冲咖啡', '露天影院']
const campTerrainTags204: string[] = ['松林营地', '湖畔草坪', '山顶平台', '溪谷沙地', '草原牧场']
const gearCatTags204: string[] = ['睡眠系统', '炊事用具', '照明供电', '服饰鞋包', '急救防护']

// ================= 全局函数 =================

function campStateColor204(state: string): string {
  if (state === '今晚有位') {
    return '#2E7D32'
  }
  if (state === '火爆满员') {
    return '#E53935'
  }
  if (state === '雨备中') {
    return '#FB8C00'
  }
  return '#90A4AE'
}

function topicStateColor204(state: string): string {
  if (state === '开聊中') {
    return '#FF8F00'
  }
  if (state === '预告') {
    return '#2E7D32'
  }
  return '#90A4AE'
}

function gearCatColor204(cat: string): string {
  if (cat === '睡眠系统') {
    return '#2E7D32'
  }
  if (cat === '炊事用具') {
    return '#FF8F00'
  }
  if (cat === '照明供电') {
    return '#FBC02D'
  }
  if (cat === '服饰鞋包') {
    return '#00838F'
  }
  return '#E53935'
}

function maxCampHeat204(camps: Camp204[]): number {
  let m: number = 1
  for (let i = 0; i < camps.length; i++) {
    if (camps[i].heat > m) {
      m = camps[i].heat
    }
  }
  return m
}

function packedGearCount204(gears: Gear204[]): number {
  let n: number = 0
  for (let i = 0; i < gears.length; i++) {
    if (gears[i].packed) {
      n++
    }
  }
  return n
}

function urgentGearCount204(gears: Gear204[]): number {
  let n: number = 0
  for (let i = 0; i < gears.length; i++) {
    if (gears[i].urgent) {
      n++
    }
  }
  return n
}

function gearCatCounts204(gears: Gear204[]): GearCount204[] {
  const counts: GearCount204[] = []
  for (let i = 0; i < gearCatTags204.length; i++) {
    let n: number = 0
    for (let j = 0; j < gears.length; j++) {
      if (gears[j].cat === gearCatTags204[i]) {
        n++
      }
    }
    counts.push({ label: gearCatTags204[i], count: n, color: ['#2E7D32', '#FF8F00', '#FBC02D', '#00838F', '#E53935'][i] })
  }
  return counts
}

function onlineLeaderCount204(leaders: Leader204[]): number {
  let n: number = 0
  for (let i = 0; i < leaders.length; i++) {
    if (leaders[i].online) {
      n++
    }
  }
  return n
}

// ================= 主页面 =================

@Entry
@Component
struct Index204 {
  @State tabIndex1: number = 0

  // 弹框开关
  @State showNightSheet: boolean = false
  @State showGearSheet: boolean = false
  @State showEditSheet: boolean = false
  @State showDelDialog: boolean = false
  @State showDetailDialog: boolean = false

  // 预约云露营夜表单
  @State nightTheme: number = 0
  @State nightTerrain: number = 0
  @State nightPeople: number = 2
  @State nightGear: boolean = false
  @State nightStory: boolean = true

  // 新增装备表单
  @State gearName: string = ''
  @State gearCat: number = 0
  @State gearQty: number = 1
  @State gearUrgent: boolean = false

  // 编辑表单
  @State editIndex: number = -1
  @State editName: string = ''
  @State editQty: number = 1
  @State editPacked: boolean = false

  // 删除
  @State delIndex: number = -1
  @State delKeepProfile: boolean = true

  // 详情
  @State detailIndex: number = 0

  // 数据
  @State gears: Gear204[] = [
    { id: 1, name: '三人隧道帐', cat: '睡眠系统', qty: 1, urgent: false, packed: true },
    { id: 2, name: '羽绒睡袋 (-10℃)', cat: '睡眠系统', qty: 2, urgent: false, packed: true },
    { id: 3, name: '充气防潮垫', cat: '睡眠系统', qty: 2, urgent: true, packed: false },
    { id: 4, name: '焚火台 + 柴刀', cat: '炊事用具', qty: 1, urgent: false, packed: true },
    { id: 5, name: '钛合金锅具套装', cat: '炊事用具', qty: 1, urgent: false, packed: false },
    { id: 6, name: '手摇磨豆机', cat: '炊事用具', qty: 1, urgent: false, packed: false },
    { id: 7, name: '露营灯串 12 米', cat: '照明供电', qty: 1, urgent: true, packed: false },
    { id: 8, name: '户外电源 600W', cat: '照明供电', qty: 1, urgent: false, packed: true },
    { id: 9, name: '冲锋衣三合一', cat: '服饰鞋包', qty: 2, urgent: false, packed: true },
    { id: 10, name: '急救包 + 驱蚊喷雾', cat: '急救防护', qty: 1, urgent: true, packed: false }
  ]

  @State camps: Camp204[] = [
    { id: 1, name: '云顶松林营地', terrain: '松林营地', altitude: 1180, score: 4.9, dist: 86, state: '今晚有位', heat: 95 },
    { id: 2, name: '月牙湖畔草坪', terrain: '湖畔草坪', altitude: 320, score: 4.8, dist: 52, state: '火爆满员', heat: 92 },
    { id: 3, name: '老鹰岩山顶平台', terrain: '山顶平台', altitude: 1560, score: 4.9, dist: 120, state: '今晚有位', heat: 88 },
    { id: 4, name: '鹿鸣溪谷沙地', terrain: '溪谷沙地', altitude: 460, score: 4.6, dist: 68, state: '雨备中', heat: 75 },
    { id: 5, name: '风吹草原牧场', terrain: '草原牧场', altitude: 890, score: 4.7, dist: 150, state: '今晚有位', heat: 81 },
    { id: 6, name: '杉语湖湾秘境', terrain: '湖畔草坪', altitude: 540, score: 4.8, dist: 95, state: '火爆满员', heat: 86 }
  ]

  @State topics: Topic204[] = [
    { id: 1, name: '篝火夜话 · 把遗憾说给火星听', host: '老鹿领队', joins: 68, state: '开聊中' },
    { id: 2, name: '星空观测 · 找到你的守护星', host: '星姐', joins: 45, state: '开聊中' },
    { id: 3, name: '森林茶席 · 围炉煮老白茶', host: '茶叔', joins: 32, state: '预告' },
    { id: 4, name: '手冲咖啡 · 篝火旁的水温哲学', host: '豆子', joins: 51, state: '预告' },
    { id: 5, name: '露天影院 · 胶片老片连播', host: '放映员K', joins: 74, state: '开聊中' },
    { id: 6, name: '晨光瑜伽 · 帐篷前的第一缕光', host: '小禾', joins: 28, state: '预告' }
  ]

  @State leaders: Leader204[] = [
    { id: 1, name: '老鹿领队', city: '杭州', years: 11, online: true, heat: 94 },
    { id: 2, name: '星姐', city: '丽江', years: 8, online: true, heat: 90 },
    { id: 3, name: '茶叔', city: '成都', years: 13, online: false, heat: 82 },
    { id: 4, name: '豆子', city: '昆明', years: 6, online: true, heat: 71 },
    { id: 5, name: '放映员K', city: '重庆', years: 9, online: false, heat: 66 },
    { id: 6, name: '小禾', city: '大理', years: 5, online: true, heat: 58 }
  ]

  @State campNights: CampNight204[] = [
    { day: '周一', people: 45 },
    { day: '周二', people: 38 },
    { day: '周三', people: 52 },
    { day: '周四', people: 60 },
    { day: '周五', people: 78 },
    { day: '周六', people: 96 },
    { day: '周日', people: 88 }
  ]

  @State tempLogs: TempLog204[] = [
    { month: '3月', temp: 12 },
    { month: '4月', temp: 16 },
    { month: '5月', temp: 20 },
    { month: '6月', temp: 24 },
    { month: '7月', temp: 26 },
    { month: '8月', temp: 25 },
    { month: '9月', temp: 21 }
  ]

  @State barrages: Barrage204[] = [
    { id: 1, text: '火星噼里啪啦的声音太治愈了' },
    { id: 2, text: '第一次云露营,比想象中好玩' },
    { id: 3, text: '烤棉花糖记得翻面!' },
    { id: 4, text: '星姐带我找到了北斗七星' },
    { id: 5, text: '柴刀那段太解压了' },
    { id: 6, text: '已下单焚火台,谢谢种草' },
    { id: 7, text: '今晚山顶 3℃ 大家多穿点' },
    { id: 8, text: '篝火夜话听哭了,抱抱楼主' }
  ]

  @State fireSteps: FireStep204[] = [
    { id: 1, title: '选位与防火圈', tip: '离帐篷 5 米 · 清理枯叶 · 石块围圈', done: true },
    { id: 2, title: '引火物打底', tip: '松果 · 干苔藓 · 桦树皮做火绒', done: true },
    { id: 3, title: '搭井字形柴堆', tip: '细柴在内 · 粗柴在外 · 留通风口', done: true },
    { id: 4, title: '点火与控火', tip: '顺风点 · 小火养 · 逐步添柴', done: false },
    { id: 5, title: '围炉就位', tip: '添一把迷迭香 · 分发烤棉花糖', done: false },
    { id: 6, title: '熄火与无痕', tip: '浇透水 · 搅拌灰烬 · 恢复地面', done: false }
  ]

  tabs204: string[] = ['篝火夜', '营地墙', '装备库', '行程', '领队团', '我的']
  tabIcons204: string[] = ['🔥', '⛺', '🎒', '🧭', '🧑‍🌾', '👤']

  // ---------- 头部(电商出游季风,无动画) ----------
  @Builder
  header204() {
    Column({ space: 12 }) {
      Row({ space: 10 }) {
        Column({ space: 4 }) {
          Text('篝语 · 云露营夜').fontSize(19).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
          Text('营地篝火夜话直播 · 星空围炉连麦').fontSize(11).fontColor('#DCEDC8')
        }
        .alignItems(HorizontalAlign.Start)
        Text('').layoutWeight(1)
        Column({ space: 2 }) {
          Text('🔥').fontSize(20)
          Text('986').fontSize(12).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
          Text('围炉在线').fontSize(9).fontColor('#DCEDC8')
        }
        .alignItems(HorizontalAlign.Center)
      }
      .width('100%')

      Row({ space: 10 }) {
        Column().width(4).height(34).borderRadius(2).backgroundColor('#FFF8E1')
        Column({ space: 3 }) {
          Text('出游季 · 装备焕新周').fontSize(13).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
          Text('帐篷睡袋满 599 减 80 · 赠星空投影灯').fontSize(10).fontColor('#FFE0B2')
        }
        .alignItems(HorizontalAlign.Start)
        Text('').layoutWeight(1)
        Column() {
          Text('去逛逛 →').fontSize(11).fontColor('#1B5E20').fontWeight(FontWeight.Bold)
        }
        .padding({ left: 12, right: 12, top: 7, bottom: 7 })
        .borderRadius(14)
        .backgroundColor('#FFF8E1')
        .onClick(() => {
          this.tabIndex1 = 2
        })
      }
      .width('100%')
      .padding(12)
      .borderRadius(12)
      .backgroundColor('#1B5E20')
    }
    .alignItems(HorizontalAlign.Start)
    .padding(14)
    .linearGradient({ angle: 140, colors: [['#43A047', 0], ['#2E7D32', 1]] })
  }

  // ---------- 底部「火苗」tab ----------
  @Builder
  tabBar204() {
    Column({ space: 4 }) {
      Row({ space: 5 }) {
        ForEach(this.tabs204, (t: string, i: number) => {
          Column({ space: 3 }) {
            Text(this.tabIcons204[i]).fontSize(16)
            Text(t)
              .fontSize(10)
              .fontColor(this.tabIndex1 === i ? '#FFFFFF' : '#5D4037')
              .fontWeight(this.tabIndex1 === i ? FontWeight.Bold : FontWeight.Normal)
            Text('')
              .width(this.tabIndex1 === i ? 18 : 8)
              .height(3)
              .borderRadius(2)
              .backgroundColor(this.tabIndex1 === i ? '#FFF8E1' : '#D7CCC8')
          }
          .justifyContent(FlexAlign.Center)
          .padding({ left: 10, right: 10, top: 9, bottom: 8 })
          .borderRadius({ topLeft: 24, topRight: 8, bottomLeft: 8, bottomRight: 24 })
          .backgroundColor(this.tabIndex1 === i ? '#FF8F00' : '#F5EFE6')
          .shadow({
            radius: this.tabIndex1 === i ? 10 : 0,
            color: '#66FF8F00',
            offsetY: 3
          })
          .scale({ x: this.tabIndex1 === i ? 1.08 : 1, y: this.tabIndex1 === i ? 1.08 : 1 })
          .animation({ duration: 180 })
          .onClick(() => {
            this.tabIndex1 = i
          })
        }, (t: string) => t)
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceEvenly)
    }
    .width('100%')
    .padding({ left: 8, right: 8, top: 8, bottom: 10 })
    .backgroundColor('#FFFFFF')
    .shadow({ radius: 12, color: '#142E7D32', offsetY: -4 })
  }

  // ---------- 弹框一:预约云露营夜(底部抽屉) ----------
  @Builder
  nightSheet204() {
    Column() {
      Column() {
      }
      .width(40)
      .height(4)
      .borderRadius(2)
      .backgroundColor('#D7CCC8')
      .margin({ top: 10 })

      Row({ space: 8 }) {
        Text('预约今晚云露营夜').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#2E7D32')
        Text('').layoutWeight(1)
        Column() {
          Text('×').fontSize(16).fontColor('#78909C')
        }
        .width(28)
        .height(28)
        .borderRadius(14)
        .backgroundColor('#EFEBE9')
        .justifyContent(FlexAlign.Center)
        .onClick(() => {
          this.showNightSheet = false
        })
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12 })

      Scroll() {
        Column({ space: 16 }) {
          Column({ space: 8 }) {
            Text('夜话主题').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#3E2723')
            Flex({ wrap: FlexWrap.Wrap }) {
              ForEach(campThemeTags204, (tag: string, i: number) => {
                Text(tag)
                  .fontSize(11)
                  .fontColor(this.nightTheme === i ? '#FFFFFF' : '#5D4037')
                  .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                  .borderRadius(16)
                  .backgroundColor(this.nightTheme === i ? '#2E7D32' : '#EFEBE9')
                  .margin(4)
                  .onClick(() => {
                    this.nightTheme = i
                  })
              }, (tag: string) => tag)
            }
          }
          .alignItems(HorizontalAlign.Start)
          .width('100%')

          Column({ space: 8 }) {
            Text('营地形制').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#3E2723')
            Flex({ wrap: FlexWrap.Wrap }) {
              ForEach(campTerrainTags204, (tag: string, i: number) => {
                Text(tag)
                  .fontSize(11)
                  .fontColor(this.nightTerrain === i ? '#FFFFFF' : '#5D4037')
                  .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                  .borderRadius(16)
                  .backgroundColor(this.nightTerrain === i ? '#FF8F00' : '#EFEBE9')
                  .margin(4)
                  .onClick(() => {
                    this.nightTerrain = i
                  })
              }, (tag: string) => tag)
            }
          }
          .alignItems(HorizontalAlign.Start)
          .width('100%')

          Row({ space: 14 }) {
            Column() {
              Text('-').fontSize(16).fontColor('#2E7D32')
            }
            .width(34)
            .height(34)
            .borderRadius(17)
            .backgroundColor('#E8F5E9')
            .justifyContent(FlexAlign.Center)
            .onClick(() => {
              if (this.nightPeople > 1) {
                this.nightPeople -= 1
              }
            })
            Column({ space: 2 }) {
              Text('围炉人数 ' + this.nightPeople + ' 人').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#2E7D32')
              Text('每个营位最多围炉 4 人').fontSize(9).fontColor('#90A4AE')
            }
            .alignItems(HorizontalAlign.Start)
            Column() {
              Text('+').fontSize(16).fontColor('#2E7D32')
            }
            .width(34)
            .height(34)
            .borderRadius(17)
            .backgroundColor('#E8F5E9')
            .justifyContent(FlexAlign.Center)
            .onClick(() => {
              if (this.nightPeople < 4) {
                this.nightPeople += 1
              }
            })
            Text('').layoutWeight(1)
          }
          .width('100%')

          Row({ space: 10 }) {
            Column({ space: 2 }) {
              Text('提供全套装备').fontSize(13).fontColor('#3E2723')
              Text('帐篷桌椅焚火台由营地配齐').fontSize(9).fontColor('#90A4AE')
            }
            .alignItems(HorizontalAlign.Start)
            Text('').layoutWeight(1)
            Toggle({ type: ToggleType.Switch, isOn: this.nightGear })
              .onChange((v: boolean) => {
                this.nightGear = v
              })
          }
          .width('100%')
          .padding(12)
          .borderRadius(12)
          .backgroundColor('#E8F5E9')

          Row({ space: 10 }) {
            Column({ space: 2 }) {
              Text('晚安故事环节').fontSize(13).fontColor('#3E2723')
              Text('熄灯前领队讲一个营地老故事').fontSize(9).fontColor('#90A4AE')
            }
            .alignItems(HorizontalAlign.Start)
            Text('').layoutWeight(1)
            Toggle({ type: ToggleType.Switch, isOn: this.nightStory })
              .onChange((v: boolean) => {
                this.nightStory = v
              })
          }
          .width('100%')
          .padding(12)
          .borderRadius(12)
          .backgroundColor('#FFF3E0')
        }
        .padding({ left: 16, right: 16, bottom: 8 })
      }
      .layoutWeight(1)
      .scrollBar(BarState.Off)

      Row({ space: 12 }) {
        Column({ space: 2 }) {
          Text('营位费').fontSize(10).fontColor('#90A4AE')
          Text('¥ ' + (this.nightPeople * 49 + (this.nightGear ? 60 : 0))).fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FF8F00')
        }
        .alignItems(HorizontalAlign.Start)
        Text('').layoutWeight(1)
        Button() {
          Text('占个营位').fontSize(14).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
        }
        .padding({ left: 28, right: 28, top: 11, bottom: 11 })
        .borderRadius(22)
        .backgroundColor('#2E7D32')
        .onClick(() => {
          this.showNightSheet = false
        })
      }
      .width('100%')
      .padding(14)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FFFFFF')
  }

  // ---------- 弹框二:新增装备(底部抽屉) ----------
  @Builder
  gearSheet204() {
    Column() {
      Column() {
      }
      .width(40)
      .height(4)
      .borderRadius(2)
      .backgroundColor('#D7CCC8')
      .margin({ top: 10 })

      Row({ space: 8 }) {
        Text('新增装备清单').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#FF8F00')
        Text('').layoutWeight(1)
        Column() {
          Text('×').fontSize(16).fontColor('#78909C')
        }
        .width(28)
        .height(28)
        .borderRadius(14)
        .backgroundColor('#EFEBE9')
        .justifyContent(FlexAlign.Center)
        .onClick(() => {
          this.showGearSheet = false
        })
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12 })

      Scroll() {
        Column({ space: 16 }) {
          Column({ space: 8 }) {
            Text('装备名称').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#3E2723')
            TextInput({ placeholder: '例如:折叠月亮椅', text: this.gearName })
              .fontSize(13)
              .padding(12)
              .borderRadius(12)
              .backgroundColor('#FFF8E1')
              .onChange((v: string) => {
                this.gearName = v
              })
          }
          .alignItems(HorizontalAlign.Start)
          .width('100%')

          Column({ space: 8 }) {
            Text('分类').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#3E2723')
            Flex({ wrap: FlexWrap.Wrap }) {
              ForEach(gearCatTags204, (tag: string, i: number) => {
                Text(tag)
                  .fontSize(11)
                  .fontColor(this.gearCat === i ? '#FFFFFF' : '#5D4037')
                  .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                  .borderRadius(16)
                  .backgroundColor(this.gearCat === i ? gearCatColor204(tag) : '#EFEBE9')
                  .margin(4)
                  .onClick(() => {
                    this.gearCat = i
                  })
              }, (tag: string) => tag)
            }
          }
          .alignItems(HorizontalAlign.Start)
          .width('100%')

          Row({ space: 14 }) {
            Column() {
              Text('-').fontSize(16).fontColor('#EF6C00')
            }
            .width(34)
            .height(34)
            .borderRadius(17)
            .backgroundColor('#FFF3E0')
            .justifyContent(FlexAlign.Center)
            .onClick(() => {
              if (this.gearQty > 1) {
                this.gearQty -= 1
              }
            })
            Column({ space: 2 }) {
              Text('数量 ' + this.gearQty + ' 件').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#EF6C00')
              Text('同一装备可一次记多件').fontSize(9).fontColor('#90A4AE')
            }
            .alignItems(HorizontalAlign.Start)
            Column() {
              Text('+').fontSize(16).fontColor('#EF6C00')
            }
            .width(34)
            .height(34)
            .borderRadius(17)
            .backgroundColor('#FFF3E0')
            .justifyContent(FlexAlign.Center)
            .onClick(() => {
              if (this.gearQty < 9) {
                this.gearQty += 1
              }
            })
            Text('').layoutWeight(1)
          }
          .width('100%')

          Row({ space: 10 }) {
            Column({ space: 2 }) {
              Text('标记为急需补货').fontSize(13).fontColor('#3E2723')
              Text('出发前 3 天会推送提醒').fontSize(9).fontColor('#90A4AE')
            }
            .alignItems(HorizontalAlign.Start)
            Text('').layoutWeight(1)
            Toggle({ type: ToggleType.Switch, isOn: this.gearUrgent })
              .onChange((v: boolean) => {
                this.gearUrgent = v
              })
          }
          .width('100%')
          .padding(12)
          .borderRadius(12)
          .backgroundColor('#FFEBEE')
        }
        .padding({ left: 16, right: 16, bottom: 8 })
      }
      .layoutWeight(1)
      .scrollBar(BarState.Off)

      Button() {
        Text('加入装备清单').fontSize(14).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
      }
      .width('90%')
      .padding({ top: 12, bottom: 12 })
      .borderRadius(22)
      .backgroundColor('#FF8F00')
      .margin({ bottom: 14 })
      .onClick(() => {
        this.showGearSheet = false
        this.gearName = ''
        this.gearCat = 0
        this.gearQty = 1
        this.gearUrgent = false
      })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FFFFFF')
  }

  // ---------- 弹框三:编辑装备(底部抽屉) ----------
  @Builder
  editSheet204() {
    Column() {
      Column() {
      }
      .width(40)
      .height(4)
      .borderRadius(2)
      .backgroundColor('#D7CCC8')
      .margin({ top: 10 })

      Row({ space: 8 }) {
        Text('编辑装备').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#2E7D32')
        Text('').layoutWeight(1)
        Column() {
          Text('×').fontSize(16).fontColor('#78909C')
        }
        .width(28)
        .height(28)
        .borderRadius(14)
        .backgroundColor('#EFEBE9')
        .justifyContent(FlexAlign.Center)
        .onClick(() => {
          this.showEditSheet = false
        })
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12 })

      Scroll() {
        Column({ space: 16 }) {
          Column({ space: 8 }) {
            Text('装备名称').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#3E2723')
            TextInput({ placeholder: '输入新名称', text: this.editName })
              .fontSize(13)
              .padding(12)
              .borderRadius(12)
              .backgroundColor('#E8F5E9')
              .onChange((v: string) => {
                this.editName = v
              })
          }
          .alignItems(HorizontalAlign.Start)
          .width('100%')

          Row({ space: 14 }) {
            Column() {
              Text('-').fontSize(16).fontColor('#2E7D32')
            }
            .width(34)
            .height(34)
            .borderRadius(17)
            .backgroundColor('#E8F5E9')
            .justifyContent(FlexAlign.Center)
            .onClick(() => {
              if (this.editQty > 1) {
                this.editQty -= 1
              }
            })
            Column({ space: 2 }) {
              Text('数量 ' + this.editQty + ' 件').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#2E7D32')
              Text('调整后自动更新打包进度').fontSize(9).fontColor('#90A4AE')
            }
            .alignItems(HorizontalAlign.Start)
            Column() {
              Text('+').fontSize(16).fontColor('#2E7D32')
            }
            .width(34)
            .height(34)
            .borderRadius(17)
            .backgroundColor('#E8F5E9')
            .justifyContent(FlexAlign.Center)
            .onClick(() => {
              if (this.editQty < 9) {
                this.editQty += 1
              }
            })
            Text('').layoutWeight(1)
          }
          .width('100%')

          Row({ space: 10 }) {
            Column({ space: 2 }) {
              Text('已装车').fontSize(13).fontColor('#3E2723')
              Text('勾掉即视为待补货').fontSize(9).fontColor('#90A4AE')
            }
            .alignItems(HorizontalAlign.Start)
            Text('').layoutWeight(1)
            Toggle({ type: ToggleType.Switch, isOn: this.editPacked })
              .onChange((v: boolean) => {
                this.editPacked = v
              })
          }
          .width('100%')
          .padding(12)
          .borderRadius(12)
          .backgroundColor('#E8F5E9')
        }
        .padding({ left: 16, right: 16, bottom: 8 })
      }
      .layoutWeight(1)
      .scrollBar(BarState.Off)

      Button() {
        Text('保存修改').fontSize(14).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
      }
      .width('90%')
      .padding({ top: 12, bottom: 12 })
      .borderRadius(22)
      .backgroundColor('#2E7D32')
      .margin({ bottom: 14 })
      .onClick(() => {
        this.gears = this.gears.map((g: Gear204, i: number) => i === this.editIndex ? {
          id: g.id,
          name: this.editName === '' ? g.name : this.editName,
          cat: g.cat,
          qty: this.editQty,
          urgent: g.urgent,
          packed: this.editPacked
        } : g)
        this.showEditSheet = false
      })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FFFFFF')
  }

  // ---------- 弹框四:删除装备(居中弹框) ----------
  @Builder
  delDialog204() {
    Column({ space: 16 }) {
      Text('🎒').fontSize(34)
      Text('从清单移除这件装备?').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#3E2723')
      Text('「' + (this.delIndex >= 0 && this.delIndex < this.gears.length ? this.gears[this.delIndex].name : '') + '」将不再出现在打包清单')
        .fontSize(12)
        .fontColor('#90A4AE')
        .textAlign(TextAlign.Center)

      Row({ space: 10 }) {
        Column({ space: 2 }) {
          Text('保留装备档案').fontSize(12).fontColor('#5D4037')
        }
        .alignItems(HorizontalAlign.Start)
        Text('').layoutWeight(1)
        Toggle({ type: ToggleType.Switch, isOn: this.delKeepProfile })
          .onChange((v: boolean) => {
            this.delKeepProfile = v
          })
      }
      .width('100%')
      .padding(12)
      .borderRadius(12)
      .backgroundColor('#F5EFE6')

      Row({ space: 12 }) {
        Button() {
          Text('再想想').fontSize(13).fontColor('#5D4037')
        }
        .layoutWeight(1)
        .padding({ top: 10, bottom: 10 })
        .borderRadius(20)
        .backgroundColor('#EFEBE9')
        .onClick(() => {
          this.showDelDialog = false
        })
        Button() {
          Text('移除').fontSize(13).fontColor('#FFFFFF')
        }
        .layoutWeight(1)
        .padding({ top: 10, bottom: 10 })
        .borderRadius(20)
        .backgroundColor('#E53935')
        .onClick(() => {
          this.gears = this.gears.filter((g: Gear204, i: number) => i !== this.delIndex)
          this.showDelDialog = false
        })
      }
      .width('100%')
    }
    .width('84%')
    .padding(22)
    .borderRadius(18)
    .backgroundColor('#FFFFFF')
  }

  // ---------- 弹框五:营地详情(居中弹框) ----------
  @Builder
  detailDialog204() {
    Column() {
      Scroll() {
        Column({ space: 0 }) {
          Column({ space: 6 }) {
            Text('⛺').fontSize(40)
            Text(this.detailIndex >= 0 && this.detailIndex < this.camps.length ? this.camps[this.detailIndex].name : '').fontSize(19).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
            Text(this.detailIndex >= 0 && this.detailIndex < this.camps.length ? this.camps[this.detailIndex].terrain + ' · 海拔 ' + this.camps[this.detailIndex].altitude + ' 米' : '').fontSize(11).fontColor('#DCEDC8')
          }
          .width('100%')
          .padding({ top: 28, bottom: 22 })
          .linearGradient({ angle: 140, colors: [['#43A047', 0], ['#1B5E20', 1]] })

          Column({ space: 14 }) {
            Row() {
              Column({ space: 3 }) {
                Text(this.detailIndex >= 0 && this.detailIndex < this.camps.length ? '⭐ ' + this.camps[this.detailIndex].score : '').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#2E7D32')
                Text('营地评分').fontSize(9).fontColor('#90A4AE')
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
              Column({ space: 3 }) {
                Text(this.detailIndex >= 0 && this.detailIndex < this.camps.length ? this.camps[this.detailIndex].dist + ' km' : '').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#FF8F00')
                Text('距市区').fontSize(9).fontColor('#90A4AE')
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
              Column({ space: 3 }) {
                Text(this.detailIndex >= 0 && this.detailIndex < this.camps.length ? this.camps[this.detailIndex].state : '').fontSize(15).fontWeight(FontWeight.Bold).fontColor(campStateColor204(this.detailIndex >= 0 && this.detailIndex < this.camps.length ? this.camps[this.detailIndex].state : ''))
                Text('今晚状态').fontSize(9).fontColor('#90A4AE')
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
            }
            .width('100%')

            Column({ space: 8 }) {
              Text('月度夜间气温(℃)').fontSize(12).fontWeight(FontWeight.Bold).fontColor('#3E2723')
              Row({ space: 8 }) {
                ForEach(this.tempLogs, (t: TempLog204) => {
                  Column({ space: 4 }) {
                    Text(t.temp + '').fontSize(9).fontColor('#2E7D32')
                    Text('')
                      .width(14)
                      .height(t.temp * 4)
                      .borderRadius(4)
                      .linearGradient({ angle: 180, colors: [['#A5D6A7', 0], ['#2E7D32', 1]] })
                    Text(t.month).fontSize(8).fontColor('#78909C')
                  }
                }, (t: TempLog204) => t.month)
              }
              .alignItems(VerticalAlign.Bottom)
              .width('100%')
            }
            .width('100%')
            .padding(12)
            .borderRadius(12)
            .backgroundColor('#F5EFE6')

            Row({ space: 8 }) {
              ForEach(campTerrainTags204.slice(0, 3), (tag: string) => {
                Text(tag)
                  .fontSize(10)
                  .fontColor('#33691E')
                  .padding({ left: 10, right: 10, top: 5, bottom: 5 })
                  .borderRadius(12)
                  .backgroundColor('#E8F5E9')
              }, (tag: string) => tag)
            }
            .width('100%')

            Button() {
              Text('预约今晚围炉 →').fontSize(13).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
            }
            .width('100%')
            .padding({ top: 11, bottom: 11 })
            .borderRadius(20)
            .backgroundColor('#FF8F00')
            .onClick(() => {
              this.showDetailDialog = false
              this.showNightSheet = true
            })
          }
          .padding(16)
        }
        .constraintSize({ maxHeight: '80%' })
      }
      .scrollBar(BarState.Off)

      Column() {
        Text('×').fontSize(16).fontColor('#FFFFFF')
      }
      .width(30)
      .height(30)
      .borderRadius(15)
      .backgroundColor('#33000000')
      .justifyContent(FlexAlign.Center)
      .margin({ top: -44, right: 14 })
      .onClick(() => {
        this.showDetailDialog = false
      })
    }
    .width('88%')
    .borderRadius(18)
    .backgroundColor('#FFFFFF')
  }

  build() {
    Column() {
      this.header204()

      Column() {
        if (this.tabIndex1 === 0) {
          LiveTab204({
            barrages: this.barrages,
            fireSteps: this.fireSteps,
            topics: this.topics,
            onNight: () => {
              this.showNightSheet = true
            },
            onStep: (i: number) => {
              this.fireSteps = this.fireSteps.map((s: FireStep204, idx: number) => idx === i ? {
                id: s.id,
                title: s.title,
                tip: s.tip,
                done: !s.done
              } : s)
            }
          })
        } else if (this.tabIndex1 === 1) {
          CampTab204({
            camps: this.camps,
            campNights: this.campNights,
            onNight: () => {
              this.showNightSheet = true
            },
            onDetail: (i: number) => {
              this.detailIndex = i
              this.showDetailDialog = true
            }
          })
        } else if (this.tabIndex1 === 2) {
          GearTab204({
            gears: this.gears,
            onNew: () => {
              this.showGearSheet = true
            },
            onEdit: (i: number) => {
              this.editIndex = i
              this.editName = this.gears[i].name
              this.editQty = this.gears[i].qty
              this.editPacked = this.gears[i].packed
              this.showEditSheet = true
            },
            onDelete: (i: number) => {
              this.delIndex = i
              this.delKeepProfile = true
              this.showDelDialog = true
            }
          })
        } else if (this.tabIndex1 === 3) {
          TopicTab204({
            topics: this.topics,
            onNight: () => {
              this.showNightSheet = true
            }
          })
        } else if (this.tabIndex1 === 4) {
          LeaderTab204({
            leaders: this.leaders
          })
        } else {
          MineTab204({
            gears: this.gears,
            campNights: this.campNights,
            onNew: () => {
              this.showGearSheet = true
            },
            onEdit: (i: number) => {
              this.editIndex = i
              this.editName = this.gears[i].name
              this.editQty = this.gears[i].qty
              this.editPacked = this.gears[i].packed
              this.showEditSheet = true
            },
            onDelete: (i: number) => {
              this.delIndex = i
              this.delKeepProfile = true
              this.showDelDialog = true
            }
          })
        }
      }
      .layoutWeight(1)
      .width('100%')

      this.tabBar204()
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5EFE6')
    .bindSheet($$this.showNightSheet, this.nightSheet204(), {
      height: 620,
      dragBar: true,
      showClose: false,
      backgroundColor: '#FFFFFF'
    })
    .bindSheet($$this.showGearSheet, this.gearSheet204(), {
      height: 600,
      dragBar: true,
      showClose: false,
      backgroundColor: '#FFFFFF'
    })
    .bindSheet($$this.showEditSheet, this.editSheet204(), {
      height: 520,
      dragBar: true,
      showClose: false,
      backgroundColor: '#FFFFFF'
    })
    .bindContentCover($$this.showDelDialog, this.delDialog204(), {
    })
    .bindContentCover($$this.showDetailDialog, this.detailDialog204(), {
    })
  }
}

// ================= Tab 1:篝火夜(直播) =================

@Component
struct LiveTab204 {
  @Prop barrages: Barrage204[] = []
  @Prop fireSteps: FireStep204[] = []
  @Prop topics: Topic204[] = []
  @State micOn: boolean = true
  @State camOn: boolean = true
  @State fireOn: boolean = true
  @State likeCount: number = 268
  onNight: () => void = () => {}
  onStep: (i: number) => void = () => {}

  build() {
    Scroll() {
      Column({ space: 12 }) {
        Row({ space: 10 }) {
          Column() {
            Text('LIVE').fontSize(9).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
          }
          .padding({ left: 8, right: 8, top: 3, bottom: 3 })
          .borderRadius(8)
          .backgroundColor('#E53935')
          Text('云顶松林营地 · 篝火夜话直播中').fontSize(13).fontColor('#33691E').fontWeight(FontWeight.Bold)
          Text('').layoutWeight(1)
          Text('🌡️ 山顶 9℃ · 微风').fontSize(10).fontColor('#90A4AE')
        }
        .width('100%')
        .padding(12)
        .borderRadius(12)
        .backgroundColor('#FFFFFF')

        Grid() {
          GridItem() {
            Column({ space: 4 }) {
              Text('').layoutWeight(1)
              Row({ space: 6 }) {
                Text('🔥 主镜 · 篝火特写').fontSize(11).fontColor('#FFFFFF')
                Text('986').fontSize(9).fontColor('#FFE0B2')
              }
            }
            .padding(8)
            .linearGradient({ angle: 140, colors: [['#FF8F00', 0], ['#E65100', 1]] })
          }
          GridItem() {
            Column({ space: 4 }) {
              Text('').layoutWeight(1)
              Row({ space: 6 }) {
                Text('🌌 星空机位').fontSize(11).fontColor('#FFFFFF')
                Text('延时中').fontSize(9).fontColor('#C5E1A5')
              }
            }
            .padding(8)
            .linearGradient({ angle: 140, colors: [['#283593', 0], ['#1A237E', 1]] })
          }
          GridItem() {
            Column({ space: 4 }) {
              Text('').layoutWeight(1)
              Row({ space: 6 }) {
                Text('⛺ 营地全景位').fontSize(11).fontColor('#FFFFFF')
                Text('灯串已亮').fontSize(9).fontColor('#C5E1A5')
              }
            }
            .padding(8)
            .linearGradient({ angle: 140, colors: [['#43A047', 0], ['#1B5E20', 1]] })
          }
          GridItem() {
            Column({ space: 4 }) {
              Text('').layoutWeight(1)
              Row({ space: 6 }) {
                Text('🎥 我的帐篷位').fontSize(11).fontColor('#FFFFFF')
                Text(this.camOn ? '已开启' : '已关闭').fontSize(9).fontColor(this.camOn ? '#C5E1A5' : '#FFCDD2')
              }
            }
            .padding(8)
            .linearGradient({ angle: 140, colors: [['#5D4037', 0], ['#3E2723', 1]] })
          }
        }
        .columnsTemplate('1fr 1fr')
        .rowsTemplate('1fr 1fr')
        .columnsGap(8)
        .rowsGap(8)
        .height(210)
        .borderRadius(14)
        .width('100%')

        Row({ space: 10 }) {
          Row({ space: 6 }) {
            Text(this.micOn ? '🎤' : '🔇').fontSize(14)
            Text('夜话连麦').fontSize(11).fontColor(this.micOn ? '#33691E' : '#90A4AE')
          }
          .padding({ left: 12, right: 12, top: 8, bottom: 8 })
          .borderRadius(16)
          .backgroundColor(this.micOn ? '#E8F5E9' : '#EFEBE9')
          .onClick(() => {
            this.micOn = !this.micOn
          })

          Row({ space: 6 }) {
            Text(this.camOn ? '📹' : '📷').fontSize(14)
            Text('我的机位').fontSize(11).fontColor(this.camOn ? '#33691E' : '#90A4AE')
          }
          .padding({ left: 12, right: 12, top: 8, bottom: 8 })
          .borderRadius(16)
          .backgroundColor(this.camOn ? '#E8F5E9' : '#EFEBE9')
          .onClick(() => {
            this.camOn = !this.camOn
          })

          Row({ space: 6 }) {
            Text('🔥').fontSize(14)
            Text(this.fireOn ? '旺火' : '文火').fontSize(11).fontColor(this.fireOn ? '#EF6C00' : '#90A4AE')
          }
          .padding({ left: 12, right: 12, top: 8, bottom: 8 })
          .borderRadius(16)
          .backgroundColor(this.fireOn ? '#FFF3E0' : '#EFEBE9')
          .onClick(() => {
            this.fireOn = !this.fireOn
          })

          Text('').layoutWeight(1)

          Row({ space: 6 }) {
            Text('🔥').fontSize(14)
            Text(this.likeCount + '').fontSize(11).fontColor('#EF6C00')
          }
          .padding({ left: 12, right: 12, top: 8, bottom: 8 })
          .borderRadius(16)
          .backgroundColor('#FFF3E0')
          .onClick(() => {
            this.likeCount++
          })
        }
        .width('100%')

        Column({ space: 10 }) {
          Text('生火六步 · 老鹿领队现场教学').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#3E2723')
          ForEach(this.fireSteps, (s: FireStep204, i: number) => {
            Row({ space: 10 }) {
              Column() {
                Text(s.done ? '✓' : (i + 1) + '')
                  .fontSize(12)
                  .fontColor('#FFFFFF')
                  .textAlign(TextAlign.Center)
                  .width(26)
                  .height(26)
                  .borderRadius(13)
                  .backgroundColor(s.done ? '#FF8F00' : '#BCAAA4')
                if (i < this.fireSteps.length - 1) {
                  Text('')
                    .width(2)
                    .layoutWeight(1)
                    .backgroundColor('#D7CCC8')
                    .margin({ top: 2, bottom: 2 })
                }
              }
              Column({ space: 3 }) {
                Row({ space: 8 }) {
                  Text(s.title).fontSize(12).fontWeight(FontWeight.Bold).fontColor(s.done ? '#EF6C00' : '#3E2723')
                  Text(s.done ? '已完成' : '待进行').fontSize(9).fontColor(s.done ? '#EF6C00' : '#8D6E63')
                }
                Text(s.tip).fontSize(10).fontColor('#90A4AE')
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              .padding({ top: 2, bottom: 8 })
              .onClick(() => {
                this.onStep(i)
              })
            }
            .alignItems(VerticalAlign.Top)
            .width('100%')
          }, (s: FireStep204) => s.id.toString())
        }
        .width('100%')
        .padding(12)
        .borderRadius(12)
        .backgroundColor('#FFFFFF')

        Column({ space: 8 }) {
          Text('今晚话题房').fontSize(12).fontWeight(FontWeight.Bold).fontColor('#3E2723')
          ForEach(this.topics, (t: Topic204) => {
            Row({ space: 8 }) {
              Text('🎙️').fontSize(14)
              Column({ space: 2 }) {
                Text(t.name).fontSize(11).fontWeight(FontWeight.Bold).fontColor('#3E2723')
                Text(t.host + ' · ' + t.joins + ' 人已就位').fontSize(9).fontColor('#90A4AE')
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              Text(t.state)
                .fontSize(8)
                .fontColor('#FFFFFF')
                .padding({ left: 5, right: 5, top: 2, bottom: 2 })
                .borderRadius(6)
                .backgroundColor(topicStateColor204(t.state))
            }
            .width('100%')
            .padding(8)
            .borderRadius(10)
            .backgroundColor('#F5EFE6')
          }, (t: Topic204) => t.id.toString())
        }
        .width('100%')
        .padding(12)
        .borderRadius(12)
        .backgroundColor('#FFFFFF')
        .alignItems(HorizontalAlign.Start)

        Column({ space: 8 }) {
          Text('弹幕 · 围炉闲聊').fontSize(12).fontWeight(FontWeight.Bold).fontColor('#3E2723')
          ForEach(this.barrages, (b: Barrage204, i: number) => {
            Text(b.text)
              .fontSize(11)
              .fontColor('#5D4037')
              .padding({ left: 10, right: 10, top: 5, bottom: 5 })
              .borderRadius(12)
              .backgroundColor(i % 2 === 0 ? '#E8F5E9' : '#FFF3E0')
              .margin({ left: (i % 3) * 36 })
              .alignSelf(ItemAlign.Start)
          }, (b: Barrage204) => b.id.toString())
        }
        .width('100%')
        .padding(12)
        .borderRadius(12)
        .backgroundColor('#FFFFFF')
        .alignItems(HorizontalAlign.Start)

        Button() {
          Text('预约明晚云露营夜').fontSize(13).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
        }
        .width('100%')
        .padding({ top: 11, bottom: 11 })
        .borderRadius(18)
        .backgroundColor('#2E7D32')
        .onClick(() => {
          this.onNight()
        })
      }
      .padding(12)
      .alignItems(HorizontalAlign.Start)
    }
    .scrollBar(BarState.Off)
    .width('100%')
    .height('100%')
  }
}

// ================= Tab 2:营地墙 =================

@Component
struct CampTab204 {
  @Prop camps: Camp204[] = []
  @Prop campNights: CampNight204[] = []
  onNight: () => void = () => {}
  onDetail: (i: number) => void = () => {}

  build() {
    Scroll() {
      Column({ space: 12 }) {
        Column({ space: 8 }) {
          Text('本周每晚围炉人数').fontSize(12).fontWeight(FontWeight.Bold).fontColor('#3E2723')
          Row({ space: 10 }) {
            ForEach(this.campNights, (n: CampNight204) => {
              Column({ space: 4 }) {
                Text(n.people + '').fontSize(9).fontColor('#2E7D32')
                Text('')
                  .width(16)
                  .height(n.people)
                  .borderRadius(4)
                  .linearGradient({ angle: 180, colors: [['#FFB74D', 0], ['#FF8F00', 1]] })
                Text(n.day.slice(1)).fontSize(9).fontColor('#78909C')
              }
            }, (n: CampNight204) => n.day)
          }
          .alignItems(VerticalAlign.Bottom)
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
        }
        .width('100%')
        .padding(12)
        .borderRadius(12)
        .backgroundColor('#FFFFFF')
        .alignItems(HorizontalAlign.Start)

        Text('热门营地').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#3E2723')

        ForEach(this.camps, (c: Camp204, i: number) => {
          Row({ space: 10 }) {
            Stack() {
              Column()
                .width(52)
                .height(52)
                .borderRadius(12)
                .backgroundColor(i % 2 === 0 ? '#E8F5E9' : '#FFF3E0')
              Text('⛺').fontSize(24)
              if (i < 2) {
                Text('HOT')
                  .fontSize(8)
                  .fontColor('#FFFFFF')
                  .padding({ left: 5, right: 5, top: 2, bottom: 2 })
                  .borderRadius(8)
                  .backgroundColor('#E53935')
                  .position({ x: 28, y: 40 })
              }
            }
            .width(52)
            .height(52)

            Column({ space: 4 }) {
              Row({ space: 6 }) {
                Text(c.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor('#3E2723')
                Text(c.state)
                  .fontSize(8)
                  .fontColor('#FFFFFF')
                  .padding({ left: 5, right: 5, top: 2, bottom: 2 })
                  .borderRadius(6)
                  .backgroundColor(campStateColor204(c.state))
              }
              Text(c.terrain + ' · 海拔 ' + c.altitude + ' 米 · 距城 ' + c.dist + ' km').fontSize(10).fontColor('#90A4AE')
              Row({ space: 6 }) {
                Text('⭐ ' + c.score).fontSize(10).fontColor('#EF6C00')
                Text('🔥 热度 ' + c.heat).fontSize(10).fontColor('#E53935')
              }
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Column() {
              Text('看详情').fontSize(11).fontColor('#FFFFFF')
            }
            .padding({ left: 12, right: 12, top: 7, bottom: 7 })
            .borderRadius(14)
            .backgroundColor('#2E7D32')
            .onClick(() => {
              this.onDetail(i)
            })
          }
          .width('100%')
          .padding(12)
          .borderRadius(14)
          .backgroundColor('#FFFFFF')
        }, (c: Camp204) => c.id.toString())
      }
      .padding(12)
      .alignItems(HorizontalAlign.Start)
    }
    .scrollBar(BarState.Off)
    .width('100%')
    .height('100%')
  }
}

// ================= Tab 3:装备库 =================

@Component
struct GearTab204 {
  @Prop gears: Gear204[] = []
  onNew: () => void = () => {}
  onEdit: (i: number) => void = () => {}
  onDelete: (i: number) => void = () => {}

  build() {
    Scroll() {
      Column({ space: 12 }) {
        Row({ space: 8 }) {
          Column({ space: 2 }) {
            Text(this.gears.length + '').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#2E7D32')
            Text('清单总数').fontSize(9).fontColor('#90A4AE')
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .padding({ top: 8, bottom: 8 })
          .borderRadius(12)
          .backgroundColor('#FFFFFF')
          Column({ space: 2 }) {
            Text(packedGearCount204(this.gears) + '').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#EF6C00')
            Text('已装车').fontSize(9).fontColor('#90A4AE')
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .padding({ top: 8, bottom: 8 })
          .borderRadius(12)
          .backgroundColor('#FFFFFF')
          Column({ space: 2 }) {
            Text(urgentGearCount204(this.gears) + '').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#E53935')
            Text('急需补货').fontSize(9).fontColor('#90A4AE')
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .padding({ top: 8, bottom: 8 })
          .borderRadius(12)
          .backgroundColor('#FFFFFF')
        }
        .width('100%')

        Column({ space: 8 }) {
          Text('装备分类占比').fontSize(12).fontWeight(FontWeight.Bold).fontColor('#3E2723')
          Row() {
            ForEach(gearCatCounts204(this.gears), (t: GearCount204) => {
              Column() {
              }
              .layoutWeight(t.count > 0 ? t.count : 1)
              .height(14)
              .backgroundColor(t.color)
            }, (t: GearCount204) => t.label)
          }
          .width('100%')
          .borderRadius(7)
          .clip(true)
          Row({ space: 8 }) {
            ForEach(gearCatCounts204(this.gears), (t: GearCount204) => {
              Row({ space: 4 }) {
                Text('').width(8).height(8).borderRadius(2).backgroundColor(t.color)
                Text(t.label + ' ' + t.count).fontSize(9).fontColor('#78909C')
              }
            }, (t: GearCount204) => t.label)
          }
          .width('100%')
        }
        .width('100%')
        .padding(12)
        .borderRadius(12)
        .backgroundColor('#FFFFFF')
        .alignItems(HorizontalAlign.Start)

        Row({ space: 8 }) {
          Text('我的打包清单').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#3E2723')
          Text('').layoutWeight(1)
          Column() {
            Text('+ 新增').fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
          }
          .padding({ left: 12, right: 12, top: 6, bottom: 6 })
          .borderRadius(14)
          .backgroundColor('#FF8F00')
          .onClick(() => {
            this.onNew()
          })
        }
        .width('100%')

        ForEach(this.gears, (g: Gear204, i: number) => {
          Row({ space: 10 }) {
            Column() {
              Text(g.packed ? '✅' : '📦').fontSize(18)
            }
            .width(40)
            .height(40)
            .borderRadius(10)
            .backgroundColor('#F5EFE6')
            .justifyContent(FlexAlign.Center)

            Column({ space: 3 }) {
              Row({ space: 6 }) {
                Text(g.name).fontSize(12).fontWeight(FontWeight.Bold).fontColor('#3E2723')
                if (g.urgent) {
                  Text('急需').fontSize(8).fontColor('#FFFFFF').padding({ left: 5, right: 5, top: 2, bottom: 2 }).borderRadius(6).backgroundColor('#E53935')
                }
              }
              Row({ space: 6 }) {
                Text(g.cat).fontSize(9).fontColor('#FFFFFF').padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(6).backgroundColor(gearCatColor204(g.cat))
                Text('× ' + g.qty).fontSize(9).fontColor('#90A4AE')
                Text(g.packed ? '已装车' : '待打包').fontSize(9).fontColor(g.packed ? '#2E7D32' : '#8D6E63')
              }
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Row({ space: 6 }) {
              Text('编辑').fontSize(10).fontColor('#2E7D32').padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8).backgroundColor('#E8F5E9')
                .onClick(() => {
                  this.onEdit(i)
                })
              Text('删除').fontSize(10).fontColor('#E53935').padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8).backgroundColor('#FFEBEE')
                .onClick(() => {
                  this.onDelete(i)
                })
            }
          }
          .width('100%')
          .padding(10)
          .borderRadius(12)
          .backgroundColor('#FFFFFF')
        }, (g: Gear204) => g.id.toString())
      }
      .padding(12)
      .alignItems(HorizontalAlign.Start)
    }
    .scrollBar(BarState.Off)
    .width('100%')
    .height('100%')
  }
}

// ================= Tab 4:行程 =================

@Component
struct TopicTab204 {
  @Prop topics: Topic204[] = []
  onNight: () => void = () => {}

  build() {
    Scroll() {
      Column({ space: 12 }) {
        Column({ space: 8 }) {
          Text('话题参与人数').fontSize(12).fontWeight(FontWeight.Bold).fontColor('#3E2723')
          Row({ space: 10 }) {
            ForEach(this.topics, (t: Topic204) => {
              Column({ space: 4 }) {
                Text(t.joins + '').fontSize(9).fontColor('#EF6C00')
                Text('')
                  .width(16)
                  .height(t.joins)
                  .borderRadius(4)
                  .linearGradient({ angle: 180, colors: [['#FFCC80', 0], ['#EF6C00', 1]] })
                Text(t.name.slice(0, 2)).fontSize(8).fontColor('#78909C')
              }
            }, (t: Topic204) => t.id.toString())
          }
          .alignItems(VerticalAlign.Bottom)
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
        }
        .width('100%')
        .padding(12)
        .borderRadius(12)
        .backgroundColor('#FFFFFF')
        .alignItems(HorizontalAlign.Start)

        ForEach(this.topics, (t: Topic204, i: number) => {
          Column({ space: 10 }) {
            Row({ space: 10 }) {
              Column({ space: 2 }) {
                Text('0' + (i + 1)).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#2E7D32')
              }
              Column({ space: 4 }) {
                Row({ space: 6 }) {
                  Text(t.name).fontSize(12).fontWeight(FontWeight.Bold).fontColor('#3E2723')
                  Text(t.state)
                    .fontSize(8)
                    .fontColor('#FFFFFF')
                    .padding({ left: 5, right: 5, top: 2, bottom: 2 })
                    .borderRadius(6)
                    .backgroundColor(topicStateColor204(t.state))
                }
                Text('主理人:' + t.host + ' · ' + t.joins + ' 人已就位').fontSize(10).fontColor('#90A4AE')
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              Column() {
                Text('加入').fontSize(11).fontColor('#FFFFFF')
              }
              .padding({ left: 14, right: 14, top: 7, bottom: 7 })
              .borderRadius(14)
              .backgroundColor('#FF8F00')
              .onClick(() => {
                this.onNight()
              })
            }
            .width('100%')

            Row() {
              Text('')
                .height(5)
                .borderRadius(3)
                .layoutWeight(t.joins)
                .backgroundColor('#FF8F00')
              Text('')
                .height(5)
                .borderRadius(3)
                .layoutWeight(100 - t.joins)
                .backgroundColor('#EFEBE9')
            }
            .width('100%')
            .clip(true)
          }
          .width('100%')
          .padding(12)
          .borderRadius(14)
          .backgroundColor('#FFFFFF')
        }, (t: Topic204) => t.id.toString())
      }
      .padding(12)
      .alignItems(HorizontalAlign.Start)
    }
    .scrollBar(BarState.Off)
    .width('100%')
    .height('100%')
  }
}

// ================= Tab 5:领队团 =================

@Component
struct LeaderTab204 {
  @Prop leaders: Leader204[] = []

  build() {
    Scroll() {
      Column({ space: 12 }) {
        Column({ space: 10 }) {
          Text('领队人气榜').fontSize(12).fontWeight(FontWeight.Bold).fontColor('#3E2723')
          ForEach(this.leaders, (l: Leader204, i: number) => {
            Row({ space: 8 }) {
              Text(l.name).fontSize(11).fontColor('#3E2723').width(64)
              Row() {
                Text('')
                  .height(10)
                  .borderRadius(5)
                  .backgroundColor(i === 0 ? '#FF8F00' : (i === 1 ? '#2E7D32' : '#FBC02D'))
                  .width((l.heat / 100 * 100) + '%')
                Text('')
                  .layoutWeight(1)
                  .height(10)
              }
              .layoutWeight(1)
              .borderRadius(5)
              .clip(true)
              Text(l.heat + '').fontSize(10).fontColor('#90A4AE').width(24)
            }
            .width('100%')
          }, (l: Leader204) => l.id.toString())
        }
        .width('100%')
        .padding(12)
        .borderRadius(12)
        .backgroundColor('#FFFFFF')
        .alignItems(HorizontalAlign.Start)

        Text('在线领队 · ' + onlineLeaderCount204(this.leaders) + ' 位').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#3E2723')

        ForEach(this.leaders, (l: Leader204) => {
          Row({ space: 10 }) {
            Stack() {
              Column()
                .width(46)
                .height(46)
                .borderRadius(23)
                .backgroundColor('#E8F5E9')
              Text('🧑‍🌾').fontSize(20)
              if (l.online) {
                Text('')
                  .width(10)
                  .height(10)
                  .borderRadius(5)
                  .backgroundColor('#2E7D32')
                  .position({ x: 34, y: 34 })
              }
            }
            .width(46)
            .height(46)

            Column({ space: 4 }) {
              Row({ space: 6 }) {
                Text(l.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor('#3E2723')
                Text(l.online ? '在线' : '离线').fontSize(8).fontColor('#FFFFFF').padding({ left: 5, right: 5, top: 2, bottom: 2 }).borderRadius(6).backgroundColor(l.online ? '#2E7D32' : '#B0BEC5')
              }
              Text(l.city + ' · 带队 ' + l.years + ' 年 · 人气 ' + l.heat).fontSize(10).fontColor('#90A4AE')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Column() {
              Text('约带队').fontSize(11).fontColor('#FFFFFF')
            }
            .padding({ left: 14, right: 14, top: 7, bottom: 7 })
            .borderRadius(14)
            .backgroundColor('#43A047')
          }
          .width('100%')
          .padding(12)
          .borderRadius(14)
          .backgroundColor('#FFFFFF')
        }, (l: Leader204) => l.id.toString())
      }
      .padding(12)
      .alignItems(HorizontalAlign.Start)
    }
    .scrollBar(BarState.Off)
    .width('100%')
    .height('100%')
  }
}

// ================= Tab 6:我的 =================

@Component
struct MineTab204 {
  @Prop gears: Gear204[] = []
  @Prop campNights: CampNight204[] = []
  onNew: () => void = () => {}
  onEdit: (i: number) => void = () => {}
  onDelete: (i: number) => void = () => {}

  build() {
    Scroll() {
      Column({ space: 12 }) {
        Row({ space: 12 }) {
          Column()
            .width(56)
            .height(56)
            .borderRadius(28)
            .linearGradient({ angle: 140, colors: [['#A5D6A7', 0], ['#2E7D32', 1]] })
            .justifyContent(FlexAlign.Center)
          Column({ space: 4 }) {
            Text('山系青年').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#3E2723')
            Text('云露营 18 晚 · 解锁 6 个营地 · 收藏 23 件装备').fontSize(10).fontColor('#90A4AE')
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
        }
        .width('100%')
        .padding(14)
        .borderRadius(14)
        .linearGradient({ angle: 140, colors: [['#E8F5E9', 0], ['#FFF8E1', 1]] })

        Column({ space: 8 }) {
          Text('我的围炉出勤(本周)').fontSize(12).fontWeight(FontWeight.Bold).fontColor('#3E2723')
          Row({ space: 10 }) {
            ForEach(this.campNights, (n: CampNight204) => {
              Column({ space: 4 }) {
                Text(n.people + '').fontSize(9).fontColor('#EF6C00')
                Text('')
                  .width(16)
                  .height(n.people)
                  .borderRadius(4)
                  .linearGradient({ angle: 180, colors: [['#FFCC80', 0], ['#EF6C00', 1]] })
                Text(n.day.slice(1)).fontSize(9).fontColor('#78909C')
              }
            }, (n: CampNight204) => n.day)
          }
          .alignItems(VerticalAlign.Bottom)
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
        }
        .width('100%')
        .padding(12)
        .borderRadius(12)
        .backgroundColor('#FFFFFF')
        .alignItems(HorizontalAlign.Start)

        Row({ space: 8 }) {
          Text('我的装备管理').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#3E2723')
          Text('').layoutWeight(1)
          Column() {
            Text('+ 新增装备').fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
          }
          .padding({ left: 12, right: 12, top: 6, bottom: 6 })
          .borderRadius(14)
          .backgroundColor('#FF8F00')
          .onClick(() => {
            this.onNew()
          })
        }
        .width('100%')

        ForEach(this.gears, (g: Gear204, i: number) => {
          Row({ space: 10 }) {
            Column() {
              Text(g.packed ? '✅' : '📦').fontSize(18)
            }
            .width(40)
            .height(40)
            .borderRadius(10)
            .backgroundColor('#FFF3E0')
            .justifyContent(FlexAlign.Center)

            Column({ space: 3 }) {
              Text(g.name).fontSize(12).fontWeight(FontWeight.Bold).fontColor('#3E2723')
              Text(g.cat + ' × ' + g.qty).fontSize(9).fontColor('#90A4AE')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Row({ space: 6 }) {
              Text('编辑').fontSize(10).fontColor('#2E7D32').padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8).backgroundColor('#E8F5E9')
                .onClick(() => {
                  this.onEdit(i)
                })
              Text('删除').fontSize(10).fontColor('#E53935').padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8).backgroundColor('#FFEBEE')
                .onClick(() => {
                  this.onDelete(i)
                })
            }
          }
          .width('100%')
          .padding(10)
          .borderRadius(12)
          .backgroundColor('#FFFFFF')
        }, (g: Gear204) => ('m' + g.id))

        Text('篝语 · 云露营夜 v1.8.2 · 把日子过到野外去').fontSize(10).fontColor('#BCAAA4').margin({ top: 8, bottom: 20 })
      }
      .padding(12)
      .alignItems(HorizontalAlign.Start)
    }
    .scrollBar(BarState.Off)
    .width('100%')
    .height('100%')
  }
}

总结

在这里插入图片描述

从命令式到声明式,从虚拟DOM到原生声明式,移动端UI开发的演进史是一部不断减轻开发者认知负担的历史。这款云露营夜话应用的代码,在演进坐标系的每一个维度上都展现了HarmonyOS ArkTS作为"第三代声明式UI框架"的技术优势。底部火苗导航的不对称圆角、bindSheet的双向绑定弹窗、@State的编译期状态追踪、纯布局柱图的零依赖渲染、Flex弹性换行的标签选择器——每一个实现都对照着传统方案中的痛点,用更简洁的语法、更少的样板代码、更强的自动化能力解决了相同的问题。

从横向比较来看,ArkTS在几个关键维度上超越了前两代方案。首先是状态追踪的自动化程度——Android需要手动调用刷新方法,React需要运行时虚拟DOM diff和useMemo依赖管理,而ArkTS在编译期就生成了状态追踪代码,运行时零开销。其次是弹窗生命周期管理——Android的Fragment生命周期有十余个回调,React需要手动管理visible状态和关闭动画,而ArkTS的$$双向绑定将显示和隐藏统一为一个原子操作。再次是视觉效果的声明能力——不对称圆角、属性级动画、线性渐变在传统方案中需要XML文件、第三方库或大量代码,在ArkTS中都是一个属性值。

第一是"按需选库"的工程成熟度——简单图表用纯布局实现而非引入MPAndroidChart,只有在需要复杂交互时才考虑第三方依赖。第二是"不可变更新"的最佳实践——所有数组操作都使用map/filter创建新引用,确保状态检测可靠工作,这从React时代延续至今已成为业界共识。第三是"语义化配色"的深入运用——松林绿对应露营主题、篝火橙对应行动按钮、帆布米对应装饰元素,颜色不只是视觉装饰,更是信息编码和品牌语言。技术演进的本质不是追求新,而是追求简——用更少的代码、更少的依赖、更少的样板来表达相同的意图。HarmonyOS ArkTS正在这条简化的道路上稳步前行,而这款云露营应用,就是这条道路上的一个清晰路标。

Logo

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

更多推荐