一、技术背景

1.1 HarmonyOS 声明式 UI 框架

HarmonyOS 采用声明式 UI 开发范式,开发者只需描述 UI 的"最终状态",系统便会自动处理中间的渲染差异与状态同步。这种开发模式与传统命令式 UI 相比,大幅降低了界面逻辑的复杂度,让开发者能够将更多精力聚焦于业务本身。声明式 UI 的核心在于组件化与数据驱动,每一个 @Component 装饰的结构体都是一个独立的可复用单元,其内部的 build() 方法描述了该组件的渲染结构。当组件所依赖的状态变量发生变化时,框架会自动触发重新渲染,确保界面始终与数据保持一致。

声明式 UI 框架的另一大优势在于其内置的布局系统。基于 Flex 布局模型的 RowColumnStack 等基础容器组件,配合 layoutWeightjustifyContentalignItems 等属性,能够快速构建出各种复杂的界面结构。同时,@Builder 装饰器提供了一种轻量级的 UI 抽离机制,允许开发者将重复的 UI 片段封装为可调用的构建函数,进一步提升代码的可维护性与复用性。对于中大型应用而言,合理使用 @Builder 能够有效控制 build() 方法的体积,使代码结构更加清晰。

在性能方面,声明式 UI 框架采用了精细的 diff 算法与组件复用机制。ForEach 循环渲染通过 keyGenerator 参数为每个子项生成唯一标识,确保在数据变化时仅更新必要的组件,而非全量重建。这种按需更新的策略在长列表场景中尤为重要,能够显著降低渲染开销,提升滑动流畅度。此外,框架还提供了 @State@Prop@Link@Observed 等多种状态装饰器,覆盖了从组件内部状态到跨组件共享状态的完整场景,开发者可以根据数据的作用范围选择最合适的状态管理方式。
在这里插入图片描述

1.2 ArkTS 语言特性

ArkTS 是 HarmonyOS 的主力应用开发语言,它在 TypeScript 的基础上进行了扩展与优化,既保留了 TypeScript 灵活易用的特性,又通过静态类型检查与运行时优化提供了接近原生语言的性能表现。ArkTS 支持类、接口、枚举、泛型等现代编程语言特性,同时引入了装饰器(Decorator)机制,用于标记组件、状态变量、生命周期方法等特殊语义。这种基于装饰器的声明式编程风格,使得代码的意图更加明确,框架也能够更好地进行静态分析与优化。

在类型系统方面,ArkTS 强化了类型推断与类型安全。通过显式的类型注解,开发者可以在编译阶段发现大量潜在的类型错误,减少运行时异常的发生。对于 UI 开发而言,类型安全尤为重要——颜色值、尺寸单位、布局参数等都能够通过类型系统进行约束,避免因参数传递错误导致的界面异常。此外,ArkTS 还支持可选链、空值合并、解构赋值等现代语法特性,让代码更加简洁优雅。

ArkTS 的另一个显著特点是其与方舟编译器(ArkCompiler)的深度集成。方舟编译器能够将 ArkTS 代码编译为高效的字节码或机器码,在运行时提供出色的执行性能。特别是在计算密集型场景,如动画计算、数据处理等方面,编译优化带来的性能提升尤为明显。同时,方舟运行时(ArkRuntime)提供了高效的内存管理与垃圾回收机制,能够有效减少应用的内存占用与卡顿现象,为用户带来流畅的使用体验。

1.3 HarmonyOS 生态与跨设备能力

HarmonyOS 作为一款面向全场景的分布式操作系统,其核心优势在于跨设备协同与分布式能力。通过统一的分布式软总线技术,不同设备之间可以实现高速、低延迟的通信与数据共享,为开发者构建跨设备应用提供了坚实的基础。在 UI 层面,HarmonyOS 提供了多态控件与自适应布局机制,使得同一套代码能够在手机、平板、手表、车机等不同形态的设备上呈现出最佳的显示效果。这种"一次开发,多端部署"的开发模式,极大地降低了跨设备应用的开发成本。

在分布式数据管理方面,HarmonyOS 提供了分布式数据服务(Distributed Data Service),支持应用数据在多设备之间的自动同步与持久化。对于苔藓微景观这类偏生活方式的应用而言,用户可以在手机上查看养护日历,在平板上浏览苔景画廊,数据始终保持一致。此外,HarmonyOS 还提供了原子化服务(Atomic Service)能力,允许应用以免安装的形式被用户获取和使用,进一步降低了用户的使用门槛,扩大了应用的触达范围。

从生态角度来看,HarmonyOS 拥有丰富的 API 能力与开发工具链。DevEco Studio 提供了完整的集成开发环境,包括代码编辑、调试、预览、性能分析等功能,为开发者提供了高效的开发体验。同时,HarmonyOS 还提供了丰富的系统能力接口,涵盖相机、位置、传感器、网络等多个领域,开发者可以充分调用设备能力,打造功能丰富、体验出色的应用。对于垂直行业应用而言,完善的生态意味着更少的底层开发工作,开发者可以更加专注于业务创新与用户体验的提升。
在这里插入图片描述

1.4 @Observed 装饰器与状态管理机制

在 HarmonyOS 的状态管理体系中,@Observed 装饰器扮演着至关重要的角色。它用于标记需要被观察的数据类,使得该类的实例属性变化能够被 UI 框架捕获并触发重新渲染。与 @State 装饰器不同,@Observed 作用于类本身而非具体的变量,它使得整个类的实例都具备了响应式能力。当使用 @Observed 装饰的类的实例被 @State@Link@ObjectLink 装饰的变量引用时,框架会自动监听实例属性的变化,并在变化发生时更新相关的 UI 组件。

@Observed 装饰器的工作原理基于对象属性的劫持与观察者模式。当一个被 @Observed 标记的类的实例属性被修改时,框架会通知所有依赖该属性的组件进行更新。这种细粒度的状态追踪机制,确保了只有真正发生变化的部分才会触发重新渲染,从而最大化渲染性能。在实际开发中,对于结构较为复杂的数据模型,如用户信息、商品列表、动态数据等,使用 @Observed 装饰的类来封装数据是一种最佳实践。

状态管理的另一个重要概念是状态的作用域与传递方式。@State 用于组件内部的私有状态,@Prop 用于父组件向子组件的单向数据传递,@Link 用于父子组件之间的双向数据绑定,而 @Provide/@Consume 则用于跨层级的状态共享。合理选择状态装饰器,能够有效降低组件之间的耦合度,提升代码的可维护性。对于本应用中的动态列表、生态瓶数据、养护任务等数据模型,采用 @Observed 类配合 @State 数组的方式,既保证了数据的响应式更新,又使得数据结构清晰易读。
在这里插入图片描述

1.5 苔藓微景观行业的技术特点与挑战

苔藓微景观是近年来兴起的一种小众生活方式,它将苔藓植物与微缩造景相结合,打造出"一平方厘米的森林"般的微观世界。从技术角度来看,这类应用面临着几个独特的挑战。首先是数据的专业性与多样性——苔藓品种繁多,每种苔藓的养护条件(湿度、光照、温度)各不相同,如何以直观的方式呈现这些专业数据,是设计时需要重点考虑的问题。本应用通过湿度进度条、光照标签、温度徽章等多种视觉元素,将抽象的养护参数转化为用户易于理解的图形化信息。

其次是养护管理的时序性。苔藓养护是一个持续性的过程,涉及喷水、通风、修剪等周期性任务。应用需要提供清晰的养护日历与待办管理功能,帮助用户建立规律的养护习惯。本应用中的养护日历模块,通过周视图的方式展示每日养护任务,并支持任务的完成勾选与编辑删除,为用户提供了完整的养护管理闭环。同时,"下次护理"的提醒机制也能够有效降低用户的记忆负担。

第三个挑战是社区氛围的营造。苔藓爱好者群体相对小众但粘性很高,用户之间的经验分享与作品展示是增强社区活力的关键。本应用的苔友圈模块提供了动态发布、点赞评论等社交功能,通过用户生成内容(UGC)的方式构建社区生态。此外,苔景画廊与品种图鉴等内容模块,既满足了用户的浏览学习需求,也为社区内容的生产提供了素材与灵感。如何在保证内容质量的同时提升用户参与度,是这类垂直社区应用需要持续探索的方向。


二、项目概览

在这里插入图片描述

本应用是一款面向苔藓微景观爱好者的综合性工具与社区应用,涵盖了品种图鉴、生态瓶管理、养护日历、作品展示与社交互动等核心功能。整体采用清新苔绿亮底的配色方案,以 #F2F7EF 作为页面背景色,以 #3E7B4F 深苔绿作为主色调,配合琥珀色的辅助色 #C98A2D,营造出自然、治愈、富有生机的视觉氛围。界面顶部为深绿色的头部导航栏,中部为可横向滚动的内容分类 Tab,底部为四栏式的主导航栏,整体结构清晰、层次分明。

应用的功能模块可以分为六大板块:精选首页、苔藓图鉴、生态瓶工位、养护日历、苔景画廊和苔友圈。精选首页聚合了本周湿度变化图表、热门生态瓶排行与最新苔友动态,为用户提供一站式的信息概览;苔藓图鉴展示了十种常见苔藓品种的详细信息,包括科属、难度等级、湿度偏好与适宜温度;生态瓶工位用于管理用户拥有的各个微景观瓶,展示每个瓶子的苔藓种类数量、养护进度与生长状态;养护日历以周为单位规划养护任务,支持任务的勾选完成、编辑与删除;苔景画廊以瀑布流的形式展示精选苔藓造景作品,并附带品种分布统计图表;苔友圈则是社区互动模块,用户可以发布动态、浏览他人分享并进行互动。

在交互设计方面,应用采用了双层 Tab 导航架构。底部主导航分为首页、工坊、养护、我的四个入口,每个入口下又包含六个内容子 Tab,通过不同的默认展示页面与排序方式来适应用户在不同场景下的使用需求。例如,点击"工坊"Tab 后默认展示生态瓶工位,点击"养护"Tab 后默认展示养护日历,这种设计使得用户能够快速直达核心功能。同时,应用还设计了三种弹框交互——新增苔友动态、编辑养护任务、删除确认,分别对应不同的操作场景,弹框采用半透明遮罩加居中卡片的经典样式,操作流程简洁明了。

在视觉特效方面,应用实现了水珠凝结下落与雾气飘动两种动态效果。水珠效果通过九个小圆点以不同速度沿竖直方向下落来模拟,水珠在下落初期呈现高透明度的凝露状态,随后逐渐变淡消失。雾气效果则通过五个半透明白色椭圆在水平方向上缓慢飘移来模拟,配合呼吸般的透明度变化,营造出湿润朦胧的氛围。这两种特效叠加在页面最上层,并设置了 hitTestBehavior(HitTestMode.None) 以确保不影响下层交互,既增强了视觉表现力,又不会对用户操作造成干扰。

整体架构采用单页面组件化设计,所有功能模块都封装在一个 @Entry 主组件中,通过 @Builder 装饰器将各个 UI 片段抽离为独立的构建方法。状态管理方面,使用 @State 装饰器管理组件级状态,使用 @Observed 装饰的数据类封装业务数据模型。这种架构在中小型应用中具有结构简单、开发效率高的优势,同时通过合理的代码组织与方法抽离,也保证了代码的可读性与可维护性。


三、逐段代码分析

3.1 ColorPalette 接口:颜色体系的类型化定义

在这里插入图片描述

interface ColorPalette {
  primary: string
  primaryDeep: string
  primaryLight: string
  accent: string
  accentLight: string
  bg: string
  cardBg: string
  title: string
  subTitle: string
  hint: string
  border: string
  success: string
  warning: string
  danger: string
  white: string
  night: string
}

ColorPalette 接口定义了应用完整的颜色体系,共包含十六个颜色字段,覆盖了从主色到辅助色、从背景色到文字色、从状态色到特殊色的完整色板。这种将颜色集中定义为接口的做法,体现了设计系统(Design System)的思想——通过统一的颜色命名与规范,确保整个应用在视觉上的一致性。

接口中的字段命名遵循了清晰的语义化原则。primaryprimaryDeepprimaryLight 构成了主色系的三个层次,分别用于主要强调、深色背景与浅色填充;accentaccentLight 构成了辅助色系,用于突出次要信息与点缀;bgcardBg 负责页面与卡片的背景层次;titlesubTitlehint 则定义了三级文字颜色,分别对应标题、副标题与提示文字,形成了清晰的文字信息层级。

状态色方面,successwarningdanger 三种颜色分别对应成功、警告与危险三种语义状态,用于养护状态标识、任务提醒与删除操作等场景。此外,whitenight 作为两个极值颜色,分别用于纯白背景与极深文字,确保在特殊场景下的颜色可用性。这种系统化的颜色定义方式,不仅方便了开发时的调用,也为后续的主题切换或深色模式适配预留了扩展空间。

3.2 COLORS 常量:清新苔绿配色方案的具体实现

在这里插入图片描述

const COLORS: ColorPalette = {
  primary: '#3E7B4F',
  primaryDeep: '#2C5C3B',
  primaryLight: '#D9EBDD',
  accent: '#C98A2D',
  accentLight: '#F5E8CC',
  bg: '#F2F7EF',
  cardBg: '#FFFFFF',
  title: '#23382B',
  subTitle: '#5E7668',
  hint: '#9FB5A6',
  border: '#E2EEDD',
  success: '#57A773',
  warning: '#E0A64A',
  danger: '#D96B5F',
  white: '#FFFFFF',
  night: '#2B4632'
};

COLORS 常量是 ColorPalette 接口的具体实例化,定义了应用"清新苔绿亮底"的完整配色方案。主色 #3E7B4F 是一种饱和度适中的苔绿色,既不过于鲜艳刺眼,也不过于暗淡无光,恰好传达出自然、沉静、富有生命力的视觉感受。primaryDeep #2C5C3B 作为更深的版本,主要用于头部导航栏等需要稳重感的区域;primaryLight #D9EBDD 则作为浅苔绿,用于标签背景、进度条底色等填充场景。

辅助色选择了琥珀色 #C98A2D,这种暖色调与苔绿色形成了柔和的对比,既能够起到强调作用,又不会破坏整体的自然氛围。accentLight #F5E8CC 作为浅琥珀色,用于辅助色的浅色填充,与 primaryLight 形成呼应。背景色 #F2F7EF 是一种带有淡淡绿调的米白色,比纯白更温暖,比纯绿更清新,为整个应用奠定了"亮底"的基调。

文字颜色的设计也颇具匠心,三级文字色 #23382B#5E7668#9FB5A6 都带有绿色倾向,与整体配色协调统一。同时,三级颜色之间保持了足够的对比度——标题色与背景色的对比度约为 12:1,副标题色约为 5.5:1,提示色约为 2.8:1,既保证了可读性,又形成了清晰的视觉层次。状态色的设计同样遵循了这一原则,成功绿、警告黄、危险红都在各自语义的基础上进行了柔化处理,与整体的清新风格保持一致。

3.3 数据接口定义:MossMeta、NavItem 与图表数据结构

在这里插入图片描述

interface MossMeta {
  label: string
  icon: string
  color: string
}

interface NavItem {
  icon: string
  label: string
}

interface HumChartItem {
  label: string
  value: number
}

interface SpecChartItem {
  label: string
  value: number
  color: string
}

这四个接口分别定义了应用中不同场景下的数据结构,体现了 TypeScript/ArkTS 中接口对数据形状的约束能力。MossMeta 接口用于描述苔藓难度等级的元信息,包含标签文字、图标符号与对应颜色三个字段。这种将难度等级的视觉表现抽象为元数据的做法,使得难度等级的展示逻辑与数据本身解耦,便于后续统一调整或扩展难度等级体系。

NavItem 接口定义了底部导航栏的数据结构,包含图标与标签两个字段。这种结构简单而实用,通过 ForEach 遍历 NAV_LIST 数组即可生成完整的底部导航。将导航项数据与 UI 渲染逻辑分离,使得导航项的增删修改变得极为便捷——只需修改数据数组即可,无需改动渲染代码。这种数据驱动的设计思想,正是声明式 UI 框架的精髓所在。

HumChartItemSpecChartItem 两个接口分别对应湿度图表与品种分布图表的数据结构。HumChartItem 包含标签与数值,用于展示一周内每天的湿度变化;SpecChartItem 在标签与数值的基础上增加了颜色字段,用于展示不同苔藓品种的分布占比,每个品种对应不同的颜色。为图表数据定义专门的接口,既保证了数据格式的一致性,也提高了代码的可读性——开发者通过接口名称即可明确数据的用途与结构。

3.4 PostItem 数据模型:苔友动态的响应式封装

@Observed
export class PostItem {
  id: number = 0
  nick: string = ''
  avatar: string = ''
  content: string = ''
  likes: number = 0
  comments: number = 0
  tag: string = ''
  time: string = ''
  constructor(id: number, nick: string, avatar: string, content: string,
              likes: number, comments: number, tag: string, time: string) {
    this.id = id; this.nick = nick; this.avatar = avatar; this.content = content
    this.likes = likes; this.comments = comments; this.tag = tag; this.time = time
  }
}

PostItem 类是苔友圈动态的数据模型,使用 @Observed 装饰器标记,使其具备响应式能力。该类包含八个属性:id 作为唯一标识,nickavatar 分别表示发布者的昵称与头像,content 存储动态正文内容,likescomments 分别记录点赞数与评论数,tag 标识动态所属的分类标签,time 记录发布时间。这些字段完整覆盖了一条社交动态所需的核心信息。

构造函数采用了参数赋值的简洁写法,将八个参数一次性赋值给对应的实例属性。值得注意的是,代码中使用了分号分隔的多行赋值方式(this.id = id; this.nick = nick; ...),这种写法在功能上与逐行赋值完全一致,但能够减少代码行数,使数据类的定义更加紧凑。对于属性较多的数据模型类,这种写法能够在保证可读性的前提下提升代码的凝练度。

@Observed 装饰器的使用是该类的关键技术点。它使得 PostItem 的每一个实例属性都能被框架追踪变化。例如,当用户点赞某条动态时,只需修改 p.likes = p.likes + 1,框架便会自动检测到 likes 属性的变化,并更新界面上对应的点赞数字显示。这种细粒度的属性级响应式更新,比替换整个数组元素的方式更加高效,因为它只会触发依赖该属性的具体 UI 组件重新渲染,而非整条动态卡片的完整重建。

3.5 MossItem 数据模型:苔藓品种的专业信息结构

@Observed
export class MossItem {
  id: number = 0
  name: string = ''
  icon: string = ''
  family: string = ''
  humidity: number = 0
  light: string = ''
  temp: string = ''
  growth: string = ''
  difficulty: string = ''
  desc: string = ''
  constructor(id: number, name: string, icon: string, family: string,
              humidity: number, light: string, temp: string, growth: string,
              difficulty: string, desc: string) {
    this.id = id; this.name = name; this.icon = icon; this.family = family
    this.humidity = humidity; this.light = light; this.temp = temp
    this.growth = growth; this.difficulty = difficulty; this.desc = desc
  }
}

MossItem 类是苔藓品种的数据模型,同样使用 @Observed 装饰器标记。该类包含十个属性,从多个维度描述一种苔藓的完整信息:name 是品种名称,icon 是对应的表情图标,family 表示科属分类,humidity 是湿度偏好的百分比数值,light 描述光照需求类型,temp 记录适宜温度范围,growth 表示生长速度,difficulty 标识养护难度等级,desc 则是品种的简要描述。

这个数据模型的设计充分体现了苔藓养护的专业性。湿度、光照、温度是苔藓生长的三大核心要素,模型中分别用 humidity(数值型)、light(字符串枚举型)、temp(字符串范围型)三种不同的数据类型来表达,与数据本身的特性相匹配。湿度作为可以量化的指标,使用数值类型便于进度条可视化与大小比较;光照类型有限,使用字符串枚举即可满足需求;温度范围则以区间字符串的形式存储,便于直接展示。

difficulty 字段与 MOSS_META 元数据配合使用,通过难度等级标签(入门/进阶/高手)映射到对应的图标与颜色,实现了数据与表现的分离。这种设计模式的优势在于,当需要增加新的难度等级或调整难度的视觉表现时,只需修改 MOSS_META 映射表与 mossColormossIcon 工具函数即可,无需改动 MossItem 类本身。同时,@Observed 装饰器确保了苔藓信息的任何属性变化都能被及时反映到界面上,为后续可能的编辑功能预留了响应式能力。

3.6 BottleItem 数据模型:生态瓶的全方位状态描述

@Observed
export class BottleItem {
  id: number = 0
  name: string = ''
  icon: string = ''
  style: string = ''
  mossCount: number = 0
  progress: number = 0
  state: string = ''
  days: number = 0
  nextCare: string = ''
  constructor(id: number, name: string, icon: string, style: string,
              mossCount: number, progress: number, state: string,
              days: number, nextCare: string) {
    this.id = id; this.name = name; this.icon = icon; this.style = style
    this.mossCount = mossCount; this.progress = progress; this.state = state
    this.days = days; this.nextCare = nextCare
  }
}

BottleItem 类是生态瓶的数据模型,使用 @Observed 装饰器标记,包含九个属性。nameicon 是瓶子的名称与图标标识,style 描述瓶子的造型风格(如玻璃罩、陶盆、开放式等),mossCount 记录瓶中种植的苔藓种类数量,progress 以百分比表示生态瓶的养护进度或成长进度,state 描述当前生长状态(旺盛、缓苗、休眠等),days 记录养护天数,nextCare 提示下次护理的时间。

这个数据模型的设计围绕"生态瓶工位"的核心概念展开,涵盖了一个生态瓶从基本信息到养护状态的各个维度。特别值得关注的是 progressstate 两个字段的配合使用——progress 用量化的百分比表示进度,state 用语义化的文字描述状态,二者结合既提供了精确的数值信息,又给出了直观的状态判断。在界面上,进度通过进度条可视化呈现,状态则通过彩色标签展示,不同状态对应不同的颜色(旺盛用绿色、缓苗用琥珀色、休眠用灰色),让用户一目了然。

nextCare 字段是养护提醒功能的关键数据。它以自然语言的形式描述下次护理的时间(如"明早"、“今晚”、“后天”),虽然这种字符串形式在精确计算上不如时间戳方便,但在展示层面更加友好,符合轻量级应用的设计理念。对于一款面向爱好者的工具应用而言,直观易懂往往比精确复杂更重要。@Observed 装饰器确保了当生态瓶的进度、状态等属性发生变化时,界面能够实时更新,为用户提供最新的养护状态。

3.7 CareItem 数据模型:养护任务的结构化表达

@Observed
export class CareItem {
  id: number = 0
  title: string = ''
  icon: string = ''
  day: string = ''
  period: string = ''
  done: boolean = false
  constructor(id: number, title: string, icon: string, day: string,
              period: string, done: boolean) {
    this.id = id; this.title = title; this.icon = icon; this.day = day
    this.period = period; this.done = done
  }
}

CareItem 类是养护任务的数据模型,使用 @Observed 装饰器标记,包含六个属性。title 是任务名称,icon 是对应的任务图标,day 表示执行日期(周一至周日,或"每日"),period 表示具体时段(清晨、上午、午后、傍晚等),done 是布尔值标识任务是否已完成。这个模型简洁而完整地描述了一条养护任务的核心要素——做什么、什么时候做、做了没有。

done 字段是该模型中最具交互性的属性。它是一个布尔值,直接对应界面上的复选框状态。当用户点击复选框时,通过 toggleCare 方法切换 done 的值,由于 @Observed 装饰器的存在,框架会自动检测到属性变化并更新界面:已完成的任务文字变灰并添加删除线,复选框图标从空白变为打勾。这种双向的数据-视图同步,正是声明式 UI 与响应式状态管理结合的典型体现。

日期与时段的分离设计(day + period)也是一个值得注意的细节。将星期几和具体时段拆分为两个字段,既方便了数据的独立编辑(如只修改时段而保留日期),也为界面展示提供了更大的灵活性——可以将日期和时段分别显示在不同位置,或通过组合方式呈现。对于日历类应用而言,这种时间维度的结构化拆分是一种常见的设计模式,能够更好地支撑后续的功能扩展,如按日期筛选、按时段分组等。

3.8 静态数据集合:MOSS_LIST、BOTTLE_LIST 与 CARE_LIST

const MOSS_LIST: MossItem[] = [
  new MossItem(1, '白发藓', '🌿', '白发藓科', 85, '半阴', '18-26℃', '缓慢', '入门', '叶片顶端银白色,微景观主角'),
  new MossItem(2, '大灰藓', '🌾', '灰藓科', 75, '散射光', '15-25℃', '较快', '入门', '匍匐丛生,覆盖性好'),
  new MossItem(3, '砂藓', '🪨', '砂藓科', 60, '喜光', '20-30℃', '中等', '进阶', '耐旱耐晒,适合开缸造景'),
  // ... 共 10 种苔藓
];

MOSS_LISTBOTTLE_LISTCARE_LIST 三个常量数组分别存储了苔藓品种、生态瓶与养护任务的静态数据。这些数据以 const 声明,表示它们是应用的初始数据或基准数据,在运行时不会被直接修改。实际使用时,通过 slice(0) 的方式创建数组副本赋值给 @State 变量(如 this.bottles = BOTTLE_LIST.slice(0)),确保原始数据的纯净性,同时允许状态数组进行独立的增删操作。

MOSS_LIST 包含十种常见苔藓品种的数据,从入门级的白发藓、大灰藓到进阶级的松叶白发藓、角苔,覆盖了不同难度等级与养护需求的品种。每个品种的数据都经过精心设计,湿度值从 60% 到 90% 不等,光照需求包括喜光、半阴、散射光、阴湿等多种类型,温度范围也各有差异,这种数据的多样性使得图鉴页面更加丰富真实。

BOTTLE_LIST 包含八个生态瓶示例,涵盖玻璃罩、陶盆、开放式、玻璃缸、水陆缸、壁挂瓶等多种造型风格,生长状态包括旺盛、缓苗、休眠三种类型,养护进度从 37% 到 88% 不等。这种数据的丰富性确保了生态瓶工位页面能够展示出多样化的内容,让用户感受到应用的实用性与趣味性。CARE_LIST 则包含八项养护任务,从喷水保湿到湿度记录,覆盖了一周内每天的养护工作,为养护日历功能提供了完整的初始数据支撑。

3.9 图表与导航常量:HUM_CHART、SPEC_CHART 与 NAV 系列

const HUM_CHART: HumChartItem[] = [
  { label: '周一', value: 82 },
  { label: '周二', value: 86 },
  { label: '周三', value: 74 },
  // ... 共 7 天数据
];

const SPEC_CHART: SpecChartItem[] = [
  { label: '白发藓', value: 32, color: '#6FA37E' },
  { label: '大灰藓', value: 21, color: '#8FB996' },
  // ... 共 5 项
];

const NAV_LIST: NavItem[] = [
  { icon: '🌿', label: '首页' },
  { icon: '🫙', label: '工坊' },
  { icon: '📒', label: '养护' },
  { icon: '👤', label: '我的' }
];

const SUB_NAV_LIST: string[] = ['精选', '苔藓图鉴', '生态瓶工位', '养护日历', '苔景画廊', '苔友圈'];

这组常量定义了应用的图表数据与导航配置。HUM_CHART 存储了一周七天的湿度数据,用于首页的湿度变化柱状图。数据设计上,湿度值在 74 到 90 之间波动,既有高峰也有低谷,模拟了真实的湿度变化规律。SPEC_CHART 则存储了苔藓品种分布数据,五个品种的占比从高到低排列,每个品种对应一种由深到浅的绿色,形成了和谐的同色系渐变效果。

NAV_LIST 定义了底部主导航的四个入口:首页、工坊、养护、我的。每个导航项都配有对应的 emoji 图标——🌿 代表首页(与苔藓主题呼应)、🫙 代表工坊(生态瓶的象征)、📒 代表养护(记录与计划)、👤 代表我的(个人中心)。这种图文结合的导航方式,既直观又富有情趣,与应用的自然风格相得益彰。

SUB_NAV_LIST 定义了内容子导航的六个分类:精选、苔藓图鉴、生态瓶工位、养护日历、苔景画廊、苔友圈。这六个分类覆盖了应用的全部核心功能模块,通过横向滚动的 Tab 形式展示。值得注意的是子导航与主导航的联动机制——切换主导航时,子导航的默认选中项会随之变化(首页默认精选、工坊默认生态瓶工位、养护默认养护日历、我的默认苔友圈),这种设计使得用户能够更快地到达目标功能页面,减少操作步骤。

MOSS_META 常量则定义了难度等级的元数据映射,将"入门"、“进阶”、"高手"三个等级分别映射到对应的图标与颜色。这种映射表模式的好处是将数据与视觉表现解耦,当需要调整难度等级的颜色或图标时,只需修改这一处配置即可,无需在多处代码中逐一修改。

3.10 工具函数:数据转换与可视化辅助

function barH(v: number, max: number): number {
  return max * v / 100;
}

function mossColor(tag: string): string {
  let c = MOSS_META[tag];
  return c ? c.color : COLORS.primary;
}

function mossIcon(tag: string): string {
  let c = MOSS_META[tag];
  return c ? c.icon : '🌱';
}

function lightLabel(light: string): string {
  return light === '喜光' ? '☀️ 喜光' : (light === '半阴' ? '🌤️ 半阴' : '☁️ 散射光');
}

function waterBar(h: number, max: number): number {
  return max * h / 100;
}

这组工具函数提供了数据转换与可视化辅助功能,是连接数据模型与 UI 展示的桥梁。barH 函数根据百分比值与最大高度计算柱状图的实际高度,公式为 max * v / 100,是一个简单的比例换算。waterBar 函数的实现与 barH 完全相同,只是命名不同,分别用于湿度柱状图与湿度进度条的计算。虽然两个函数逻辑相同,但语义化的命名使得代码更具可读性——barH 表示柱状图高度,waterBar 表示水分/湿度进度条宽度。

mossColormossIcon 是一对配套函数,分别根据难度等级标签获取对应的颜色与图标。它们都通过查找 MOSS_META 映射表来实现,并设置了默认值作为 fallback——当标签不在映射表中时,默认返回主色 COLORS.primary 与默认图标 🌱。这种防御性编程的做法确保了即使数据出现异常,界面也不会因此崩溃或显示异常。

lightLabel 函数将光照类型字符串转换为带有图标的完整标签文字。函数使用了嵌套的三元表达式,依次判断"喜光"、“半阴”、"散射光"三种情况,分别返回对应 emoji 加文字的组合。这种写法简洁紧凑,适合分支较少的字符串映射场景。将图标与文字的组合逻辑封装为工具函数,既避免了在 UI 代码中重复编写条件判断,也使得光照标签的展示风格保持统一。

3.11 特效计算函数:水珠与雾气的运动学模拟

function dropY(tick: number, i: number): number {
  return ((tick * 3 + i * 17) % 120) - 10;
}

function dropAlpha(tick: number, i: number): number {
  let p = (tick * 3 + i * 17) % 120;
  return p < 20 ? 0.9 : 0.3;
}

function mistX(tick: number, i: number): number {
  return ((tick * 2 + i * 31) % 140) - 20;
}

function mistAlpha(glow: number, i: number): number {
  return 0.25 + 0.15 * Math.sin(glow / 8 + i);
}

这四个函数是水珠凝结下落与雾气飘动特效的核心计算逻辑,展现了如何通过简单的数学公式实现丰富的动态视觉效果。所有特效都基于一个递增的 tickglow 计时器参数,通过取模运算实现周期性的循环动画。

水珠效果由 dropYdropAlpha 两个函数配合实现。dropY 计算水珠的竖直位置,公式中 tick * 3 控制水珠的下落速度(每帧移动 3 个单位),i * 17 为每个水珠设置不同的初始相位偏移,使得九颗水珠错落下落而非整齐划一。% 120 使得下落距离在 0-120 范围内循环,- 10 则将起始位置上移,营造出水珠从顶部"凝结"后开始下落的效果。dropAlpha 计算水珠的透明度,当水珠处于下落初期(位置小于 20)时,透明度为 0.9,呈现晶莹剔透的凝露状态;其余时间透明度为 0.3,模拟水珠下落过程中逐渐变淡消散的效果。

雾气效果由 mistXmistAlpha 两个函数配合实现。mistX 计算雾气的水平位置,tick * 2 控制飘动速度(比水珠下落更慢),i * 31 为五片雾气设置不同的相位,% 140- 20 共同定义了飘动范围。mistAlpha 则使用正弦函数实现呼吸灯般的透明度变化效果,0.25 + 0.15 * Math.sin(glow / 8 + i) 使得透明度在 0.1 到 0.4 之间缓慢波动,glow / 8 降低了变化频率,+ i 使得每片雾气的呼吸相位不同步,营造出自然随机的感觉。正弦函数的使用是这个特效的点睛之笔,它使得雾气的透明度变化更加柔和自然,而非生硬的线性渐变。

3.12 主组件与状态变量:Page856 的完整状态体系

@Entry
@Component
struct Page856 {
  @State mainTab: number = 0
  @State subTab: number = 0
  @State tick: number = 0
  @State glow: number = 0
  @State addOpen: boolean = false
  @State editOpen: boolean = false
  @State delOpen: boolean = false
  @State editIdx: number = -1
  @State delTarget: string = ''
  @State addNick: string = ''
  @State addText: string = ''
  @State editTitle: string = ''
  @State editPeriod: string = ''
  @State posts: PostItem[] = [/* ... */]
  @State bottles: BottleItem[] = BOTTLE_LIST.slice(0)
  @State cares: CareItem[] = CARE_LIST.slice(0)
  fxTimer: number = -1

Page856 是应用的主组件,使用 @Entry@Component 双重装饰器标记。@Entry 表示这是页面的入口组件,@Component 表示这是一个声明式 UI 组件。组件内部定义了丰富的状态变量,按照功能可以分为几大类:导航状态、特效状态、弹框状态、表单数据与业务数据。

导航状态由 mainTabsubTab 两个 @State 变量组成,分别控制底部主导航与内容子导航的选中项。二者的配合使用实现了双层 Tab 导航架构,是应用页面切换的核心控制机制。特效状态由 tickglow 两个计数器变量组成,分别驱动水珠特效与雾气特效的动画计算。这两个变量每 60 毫秒递增一次,通过 setInterval 定时器持续更新,从而触发特效层的重新渲染,实现动态效果。

弹框状态包含 addOpeneditOpendelOpen 三个布尔变量,分别控制新增动态弹框、编辑养护弹框与删除确认弹框的显示与隐藏。editIdx 记录当前正在编辑的养护任务索引,delTarget 记录删除操作的目标类型(care/bottle/post)。这些辅助变量确保了弹框打开时能够正确定位到对应的数据项。表单数据变量包括 addNickaddTexteditTitleeditPeriod,分别用于存储新增动态与编辑任务弹框中的用户输入,与 TextInput 组件双向绑定。

业务数据包括 postsbottlescares 三个 @State 数组,分别存储苔友动态、生态瓶与养护任务数据。其中 posts 直接以内联方式初始化了十条示例数据,bottlescares 则通过 slice(0) 从静态常量数组复制而来。最后,fxTimer 是一个普通变量(非 @State),用于存储特效定时器的 ID,以便在组件销毁时清除定时器。由于定时器 ID 不需要触发 UI 更新,因此无需使用 @State 装饰。

3.13 生命周期方法:aboutToAppear 与 aboutToDisappear

aboutToAppear(): void {
  this.fxTimer = setInterval(() => {
    this.tick = this.tick + 1
    this.glow = this.glow + 1
  }, 60)
}

aboutToDisappear(): void {
  clearInterval(this.fxTimer)
}

aboutToAppearaboutToDisappear 是 HarmonyOS 组件的两个核心生命周期方法,分别在组件即将显示与即将消失时被调用。这两个方法构成了组件生命周期的"出生"与"死亡"两个端点,是执行初始化与清理工作的最佳时机。

aboutToAppear 中,代码启动了一个 setInterval 定时器,每 60 毫秒执行一次回调,将 tickglow 两个计数器各加 1。60 毫秒的间隔约等于每秒 16-17 帧,这个帧率对于简单的位移动画来说已经足够流畅,同时不会消耗过多的系统资源。由于 tickglow 都是 @State 变量,每次值的变化都会触发依赖它们的组件(即 fxLayer 特效层)重新渲染,从而实现动画效果。这种通过状态驱动动画的方式,是声明式 UI 框架中实现动画的典型模式。

aboutToDisappear 方法的职责是清理资源,这里调用 clearInterval 清除之前启动的定时器。这一步至关重要——如果不在组件销毁时清除定时器,定时器将继续运行,导致内存泄漏与性能损耗。特别是当用户频繁进出页面时,未清理的定时器会不断累积,最终导致应用卡顿甚至崩溃。将定时器的启动与清理分别放在 aboutToAppearaboutToDisappear 中,形成了"申请-释放"的对称模式,是资源管理的最佳实践。

3.14 弹框控制方法:openAdd、openEdit 与 openDel 系列

openAdd(): void {
  this.addNick = ''
  this.addText = ''
  this.addOpen = true
}

openEdit(idx: number): void {
  this.editIdx = idx
  let c: CareItem = this.cares[idx]
  this.editTitle = c.title
  this.editPeriod = c.period
  this.editOpen = true
}

openDelA(tag: string): void {
  this.delTarget = tag
  this.delOpen = true
}

openDelB(tag: string): void {
  this.delTarget = tag
  this.delOpen = true
}

这组方法负责弹框的打开操作,是用户交互与弹框显示之间的桥梁。每个方法都遵循相似的模式:先准备弹框所需的数据,再将对应的 Open 状态变量设为 true,触发弹框的显示。

openAdd 方法用于打开新增动态弹框。在打开弹框之前,先将 addNickaddText 两个表单变量清空,确保每次打开弹框都是全新的空白表单,而非上次输入的残留内容。这种"打开前重置"的做法是表单弹框的常见处理方式,能够避免用户因看到旧数据而产生困惑。

openEdit 方法用于打开编辑养护任务弹框,接收一个索引参数 idx。方法首先保存当前编辑项的索引到 editIdx,然后根据索引从 cares 数组中取出对应的养护任务,将任务的标题与时段分别赋值给 editTitleeditPeriod 两个表单变量。这样做的效果是——弹框打开时,输入框中已经预填了当前任务的现有值,用户可以在此基础上进行修改。这种"数据回显"的设计是编辑功能的标准做法,大大提升了用户体验。

openDelAopenDelB 两个方法功能完全相同,都是设置 delTarget 并打开删除确认弹框。两个方法分别对应不同的删除触发场景(养护任务删除与生态瓶出瓶),虽然当前实现一致,但保留为独立方法为后续可能的差异化处理预留了扩展空间。删除操作需要设置 delTarget 的原因是——同一个删除确认弹框被用于多种数据类型的删除场景,通过 delTarget 区分具体的删除目标,从而在执行删除时采取不同的处理逻辑。

3.15 数据操作方法:doAdd、doEdit、doDel 与 toggleCare

doAdd(): void {
  let nick: string = this.addNick.length > 0 ? this.addNick : '苔友新丁'
  let txt: string = this.addText.length > 0 ? this.addText : '今天也在认真养苔!'
  this.posts.unshift(new PostItem(this.posts.length + 1, nick, '🌱', txt, 0, 0, '新动态', '刚刚'))
  this.addOpen = false
}

doEdit(): void {
  if (this.editIdx >= 0) {
    if (this.editTitle.length > 0) {
      this.cares[this.editIdx].title = this.editTitle
    }
    if (this.editPeriod.length > 0) {
      this.cares[this.editIdx].period = this.editPeriod
    }
  }
  this.editOpen = false
}

doDel(): void {
  if (this.delTarget === 'care') {
    let idx = this.cares.findIndex((c: CareItem) => c.done)
    if (idx >= 0) { this.cares.splice(idx, 1) }
  } else if (this.delTarget === 'bottle') {
    if (this.bottles.length > 0) { this.bottles.pop() }
  } else {
    if (this.posts.length > 0) { this.posts.pop() }
  }
  this.delOpen = false
}

toggleCare(idx: number): void {
  this.cares[idx].done = !this.cares[idx].done
}

这组方法实现了数据的增、删、改、查操作,是应用业务逻辑的核心部分。每个方法都遵循"操作数据 → 关闭弹框/更新状态"的流程,确保数据变更与界面状态的一致性。

doAdd 方法处理新增动态的提交逻辑。方法首先对用户输入进行处理——如果昵称为空,则使用默认值"苔友新丁";如果内容为空,则使用默认值"今天也在认真养苔!“。这种空值兜底的处理方式,既保证了数据的完整性,又降低了用户的发布门槛。随后,使用 unshift 方法将新动态插入到 posts 数组的最前面(即动态列表的顶部),新动态的 id 设为当前数组长度加一,头像默认为 🌱,点赞与评论数初始化为 0,标签设为"新动态”,时间标记为"刚刚"。最后将 addOpen 设为 false 关闭弹框。

doEdit 方法处理养护任务的编辑保存。方法首先校验 editIdx 是否有效(大于等于 0),然后分别检查标题与时段的输入是否非空,只有非空的字段才会被更新到对应的数据项中。这种"按需更新"的方式,允许用户只修改部分字段而保留其他字段的原值,比全量覆盖更加灵活。由于 CareItem 类使用了 @Observed 装饰器,直接修改 this.cares[this.editIdx].title 便能触发界面的响应式更新,无需手动替换数组元素。

doDel 方法处理删除操作,根据 delTarget 的值执行不同的删除逻辑:care 类型删除第一个已完成的养护任务,bottle 类型移除最后一个生态瓶,其他情况(post)移除最后一条动态。三种类型的删除都做了安全性检查——确保数组非空或找到目标索引后才执行删除操作,避免因空数组调用 splicepop 导致的异常。toggleCare 方法则实现了养护任务的完成/未完成切换,通过简单的布尔值取反操作即可完成。由于 done 属性是 @Observed 类的成员,修改后界面会自动同步更新复选框状态与文字样式。

3.16 fxLayer 特效层:水珠与雾气的视觉呈现

@Builder
fxLayer() {
  Stack() {
    ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8], (i: number) => {
      Column()
        .width(4)
        .height(4)
        .borderRadius(2)
        .backgroundColor('#8FCBA5')
        .opacity(dropAlpha(this.tick, i))
        .position({ x: 30 + (i * 41) % 340, y: dropY(this.tick, i) })
    }, (i: number) => 'drop' + i)
    ForEach([0, 1, 2, 3, 4], (i: number) => {
      Column()
        .width(56)
        .height(14)
        .borderRadius(7)
        .backgroundColor('#FFFFFF')
        .opacity(mistAlpha(this.glow, i))
        .position({ x: mistX(this.tick, i), y: 160 + (i * 72) % 320 })
    }, (i: number) => 'mist' + i)
  }
  .width('100%')
  .height('100%')
  .hitTestBehavior(HitTestMode.None)
}

fxLayer 是特效层的构建方法,使用 @Builder 装饰器标记,将水珠与雾气两种特效封装在一个独立的 UI 片段中。特效层使用 Stack 布局,水珠与雾气叠加在同一容器中,共同构成完整的动态视觉效果。整个特效层铺满整个页面(宽高均为 100%),并通过 hitTestBehavior(HitTestMode.None) 设置为不响应触摸事件,确保特效层不会遮挡下层页面的交互操作。

水珠效果通过第一个 ForEach 循环渲染九颗水珠实现。每颗水珠是一个 4x4 像素的圆角正方形(borderRadius(2) 使其呈现圆形),背景色为淡绿色 #8FCBA5。水珠的水平位置由索引 i 计算得出:30 + (i * 41) % 340,起始偏移 30px,每颗间距 41px,在 340px 范围内分布,确保水珠在页面宽度内均匀分布。竖直位置由 dropY(this.tick, i) 动态计算,透明度由 dropAlpha(this.tick, i) 控制。每个水珠的 key 为 'drop' + i,确保框架能够正确追踪与复用每个水珠组件。

雾气效果通过第二个 ForEach 循环渲染五片雾气实现。每片雾气是一个 56x14 像素的圆角矩形(borderRadius(7) 使其两端呈半圆形),背景色为白色。雾气的水平位置由 mistX(this.tick, i) 计算,竖直位置由索引计算:160 + (i * 72) % 320,五片雾气在竖直方向上错落分布。雾气的透明度由 mistAlpha(this.glow, i) 控制,呈现呼吸般的明暗变化。雾气的 key 为 'mist' + i,与水珠的 key 命名空间区分开。

这种使用 Column 组件配合 position 定位来模拟动画粒子的方式,是声明式 UI 框架中实现自定义动画的一种实用技巧。虽然相比于 Canvas 或 WebGL 方式性能较低,但实现简单、代码直观,对于粒子数量较少(本例中仅 14 个元素)的装饰性特效来说完全足够。

3.17 header 头部:深绿色顶部导航栏

@Builder
header() {
  Row() {
    Column() {
      Text('苔语 · 微景观工坊')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.white)
      Text('一平方厘米的森林')
        .fontSize(11)
        .fontColor('#DCEEDF')
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)

    Text('🔍')
      .fontSize(18)
      .padding(8)
      .backgroundColor('#00000026')
      .borderRadius(18)

    Text('🛒')
      .fontSize(18)
      .padding(8)
      .margin({ left: 6 })
      .backgroundColor('#00000026')
      .borderRadius(18)
  }
  .width('100%')
  .padding({ left: 16, right: 16, top: 12, bottom: 12 })
  .backgroundColor(COLORS.primaryDeep)
}

header 方法构建了应用的顶部导航栏,是应用品牌识别的核心区域。头部采用 COLORS.primaryDeep 深苔绿色作为背景,与浅色的页面主体形成鲜明对比,营造出沉浸式的顶部视觉效果。头部的整体布局为横向 Row,从左到右依次为应用标题区、搜索按钮与购物车按钮。

标题区使用 Column 纵向排列两行文字:主标题"苔语 · 微景观工坊"字号 20px,加粗,白色,醒目突出;副标题"一平方厘米的森林"字号 11px,淡绿色 #DCEEDF,作为品牌 Slogan 传达应用的核心理念。alignItems(HorizontalAlign.Start) 确保文字左对齐,layoutWeight(1) 使得标题区占据剩余全部空间,将右侧的按钮推到最右边。

右侧的两个功能按钮——搜索(🔍)与购物车(🛒)——采用圆形按钮设计,背景色为半透明黑色 #00000026(即黑色 15% 透明度),在深绿色背景上呈现出微妙的层次感。按钮的内边距为 8px,圆角半径 18px(即高度的一半),形成完美的圆形。两个按钮之间有 6px 的左边距。这种图标加半透明背景的按钮设计,既保证了功能的可点击性,又不会过于突出而破坏头部整体的视觉平衡。

头部的内边距设计为左右 16px、上下 12px,在保证足够触控区域的同时不会显得过于臃肿。整体来看,这个头部设计简洁而不失细节,品牌信息传达清晰,功能入口明确,与应用的清新自然风格高度契合。

3.18 subNav 子导航:横向滚动的内容分类 Tab

@Builder
subNav() {
  Scroll() {
    Row({ space: 10 }) {
      ForEach(SUB_NAV_LIST, (s: string, i: number) => {
        Column() {
          Text(s)
            .fontSize(13)
            .fontWeight(this.subTab === i ? FontWeight.Bold : FontWeight.Normal)
            .fontColor(this.subTab === i ? COLORS.primaryDeep : COLORS.subTitle)
            .padding({ left: 14, right: 14, top: 7, bottom: 7 })
            .backgroundColor(this.subTab === i ? COLORS.primaryLight : COLORS.cardBg)
            .borderRadius(16)
        }
        .onClick(() => {
          this.subTab = i
        })
      }, (s: string) => s)
    }
    .padding({ left: 12, right: 12 })
  }
  .scrollable(ScrollDirection.Horizontal)
  .scrollBar(BarState.Off)
  .height(46)
}

subNav 方法构建了内容子导航栏,是六大功能模块之间切换的入口。子导航采用横向滚动设计,当分类项较多超出屏幕宽度时,用户可以左右滑动查看更多分类。这种设计在分类数量较多(本例中有 6 个分类)时,比固定宽度均分的方式更加灵活,也为后续增加新的分类预留了空间。

每个分类 Tab 是一个包含文字的圆角胶囊形按钮。选中态与未选中态在三个维度上形成差异:字体粗细(粗体/常规)、文字颜色(深苔绿/灰绿)、背景色(浅苔绿/白色)。这种多维度的视觉差异使得选中状态非常明确,用户一眼就能识别当前所在的分类。Tab 的内边距为左右 14px、上下 7px,圆角半径 16px,整体呈现出圆润可爱的胶囊形状,与应用的清新风格相得益彰。

交互方面,每个 Tab 绑定了 onClick 事件,点击时将 subTab 设为对应索引。由于 subTab@State 变量,赋值后框架会自动更新所有 Tab 的选中态样式,并触发主内容区的页面切换。Tab 之间的间距为 10px,Row 容器的左右内边距为 12px,确保分类项与屏幕边缘保持适当的距离。

Scroll 容器的配置也值得注意:scrollable(ScrollDirection.Horizontal) 设置为水平滚动方向,scrollBar(BarState.Off) 隐藏滚动条以保持界面整洁,固定高度 46px 确保子导航栏在任何情况下都保持一致的高度。这种"横向滚动 + 隐藏滚动条 + 胶囊形 Tab"的设计模式,是移动端应用中内容分类导航的经典实现方式。

3.19 pageFeed 精选页:首页信息流聚合

@Builder
pageFeed() {
  Scroll() {
    Column({ space: 12 }) {
      Row() {
        Text('🌿 本周苔况')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.title)
        Text('湿度偏好 80%')
          .fontSize(10)
          .fontColor(COLORS.hint)
          .margin({ left: 8 })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)

      Row({ space: 8 }) {
        ForEach(HUM_CHART, (h: HumChartItem) => {
          Column({ space: 4 }) {
            Column()
              .width(18)
              .height(barH(h.value, 66))
              .borderRadius(5)
              .backgroundColor(h.value > 84 ? COLORS.primary : COLORS.primaryLight)
          }
          // ... 柱状图 + 标签
        }, (h: HumChartItem) => h.label)
      }
      // ... 湿度图表卡片

      Text('🫙 热养生态瓶 TOP3')
      Text('🌱 最新苔友圈')
      // ... 生态瓶排行 + 动态列表
    }
  }
}

pageFeed 方法构建了精选首页的内容,是用户进入应用后看到的第一个页面。首页采用信息流聚合的设计思路,将应用中最核心的信息浓缩在一个页面中,让用户通过一次滚动即可了解本周的养护情况、热门的生态瓶与最新的社区动态。

页面的第一模块是"本周苔况"湿度图表。标题栏左侧为模块标题,右侧标注"湿度偏好 80%"作为参考值。图表部分以七根竖向柱状图展示周一到周日的湿度变化,每根柱子的宽度为 18px,高度根据湿度值按比例计算(最大高度 66px)。湿度高于 84% 的柱子使用主色 COLORS.primary,低于 84% 的使用浅色 COLORS.primaryLight,通过颜色深浅直观地突出高湿度的日子。每根柱子下方对应星期标签,形成完整的柱状图信息。整个图表放置在白色卡片中,圆角 14px,内边距 10px。

第二模块是"热养生态瓶 TOP3",展示最热门的三个生态瓶。每条记录包含排名奖牌(🥇🥈🥉)、瓶子名称与风格标签、养护进度条与百分比、生长状态标签。进度条宽度为进度值乘以 2.4(即 100% 对应 240px 宽度),高度 5px,圆角 3px。生长状态标签根据状态显示不同颜色——旺盛状态使用绿色系,其他状态使用琥珀色系。这种设计使用户能够快速了解每个生态瓶的核心信息。

第三模块是"最新苔友圈",展示最新的三条社区动态。每条动态包含用户头像、昵称、内容摘要(最多两行,超出省略)、发布时间与点赞数。动态内容使用 maxLines(2)textOverflow({ overflow: TextOverflow.Ellipsis }) 限制为两行,确保列表布局的整齐统一。三个模块统一放在可垂直滚动的 Scroll 容器中,模块间距为 12px,整体内边距 12px。

3.20 pageMoss 苔藓图鉴:品种百科全书

@Builder
pageMoss() {
  Scroll() {
    Column({ space: 12 }) {
      Text('🍀 苔藓品种图鉴 · 共' + MOSS_LIST.length + '种')
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.title)
        .width('100%')

      ForEach(MOSS_LIST, (m: MossItem) => {
        Row({ space: 10 }) {
          Text(m.icon)
            .fontSize(24)
            .width(44)
            .height(44)
            .textAlign(TextAlign.Center)
            .backgroundColor(COLORS.primaryLight)
            .borderRadius(12)

          Column({ space: 4 }) {
            Row() {
              Text(m.name)
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.title)
              Text(m.family)
                .fontSize(9)
                .fontColor(COLORS.hint)
                .margin({ left: 6 })
              Text(mossIcon(m.difficulty) + ' ' + m.difficulty)
                .fontSize(9)
                .fontColor(mossColor(m.difficulty))
                .margin({ left: 6 })
            }
            Text(m.desc)
              .fontSize(10)
              .fontColor(COLORS.subTitle)
              .maxLines(1)
              .textOverflow({ overflow: TextOverflow.Ellipsis })
            Row({ space: 8 }) {
              Column()
                .width(waterBar(m.humidity, 88))
                .height(5)
                .borderRadius(3)
                .backgroundColor(COLORS.primary)
              Text('湿度' + m.humidity + '% · ' + lightLabel(m.light))
                .fontSize(9)
                .fontColor(COLORS.subTitle)
            }
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)

          Text(m.temp)
            .fontSize(10)
            .fontColor(COLORS.primaryDeep)
            .padding({ left: 6, right: 6, top: 3, bottom: 3 })
            .backgroundColor('#EAF4EC')
            .borderRadius(8)
        }
        // ... 卡片样式
      }, (m: MossItem) => 'moss' + m.id)
    }
  }
}

pageMoss 方法构建了苔藓品种图鉴页面,是应用的核心知识库模块。页面以列表形式展示十种苔藓品种的详细信息,每条品种记录采用卡片式设计,信息密度高但层次分明。

每条品种卡片从左到右分为三个区域:图标区、信息区与温度标签区。左侧图标区是一个 44x44px 的圆角方块,背景为浅苔绿色,居中显示品种对应的 emoji 图标,尺寸 24px,视觉上醒目而统一。中间信息区是卡片的主体,采用纵向布局,从上到下依次为标题行、描述行与湿度行。标题行包含品种名称(加粗主色)、科属信息(小号提示色)与难度等级(带图标与对应颜色),三个元素水平排列,通过字号与颜色的差异形成清晰的信息层级。

描述行展示品种简介,使用 10px 小号字与副标题色,限制为单行,超出部分以省略号结尾,确保卡片高度的一致性。湿度行包含湿度进度条与湿度-光照组合文字。进度条宽度根据湿度值按比例计算(最大宽度 88px),以可视化的方式直观呈现湿度需求。右侧的文字则显示具体的湿度百分比与光照类型,光照类型通过 lightLabel 函数转换为带图标的友好格式。

最右侧的温度标签以徽章形式展示适宜温度范围,背景为极浅的绿色 #EAF4EC,文字为深苔绿色,圆角 8px,小巧精致。整个品种卡片的设计在有限的空间内浓缩了丰富的信息,通过合理的布局与视觉层次设计,使用户能够快速扫描并获取所需信息。

3.21 pageBottle 生态瓶工位:个人收藏管理

@Builder
pageBottle() {
  Scroll() {
    Column({ space: 12 }) {
      Row({ space: 10 }) {
        Column({ space: 4 }) {
          Text(this.bottles.length + '')
            .fontSize(22)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primaryDeep)
          Text('在养生态瓶')
            .fontSize(10)
            .fontColor(COLORS.subTitle)
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .padding(12)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)

        Column({ space: 4 }) {
          Text('6')
            .fontSize(22)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.accent)
          Text('待养护任务')
            .fontSize(10)
            .fontColor(COLORS.subTitle)
        }
        // ... 第二张数据卡片
      }
      .width('100%')

      Text('🫙 我的生态瓶工位')
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.title)
        .width('100%')

      ForEach(this.bottles, (b: BottleItem) => {
        Column({ space: 8 }) {
          Row({ space: 8 }) {
            Text(b.icon).fontSize(22)
            Text(b.name)
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.title)
            Text(b.state)
              .fontSize(9)
              .fontColor(mossColor(b.state))
              .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              .backgroundColor(b.state === '旺盛' ? COLORS.primaryLight : '#F5EEE0')
              .borderRadius(8)
          }
          Row({ space: 8 }) {
            Column()
              .width(b.progress * 2.4)
              .height(6)
              .borderRadius(3)
              .backgroundColor(b.progress > 60 ? COLORS.primary : COLORS.warning)
            Text(b.progress + '%')
              .fontSize(10)
              .fontColor(COLORS.subTitle)
          }
          Row() {
            Text('苔种 ' + b.mossCount + ' 种 · 养了 ' + b.days + ' 天')
              .fontSize(10)
              .fontColor(COLORS.subTitle)
            Text('下次护理 ' + b.nextCare)
              .fontSize(10)
              .fontColor(COLORS.warning)
              .margin({ left: 10 })
          }
          Row() {
            Text('出瓶转让')
              .fontSize(10)
              .fontColor(COLORS.danger)
              .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              .backgroundColor('#FBE9E7')
              .borderRadius(9)
              .onClick(() => { this.openDelB('bottle') })
          }
        }
        // ... 卡片样式
      }, (b: BottleItem) => 'bot' + b.id)
    }
  }
}

pageBottle 方法构建了生态瓶工位页面,是用户管理个人生态瓶收藏的核心区域。页面顶部首先展示两张数据统计卡片,分别显示"在养生态瓶"的数量与"待养护任务"的数量。两张卡片并排排列,各占一半宽度,使用不同的颜色区分——生态瓶数量用深苔绿色,待养护任务用琥珀色辅助色。这种数据概览卡片的设计,让用户进入页面后一眼就能掌握关键指标。

生态瓶列表是页面的主体部分,每个生态瓶以卡片形式展示,包含四行信息。第一行是瓶子的图标、名称与状态标签。图标使用 22px 的 emoji,名称加粗显示,状态标签根据生长状态使用不同的颜色与背景——"旺盛"状态用绿色系,其他状态用琥珀色系,与首页的设计保持一致。

第二行是养护进度条与百分比文字。进度条高度 6px,宽度为进度值乘以 2.4,进度大于 60% 时使用主色绿色,低于 60% 时使用警告色黄色。这种颜色随进度变化的设计,能够让用户通过颜色快速判断进度是否达标。第三行展示苔种数量、养护天数与下次护理时间,其中"下次护理"使用警告色突出显示,起到提醒作用。

第四行是操作按钮——“出瓶转让”,使用危险红色系(红色文字 + 浅红背景),暗示这是一个不可逆的操作。点击该按钮会弹出删除确认弹框(openDelB('bottle')),让用户进行二次确认。这种危险操作的双重确认机制,是防止用户误操作的标准交互设计模式。

3.22 pageCare 养护日历:周计划与待办管理

@Builder
pageCare() {
  Scroll() {
    Column({ space: 12 }) {
      Text('📒 本周养护日历')
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.title)
        .width('100%')

      Row({ space: 6 }) {
        ForEach(['一', '二', '三', '四', '五', '六', '日'], (d: string, i: number) => {
          Column({ space: 4 }) {
            Text(d)
              .fontSize(10)
              .fontColor(i < 5 ? COLORS.primaryDeep : COLORS.warning)
            Column()
              .width(26)
              .height(26)
              .borderRadius(13)
              .backgroundColor(i % 2 === 0 ? COLORS.primaryLight : '#F5EEE0')
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
        }, (d: string) => d)
      }
      .width('100%')
      .padding(10)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(12)

      Text('🌿 待办任务')
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.title)
        .width('100%')

      ForEach(this.cares, (c: CareItem, i: number) => {
        Row({ space: 10 }) {
          Text(c.done ? '✅' : '⬜')
            .fontSize(18)
            .onClick(() => { this.toggleCare(i) })
          Column({ space: 3 }) {
            Text(c.title)
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(c.done ? COLORS.hint : COLORS.title)
              .decoration({ type: c.done ? TextDecorationType.LineThrough : TextDecorationType.None })
            Text(c.day + ' · ' + c.period)
              .fontSize(10)
              .fontColor(COLORS.subTitle)
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)

          Text('编辑')
            .fontSize(10)
            .fontColor(COLORS.primary)
            .padding({ left: 8, right: 8, top: 4, bottom: 4 })
            .backgroundColor(COLORS.primaryLight)
            .borderRadius(8)
            .onClick(() => { this.openEdit(i) })

          Text('删除')
            .fontSize(10)
            .fontColor(COLORS.danger)
            .padding({ left: 8, right: 8, top: 4, bottom: 4 })
            .backgroundColor('#FBE9E7')
            .borderRadius(8)
            .onClick(() => { this.openDelA('care') })
        }
        // ... 卡片样式
      }, (c: CareItem) => 'care' + c.id)
    }
  }
}

pageCare 方法构建了养护日历页面,是用户规划与管理养护任务的核心功能区。页面分为两个主要部分:周历概览与待办任务列表。

周历概览以卡片形式展示一周七天的日历视图,每天由星期文字与一个圆形图标组成。周一至周五的文字使用深苔绿色,周六与周日使用琥珀色警告色,区分工作日与周末的视觉惯例。圆形图标的背景色交替使用浅苔绿与浅琥珀色,形成棋盘格般的韵律感。每个日期项使用 layoutWeight(1) 等分宽度,确保七天均匀分布。这个周历视图虽然目前主要是装饰性的(未绑定具体的任务数量标记),但为后续的功能扩展(如在日期上标记任务数量、点击切换日期等)提供了良好的基础。

待办任务列表是页面的核心功能区。每条任务采用横向布局,从左到右依次为:复选框、任务信息、编辑按钮、删除按钮。复选框使用 emoji 实现,未完成时显示 ⬜,完成后显示 ✅,点击可切换状态。这种纯符号实现的复选框虽然简单,但在轻量级应用中完全够用,且无需引入额外的组件资源。

任务信息区包含任务标题与时间信息。已完成的任务标题会变为提示灰色,并添加删除线效果(TextDecorationType.LineThrough),这种视觉上的"划掉"效果是待办类应用的经典交互,给予用户明确的完成反馈。时间信息以"星期 · 时段"的格式显示在第二行,使用小号灰色文字。

右侧的两个操作按钮——编辑与删除——分别使用绿色系与红色系,语义清晰。编辑按钮打开编辑弹框,删除按钮打开删除确认弹框,均遵循应用统一的弹框交互模式。每条任务卡片使用白色背景与圆角设计,与整体视觉风格保持一致。

3.23 pageGallery 苔景画廊:瀑布流展示与品种统计

@Builder
pageGallery() {
  Scroll() {
    Column({ space: 12 }) {
      Text('🖼️ 苔景画廊')
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.title)
        .width('100%')

      Row({ space: 10 }) {
        Column({ space: 10 }) {
          ForEach(['🌄', '🏞️', '🌲', '🪨'], (e: string, i: number) => {
            Column({ space: 4 }) {
              Text(e)
                .fontSize(46)
                .width('100%')
                .height(i % 2 === 0 ? 108 : 82)
                .textAlign(TextAlign.Center)
                .backgroundColor(COLORS.primaryLight)
                .borderRadius(12)
              Text('苔景 No.' + (i + 1))
                .fontSize(10)
                .fontColor(COLORS.subTitle)
            }
          }, (e: string, i: number) => 'g1' + e + i)
        }
        .layoutWeight(1)

        Column({ space: 10 }) {
          ForEach(['🌋', '🏔️', '🌊', '⛰️'], (e: string, i: number) => {
            Column({ space: 4 }) {
              Text(e)
                .fontSize(46)
                .width('100%')
                .height(i % 2 === 0 ? 82 : 108)
                .textAlign(TextAlign.Center)
                .backgroundColor('#F5EEE0')
                .borderRadius(12)
              Text('苔景 No.' + (i + 5))
                .fontSize(10)
                .fontColor(COLORS.subTitle)
            }
          }, (e: string, i: number) => 'g2' + e + i)
        }
        .layoutWeight(1)
      }
      .width('100%')
      .alignItems(VerticalAlign.Top)

      Row({ space: 8 }) {
        ForEach(SPEC_CHART, (s: SpecChartItem) => {
          Column({ space: 4 }) {
            Column()
              .width(28)
              .height(barH(s.value, 60))
              .borderRadius(5)
              .backgroundColor(s.color)
            Text(s.label)
              .fontSize(9)
              .fontColor(COLORS.subTitle)
            Text(s.value + '%')
              .fontSize(9)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.title)
          }
          .layoutWeight(1)
          .justifyContent(FlexAlign.End)
          .alignItems(HorizontalAlign.Center)
        }, (s: SpecChartItem) => s.label)
      }
      .width('100%')
      .height(110)
      .padding(10)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(12)
    }
  }
}

pageGallery 方法构建了苔景画廊页面,是用户浏览与欣赏苔藓造景作品的展示区。页面采用瀑布流双列布局配合品种分布图表的设计,兼具视觉吸引力与数据信息性。

瀑布流是画廊页面的核心视觉元素。通过左右两列 Column 分别放置四张图片,每张图片的高度交替变化(左列偶数索引高 108px、奇数索引高 82px;右列反之),形成了错落有致的瀑布流效果。左列图片使用浅苔绿色背景,右列使用浅琥珀色背景,两列颜色的差异既增加了视觉层次,又呼应了应用的主辅配色体系。每张图片下方标注"苔景 No.x"的编号文字,使用小号灰色字体。

值得注意的是瀑布流的实现方式——这里采用的是双列固定布局的简化版瀑布流,而非真正的动态高度计算。这种实现方式虽然灵活性较低(图片数量与高度都是预设的),但实现简单、性能优异,对于数量有限的精选作品展示来说完全够用。两列之间的间距为 10px,列内图片之间的间距也为 10px,整体间距统一协调。

页面底部是品种分布统计图表,展示了用户收藏中各苔藓品种的占比情况。图表为竖向柱状图,五根柱子各占等分宽度,高度根据品种占比值按比例计算(最大高度 60px)。每根柱子使用对应品种的颜色(从深绿到浅绿渐变),下方标注品种名称与百分比数值。品种名称使用小号灰色字,百分比使用加粗深色字,形成信息层级。整个图表放置在白色卡片中,高度固定为 110px,内边距 10px,圆角 12px。

3.24 pageFriends 苔友圈:社区互动与动态发布

@Builder
pageFriends() {
  Scroll() {
    Column({ space: 12 }) {
      Row() {
        Text('🌱 苔友圈')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.title)
        Text('发动态')
          .fontSize(11)
          .fontColor(COLORS.white)
          .padding({ left: 12, right: 12, top: 5, bottom: 5 })
          .backgroundColor(COLORS.primary)
          .borderRadius(12)
          .margin({ left: 10 })
          .onClick(() => { this.openAdd() })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)

      ForEach(this.posts, (p: PostItem) => {
        Column({ space: 6 }) {
          Row({ space: 8 }) {
            Text(p.avatar)
              .fontSize(24)
            Column({ space: 2 }) {
              Text(p.nick)
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.title)
              Text(p.time)
                .fontSize(9)
                .fontColor(COLORS.hint)
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Text(p.tag)
              .fontSize(9)
              .fontColor(COLORS.primary)
              .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              .backgroundColor(COLORS.primaryLight)
              .borderRadius(8)
          }
          Text(p.content)
            .fontSize(12)
            .fontColor(COLORS.title)
            .width('100%')
            .lineHeight(18)

          Row() {
            Text('👍 ' + p.likes)
              .fontSize(10)
              .fontColor(COLORS.subTitle)
            Text('💬 ' + p.comments)
              .fontSize(10)
              .fontColor(COLORS.subTitle)
              .margin({ left: 14 })
            Text('转发')
              .fontSize(10)
              .fontColor(COLORS.subTitle)
              .margin({ left: 14 })
          }
        }
        .width('100%')
        .alignItems(HorizontalAlign.Start)
        .padding(12)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
      }, (p: PostItem) => 'post' + p.id)
    }
  }
}

pageFriends 方法构建了苔友圈页面,是社区互动的核心模块。页面顶部为标题栏,左侧显示"🌱 苔友圈"模块标题,右侧为"发动态"按钮。按钮使用主色绿色填充,白色文字,圆角胶囊形,点击后调用 openAdd() 方法打开新增动态弹框。标题栏使用 justifyContent(FlexAlign.SpaceBetween) 使标题与按钮分别靠左右两端对齐。

动态列表是苔友圈的主体内容,每条动态以卡片形式展示,包含三部分内容:头部信息区、正文内容区与底部操作区。头部信息区采用横向布局,从左到右依次为用户头像(24px emoji)、用户信息列与分类标签。用户信息列包含昵称(加粗 12px)与发布时间(小号灰色 9px),使用 layoutWeight(1) 占据中间剩余空间。分类标签(如"养护心得"、"造景记录"等)使用绿色系胶囊形标签,靠右对齐。

正文内容区显示动态正文,使用 12px 主标题色,行高 18px(行高约为字号的 1.5 倍,提供舒适的阅读体验)。正文宽度铺满卡片,不限制行数——与首页动态摘要不同,苔友圈中的动态展示完整内容,使用户能够阅读到全部信息。

底部操作区包含三个互动按钮:点赞(👍)、评论(💬)与转发。三个按钮均使用小号灰色文字,按钮之间以 14px 的左边距分隔。当前实现中,这些按钮仅展示数据(点赞数与评论数),尚未绑定点击交互,但结构上已经为后续的功能扩展做好了准备。每条动态卡片的内边距为 12px,圆角 12px,白色背景,与应用中其他卡片的设计语言保持一致。

3.25 弹框遮罩与新增弹框:modalOverlay 与 addModalBody

@Builder
modalOverlay() {
  Stack() {
    Column()
      .width('100%')
      .height('100%')
      .backgroundColor('#00000066')
      .onClick(() => {
        this.addOpen = false
        this.editOpen = false
        this.delOpen = false
      })
  }
  .width('100%')
  .height('100%')
}

@Builder
addModalBody() {
  Column({ space: 12 }) {
    Text('🌱 发苔友动态')
      .fontSize(16)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.title)

    TextInput({ placeholder: '你的昵称', text: this.addNick })
      .fontSize(13)
      .onChange((v: string) => { this.addNick = v })

    TextInput({ placeholder: '分享今天的苔况…', text: this.addText })
      .fontSize(13)
      .height(72)
      .onChange((v: string) => { this.addText = v })

    Row() {
      Text('发布')
        .fontSize(13)
        .fontColor(COLORS.white)
        .padding({ left: 28, right: 28, top: 8, bottom: 8 })
        .backgroundColor(COLORS.primary)
        .borderRadius(18)
        .onClick(() => { this.doAdd() })
      Text('取消')
        .fontSize(13)
        .fontColor(COLORS.subTitle)
        .padding({ left: 28, right: 28, top: 8, bottom: 8 })
        .backgroundColor('#F0F0F0')
        .borderRadius(18)
        .margin({ left: 10 })
        .onClick(() => { this.addOpen = false })
    }
  }
  .width('88%')
  .alignItems(HorizontalAlign.Start)
  .padding(18)
  .backgroundColor(COLORS.cardBg)
  .borderRadius(16)
  .constraintSize({ maxHeight: '80%' })
  .zIndex(999)
}

弹框系统由遮罩层与弹框主体两部分组成,是应用中模态交互的基础组件。modalOverlay 方法构建了半透明遮罩层,它是一个铺满全屏的 Column,背景色为 #00000066(即黑色 40% 透明度)。遮罩层的作用是弱化背景内容,将用户的注意力引导到弹框本身。同时,遮罩层还绑定了 onClick 事件,点击遮罩时会关闭所有弹框(将三个 Open 状态全部设为 false),这是弹框交互的标准做法——允许用户通过点击背景来取消操作。

addModalBody 方法构建了新增动态弹框的主体内容。弹框宽度为屏幕的 88%,内边距 18px,白色背景,圆角 16px。constraintSize({ maxHeight: '80%' }) 限制弹框的最大高度为屏幕的 80%,防止内容过多时弹框超出屏幕。zIndex(999) 确保弹框显示在遮罩层之上。

弹框内部采用纵向布局,间距 12px。顶部是弹框标题"🌱 发苔友动态",使用 16px 加粗字体。下方依次是两个输入框:昵称输入框与内容输入框。昵称输入框为单行,内容输入框高度 72px(约可容纳 4-5 行文字)。两个输入框都通过 onChange 回调实时将用户输入同步到对应的 @State 变量,实现数据的双向绑定。

弹框底部是操作按钮区,包含"发布"与"取消"两个按钮。发布按钮使用主色填充 + 白色文字,是主要操作(Primary Action);取消按钮使用灰色背景 + 灰色文字,是次要操作(Secondary Action)。两个按钮均使用胶囊形圆角设计,间距 10px。发布按钮点击后调用 doAdd() 执行发布逻辑,取消按钮直接关闭弹框。这种主次按钮的视觉区分,符合用户对弹框操作的预期认知。

3.26 编辑弹框与删除弹框:editModalBody 与 delModalBody

@Builder
editModalBody() {
  Column({ space: 12 }) {
    Text('📒 编辑养护任务')
      .fontSize(16)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.title)

    TextInput({ placeholder: '任务名称', text: this.editTitle })
      .fontSize(13)
      .onChange((v: string) => { this.editTitle = v })

    TextInput({ placeholder: '时段(如:清晨/午后)', text: this.editPeriod })
      .fontSize(13)
      .onChange((v: string) => { this.editPeriod = v })

    Row() {
      Text('保存')
        .fontSize(13)
        .fontColor(COLORS.white)
        .padding({ left: 28, right: 28, top: 8, bottom: 8 })
        .backgroundColor(COLORS.primary)
        .borderRadius(18)
        .onClick(() => { this.doEdit() })
      Text('取消')
        .fontSize(13)
        .fontColor(COLORS.subTitle)
        .padding({ left: 28, right: 28, top: 8, bottom: 8 })
        .backgroundColor('#F0F0F0')
        .borderRadius(18)
        .margin({ left: 10 })
        .onClick(() => { this.editOpen = false })
    }
  }
  .width('88%')
  .alignItems(HorizontalAlign.Start)
  .padding(18)
  .backgroundColor(COLORS.cardBg)
  .borderRadius(16)
  .constraintSize({ maxHeight: '70%' })
  .zIndex(999)
}

@Builder
delModalBody() {
  Column({ space: 12 }) {
    Text('⚠️ 确认操作')
      .fontSize(16)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.title)

    Text(this.delTarget === 'care' ? '确定删除选中的养护任务吗?' :
         (this.delTarget === 'bottle' ? '确定将该生态瓶出瓶转让吗?' :
          '确定删除这条苔友动态吗?'))
      .fontSize(12)
      .fontColor(COLORS.subTitle)

    Row() {
      Text('确认')
        .fontSize(13)
        .fontColor(COLORS.white)
        .padding({ left: 28, right: 28, top: 8, bottom: 8 })
        .backgroundColor(COLORS.danger)
        .borderRadius(18)
        .onClick(() => { this.doDel() })
      Text('取消')
        .fontSize(13)
        .fontColor(COLORS.subTitle)
        .padding({ left: 28, right: 28, top: 8, bottom: 8 })
        .backgroundColor('#F0F0F0')
        .borderRadius(18)
        .margin({ left: 10 })
        .onClick(() => { this.delOpen = false })
    }
  }
  .width('88%')
  .alignItems(HorizontalAlign.Start)
  .padding(18)
  .backgroundColor(COLORS.cardBg)
  .borderRadius(16)
  .constraintSize({ maxHeight: '60%' })
  .zIndex(999)
}

editModalBody 方法构建了编辑养护任务弹框。弹框结构与新增动态弹框类似,但内容更简洁——只包含任务名称与时段两个输入框。弹框标题为"📒 编辑养护任务",操作按钮为"保存"与"取消"。最大高度限制为 70%,比新增弹框略低,因为内容较少。输入框中的初始值在弹框打开时(openEdit 方法中)已经从对应的数据项中复制过来,用户可以直接在现有内容上修改。

delModalBody 方法构建了删除确认弹框。这是三种弹框中最简单的一种,仅包含标题、提示文字与两个操作按钮。标题为"⚠️ 确认操作",使用警告图标引起用户注意。提示文字根据 delTarget 的不同而动态变化——养护任务删除提示"确定删除选中的养护任务吗?“,生态瓶出瓶提示"确定将该生态瓶出瓶转让吗?”,动态删除提示"确定删除这条苔友动态吗?"。这种动态文案的设计使得同一个弹框组件能够服务于多种删除场景,提高了代码的复用性。

删除弹框的按钮配色与前两种弹框有所不同——确认按钮使用的是危险红色 COLORS.danger 而非主色绿色。这是一个重要的交互设计细节:对于破坏性操作(删除、出瓶等),使用红色作为确认按钮的颜色,能够有效警示用户,降低误操作的风险。用户在看到红色按钮时会更加谨慎地确认自己的操作意图。取消按钮则保持灰色次要样式不变。删除弹框的最大高度限制为 60%,是三种弹框中最矮的,符合其内容最少的特点。

3.27 bottomBar 底部导航:四栏式主导航

@Builder
bottomBar() {
  Row() {
    ForEach(NAV_LIST, (n: NavItem, i: number) => {
      Column({ space: 3 }) {
        Text(n.icon)
          .fontSize(20)
          .opacity(this.mainTab === i ? 1 : 0.45)
        Text(n.label)
          .fontSize(10)
          .fontWeight(this.mainTab === i ? FontWeight.Bold : FontWeight.Normal)
          .fontColor(this.mainTab === i ? COLORS.primaryDeep : COLORS.hint)
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      .padding({ top: 8, bottom: 8 })
      .onClick(() => {
        this.mainTab = i
        this.subTab = 0
      })
    }, (n: NavItem) => n.label)
  }
  .width('100%')
  .backgroundColor(COLORS.cardBg)
  .border({ width: { top: 1 }, color: COLORS.border })
}

bottomBar 方法构建了应用的底部导航栏,是四个主要功能模块之间切换的入口。底部导航采用经典的四栏式设计,每个导航项使用"图标 + 文字"的垂直布局,四个导航项等分屏幕宽度。

每个导航项的选中态通过三种视觉变化来表达:图标的透明度(选中时 1.0 完全不透明,未选中时 0.45 半透明)、文字的粗细(选中时加粗,未选中时常规)、文字的颜色(选中时深苔绿,未选中时灰绿色提示色)。这种多维度的视觉反馈,使得选中状态非常明确。图标的大小为 20px,文字大小为 10px,二者间距 3px,整体比例协调。

交互方面,点击导航项会触发两个状态变化:将 mainTab 设为当前索引,同时将 subTab 重置为 0。重置子 Tab 的设计很重要——它确保了切换主导航后,子导航始终从第一个分类开始,避免了用户在不同主导航间切换时子 Tab 停留在意外位置的困惑。每个主导航对应的默认子页面不同(首页默认精选、工坊默认生态瓶工位、养护默认养护日历、我的默认苔友圈),这一逻辑在 mainContent 方法中实现。

底部导航的容器使用白色背景,顶部有一条 1px 的边框线(颜色为 COLORS.border),将导航栏与内容区域在视觉上分隔开。导航项的上下内边距各为 8px,确保触控区域足够大(实际高度约为 20 + 10 + 3 + 16 = 49px),符合移动端触控设计的最佳实践(建议触控区域不小于 44x44px)。

3.28 mainContent 主内容区:双层 Tab 的路由逻辑

@Builder
mainContent() {
  Column() {
    if (this.mainTab === 0) {
      Column() {
        this.header()
        this.subNav()
        if (this.subTab === 0) {
          this.pageFeed()
        } else if (this.subTab === 1) {
          this.pageMoss()
        } else if (this.subTab === 2) {
          this.pageBottle()
        } else if (this.subTab === 3) {
          this.pageCare()
        } else if (this.subTab === 4) {
          this.pageGallery()
        } else {
          this.pageFriends()
        }
      }
      .layoutWeight(1)
    } else if (this.mainTab === 1) {
      // 工坊 Tab:默认 pageBottle,其余顺序调整
    } else if (this.mainTab === 2) {
      // 养护 Tab:默认 pageCare,其余顺序调整
    } else {
      // 我的 Tab:默认 pageFriends,其余顺序调整
    }
  }
  .layoutWeight(1)
}

mainContent 方法是应用的页面路由中枢,管理着主导航与子导航的双层 Tab 切换逻辑。方法的外层使用 Column 布局,配合 layoutWeight(1) 占据除底部导航栏外的全部空间。

方法内部通过 if/else if 条件判断 mainTab 的值来决定显示哪个主导航的内容。每个主导航分支内部都有相同的结构:先渲染 header() 头部,再渲染 subNav() 子导航,然后根据 subTab 的值渲染对应的内容页面。这种"头部 + 子导航 + 内容页"的三层结构,在四个主导航下都是一致的,确保了应用整体布局的统一性。

值得注意的是,四个主导航下子 Tab 的默认页面与排列顺序各不相同。当 mainTab === 0(首页)时,subTab === 0 对应 pageFeed(精选);当 mainTab === 1(工坊)时,subTab === 0 对应 pageBottle(生态瓶工位);当 mainTab === 2(养护)时,subTab === 0 对应 pageCare(养护日历);当 mainTab === 3(我的)时,subTab === 0 对应 pageFriends(苔友圈)。这种设计使得每个主导航的默认展示页面都是该模块最核心的功能,用户点击主导航后一步直达核心内容。

这种使用条件渲染实现路由的方式,是单组件应用中常见的简单路由实现。它的优点是实现简单、状态共享方便(所有页面共享同一个组件的状态),缺点是当页面数量增多时 if/else 链会变得冗长。对于当前这种六个内容页面、四个主导航的规模来说,这种方式既简单又高效,是合适的选择。

3.29 build 主布局:Stack 叠加的页面结构

build() {
  Stack() {
    Column() {
      this.mainContent()
      this.bottomBar()
    }
    .width('100%')
    .height('100%')

    this.fxLayer()

    if (this.addOpen) {
      Stack() {
        this.modalOverlay()
        this.addModalBody()
      }
      .width('100%')
      .height('100%')
    }

    if (this.editOpen) {
      Stack() {
        this.modalOverlay()
        this.editModalBody()
      }
      .width('100%')
      .height('100%')
    }

    if (this.delOpen) {
      Stack() {
        this.modalOverlay()
        this.delModalBody()
      }
      .width('100%')
      .height('100%')
    }
  }
  .width('100%')
  .height('100%')
  .backgroundColor(COLORS.bg)
}

build() 方法是组件的主入口,定义了整个页面的整体结构。它使用 Stack 堆叠布局作为根容器,将页面分为四层,从下到上依次为:主内容层、特效层、弹框遮罩层与弹框内容层。

最底层是主内容层,由 Column 容器包含 mainContent()bottomBar() 组成。Column 的纵向布局使得内容区域在上、底部导航在下,mainContent() 使用 layoutWeight(1) 占据除底部导航外的全部空间。主内容层铺满整个屏幕(宽高均为 100%),是页面的基础层。

第二层是特效层 fxLayer(),叠加在主内容层之上。特效层同样铺满全屏,但由于设置了 hitTestBehavior(HitTestMode.None),触摸事件会穿透到下层,不影响用户与主内容的交互。水珠与雾气特效漂浮在页面内容之上,营造出湿润朦胧的视觉氛围。

第三层及以上是弹框层。三种弹框(新增、编辑、删除)分别通过 if 条件判断来控制显示与隐藏。每个弹框都是一个独立的 Stack,包含遮罩层(modalOverlay)与弹框主体(addModalBody/editModalBody/delModalBody)。弹框显示时,半透明遮罩覆盖整个页面,弹框内容居中悬浮在遮罩之上,形成模态对话框的效果。由于三种弹框分别使用独立的 if 判断,它们不会同时显示(每次只能打开一个弹框),这符合模态弹框的交互原则。

整个 Stack 容器的背景色为 COLORS.bg#F2F7EF 苔绿亮色),这是应用的页面底色。build() 方法作为页面结构的总纲,以清晰的层级关系将各个功能模块组织在一起,体现了声明式 UI 的结构化优势。


四、应用运行全流程图

以下流程图展示了应用从生命周期启动、页面渲染到用户交互的完整运行流程:

渲染错误: Mermaid 渲染失败: Parse error on line 5: ... D --> E[执行 build() 渲染] E -- -----------------------^ Expecting 'SQE', 'DOUBLECIRCLEEND', 'PE', '-)', 'STADIUMEND', 'SUBROUTINEEND', 'PIPE', 'CYLINDEREND', 'DIAMOND_STOP', 'TAGEND', 'TRAPEND', 'INVTRAPEND', 'UNICODE_TEXT', 'TEXT', 'TAGSTART', got 'PS'

五、技术对比表格

技术维度实现方式设计特点性能考量
颜色体系ColorPalette 接口 + COLORS 常量16 色完整色板,语义化命名,三级文字色阶集中定义减少重复,常量引用无运行时开销
数据模型@Observed 装饰的 class(PostItem/MossItem 等)属性级响应式,构造函数紧凑赋值,字段完整覆盖业务细粒度属性追踪,仅变更属性触发局部重渲染
状态管理@State 组件内状态 + @Observed 数据类导航状态、弹框状态、表单数据、业务数据分类管理非 UI 状态(如 fxTimer)不用 @State,减少无效渲染
页面路由if/else if 条件渲染 + 双层 Tab四个主导航各有独立默认页,子 Tab 六模块共用条件渲染仅挂载当前页面,非活动页不占用渲染资源
UI 抽离@Builder 装饰器(29 个构建方法)每个功能模块独立 Builder,职责单一,调用简洁Builder 内联展开无额外组件开销,代码组织清晰
列表渲染ForEach + keyGenerator按 id/标签生成唯一 key,支持数组增删改的高效 diffkey 唯一性确保组件复用,避免全量重建
弹框系统遮罩层 + 弹框主体 Stack 叠加三种弹框共用遮罩,动态文案适配多场景按需渲染(if 判断),未激活弹框不参与渲染树
动画特效setInterval 驱动 @State 计数器水珠下落 + 雾气飘动双特效,数学公式计算位置与透明度仅 14 个粒子元素,60ms 帧频,特效层不拦截触摸
图表实现纯组件组合(Column + 比例高度)湿度柱状图 + 品种分布图,颜色编码数据值无额外图表库依赖,渲染轻量,适配声明式框架
瀑布流双列 Column + 交替高度左右列不同底色,高低错落形成视觉韵律固定数量简化实现,无动态高度计算开销
表单交互TextInput + onChange 双向绑定弹框内表单,打开前重置/回显,空值兜底实时同步到 @State 变量,提交时直接取用
导航设计双层 Tab(底部主导航 + 横向子导航)四主六子,主导航切换时重置子 Tab 索引状态驱动切换,无路由框架依赖,切换响应即时
生命周期aboutToAppear / aboutToDisappear定时器启动与清理对称,资源管理规范页面销毁及时清除定时器,防止内存泄漏
工具函数纯函数式数据转换(barH/mossColor 等)数据与表现解耦,映射表模式,防御性 fallback无状态纯函数,可缓存结果,计算开销极低
卡片设计统一白色圆角卡片 + 内边距体系12px/14px 圆角,10-12px 内边距,模块间距 12px统一视觉语言减少认知负荷,布局规则一致
文字层级三级文字色(title/subTitle/hint)16/15/13/12/11/10/9px 多字号体系,行高 1.5x对比度符合可读性标准,信息层次清晰分明
按钮体系胶囊形圆角 + 填充/线框双样式主操作绿底白字,次操作灰底灰字,危险操作红底白字视觉权重引导用户操作路径,降低误操作概率
数据校验空值兜底 + 索引边界检查昵称/内容空默认值,编辑索引有效性判断,删除前数组非空检查防御性编程避免运行时异常,提升应用健壮性
图标系统Emoji 符号作为图标统一 20-24px 尺寸,与文字混合排列,零资源依赖无图片资源加载开销,跨平台一致,主题色适配灵活

六、详细总结

6.1 架构设计:单组件多 Builder 的实用主义

本应用采用了单页面组件配合 @Builder 抽离的架构模式,所有功能模块集中在一个 @Entry 组件中,通过 29 个 @Builder 方法将 UI 片段化、模块化。这种架构在中小型应用中具有显著的优势——首先是开发效率高,无需设计复杂的组件通信机制,所有状态都在同一个组件内共享,数据流转简单直接;其次是性能优异,没有多层组件嵌套带来的额外开销,状态变化的传播路径短,渲染响应迅速;最后是代码组织清晰,每个 @Builder 方法对应一个独立的功能模块,开发者可以快速定位到需要修改的代码位置。

当然,这种架构也有其适用边界。随着应用功能的不断扩展,当 @Builder 方法数量增长到一定程度时,单个组件文件会变得过于庞大,不利于团队协作与维护。此时可以考虑将独立的功能模块拆分为子组件,通过 @Prop@Link 等状态传递机制实现父子组件间的通信。但对于当前这个包含六个功能模块、约一千三百行代码的应用来说,单组件多 Builder 的架构是恰到好处的选择,既保证了代码的可维护性,又避免了过度设计。

6.2 状态管理:@Observed 与 @State 的分层协作

应用的状态管理体系体现了清晰的分层思想。最底层是四个 @Observed 数据模型类(PostItemMossItemBottleItemCareItem),它们封装了业务数据的结构与响应式能力;中间层是三个 @State 数组(postsbottlescares),它们持有数据集合并驱动列表渲染;最上层是各种 UI 状态变量(导航状态、弹框状态、表单状态等),它们控制着界面的显示与交互。

这种分层状态管理的优势在于职责清晰——数据模型专注于数据结构的定义与响应式能力,数组状态专注于集合的增删改操作,UI 状态专注于界面的显示逻辑。@Observed 装饰器使得数据属性的变更能够被精确追踪,当用户勾选养护任务时,只需修改 done 属性即可触发界面更新,无需替换整个数组元素。这种细粒度的响应式更新,既提升了渲染性能,又简化了代码逻辑——开发者不需要手动处理数组的不可变更新,直接修改属性即可。

6.3 性能优化:精细化渲染与资源管理

应用在性能优化方面体现了多处精细化的设计。首先是 ForEach 循环的 keyGenerator 使用,每个列表项都有明确的唯一标识(如 'post' + p.id'moss' + m.id),确保框架能够高效地进行 diff 与组件复用,在数据增删时避免不必要的组件重建。其次是条件渲染的合理运用,弹框、页面内容等都通过 if 判断按需渲染,未激活的 UI 元素不会出现在渲染树中,有效减少了渲染开销。

特效动画的性能控制也值得称道。通过控制粒子数量(9 颗水珠 + 5 片雾气,共 14 个元素)与帧率(60ms 间隔,约 17fps),在保证视觉效果的同时将性能开销控制在较低水平。更重要的是,hitTestBehavior(HitTestMode.None) 的设置使得特效层不拦截触摸事件,既保证了交互流畅性,又避免了因事件冒泡导致的意外行为。生命周期中的资源管理同样规范——aboutToAppear 中启动定时器,aboutToDisappear 中清除定时器,形成了完美的资源申请-释放对称,有效防止了内存泄漏。

6.4 交互设计:以用户为中心的细节打磨

应用的交互设计处处体现了以用户为中心的思考。弹框交互遵循了标准的模态对话框模式——半透明遮罩弱化背景、居中卡片聚焦内容、点击遮罩可关闭、提供明确的确认与取消按钮。特别是删除确认弹框的设计,使用红色确认按钮强化危险操作的警示性,配合动态的提示文案,在保持代码复用的同时提供了场景化的用户提示。

导航交互的设计也颇具匠心。双层 Tab 的架构中,切换主导航时自动重置子 Tab 到各模块的核心功能页,这种设计减少了用户的操作步骤,提升了导航效率。子导航采用横向滚动 + 胶囊形 Tab 的形式,选中态通过字体粗细、文字颜色、背景色三重维度的变化来表达,视觉反馈明确而强烈。养护任务的完成交互同样经过了精心设计——复选框 + 删除线 + 灰色文字的三重反馈,给予用户充分的完成感与成就感。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// ============ 苔语·苔藓微景观生态瓶工坊 ============
// 场景:苔藓品种图鉴 / 生态瓶工位 / 养护日历 / 苔景画廊 / 苔友圈
// 配色:清新苔绿亮底 #F2F7EF
// 特效:水珠凝结下落 + 雾气飘动
// 弹框:新增苔友动态 / 编辑养护计划 / 删除动态·出瓶

interface ColorPalette {
  primary: string
  primaryDeep: string
  primaryLight: string
  accent: string
  accentLight: string
  bg: string
  cardBg: string
  title: string
  subTitle: string
  hint: string
  border: string
  success: string
  warning: string
  danger: string
  white: string
  night: string
}

const COLORS: ColorPalette = {
  primary: '#3E7B4F',
  primaryDeep: '#2C5C3B',
  primaryLight: '#D9EBDD',
  accent: '#C98A2D',
  accentLight: '#F5E8CC',
  bg: '#F2F7EF',
  cardBg: '#FFFFFF',
  title: '#23382B',
  subTitle: '#5E7668',
  hint: '#9FB5A6',
  border: '#E2EEDD',
  success: '#57A773',
  warning: '#E0A64A',
  danger: '#D96B5F',
  white: '#FFFFFF',
  night: '#2B4632'
};

interface MossMeta {
  label: string
  icon: string
  color: string
}

interface NavItem {
  icon: string
  label: string
}

interface HumChartItem {
  label: string
  value: number
}

interface SpecChartItem {
  label: string
  value: number
  color: string
}

@Observed
export class PostItem {
  id: number = 0
  nick: string = ''
  avatar: string = ''
  content: string = ''
  likes: number = 0
  comments: number = 0
  tag: string = ''
  time: string = ''
  constructor(id: number, nick: string, avatar: string, content: string, likes: number, comments: number, tag: string, time: string) {
    this.id = id; this.nick = nick; this.avatar = avatar; this.content = content
    this.likes = likes; this.comments = comments; this.tag = tag; this.time = time
  }
}

@Observed
export class MossItem {
  id: number = 0
  name: string = ''
  icon: string = ''
  family: string = ''
  humidity: number = 0
  light: string = ''
  temp: string = ''
  growth: string = ''
  difficulty: string = ''
  desc: string = ''
  constructor(id: number, name: string, icon: string, family: string, humidity: number, light: string, temp: string, growth: string, difficulty: string, desc: string) {
    this.id = id; this.name = name; this.icon = icon; this.family = family
    this.humidity = humidity; this.light = light; this.temp = temp
    this.growth = growth; this.difficulty = difficulty; this.desc = desc
  }
}

@Observed
export class BottleItem {
  id: number = 0
  name: string = ''
  icon: string = ''
  style: string = ''
  mossCount: number = 0
  progress: number = 0
  state: string = ''
  days: number = 0
  nextCare: string = ''
  constructor(id: number, name: string, icon: string, style: string, mossCount: number, progress: number, state: string, days: number, nextCare: string) {
    this.id = id; this.name = name; this.icon = icon; this.style = style
    this.mossCount = mossCount; this.progress = progress; this.state = state
    this.days = days; this.nextCare = nextCare
  }
}

@Observed
export class CareItem {
  id: number = 0
  title: string = ''
  icon: string = ''
  day: string = ''
  period: string = ''
  done: boolean = false
  constructor(id: number, title: string, icon: string, day: string, period: string, done: boolean) {
    this.id = id; this.title = title; this.icon = icon; this.day = day
    this.period = period; this.done = done
  }
}

const MOSS_LIST: MossItem[] = [
  new MossItem(1, '白发藓', '🌿', '白发藓科', 85, '半阴', '18-26℃', '缓慢', '入门', '叶片顶端银白色,微景观主角'),
  new MossItem(2, '大灰藓', '🌾', '灰藓科', 75, '散射光', '15-25℃', '较快', '入门', '匍匐丛生,覆盖性好'),
  new MossItem(3, '砂藓', '🪨', '砂藓科', 60, '喜光', '20-30℃', '中等', '进阶', '耐旱耐晒,适合开缸造景'),
  new MossItem(4, '羽藓', '🪶', '羽藓科', 80, '半阴', '16-24℃', '中等', '入门', '羽毛状叶片,适合垂吊'),
  new MossItem(5, '朵状苔', '☂️', '丛藓科', 70, '散射光', '15-22℃', '缓慢', '进阶', '伞状顶端,造型独特'),
  new MossItem(6, '地钱', '🟢', '地钱科', 90, '阴湿', '12-20℃', '较快', '入门', '叶状体贴地生长'),
  new MossItem(7, '角苔', '🦌', '角苔科', 88, '阴湿', '10-18℃', '中等', '进阶', '角状孢蒴向上挺立'),
  new MossItem(8, '柳叶藓', '🌱', '柳叶藓科', 82, '半阴', '14-22℃', '较快', '入门', '细柳叶形,枝条柔美'),
  new MossItem(9, '暖地大叶藓', '🍃', '真藓科', 78, '散射光', '18-28℃', '较快', '入门', '大叶明亮,南方常见'),
  new MossItem(10, '松叶白发藓', '🌲', '白发藓科', 86, '半阴', '16-26℃', '缓慢', '进阶', '松针状叶丛,高级缸首选')
];

const BOTTLE_LIST: BottleItem[] = [
  new BottleItem(1, '晨雾玻璃罩瓶', '🫙', '玻璃罩', 3, 68, '旺盛', 42, '明早'),
  new BottleItem(2, '石崖陶盆瓶', '🏺', '陶盆', 2, 52, '缓苗', 18, '今晚'),
  new BottleItem(3, '漂流木雨林瓶', '🪵', '开放式', 5, 81, '旺盛', 66, '后天'),
  new BottleItem(4, '悬崖峭壁瓶', '🏔️', '玻璃缸', 4, 44, '缓苗', 12, '今晚'),
  new BottleItem(5, '水陆双栖瓶', '💧', '水陆缸', 6, 73, '旺盛', 51, '明早'),
  new BottleItem(6, '月光石壁瓶', '🌙', '壁挂瓶', 2, 37, '休眠', 25, '周六'),
  new BottleItem(7, '山谷苔径瓶', '⛰️', '长条缸', 4, 88, '旺盛', 77, '明晚'),
  new BottleItem(8, '苔球悬垂瓶', '🪢', '悬垂瓶', 3, 59, '缓苗', 20, '周五')
];

const CARE_LIST: CareItem[] = [
  new CareItem(1, '喷水保湿', '💦', '周一', '清晨', true),
  new CareItem(2, '散射光晒苔', '☀️', '周二', '上午', true),
  new CareItem(3, '开盖通风', '🌬️', '周三', '午后', false),
  new CareItem(4, '修剪徒长枝', '✂️', '周四', '傍晚', false),
  new CareItem(5, '换纯净水', '🫗', '周五', '上午', false),
  new CareItem(6, '除虫检查', '🐞', '周六', '午后', false),
  new CareItem(7, '补苔铺新', '🌱', '周日', '上午', false),
  new CareItem(8, '湿度记录', '📒', '每日', '睡前', true)
];

const HUM_CHART: HumChartItem[] = [
  { label: '周一', value: 82 },
  { label: '周二', value: 86 },
  { label: '周三', value: 74 },
  { label: '周四', value: 79 },
  { label: '周五', value: 88 },
  { label: '周六', value: 83 },
  { label: '周日', value: 90 }
];

const SPEC_CHART: SpecChartItem[] = [
  { label: '白发藓', value: 32, color: '#6FA37E' },
  { label: '大灰藓', value: 21, color: '#8FB996' },
  { label: '羽藓', value: 15, color: '#B3C9A9' },
  { label: '砂藓', value: 12, color: '#C4D9B8' },
  { label: '其他', value: 20, color: '#DFEBD7' }
];

const NAV_LIST: NavItem[] = [
  { icon: '🌿', label: '首页' },
  { icon: '🫙', label: '工坊' },
  { icon: '📒', label: '养护' },
  { icon: '👤', label: '我的' }
];

const SUB_NAV_LIST: string[] = ['精选', '苔藓图鉴', '生态瓶工位', '养护日历', '苔景画廊', '苔友圈'];

const MOSS_META: Record<string, MossMeta> = {
  '入门': { label: '入门', icon: '🌱', color: '#57A773' },
  '进阶': { label: '进阶', icon: '🔥', color: '#E0A64A' },
  '高手': { label: '高手', icon: '👑', color: '#D96B5F' }
};

function barH(v: number, max: number): number {
  return max * v / 100;
}

function mossColor(tag: string): string {
  let c = MOSS_META[tag];
  return c ? c.color : COLORS.primary;
}

function mossIcon(tag: string): string {
  let c = MOSS_META[tag];
  return c ? c.icon : '🌱';
}

function lightLabel(light: string): string {
  return light === '喜光' ? '☀️ 喜光' : (light === '半阴' ? '🌤️ 半阴' : '☁️ 散射光');
}

function waterBar(h: number, max: number): number {
  return max * h / 100;
}

function dropY(tick: number, i: number): number {
  return ((tick * 3 + i * 17) % 120) - 10;
}

function dropAlpha(tick: number, i: number): number {
  let p = (tick * 3 + i * 17) % 120;
  return p < 20 ? 0.9 : 0.3;
}

function mistX(tick: number, i: number): number {
  return ((tick * 2 + i * 31) % 140) - 20;
}

function mistAlpha(glow: number, i: number): number {
  return 0.25 + 0.15 * Math.sin(glow / 8 + i);
}

@Entry
@Component
struct Page856 {
  @State mainTab: number = 0
  @State subTab: number = 0
  @State tick: number = 0
  @State glow: number = 0
  @State addOpen: boolean = false
  @State editOpen: boolean = false
  @State delOpen: boolean = false
  @State editIdx: number = -1
  @State delTarget: string = ''
  @State addNick: string = ''
  @State addText: string = ''
  @State editTitle: string = ''
  @State editPeriod: string = ''
  @State posts: PostItem[] = [
    new PostItem(1, '苔径行者', '🦎', '清晨给月光石壁瓶喷了水,水珠挂在苔尖上的样子太治愈了,就像一座迷你森林!', 68, 12, '养护心得', '10分钟前'),
    new PostItem(2, '青苔小姐', '🧚', '悬崖峭壁瓶第12天,砂藓开始爬满岩壁缝隙了,成就感爆棚!', 45, 8, '造景记录', '32分钟前'),
    new PostItem(3, '瓶中小世界', '🫙', '今日份水陆缸开缸记录:白发藓打底 + 大灰藓铺坡,等它慢慢长满。', 92, 21, '开缸日记', '1小时前'),
    new PostItem(4, '微缩山景师', '⛰️', '山脊线要用羽藓垂下来才自然,分享一个苔径打弯的小技巧。', 77, 15, '造景技巧', '2小时前'),
    new PostItem(5, '苔原守夜人', '🌙', '灯下看苔藓,每片叶尖都在反光,像碎了一地的绿宝石。', 53, 6, '随手拍', '3小时前'),
    new PostItem(6, '森林收集者', '🌲', '在溪谷边采到一片角苔,孢子体小鹿角一样立着,太神奇了。', 39, 4, '野采记录', '5小时前'),
    new PostItem(7, '氧气面包师', '🍞', '把吃剩的果酱瓶改造成了苔球瓶,废物利用真香!', 84, 19, 'DIY改造', '7小时前'),
    new PostItem(8, '绿野仙踪', '🧚‍♀️', '养护半年的大灰藓坡,已经长出小蘑菇了,生态系统自成一体。', 61, 9, '养护心得', '9小时前'),
    new PostItem(9, '苔藓博士', '🥼', '实验:增加通风频率后,大灰藓颜色明显更鲜亮了,推荐大家试试。', 47, 11, '干货分享', '11小时前'),
    new PostItem(10, '掌上森林', '🤲', '出差一周前把苔瓶装进密封袋保湿,回来完好无损!', 72, 13, '经验总结', '13小时前')
  ]
  @State bottles: BottleItem[] = BOTTLE_LIST.slice(0)
  @State cares: CareItem[] = CARE_LIST.slice(0)
  fxTimer: number = -1

  aboutToAppear(): void {
    this.fxTimer = setInterval(() => {
      this.tick = this.tick + 1
      this.glow = this.glow + 1
    }, 60)
  }

  aboutToDisappear(): void {
    clearInterval(this.fxTimer)
  }

  openAdd(): void {
    this.addNick = ''
    this.addText = ''
    this.addOpen = true
  }

  openEdit(idx: number): void {
    this.editIdx = idx
    let c: CareItem = this.cares[idx]
    this.editTitle = c.title
    this.editPeriod = c.period
    this.editOpen = true
  }

  openDelA(tag: string): void {
    this.delTarget = tag
    this.delOpen = true
  }

  openDelB(tag: string): void {
    this.delTarget = tag
    this.delOpen = true
  }

  doAdd(): void {
    let nick: string = this.addNick.length > 0 ? this.addNick : '苔友新丁'
    let txt: string = this.addText.length > 0 ? this.addText : '今天也在认真养苔!'
    this.posts.unshift(new PostItem(this.posts.length + 1, nick, '🌱', txt, 0, 0, '新动态', '刚刚'))
    this.addOpen = false
  }

  doEdit(): void {
    if (this.editIdx >= 0) {
      if (this.editTitle.length > 0) {
        this.cares[this.editIdx].title = this.editTitle
      }
      if (this.editPeriod.length > 0) {
        this.cares[this.editIdx].period = this.editPeriod
      }
    }
    this.editOpen = false
  }

  doDel(): void {
    if (this.delTarget === 'care') {
      let idx = this.cares.findIndex((c: CareItem) => c.done)
      if (idx >= 0) {
        this.cares.splice(idx, 1)
      }
    } else if (this.delTarget === 'bottle') {
      if (this.bottles.length > 0) {
        this.bottles.pop()
      }
    } else {
      if (this.posts.length > 0) {
        this.posts.pop()
      }
    }
    this.delOpen = false
  }

  toggleCare(idx: number): void {
    this.cares[idx].done = !this.cares[idx].done
  }

  @Builder
  fxLayer() {
    Stack() {
      ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8], (i: number) => {
        Column()
          .width(4)
          .height(4)
          .borderRadius(2)
          .backgroundColor('#8FCBA5')
          .opacity(dropAlpha(this.tick, i))
          .position({ x: 30 + (i * 41) % 340, y: dropY(this.tick, i) })
      }, (i: number) => 'drop' + i)
      ForEach([0, 1, 2, 3, 4], (i: number) => {
        Column()
          .width(56)
          .height(14)
          .borderRadius(7)
          .backgroundColor('#FFFFFF')
          .opacity(mistAlpha(this.glow, i))
          .position({ x: mistX(this.tick, i), y: 160 + (i * 72) % 320 })
      }, (i: number) => 'mist' + i)
    }
    .width('100%')
    .height('100%')
    .hitTestBehavior(HitTestMode.None)
  }

  @Builder
  header() {
    Row() {
      Column() {
        Text('苔语 · 微景观工坊')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
        Text('一平方厘米的森林')
          .fontSize(11)
          .fontColor('#DCEEDF')
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)

      Text('🔍')
        .fontSize(18)
        .padding(8)
        .backgroundColor('#00000026')
        .borderRadius(18)

      Text('🛒')
        .fontSize(18)
        .padding(8)
        .margin({ left: 6 })
        .backgroundColor('#00000026')
        .borderRadius(18)
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 12, bottom: 12 })
    .backgroundColor(COLORS.primaryDeep)
  }

  @Builder
  subNav() {
    Scroll() {
      Row({ space: 10 }) {
        ForEach(SUB_NAV_LIST, (s: string, i: number) => {
          Column() {
            Text(s)
              .fontSize(13)
              .fontWeight(this.subTab === i ? FontWeight.Bold : FontWeight.Normal)
              .fontColor(this.subTab === i ? COLORS.primaryDeep : COLORS.subTitle)
              .padding({ left: 14, right: 14, top: 7, bottom: 7 })
              .backgroundColor(this.subTab === i ? COLORS.primaryLight : COLORS.cardBg)
              .borderRadius(16)
          }
          .onClick(() => {
            this.subTab = i
          })
        }, (s: string) => s)
      }
      .padding({ left: 12, right: 12 })
    }
    .scrollable(ScrollDirection.Horizontal)
    .scrollBar(BarState.Off)
    .height(46)
  }

  @Builder
  pageFeed() {
    Scroll() {
      Column({ space: 12 }) {
        Row() {
          Text('🌿 本周苔况')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.title)
          Text('湿度偏好 80%')
            .fontSize(10)
            .fontColor(COLORS.hint)
            .margin({ left: 8 })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)

        Row({ space: 8 }) {
          ForEach(HUM_CHART, (h: HumChartItem) => {
            Column({ space: 4 }) {
              Column()
                .width(18)
                .height(barH(h.value, 66))
                .borderRadius(5)
                .backgroundColor(h.value > 84 ? COLORS.primary : COLORS.primaryLight)
              Text(h.label)
                .fontSize(9)
                .fontColor(COLORS.subTitle)
            }
            .layoutWeight(1)
            .justifyContent(FlexAlign.End)
            .alignItems(HorizontalAlign.Center)
          }, (h: HumChartItem) => h.label)
        }
        .width('100%')
        .height(96)
        .padding(10)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(14)

        Text('🫙 热养生态瓶 TOP3')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.title)
          .width('100%')

        ForEach(this.bottles.slice(0, 3), (b: BottleItem, i: number) => {
          Row({ space: 10 }) {
            Text(i === 0 ? '🥇' : (i === 1 ? '🥈' : '🥉'))
              .fontSize(20)
            Column({ space: 4 }) {
              Row() {
                Text(b.name)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.title)
                Text(b.style)
                  .fontSize(9)
                  .fontColor(COLORS.primary)
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                  .backgroundColor(COLORS.primaryLight)
                  .borderRadius(8)
                  .margin({ left: 6 })
              }
              Row({ space: 8 }) {
                Column()
                  .width(b.progress * 2.4)
                  .height(5)
                  .borderRadius(3)
                  .backgroundColor(COLORS.primary)
                Text(b.progress + '%')
                  .fontSize(9)
                  .fontColor(COLORS.subTitle)
              }
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)

            Text(b.state)
              .fontSize(10)
              .fontColor(mossColor(b.state))
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
              .backgroundColor(b.state === '旺盛' ? COLORS.primaryLight : '#F5EEE0')
              .borderRadius(10)
          }
          .width('100%')
          .padding(10)
          .backgroundColor(COLORS.cardBg)
          .borderRadius(12)
        }, (b: BottleItem, i: number) => b.id + '-' + i)

        Text('🌱 最新苔友圈')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.title)
          .width('100%')

        ForEach(this.posts.slice(0, 3), (p: PostItem) => {
          Row({ space: 10 }) {
            Text(p.avatar)
              .fontSize(22)
            Column({ space: 4 }) {
              Text(p.nick)
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.title)
              Text(p.content)
                .fontSize(11)
                .fontColor(COLORS.subTitle)
                .maxLines(2)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
              Text(p.time + ' · ' + p.likes + '赞')
                .fontSize(9)
                .fontColor(COLORS.hint)
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)
          }
          .width('100%')
          .padding(10)
          .backgroundColor(COLORS.cardBg)
          .borderRadius(12)
        }, (p: PostItem) => 'feed' + p.id)
      }
      .width('100%')
      .padding(12)
    }
    .scrollBar(BarState.Off)
  }

  @Builder
  pageMoss() {
    Scroll() {
      Column({ space: 12 }) {
        Text('🍀 苔藓品种图鉴 · 共' + MOSS_LIST.length + '种')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.title)
          .width('100%')

        ForEach(MOSS_LIST, (m: MossItem) => {
          Row({ space: 10 }) {
            Text(m.icon)
              .fontSize(24)
              .width(44)
              .height(44)
              .textAlign(TextAlign.Center)
              .backgroundColor(COLORS.primaryLight)
              .borderRadius(12)

            Column({ space: 4 }) {
              Row() {
                Text(m.name)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.title)
                Text(m.family)
                  .fontSize(9)
                  .fontColor(COLORS.hint)
                  .margin({ left: 6 })
                Text(mossIcon(m.difficulty) + ' ' + m.difficulty)
                  .fontSize(9)
                  .fontColor(mossColor(m.difficulty))
                  .margin({ left: 6 })
              }
              Text(m.desc)
                .fontSize(10)
                .fontColor(COLORS.subTitle)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
              Row({ space: 8 }) {
                Column()
                  .width(waterBar(m.humidity, 88))
                  .height(5)
                  .borderRadius(3)
                  .backgroundColor(COLORS.primary)
                Text('湿度' + m.humidity + '% · ' + lightLabel(m.light))
                  .fontSize(9)
                  .fontColor(COLORS.subTitle)
              }
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)

            Text(m.temp)
              .fontSize(10)
              .fontColor(COLORS.primaryDeep)
              .padding({ left: 6, right: 6, top: 3, bottom: 3 })
              .backgroundColor('#EAF4EC')
              .borderRadius(8)
          }
          .width('100%')
          .padding(10)
          .backgroundColor(COLORS.cardBg)
          .borderRadius(12)
        }, (m: MossItem) => 'moss' + m.id)
      }
      .width('100%')
      .padding(12)
    }
    .scrollBar(BarState.Off)
  }

  @Builder
  pageBottle() {
    Scroll() {
      Column({ space: 12 }) {
        Row({ space: 10 }) {
          Column({ space: 4 }) {
            Text(this.bottles.length + '')
              .fontSize(22)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.primaryDeep)
            Text('在养生态瓶')
              .fontSize(10)
              .fontColor(COLORS.subTitle)
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .padding(12)
          .backgroundColor(COLORS.cardBg)
          .borderRadius(12)

          Column({ space: 4 }) {
            Text('6')
              .fontSize(22)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.accent)
            Text('待养护任务')
              .fontSize(10)
              .fontColor(COLORS.subTitle)
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .padding(12)
          .backgroundColor(COLORS.cardBg)
          .borderRadius(12)
        }
        .width('100%')

        Text('🫙 我的生态瓶工位')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.title)
          .width('100%')

        ForEach(this.bottles, (b: BottleItem) => {
          Column({ space: 8 }) {
            Row({ space: 8 }) {
              Text(b.icon)
                .fontSize(22)
              Text(b.name)
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.title)
              Text(b.state)
                .fontSize(9)
                .fontColor(mossColor(b.state))
                .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                .backgroundColor(b.state === '旺盛' ? COLORS.primaryLight : '#F5EEE0')
                .borderRadius(8)
                .margin({ left: 4 })
            }
            Row({ space: 8 }) {
              Column()
                .width(b.progress * 2.4)
                .height(6)
                .borderRadius(3)
                .backgroundColor(b.progress > 60 ? COLORS.primary : COLORS.warning)
              Text(b.progress + '%')
                .fontSize(10)
                .fontColor(COLORS.subTitle)
            }
            Row() {
              Text('苔种 ' + b.mossCount + ' 种 · 养了 ' + b.days + ' 天')
                .fontSize(10)
                .fontColor(COLORS.subTitle)
              Text('下次护理 ' + b.nextCare)
                .fontSize(10)
                .fontColor(COLORS.warning)
                .margin({ left: 10 })
            }
            Row() {
              Text('出瓶转让')
                .fontSize(10)
                .fontColor(COLORS.danger)
                .padding({ left: 10, right: 10, top: 4, bottom: 4 })
                .backgroundColor('#FBE9E7')
                .borderRadius(9)
                .onClick(() => {
                  this.openDelB('bottle')
                })
            }
          }
          .width('100%')
          .alignItems(HorizontalAlign.Start)
          .padding(12)
          .backgroundColor(COLORS.cardBg)
          .borderRadius(12)
        }, (b: BottleItem) => 'bot' + b.id)
      }
      .width('100%')
      .padding(12)
    }
    .scrollBar(BarState.Off)
  }

  @Builder
  pageCare() {
    Scroll() {
      Column({ space: 12 }) {
        Text('📒 本周养护日历')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.title)
          .width('100%')

        Row({ space: 6 }) {
          ForEach(['一', '二', '三', '四', '五', '六', '日'], (d: string, i: number) => {
            Column({ space: 4 }) {
              Text(d)
                .fontSize(10)
                .fontColor(i < 5 ? COLORS.primaryDeep : COLORS.warning)
              Column()
                .width(26)
                .height(26)
                .borderRadius(13)
                .backgroundColor(i % 2 === 0 ? COLORS.primaryLight : '#F5EEE0')
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
          }, (d: string) => d)
        }
        .width('100%')
        .padding(10)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)

        Text('🌿 待办任务')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.title)
          .width('100%')

        ForEach(this.cares, (c: CareItem, i: number) => {
          Row({ space: 10 }) {
            Text(c.done ? '✅' : '⬜')
              .fontSize(18)
              .onClick(() => {
                this.toggleCare(i)
              })
            Column({ space: 3 }) {
              Text(c.title)
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor(c.done ? COLORS.hint : COLORS.title)
                .decoration({ type: c.done ? TextDecorationType.LineThrough : TextDecorationType.None })
              Text(c.day + ' · ' + c.period)
                .fontSize(10)
                .fontColor(COLORS.subTitle)
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)

            Text('编辑')
              .fontSize(10)
              .fontColor(COLORS.primary)
              .padding({ left: 8, right: 8, top: 4, bottom: 4 })
              .backgroundColor(COLORS.primaryLight)
              .borderRadius(8)
              .onClick(() => {
                this.openEdit(i)
              })

            Text('删除')
              .fontSize(10)
              .fontColor(COLORS.danger)
              .padding({ left: 8, right: 8, top: 4, bottom: 4 })
              .backgroundColor('#FBE9E7')
              .borderRadius(8)
              .onClick(() => {
                this.openDelA('care')
              })
          }
          .width('100%')
          .padding(10)
          .backgroundColor(COLORS.cardBg)
          .borderRadius(12)
        }, (c: CareItem) => 'care' + c.id)
      }
      .width('100%')
      .padding(12)
    }
    .scrollBar(BarState.Off)
  }

  @Builder
  pageGallery() {
    Scroll() {
      Column({ space: 12 }) {
        Text('🖼️ 苔景画廊')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.title)
          .width('100%')

        Row({ space: 10 }) {
          Column({ space: 10 }) {
            ForEach(['🌄', '🏞️', '🌲', '🪨'], (e: string, i: number) => {
              Column({ space: 4 }) {
                Text(e)
                  .fontSize(46)
                  .width('100%')
                  .height(i % 2 === 0 ? 108 : 82)
                  .textAlign(TextAlign.Center)
                  .backgroundColor(COLORS.primaryLight)
                  .borderRadius(12)
                Text('苔景 No.' + (i + 1))
                  .fontSize(10)
                  .fontColor(COLORS.subTitle)
              }
            }, (e: string, i: number) => 'g1' + e + i)
          }
          .layoutWeight(1)

          Column({ space: 10 }) {
            ForEach(['🌋', '🏔️', '🌊', '⛰️'], (e: string, i: number) => {
              Column({ space: 4 }) {
                Text(e)
                  .fontSize(46)
                  .width('100%')
                  .height(i % 2 === 0 ? 82 : 108)
                  .textAlign(TextAlign.Center)
                  .backgroundColor('#F5EEE0')
                  .borderRadius(12)
                Text('苔景 No.' + (i + 5))
                  .fontSize(10)
                  .fontColor(COLORS.subTitle)
              }
            }, (e: string, i: number) => 'g2' + e + i)
          }
          .layoutWeight(1)
        }
        .width('100%')
        .alignItems(VerticalAlign.Top)

        Row({ space: 8 }) {
          ForEach(SPEC_CHART, (s: SpecChartItem) => {
            Column({ space: 4 }) {
              Column()
                .width(28)
                .height(barH(s.value, 60))
                .borderRadius(5)
                .backgroundColor(s.color)
              Text(s.label)
                .fontSize(9)
                .fontColor(COLORS.subTitle)
              Text(s.value + '%')
                .fontSize(9)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.title)
            }
            .layoutWeight(1)
            .justifyContent(FlexAlign.End)
            .alignItems(HorizontalAlign.Center)
          }, (s: SpecChartItem) => s.label)
        }
        .width('100%')
        .height(110)
        .padding(10)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
      }
      .width('100%')
      .padding(12)
    }
    .scrollBar(BarState.Off)
  }

  @Builder
  pageFriends() {
    Scroll() {
      Column({ space: 12 }) {
        Row() {
          Text('🌱 苔友圈')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.title)
          Text('发动态')
            .fontSize(11)
            .fontColor(COLORS.white)
            .padding({ left: 12, right: 12, top: 5, bottom: 5 })
            .backgroundColor(COLORS.primary)
            .borderRadius(12)
            .margin({ left: 10 })
            .onClick(() => {
              this.openAdd()
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)

        ForEach(this.posts, (p: PostItem) => {
          Column({ space: 6 }) {
            Row({ space: 8 }) {
              Text(p.avatar)
                .fontSize(24)
              Column({ space: 2 }) {
                Text(p.nick)
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.title)
                Text(p.time)
                  .fontSize(9)
                  .fontColor(COLORS.hint)
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)

              Text(p.tag)
                .fontSize(9)
                .fontColor(COLORS.primary)
                .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                .backgroundColor(COLORS.primaryLight)
                .borderRadius(8)
            }
            Text(p.content)
              .fontSize(12)
              .fontColor(COLORS.title)
              .width('100%')
              .lineHeight(18)

            Row() {
              Text('👍 ' + p.likes)
                .fontSize(10)
                .fontColor(COLORS.subTitle)
              Text('💬 ' + p.comments)
                .fontSize(10)
                .fontColor(COLORS.subTitle)
                .margin({ left: 14 })
              Text('转发')
                .fontSize(10)
                .fontColor(COLORS.subTitle)
                .margin({ left: 14 })
            }
          }
          .width('100%')
          .alignItems(HorizontalAlign.Start)
          .padding(12)
          .backgroundColor(COLORS.cardBg)
          .borderRadius(12)
        }, (p: PostItem) => 'post' + p.id)
      }
      .width('100%')
      .padding(12)
    }
    .scrollBar(BarState.Off)
  }

  @Builder
  modalOverlay() {
    Stack() {
      Column()
        .width('100%')
        .height('100%')
        .backgroundColor('#00000066')
        .onClick(() => {
          this.addOpen = false
          this.editOpen = false
          this.delOpen = false
        })
    }
    .width('100%')
    .height('100%')
  }

  @Builder
  addModalBody() {
    Column({ space: 12 }) {
      Text('🌱 发苔友动态')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.title)

      TextInput({ placeholder: '你的昵称', text: this.addNick })
        .fontSize(13)
        .onChange((v: string) => {
          this.addNick = v
        })

      TextInput({ placeholder: '分享今天的苔况…', text: this.addText })
        .fontSize(13)
        .height(72)
        .onChange((v: string) => {
          this.addText = v
        })

      Row() {
        Text('发布')
          .fontSize(13)
          .fontColor(COLORS.white)
          .padding({ left: 28, right: 28, top: 8, bottom: 8 })
          .backgroundColor(COLORS.primary)
          .borderRadius(18)
          .onClick(() => {
            this.doAdd()
          })
        Text('取消')
          .fontSize(13)
          .fontColor(COLORS.subTitle)
          .padding({ left: 28, right: 28, top: 8, bottom: 8 })
          .backgroundColor('#F0F0F0')
          .borderRadius(18)
          .margin({ left: 10 })
          .onClick(() => {
            this.addOpen = false
          })
      }
    }
    .width('88%')
    .alignItems(HorizontalAlign.Start)
    .padding(18)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(16)
    .constraintSize({ maxHeight: '80%' })
    .zIndex(999)
  }

  @Builder
  editModalBody() {
    Column({ space: 12 }) {
      Text('📒 编辑养护任务')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.title)

      TextInput({ placeholder: '任务名称', text: this.editTitle })
        .fontSize(13)
        .onChange((v: string) => {
          this.editTitle = v
        })

      TextInput({ placeholder: '时段(如:清晨/午后)', text: this.editPeriod })
        .fontSize(13)
        .onChange((v: string) => {
          this.editPeriod = v
        })

      Row() {
        Text('保存')
          .fontSize(13)
          .fontColor(COLORS.white)
          .padding({ left: 28, right: 28, top: 8, bottom: 8 })
          .backgroundColor(COLORS.primary)
          .borderRadius(18)
          .onClick(() => {
            this.doEdit()
          })
        Text('取消')
          .fontSize(13)
          .fontColor(COLORS.subTitle)
          .padding({ left: 28, right: 28, top: 8, bottom: 8 })
          .backgroundColor('#F0F0F0')
          .borderRadius(18)
          .margin({ left: 10 })
          .onClick(() => {
            this.editOpen = false
          })
      }
    }
    .width('88%')
    .alignItems(HorizontalAlign.Start)
    .padding(18)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(16)
    .constraintSize({ maxHeight: '70%' })
    .zIndex(999)
  }

  @Builder
  delModalBody() {
    Column({ space: 12 }) {
      Text('⚠️ 确认操作')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.title)

      Text(this.delTarget === 'care' ? '确定删除选中的养护任务吗?' : (this.delTarget === 'bottle' ? '确定将该生态瓶出瓶转让吗?' : '确定删除这条苔友动态吗?'))
        .fontSize(12)
        .fontColor(COLORS.subTitle)

      Row() {
        Text('确认')
          .fontSize(13)
          .fontColor(COLORS.white)
          .padding({ left: 28, right: 28, top: 8, bottom: 8 })
          .backgroundColor(COLORS.danger)
          .borderRadius(18)
          .onClick(() => {
            this.doDel()
          })
        Text('取消')
          .fontSize(13)
          .fontColor(COLORS.subTitle)
          .padding({ left: 28, right: 28, top: 8, bottom: 8 })
          .backgroundColor('#F0F0F0')
          .borderRadius(18)
          .margin({ left: 10 })
          .onClick(() => {
            this.delOpen = false
          })
      }
    }
    .width('88%')
    .alignItems(HorizontalAlign.Start)
    .padding(18)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(16)
    .constraintSize({ maxHeight: '60%' })
    .zIndex(999)
  }

  @Builder
  bottomBar() {
    Row() {
      ForEach(NAV_LIST, (n: NavItem, i: number) => {
        Column({ space: 3 }) {
          Text(n.icon)
            .fontSize(20)
            .opacity(this.mainTab === i ? 1 : 0.45)
          Text(n.label)
            .fontSize(10)
            .fontWeight(this.mainTab === i ? FontWeight.Bold : FontWeight.Normal)
            .fontColor(this.mainTab === i ? COLORS.primaryDeep : COLORS.hint)
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .padding({ top: 8, bottom: 8 })
        .onClick(() => {
          this.mainTab = i
          this.subTab = 0
        })
      }, (n: NavItem) => n.label)
    }
    .width('100%')
    .backgroundColor(COLORS.cardBg)
    .border({ width: { top: 1 }, color: COLORS.border })
  }

  @Builder
  mainContent() {
    Column() {
      if (this.mainTab === 0) {
        Column() {
          this.header()
          this.subNav()
          if (this.subTab === 0) {
            this.pageFeed()
          } else if (this.subTab === 1) {
            this.pageMoss()
          } else if (this.subTab === 2) {
            this.pageBottle()
          } else if (this.subTab === 3) {
            this.pageCare()
          } else if (this.subTab === 4) {
            this.pageGallery()
          } else {
            this.pageFriends()
          }
        }
        .layoutWeight(1)
      } else if (this.mainTab === 1) {
        Column() {
          this.header()
          this.subNav()
          if (this.subTab === 0) {
            this.pageBottle()
          } else if (this.subTab === 1) {
            this.pageMoss()
          } else if (this.subTab === 2) {
            this.pageCare()
          } else if (this.subTab === 3) {
            this.pageFeed()
          } else if (this.subTab === 4) {
            this.pageGallery()
          } else {
            this.pageFriends()
          }
        }
        .layoutWeight(1)
      } else if (this.mainTab === 2) {
        Column() {
          this.header()
          this.subNav()
          if (this.subTab === 0) {
            this.pageCare()
          } else if (this.subTab === 1) {
            this.pageMoss()
          } else if (this.subTab === 2) {
            this.pageBottle()
          } else if (this.subTab === 3) {
            this.pageFeed()
          } else if (this.subTab === 4) {
            this.pageGallery()
          } else {
            this.pageFriends()
          }
        }
        .layoutWeight(1)
      } else {
        Column() {
          this.header()
          this.subNav()
          if (this.subTab === 0) {
            this.pageFriends()
          } else if (this.subTab === 1) {
            this.pageMoss()
          } else if (this.subTab === 2) {
            this.pageBottle()
          } else if (this.subTab === 3) {
            this.pageCare()
          } else if (this.subTab === 4) {
            this.pageGallery()
          } else {
            this.pageFeed()
          }
        }
        .layoutWeight(1)
      }
    }
    .layoutWeight(1)
  }

  build() {
    Stack() {
      Column() {
        this.mainContent()
        this.bottomBar()
      }
      .width('100%')
      .height('100%')

      this.fxLayer()

      if (this.addOpen) {
        Stack() {
          this.modalOverlay()
          this.addModalBody()
        }
        .width('100%')
        .height('100%')
      }

      if (this.editOpen) {
        Stack() {
          this.modalOverlay()
          this.editModalBody()
        }
        .width('100%')
        .height('100%')
      }

      if (this.delOpen) {
        Stack() {
          this.modalOverlay()
          this.delModalBody()
        }
        .width('100%')
        .height('100%')
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor(COLORS.bg)
  }
}


6.5 总结:

在这里插入图片描述

应用的设计中蕴含了良好的可扩展性。首先是数据驱动的思想——导航项、分类标签、难度等级等都通过数据数组来定义,而非硬编码在 UI 代码中。这意味着后续增加新的导航项或难度等级时,只需修改对应的数据数组即可,无需改动渲染逻辑。其次是数据与表现的解耦——通过 mossColormossIconlightLabel 等工具函数,将数据值到视觉表现的映射逻辑集中管理,当需要调整视觉风格时,只需修改这些函数即可全局生效。

组件化的 @Builder 抽离也为扩展提供了便利。每个功能模块都是一个独立的 @Builder 方法,增加新的功能模块时,只需新增对应的 Builder 方法,并在路由逻辑中添加相应的分支即可。特效系统同样具有良好的可扩展性——水珠与雾气分别由独立的函数计算,如果后续需要增加新的特效类型(如落叶、光斑等),只需按照相同的模式新增计算函数与渲染循环即可。

更多推荐