HarmonyOS 6效果实现:bindSheet / bindContentCover 统一管理浮层高内聚低耦合的页面结构
回顾移动端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-modal或react-native-bottom-sheet。这些库通常需要手动管理弹窗的可见性状态(visibleprop),在关闭时处理动画完成回调,还要处理Android和iOS的平台差异。弹窗的内容需要在JSX中条件渲染({visible && <Modal>...</Modal>}),关闭时手动设置setVisible(false)。
HarmonyOS ArkTS的弹窗系统代表了演进的最新阶段。bindSheet和bindContentCover是组件级方法——它们被直接链式调用在根组件上,将弹窗的显示状态与@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选择了一条不同的路线——用纯布局组件实现简单图表。这段温度柱图的实现完全基于Column、Text和Row三个基础组件。每根柱子是一个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中,整个选择逻辑被简化为一个状态变量和一个条件表达式。框架负责"根据状态自动推导样式",开发者只需声明"选中时是什么样、未选中时是什么样",无需手动管理样式切换的命令式操作。
核心架构演进对照流程图
七、头部渐变与电商大促条的演化
这款应用的头部设计融合了品牌展示和促销引导两个功能,其技术实现也体现了演进痕迹。
// ---------- 头部(电商出游季风,无动画) ----------
@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正在这条简化的道路上稳步前行,而这款云露营应用,就是这条道路上的一个清晰路标。
更多推荐


所有评论(0)