一、技术背景

1.1 HarmonyOS 声明式 UI 框架

HarmonyOS 的声明式 UI 框架是华为为全场景多设备生态打造的核心开发范式,它以 ArkUI 为基础,采用了类似于 SwiftUI 和 Flutter 的声明式编程模型。开发者通过描述界面在不同状态下的最终形态,由框架负责状态变化后的差异渲染与视图更新,而非手动命令式地操作 DOM 节点。这种范式大幅减少了样板代码量,让开发者将注意力集中在业务逻辑与状态流转上。在声明式框架中,UI 是状态的函数映射,每当状态变量发生改变,框架的依赖追踪系统会自动定位到受影响的 UI 片段并进行精准的局部刷新,从而兼顾开发效率与运行性能。

HarmonyOS 声明式 UI 还提供了丰富的布局组件(Row、Column、Stack、Flex、Grid 等)、动画能力(animateTo、transition、attributeModifier)以及多模态交互能力。更重要的是,它与 ArkCompiler 编译工具链深度耦合,在编译期进行类型检查、状态追踪分析,将声明式 DSL 编译为高效的 C++ 原生代码或 JavaScript 字节码,使得运行时性能接近原生应用。对于菌菇种植舱这类需要频繁更新环境数据、动态渲染批次进度、播放孢子飘浮特效的场景,声明式框架的局部刷新机制能够有效避免全量重绘,保障复杂动画与列表滚动的流畅度。
在这里插入图片描述

1.2 ArkTS 语言特性

ArkTS 是 HarmonyOS 在 TypeScript 基础上扩展而来的应用级编程语言。它保留了 TypeScript 的静态类型系统、接口、泛型、可选链等现代语言特性,同时引入了若干面向 UI 声明与状态管理的关键扩展语法。其中最核心的是装饰器体系,包括 @Component 用于标记自定义组件、@Entry 用于标记入口组件、@State 用于声明组件内可观察状态、@Prop/@Link/@Observed/@ObjectLink 用于跨组件与跨层级的状态同步等。这些装饰器并非简单的注解标签,而是编译期会被 ArkCompiler 转换为状态追踪代码,让框架在变量被读写时自动收集依赖关系。

ArkTS 在类型层面也做了一定的约束强化,例如不允许使用 any 类型参与 UI 渲染、要求 ForEach 的键值生成函数返回稳定唯一字符串等。这些约束在编译期就能拦截大量因类型不稳定导致的渲染抖动问题。同时,ArkTS 的 struct 语法用于声明组件,与 class 不同,struct 是值语义且不可继承,这种设计符合 UI 组件树轻量构建、频繁重建的特性。在本文剖析的菌菇种植舱应用中,ArkTS 的接口、类、常量、纯函数与装饰器协条工作,共同支撑起一套面向菌菇种植垂直行业的完整业务前端。
在这里插入图片描述

1.3 HarmonyOS 生态与跨设备能力

HarmonyOS 不仅仅是一个移动端操作系统,它定位于"一生万物"的全场景分布式操作系统,覆盖手机、平板、智慧屏、车机、智能穿戴、IoT 等多种设备形态。对于菌菇种植行业,这意味着同一份代码可以在种植户的手机、温室墙挂平板、车间智慧屏乃至便携检测设备上自适应运行。HarmonyOS 提供的分布式软总线、分布式数据管理、分布式任务调度等底层能力,允许将多个物理设备虚拟成一个"超级终端",例如在手机端发起的菌包批次记录可以无缝流转到温室平板继续编辑,温湿度传感器数据可以实时同步到种植户的随身设备。

在跨设备适配方面,HarmonyOS 声明式框架提供了自适应布局、响应式栅格、断点机制等工具,使应用界面能够根据屏幕尺寸与方向自动重组。对于菌菇种植舱这种以信息密集展示为主的应用,宽屏设备上可以并排呈现多批次卡片与图表,窄屏设备则自动退化为纵向滚动列表。这种"一次开发、多端部署"的能力,极大地降低了种植行业在不同生产环节、不同作业场景下的部署成本,让数字化真正落地到种植现场。

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

在这里插入图片描述

@Observed 是 ArkTS 状态管理体系中用于标记"可观察类"的关键装饰器。被 @Observed 修饰的类其实例化对象在被 @State、@Prop、@Link、@ObjectLink 等装饰器引用时,其内部属性的变化会被框架自动追踪并触发关联 UI 的局部刷新。这与仅靠 @State 监听引用地址变化不同——@State 只能感知到对象引用是否被重新赋值,而无法感知对象内部字段的修改;@Observed 弥补了这一盲区,使得对数组元素的 splice、对对象属性的逐一修改都能精确驱动渲染。

在本文剖析的应用中,PostItem、StrainItem、BatchItem、GearItem 四个核心数据模型均被 @Observed 修饰。这意味着当用户通过弹窗新增一条菇语动态、编辑某条菌包批次的名称与阶段、报废首个菌包或退课时,框架能够在不重建整个列表的前提下完成局部更新,避免了全量 ForEach 重绘带来的性能抖动。配合 @State 数组变量所触发的引用级监听,构成了"引用级 + 字段级"的双层状态响应网络,为高密度列表的流畅交互提供了机制保障。

1.5 菌菇种植行业的技术特点与挑战

菌菇种植是现代农业中技术密集度较高的细分领域,其生产周期横跨菌丝培养、原基形成、出菇管理、采收与间歇养菌等多个阶段,每个阶段对温度、湿度、光照、二氧化碳浓度、通风频次等环境参数都有截然不同的要求。以平菇为例,养菌期需要 22-25℃ 的暗培养,而出菇期则需要 15-20℃、85% 以上湿度与散射光刺激;金针菇的催蕾阶段甚至需要降至 12℃ 进行低温胁迫。这种多变量、多阶段、强耦合的工艺特性,决定了种植管理工具必须具备细粒度的批次追踪、阶段化进度感知与多维环境指标呈现能力。

从数字化挑战来看,菌菇种植存在三个显著特征:一是数据异构性高,既有结构化的批次进度、温湿度读数,也有非结构化的菇友动态、染菌复盘;二是时间跨度大,从 26 天的榆黄蘑到 120 天的灵芝,进度可视化必须能够适配不同长度的周期;三是容错要求高,染菌事故一旦发生需快速隔离并复盘,工具必须支持即时的报废与归档操作。本文剖析的菌菇种植舱应用,正是围绕这些挑战,以"舱"为单位组织种植现场数据,以"批次"为粒度追踪进度,以"动态"为纽带连接种植者社群,构建出一套贴合行业实际作业流程的前端体验。

二、项目概览

在这里插入图片描述

菇语·菌菇种植舱是一款面向菌菇种植从业者的垂直行业应用,定位于种植现场的全流程管理工具。应用以"舱"为隐喻,将菌菇种植的关键环节组织成四个主 tab:首页(综合概览)、菌舱(环境与设备)、课堂(系统化学习)、我的(个人中心),底部以药菌色调的圆角顶栏呈现。首页进一步细分七个内容 tab,以胶囊式横条呈现:精选、菌种图鉴、种植舱、菌包批次、菇菇课堂、菇友圈、设备库。每个内容 tab 聚焦一个子主题,从菌种全生命周期管理到采收记录、从社区互动到设备清单,覆盖种植户日常作业的方方面面。

应用顶部头部区域设计精巧,第一行展示品牌名称"菇语种植舱"与副标题"养菌 · 催蕾 · 出菇 · 采收",右侧放置"今日采收"高亮徽标;第二行则以四等分卡片陈列舱内温度、相对湿度、CO₂ 浓度、在培批次四项关键环境指标,数值以品牌色、菌绿色、警示黄、危险红分色编码,使种植户一眼即可把握当前生产环境的健康度。这种"四宫格指标 + 品牌行"的头部设计在有限的屏幕空间内最大化信息密度,是行业应用"信息优先于装饰"设计哲学的典型体现。

视觉特效层面,应用在底层叠加了一层"孢子飘浮 + 菌灯呼吸"的动态背景特效。孢子由 12 个不同半径与透明度的圆点构成,沿屏幕纵向缓慢飘动;菌灯由 5 个略大的圆点构成,呈现周期性的亮度呼吸效果。特效层通过 hitTestBehavior(HitTestMode.None) 设置为点击穿透,不遮挡上层业务交互。整套特效由一个 90 毫秒间隔的定时器驱动 tick 自增,纯函数依据 tick 与索引计算位置、半径、透明度,避免了对象创建与状态膨胀,体现了在低性能设备上也能稳定运行的性能优化意识。

弹框交互层面,应用提供了三类模态弹框:新增(发布菇语 / 记录菌况)、编辑(编辑种植批次,含名称、管理方式、阶段说明三个字段)、删除(报废菌包或退课,二者通过 delTarget 字段区分)。所有弹框共用一个半透明遮罩层,点击遮罩可关闭弹框。弹框体以卡片背景色填充、16 像素圆角呈现,宽度限制在 88%,最大高度 80%,并以 zIndex 999 置于最顶层。整个弹框体系通过 addOpen、editOpen、delOpen 三个布尔状态控制显隐,配合 addTitle、editName、editStyle、editNote 等编辑态变量实现双向数据绑定。

架构设计上,应用采用"常量数据 + 纯函数 + 组件 Builder + 状态驱动"的四层结构。常量数据层(POST_LIST、STRAIN_LIST、BATCH_LIST、GEAR_LIST、HARVEST_CHART 等)作为初始静态数据;纯函数层(barH、kindColor、sporeX、sporeY、statusColor、workProgress、stageColor、levelColor 等)负责派生计算与颜色映射,保持组件逻辑纯净;组件 Builder 层(header、subNav、pageFeatured、pageStrains 等)以 @Builder 装饰器声明可复用 UI 片段;状态层在 PageMushroomPod struct 内集中维护所有响应式状态。这种分层使应用在功能扩展时只需新增对应 Builder 与状态字段,而无需扰动既有结构。

三、逐段代码分析

3.1 ColorPalette 接口与色彩体系定义

在这里插入图片描述

interface ColorPalette {
  primary: string;
  primaryLight: string;
  primaryDark: string;
  accent: string;
  accentLight: string;
  bg: string;
  cardBg: string;
  textPrimary: string;
  textSecondary: string;
  textHint: string;
  border: string;
  success: string;
  warning: string;
  danger: string;
  white: string;
  chalk: string;
}

ColorPalette 接口定义了应用全量色彩 token 的契约,共包含 16 个色彩槽位,覆盖主色三阶(primary / primaryLight / primaryDark)、强调色两阶(accent / accentLight)、背景与卡片背景、文本三阶(textPrimary / textSecondary / textHint)、边框色、状态语义色(success / warning / danger)、白色与粉笔色。这种以接口约束色彩 token 的做法,使得任何色彩引用都经过类型检查,避免拼写错误导致的"幽灵色值"问题,也为后续主题切换(如浅色主题、夜间模式)预留了替换入口——只需替换 COLORS 常量即可全局生效。

从语义命名可以看出,色彩体系设计高度贴合菌菇种植行业的视觉隐喻。primary 选用 #C99B5F 这一介于麦芽糖与琥珀之间的暖棕色,呼应菌包外袋与培养料的自然色调;accent 选用 #8FA86B 苔绿色,象征菌丝与出菇阶段的生机;bg 与 cardBg 选用深棕色系(#1E1710 / #2A2217),营造出种植舱"暗培养"环境的沉浸感;chalk 选用 #9C8A6B 用于孢子飘浮特效,模拟培养室空气中飘散的孢子粉颗粒。整套色彩体系不仅是视觉装饰,更是行业语境的视觉化表达,让种植户在打开应用的第一秒就建立起"身处菌菇种植现场"的心理预期。

3.2 COLORS 常量的具体色值配置

在这里插入图片描述

const COLORS: ColorPalette = {
  primary: '#C99B5F',
  primaryLight: '#DDB98A',
  primaryDark: '#9A7038',
  accent: '#8FA86B',
  accentLight: '#2E2A1C',
  bg: '#1E1710',
  cardBg: '#2A2217',
  textPrimary: '#F2EAD9',
  textSecondary: '#BDB09A',
  textHint: '#83775F',
  border: '#443A28',
  success: '#8FBF7F',
  warning: '#D9A441',
  danger: '#C96A5B',
  white: '#FFFFFF',
  chalk: '#9C8A6B'
};

COLORS 常量是 ColorPalette 接口的唯一实例,全局唯一、引用不可变。从色值分布来看,这是一套经过精心校准的暖色暗系调色板:主色三阶的明度从 #9A7038(深琥珀)到 #DDB98A(浅麦色)跨越了明显的明度梯度,用于按钮的按压态、徽标的渐进态、横幅的背景层叠;文本三阶则从 #F2EAD9(接近羊皮纸白)到 #83775F(暗沙色)形成清晰的信息层级,让标题、正文、辅助说明在同一卡片内自然分层。border 色选用 #443A28,介于 bg 与 cardBg 之间,使进度条底槽与卡片边框在暗背景上既可见又不喧宾夺主。

值得注意的是 accentLight 字段被赋值为 #2E2A1C 这一极深棕黑色,而非字面意义上的"浅强调色"。这是应用的一个设计巧思——accentLight 在代码中被用作"标签底色",即菌种图鉴中 statusColor 标签、菌包批次中 levelColor 标签的背景色。将这些彩色小标签放在深色底上,能够显著提升标签的对比度与辨识度,同时避免标签抢夺主内容的视觉焦点。这种命名与语义的错位提醒我们,色彩 token 的命名应服务于设计语义而非字面含义,accentLight 在此处表达的是"用于强调元素底色的暗色",这种理解需要在团队内形成共识。

3.3 PostItem 菇友动态数据模型

在这里插入图片描述

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

PostItem 是菇友圈动态条目的数据模型,被 @Observed 装饰,使其属性变更能够被 ArkUI 框架追踪。类定义采用 ArkTS 的"字段默认值 + 构造函数赋值"双重保险模式:字段默认值确保即使通过 new PostItem() 不传参也不会出现 undefined;构造函数则用于批量初始化。六个字段语义清晰:id 作为 ForEach 的键值、nick 与 avatar 用于用户身份展示(avatar 巧妙地使用 emoji 而非图片 URL,既免去网络加载又贴合菌菇行业的"emoji 化表达"风格)、text 与 time 用于动态正文、likes 用于互动数据展示。

构造函数采用单行多赋值的紧凑写法 this.id = id; this.nick = nick; ...,虽然牺牲了一定的可读性,但在数据模型这种"字段即逻辑"的简单赋值场景下是可接受的折衷。这种写法在应用的其他数据模型中也反复出现,形成了一致的代码风格。值得注意的是 PostItem 通过 export 关键字导出,意味着它可以在其他文件中被引用(尽管当前应用是单文件结构),为未来的模块化拆分留下了入口。@Observed 的存在使得在 doAdd 方法中 unshift 新动态、在 doDel 方法中 splice 删除动态时,列表项的局部变化能够被框架自动捕捉并触发最小化的 UI 更新。

3.4 StrainItem 菌种数据模型

@Observed
export class StrainItem {
  id: number = 0
  name: string = ''
  kind: string = ''
  cycle: string = ''
  status: string = ''
  tag: string = ''
  constructor(id: number, name: string, kind: string, cycle: string, status: string, tag: string) {
    this.id = id; this.name = name; this.kind = kind; this.cycle = cycle
    this.status = status; this.tag = tag
  }
}

StrainItem 表示菌种图鉴中的一条菌种记录,承载菌种的全生命周期元信息。name 字段存储菌种的中文俗名或学名(如"糙皮侧耳"、“段木香菇”),kind 字段存储菌种所属品类(如"平菇"、“香菇”、“药用菌”),cycle 字段以字符串形式存储生长周期(如"28 天"、“90 天”),这种字符串而非数值的存储方式牺牲了排序能力,但换来了"28 天"、"120 天"这类带单位文案的直接展示便利,是一种面向展示优化的设计选择。

status 字段是 StrainItem 的语义核心,取值包括"出菇中"、“养菌中”、“冒蕾期”、“出芝期”、“染菌报废”、“已采收"等,这套术语精确对应了菌菇种植工艺的各个阶段。tag 字段则是一种面向用户的难度/属性标签,如"新手首选”、“进阶”、“低温型”、“中阶”、“高阶”、“高产”、"观赏"等。这两个字段在 UI 层会被传入 statusColor、stageColor、workProgress 等纯函数进行颜色与进度的派生计算,体现了"数据归数据、计算归计算"的关注点分离原则——模型本身不携带颜色与进度信息,这些派生属性由纯函数在渲染时计算,保证了数据的纯净与可测试性。

3.5 BatchItem 菌包批次数据模型

@Observed
export class BatchItem {
  id: number = 0
  name: string = ''
  stage: string = ''
  level: string = ''
  progress: number = 0
  constructor(id: number, name: string, stage: string, level: string, progress: number) {
    this.id = id; this.name = name; this.stage = stage; this.level = level
    this.progress = progress
  }
}

BatchItem 表示菌包批次,是应用中被最频繁操作的数据模型——它同时出现在首页"在培批次"双列卡片、菌舱排行、菌包批次列表、我的"我的批次"四处。name 字段采用"编号 + 名称"的格式(如"P-07 平菇批"、“L-02 香菇批”),编号前缀 P/L/E/H/C/W/Y/G 分别对应平菇、香菇、金针、猴头、虫草、白玉、榆黄、灵芝八类菌种的首字母,这种编码体系让种植户一眼即可识别批次所属菌种,是种植现场常用的"看码识菌"实践的数字化映射。

stage 字段存储当前阶段(如"第三潮"、“转色期”、“催蕾期”、“冒蕾期”、“染菌处置”、“养菌期”、“已采收”、“出芝期”),其中"第三潮"是采收次数概念而非阶段概念,体现了实际作业中的灵活表达。level 字段存储管理方式(“日常”、“慢养”、“低温”、“精细”、“实验”、“归档”、“观赏”),用于 levelColor 函数映射标签颜色。progress 字段是数值进度,取值 0-100,直接驱动进度条宽度。在 doEdit 方法中,编辑后会强制将 progress 重置为 40,这一处理虽然简化了真实场景下的"编辑重置进度"逻辑,但符合教学示例的克制原则,避免引入过于复杂的状态机。

3.6 GearItem 设备数据模型与图表接口

@Observed
export class GearItem {
  id: number = 0
  name: string = ''
  desc: string = ''
  icon: string = ''
  constructor(id: number, name: string, desc: string, icon: string) {
    this.id = id; this.name = name; this.desc = desc; this.icon = icon
  }
}

interface HarvestChartItem {
  label: string;
  value: number;
}

interface KindChartItem {
  label: string;
  value: number;
  color: string;
}

GearItem 是设备库的数据模型,字段极简:name(设备名)、desc(设备用途)、icon(emoji 图标)。它同样被 @Observed 修饰,尽管当前应用未对设备进行增删改操作,但保留 @Observed 为后续的设备状态变更(如"在线/离线"、“维护中”)预留了响应式能力。icon 字段使用 emoji 如 🌡️、💧、🕯️,与 PostItem 的 avatar 字段一脉相承,是应用的"全 emoji 化图标策略"的体现——这种策略在不引入图标资源文件的前提下,实现了行业感极强的视觉表达,emoji 的天然彩色与拟物感反而比线性图标更贴合种植现场的氛围。

紧接着定义的 HarvestChartItem、KindChartItem、CourseItem、LogItem 四个接口,均未使用 @Observed 修饰——它们是 interface 而非 class,且当前应用中这些图表数据是静态只读的,不需要响应式追踪。HarvestChartItem 用于"近七周采收量"柱状图,包含 label(周次)与 value(克数);KindChartItem 用于"在培菌种结构"横向进度条图,额外携带 color 字段实现每条数据的独立配色;CourseItem 与 LogItem 分别用于课程列表与生长记录时间线。这种"interface 用于只读静态数据、@Observed class 用于可变动态数据"的区分,是 ArkTS 状态管理的常见实践模式,既避免了不必要的响应式开销,又保持了类型安全。

3.7 POST_LIST 菇友动态静态数据

const POST_LIST: PostItem[] = [
  new PostItem(1, '平菇大户', '🍄', '第三潮平菇采收完毕,单个菌包累计出了 800 克,超预期。', '7分钟前', 88),
  new PostItem(2, '香菇林下派', '🌲', '段木香菇的第一茬冒蕾了,湿度拉到 85% 果然有用。', '26分钟前', 74),
  new PostItem(3, '金针菇控', '🍜', '低温催蕾是关键,12 度放了一周,整整齐齐像小森林。', '44分钟前', 66),
  new PostItem(4, '灵芝守约', '🌿', '观赏灵芝打磨成摆件,孢子粉收了两小罐,成就感满满。', '1小时前', 61),
  new PostItem(5, '虫草学徒', '🔬', '第一次尝试蛹虫草液体菌种,染菌了,通风箱该升级了。', '2小时前', 53),
  new PostItem(6, '猴头菇', '🧺', '猴头菇出菇前的白绒球太可爱了,采收时反而舍不得。', '3小时前', 49),
  new PostItem(7, '无菌操作员', '🧤', '酒精灯加接种环,家庭无菌操作的仪式感不能少。', '5小时前', 41),
  new PostItem(8, '双孢菇老手', '🏺', '堆料发酵是双孢菇的灵魂,温度曲线比什么都有说服力。', '昨天', 67)
];

POST_LIST 是菇友圈的初始动态列表,共 8 条,每条动态的文案都经过精心设计,涵盖了菌菇种植的不同品种、不同阶段、不同经验维度。从"平菇大户"分享第三潮采收喜悦,到"虫草学徒"反思染菌事故,再到"无菌操作员"强调家庭接种的仪式感,这些文案不仅是填充数据,更是一幅菌菇种植社群的微观图鉴。种植户在浏览这些动态时,能够获得同行经验、避坑提示与情感共鸣,体现了"菇友圈"作为知识沉淀与情感连接的双重定位。

时间字段从"7 分钟前"递进到"昨天",模拟了真实社交平台的滚动时间线体验。likes 字段从 88 递减到 41(中间有波动),模拟了不同内容的互动热度差异。值得注意的是这些数据是 const 常量,但会被赋值给 @State postList,因此 doAdd 与 doDel 方法对 postList 的 unshift/splice 操作不会影响 POST_LIST 本身,而是基于其副本进行——这是 ArkTS 中"常量作为初始数据源、状态变量承接运行时变更"的标准模式,保证了应用每次重启都能回到一致的初始状态,便于测试与演示。

3.8 STRAIN_LIST 菌种图鉴静态数据

const STRAIN_LIST: StrainItem[] = [
  new StrainItem(1, '糙皮侧耳', '平菇', '28 天', '出菇中', '新手首选'),
  new StrainItem(2, '段木香菇', '香菇', '90 天', '养菌中', '进阶'),
  new StrainItem(3, '金针菇', '金针', '35 天', '出菇中', '低温型'),
  new StrainItem(4, '猴头菇', '药用菌', '45 天', '冒蕾期', '中阶'),
  new StrainItem(5, '蛹虫草', '药用菌', '50 天', '染菌报废', '高阶'),
  new StrainItem(6, '白玉菇', '真姬菇', '55 天', '养菌中', '中阶'),
  new StrainItem(7, '榆黄蘑', '平菇', '26 天', '已采收', '高产'),
  new StrainItem(8, '赤灵芝', '灵芝', '120 天', '出芝期', '观赏')
];

STRAIN_LIST 是菌种图鉴的完整索引,8 条记录覆盖了平菇、香菇、金针、猴头、虫草、白玉、榆黄、灵芝八大主流菌种。每条记录的 name 字段使用了"糙皮侧耳"(平菇的学名)、“段木香菇”(强调栽培方式)、“赤灵芝”(区分于常见灵芝)等精准术语,体现了菌种图鉴的"图鉴即教材"定位——种植户在浏览图鉴时不仅看到状态,还能学到菌种的学名与栽培方式差异。cycle 字段从 26 天到 120 天跨越近 5 倍,直观呈现了不同菌种的生长周期差异,为种植户规划生产节奏提供了参考。

status 字段的取值集合是这套数据的精髓:8 条菌种恰好覆盖了"出菇中"、“养菌中”、“冒蕾期”、“出芝期”、“染菌报废”、“已采收"六种状态,使图鉴同时呈现了菌种生命周期的所有阶段。这种"以少量数据覆盖全状态空间"的设计,让种植户在一屏内即可对照自己菌包所处的阶段。tag 字段的"新手首选 / 进阶 / 低温型 / 中阶 / 高阶 / 高产 / 观赏"则构成了一个隐性的难度梯度,配合 statusColor、workProgress 函数,每条菌种的颜色编码与进度条长度都形成了视觉化的"种植难度地图”。

3.9 BATCH_LIST 菌包批次与 GEAR_LIST 设备数据

const BATCH_LIST: BatchItem[] = [
  new BatchItem(1, 'P-07 平菇批', '第三潮', '日常', 88),
  new BatchItem(2, 'L-02 香菇批', '转色期', '慢养', 64),
  new BatchItem(3, 'E-11 金针批', '催蕾期', '低温', 52),
  new BatchItem(4, 'H-03 猴头批', '冒蕾期', '精细', 46),
  new BatchItem(5, 'C-01 虫草批', '染菌处置', '实验', 12),
  new BatchItem(6, 'W-05 白玉批', '养菌期', '日常', 70),
  new BatchItem(7, 'Y-09 榆黄批', '已采收', '归档', 100),
  new BatchItem(8, 'G-04 灵芝批', '出芝期', '观赏', 58)
];

const GEAR_LIST: GearItem[] = [
  new GearItem(1, '恒温培养箱', '菌丝培养', '🌡️'),
  new GearItem(2, '加湿器', '出菇湿度', '💧'),
  new GearItem(3, '酒精灯', '无菌接种', '🕯️'),
  new GearItem(4, '接种环', '转管工具', '🪡'),
  new GearItem(5, '温湿度计', '环境监控', '📏'),
  new GearItem(6, '通风扇', '气体交换', '🌀'),
  new GearItem(7, '菌包袋', '栽培容器', '📦'),
  new GearItem(8, '喷雾壶', '补水保湿', '🚿')
];

BATCH_LIST 与 STRAIN_LIST 形成了"菌种 vs 批次"的对照关系:8 条批次对应 8 种菌种,但进度与阶段各不相同。值得注意的是 C-01 虫草批的 stage 为"染菌处置"、progress 仅为 12、level 为"实验"——这条数据是整套应用中最重要的"失败案例"样本,它在菌种图鉴、菌包批次、生长记录时间线、菇友动态中均有呼应("虫草学徒"的动态正是反思这次染菌),构成了一个跨数据源的叙事闭环,体现了数据设计的故事性与教学性。

GEAR_LIST 则呈现了菌菇种植的八件核心设备,从恒温培养箱到喷雾壶,覆盖了温控、湿控、无菌、监测、通风、容器、补水七个作业维度。desc 字段简明扼要地点明每件设备的用途场景(“菌丝培养”、“出菇湿度”、"无菌接种"等),让种植新手也能快速理解设备价值。这套设备数据在设备库 tab 中以双列卡片网格呈现,每张卡片末尾的"去添置"链接指向了一个潜在的电商或采购入口,为应用预留了商业化的可能。

3.10 HARVEST_CHART 与 KIND_CHART 图表数据

const HARVEST_CHART: HarvestChartItem[] = [
  { label: '第1周', value: 120 },
  { label: '第2周', value: 260 },
  { label: '第3周', value: 410 },
  { label: '第4周', value: 380 },
  { label: '第5周', value: 520 },
  { label: '第6周', value: 640 },
  { label: '第7周', value: 580 }
];

const KIND_CHART: KindChartItem[] = [
  { label: '平菇系', value: 90, color: '#C99B5F' },
  { label: '香菇系', value: 68, color: '#DDB98A' },
  { label: '药用菌', value: 46, color: '#8FA86B' },
  { label: '珍稀菌', value: 30, color: '#D9A441' },
  { label: '野生仿生', value: 16, color: '#C96A5B' }
];

HARVEST_CHART 是近七周采收量的柱状图数据,数值从 120 克爬升至 640 克再小幅回落到 580 克,模拟了一个菌菇种植户从起步到高峰的产能爬坡曲线。这种数据曲线既符合菌菇种植的真实节奏(前几周菌丝未成熟、中期集中出菇、后期进入间歇),又为柱状图的颜色映射函数 kindColor 提供了"低值黄、中值绿、高值主色"的完整取值覆盖。最大值 640 会被传入 barH 函数作为分母,计算出每根柱子的归一化高度,确保最高的第 6 周柱子恰为 108 像素高,其他柱子按比例缩放。

KIND_CHART 是在培菌种结构的横向占比图,五条数据分别代表平菇系、香菇系、药用菌、珍稀菌、野生仿生五大类别,每条数据携带独立的 color 字段。这与 HarvestChartItem 不同——后者没有 color 字段,颜色由 kindColor 函数依据 value 派生;前者则将颜色内嵌于数据,因为菌种类别的颜色是语义性的(平菇系用主色、药用菌用菌绿色),不应由数值大小派生。两种图表数据的设计差异,体现了"数值驱动色彩 vs 语义驱动色彩"两种数据可视化思路的灵活运用。

3.11 LOG_LIST 生长记录与 COURSE_LIST 课程数据

const LOG_LIST: LogItem[] = [
  { day: '今日', title: 'P-07 第三潮采收', desc: '称重 320 克 · 菌包进入养菌间歇', color: '#C99B5F' },
  { day: '昨日', title: 'H-03 冒蕾确认', desc: '出现 9 个白绒蕾点 · 提高湿度至 88%', color: '#8FA86B' },
  { day: '前天', title: 'E-11 入低温库', desc: '12 度催蕾 · 计划 7 天', color: '#5B9BD5' },
  { day: '3天前', title: 'C-01 发现染菌', desc: '绿霉 · 隔离处置 · 复盘接种流程', color: '#C96A5B' },
  { day: '5天前', title: 'G-04 转入出芝期', desc: '光照 200lux · 通风翻倍', color: '#D9A441' }
];

const COURSE_LIST: CourseItem[] = [
  { title: '菌菇生物学入门', week: '第 1 周', progress: 100, color: '#C99B5F' },
  { title: '无菌操作与接种', week: '第 2-3 周', progress: 84, color: '#8FA86B' },
  { title: '养菌期环境管理', week: '第 3-4 周', progress: 60, color: '#DDB98A' },
  { title: '出菇调控实战', week: '第 5-6 周', progress: 36, color: '#D9A441' },
  { title: '病虫害与染菌处置', week: '第 6-8 周', progress: 14, color: '#C96A5B' }
];

LOG_LIST 是生长记录时间线数据,五条记录横跨今日到 5 天前,每条记录的 color 字段都经过精心选择:采收用主色 #C99B5F、冒蕾用菌绿色 #8FA86B、低温用蓝色 #5B9BD5(应用中少见的冷色,专门用于低温场景)、染菌用危险红 #C96A5B、出芝用警示黄 #D9A441。这种"事件类型 → 颜色"的语义映射贯穿了整个生长记录的视觉表达,让种植户扫一眼时间线即可定位异常事件(红色即染菌)。每条记录的 desc 字段包含具体的数值参数(320 克、88%、12 度、200lux),这些参数化描述使生长记录不仅是叙事,更是可追溯的作业日志。

COURSE_LIST 是菇菇课堂的五门课程,进度从 100% 递减到 14%,模拟了一个种植户从入门到进阶的学习路径。课程标题"菌菇生物学入门 → 无菌操作与接种 → 养菌期环境管理 → 出菇调控实战 → 病虫害与染菌处置"本身就是一个完整的学习阶梯,与 StrainItem 的 tag 字段(新手首选 → 进阶 → 中阶 → 高阶)形成了呼应。每门课程的 color 字段与进度形成了视觉关联:已完成的课程用主色、进行中的用菌绿色或浅麦色、待学习的用警示黄或危险红,这种"进度即颜色"的设计让学习路径的视觉表达与认知路径高度一致。

3.12 NavItem 接口与导航常量

interface NavItem {
  icon: string;
  label: string;
}

const NAV_LIST: NavItem[] = [
  { icon: '🍄', label: '首页' },
  { icon: '🧺', label: '菌舱' },
  { icon: '📚', label: '课堂' },
  { icon: '👤', label: '我的' }
];

const SUB_NAV_LIST: string[] = ['精选', '菌种图鉴', '种植舱', '菌包批次', '菇菇课堂', '菇友圈', '设备库'];

NAV_LIST 定义了底部 4 主 tab 的图标与标签,全部使用 emoji 作为图标:🍄 首页、🧺 菌舱、📚 课堂、👤 我的。这种"emoji 即图标"的策略是应用贯穿始终的设计选择,相比引入 SVG 或字体图标,emoji 具有零资源加载、天然彩色、跨平台一致的优势,且更贴合菌菇种植这种偏生活化、社群化的应用调性。emoji 的拟物感与种植现场的实物感(菌菇、菌包、书本、人)形成了直接映射,降低了用户的图标识别成本。

SUB_NAV_LIST 定义了首页下的 7 个内容 tab 标签,全部为纯字符串数组。这 7 个标签构成了应用的"内容地图":精选(综合概览)→ 菌种图鉴(品种索引)→ 种植舱(环境与设备)→ 菌包批次(进度追踪)→ 菇菇课堂(系统学习)→ 菇友圈(社区互动)→ 设备库(设备清单)。从"看"到"学"到"做"到"聊"到"备",覆盖了种植户的完整作业链路。使用纯字符串而非对象数组,是因为内容 tab 的样式完全由 subTab 索引驱动,不需要每个 tab 携带额外属性,体现了数据结构的"最小够用"原则。

3.13 纯函数 barH 与 kindColor

function barH(v: number, max: number): number {
  return Math.round(108 * v / max);
}

function kindColor(v: number): string {
  if (v > 60) {
    return '#C99B5F';
  } else if (v > 30) {
    return '#8FA86B';
  }
  return '#D9A441';
}

barH 函数是柱状图高度的归一化计算工具,将任意数值 v 依据最大值 max 映射到 0-108 像素的高度区间。Math.round 确保结果是整数像素,避免亚像素渲染导致的模糊。108 这个上限值是经过布局测算的——柱状图容器高度为 140 像素,减去柱子下方 label 文字的高度约 20 像素与上下间距,剩余约 108 像素用于柱子本身。这种"硬编码常量"的做法虽不优雅,但在单文件应用中是可接受的折衷,避免了引入额外的布局参数对象。

kindColor 函数依据数值大小返回三种颜色:大于 60 返回主色 #C99B5F、大于 30 返回菌绿色 #8FA86B、其余返回警示黄 #D9A441。这是一个三段式的条件映射,将数值的"高/中/低"映射为颜色的"主/绿/黄"。在采收柱状图中,柱子的颜色会随着数值变化——第 6 周 640 克的柱子是主色(高产),第 1 周 120 克的柱子是黄色(低产),这种视觉编码让种植户无需读取数值即可感知产能分布。函数命名 kindColor 暗示了"类别颜色",但实际是"数值颜色",命名略有歧义,是代码可改进之处。

3.14 孢子飘浮纯函数 sporeX / sporeY / sporeA / sporeR

function sporeX(tick: number, i: number): number {
  return 20 + ((tick * 4 + i * 91) % 590);
}

function sporeY(tick: number, i: number): number {
  return 700 - ((tick * 8 + i * 67) % 680);
}

function sporeA(tick: number, i: number): number {
  return (i + tick) % 3 === 0 ? 0.5 : 0.18;
}

function sporeR(tick: number, i: number): number {
  return 2 + (i % 3);
}

这四个函数构成了孢子飘浮特效的"位置 + 外观"计算矩阵。sporeX 与 sporeY 通过取模运算生成周期性的位置序列:sporeX 在 20-610 像素区间内循环,sporeY 在 20-700 像素区间内循环。关键是这两个函数的"步长"不同——sporeX 每次 tick 增加 4 像素、sporeY 每次增加 8 像素,且不同孢子(i)的起始偏移为 91 与 67 的倍数。这种设计使得 12 个孢子在屏幕上的运动轨迹各不相同,且每个孢子的横向与纵向运动周期不同,形成了"看似随机实则确定"的飘浮轨迹,视觉上接近真实孢子的无规则飘散。

sporeA 函数控制孢子透明度,依据 (i + tick) % 3 的结果返回 0.5(高透明度)或 0.18(低透明度)。这种二值透明度切换使孢子呈现"忽明忽暗"的呼吸感,模拟了空气中孢子在光线下的折射变化。sporeR 函数返回 2-4 的半径值,依据 i % 3 使 12 个孢子分为三组大小。整套函数的精髓在于"无状态、无对象创建"——每次调用都基于 tick 与 i 这两个原始数值进行纯计算,不维护任何粒子对象,这使得 12 个孢子在 90 毫秒间隔下持续更新也不会产生 GC 压力,是性能极致优化的范例。

3.15 菌灯呼吸纯函数 lampA / lampX / lampY

function lampA(tick: number, i: number): number {
  return 0.12 + ((tick + i) % 4) * 0.06;
}

function lampX(tick: number, i: number): number {
  return 60 + ((tick * 2 + i * 130) % 520);
}

function lampY(tick: number, i: number): number {
  return 110 + i * 160;
}

菌灯呼吸函数与孢子飘浮函数共同构成了特效层的计算基础。lampA 函数的返回值在 0.12 到 0.30 之间循环(0.12 + 0/0.06/0.12/0.18),这是一个四步循环的呼吸节奏,每 4 个 tick 完成一次"暗 → 微亮 → 中亮 → 亮"的渐变。这种低频呼吸效果模拟了种植舱内培养箱指示灯的缓慢脉动,为应用增添了"舱内作业中"的沉浸感。与孢子的快速飘浮形成对比,菌灯的缓慢呼吸构成了"快慢交织"的双重动态层次。

lampX 函数的横向步长为 2,远小于 sporeX 的 4,使菌灯的横向漂移更为缓慢;lampY 函数则完全静态,y = 110 + i * 160,5 个菌灯固定分布在 110、270、430、590、750 的高度位置。这种"横移 + 纵固"的设计使菌灯看起来像是安装在舱壁不同高度的指示灯,而非飘浮的粒子。整套特效函数通过纯函数与取模运算,以零对象创建的方式实现了 17 个动态元素(12 孢子 + 5 菌灯)的持续动画,是 ArkTS 在低端设备上实现复杂特效的优秀范式。

3.16 状态色与进度派生函数 statusColor / workProgress / stageColor / levelColor

function statusColor(s: string): string {
  if (s === '已采收' || s === '出菇中') {
    return '#C99B5F';
  } else if (s === '养菌中' || s === '冒蕾期' || s === '出芝期') {
    return '#8FA86B';
  }
  return '#C96A5B';
}

function workProgress(s: string): number {
  if (s === '已采收') {
    return 100;
  } else if (s === '出菇中' || s === '出芝期') {
    return 76;
  } else if (s === '冒蕾期') {
    return 58;
  } else if (s === '养菌中') {
    return 42;
  }
  return 8;
}

statusColor 函数将菌种状态映射为三色:已采收/出菇中 → 主色(暖棕,象征成熟)、养菌中/冒蕾期/出芝期 → 菌绿色(象征生长中)、其余(含染菌报废)→ 危险红(象征异常)。这种"状态 → 颜色"映射在 UI 中被用于 StrainItem 卡片左侧的色条、状态标签的文字色与底色、进度条的前景色,使每条菌种卡片的视觉调性与其状态语义高度一致。种植户扫一眼图鉴即可识别"哪些在出菇(棕)、哪些在养菌(绿)、哪些出了问题(红)"。

workProgress 函数将状态映射为预设进度值:已采收 100、出菇中/出芝期 76、冒蕾期 58、养菌中 42、其余 8。这是一种"状态机式"的进度估算——不依赖实际天数计算,而是依据状态标签直接赋予预设进度。这种设计的优势在于简单直观、无需维护菌种起始日期;劣势是无法反映同状态下的进度差异(如同为"养菌中"的两个菌种,一个刚接种、一个即将冒蕾,进度都是 42)。在数据规模小、状态划分清晰的菌菇种植场景下,这种简化是合理的。stageColor 与 levelColor 函数采用相同的条件映射模式,分别映射批次阶段与管理方式到颜色,是 statusColor 模式的复用。

3.17 主组件状态声明 PageMushroomPod

@Entry
@Component
struct PageMushroomPod {
  @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 = 0
  @State delTarget: string = 'works'
  @State editName: string = ''
  @State editStyle: string = ''
  @State editNote: string = ''
  @State addTitle: string = ''
  @State addContent: string = ''
  @State postList: PostItem[] = POST_LIST
  @State batchList: BatchItem[] = BATCH_LIST
  @State fxTimer: number = -1

PageMushroomPod 是应用的唯一入口组件,由 @Entry 与 @Component 装饰器标记。组件声明了 16 个 @State 状态变量,可按职责分为四组:导航状态(mainTab 主 tab、subTab 内容 tab)、特效状态(tick 飘浮计数器、glow 呼吸计数器)、弹框状态(addOpen/editOpen/delOpen 三个布尔开关、editIdx/delTarget 两个目标标识、editName/editStyle/editNote/addTitle/addContent 五个编辑态文本)、数据状态(postList 动态列表、batchList 批次列表、fxTimer 定时器句柄)。

@State 装饰器使这些变量的变更能够触发依赖它们的 UI 片段的局部刷新。例如 subTab 变更时,只有 mainContent 中的 if-else 分支会重新求值,渲染新的页面 Builder;mainTab 变更时,bottomBar 的 ForEach 会重新计算激活态;postList 变更时,pageCircle、pageFeatured 中的 ForEach 会增量更新列表项。这种细粒度的依赖追踪是 ArkUI 声明式框架的核心能力,使得 16 个状态变量在频繁变更(尤其是 tick 每 90ms 自增)时,不会引发全量重绘。fxTimer 虽然也是 @State,但其变更不参与 UI 渲染(仅用于存储定时器句柄),是一个轻微的设计瑕疵——可以改为普通成员变量以避免不必要的依赖追踪。

3.18 组件生命周期 aboutToAppear / aboutToDisappear

aboutToAppear(): void {
  this.fxTimer = setInterval(() => {
    this.tick = this.tick + 1;
    this.glow = (this.glow + 1) % 2;
  }, 90);
}

aboutToDisappear(): void {
  if (this.fxTimer > 0) {
    clearInterval(this.fxTimer);
    this.fxTimer = -1;
  }
}

aboutToAppear 与 aboutToDisappear 是 ArkUI 组件生命周期的两个关键回调。aboutToAppear 在组件创建后、build 函数执行前调用,用于初始化状态、启动定时器、发起网络请求等准备工作;aboutToDisappear 在组件销毁前调用,用于清理资源、取消订阅、清除定时器等收尾工作。在本文应用中,aboutToAppear 启动了一个 90 毫秒间隔的 setInterval,每次回调将 tick 自增 1、glow 在 0/1 之间翻转,驱动孢子飘浮与菌灯呼吸特效。90ms 间隔(约 11 FPS)是一个经过权衡的选择——足够慢以降低 CPU 开销,又足够快使孢子飘浮看起来连续。

aboutToDisappear 中的清理逻辑同样重要:检查 fxTimer > 0 后执行 clearInterval 并重置为 -1。这种"先检查后清理"的模式避免了重复清理空定时器句柄的异常。在 HarmonyOS 应用中,组件可能因为页面切换、应用退后台、应用销毁等多种原因触发 aboutToDisappear,如果不在此时清理定时器,会导致内存泄漏与无效的 UI 更新——即使组件已销毁,setInterval 仍在每 90ms 触发 tick 自增,而这些状态变更已无 UI 可更新。这一对生命周期回调是 ArkTS 应用资源管理的基石,任何使用定时器、订阅、长连接的应用都必须严格遵守"在 aboutToAppear 获取、在 aboutToDisappear 释放"的对称原则。

3.19 弹框开关方法 openAdd / openEdit / openDelA / openDelB

openAdd(): void {
  this.addTitle = '';
  this.addContent = '';
  this.addOpen = true;
}

openEdit(index: number): void {
  if (index >= 0 && index < this.batchList.length) {
    this.editIdx = index;
    this.editName = this.batchList[index].name;
    this.editStyle = this.batchList[index].level;
    this.editNote = this.batchList[index].stage;
  }
  this.editOpen = true;
}

openDelA(): void {
  this.delTarget = 'works';
  this.delOpen = true;
}

openDelB(): void {
  this.delTarget = 'course';
  this.delOpen = true;
}

这四个方法是弹框的"打开器",统一遵循"先重置编辑态、再翻开关"的两步模式。openAdd 清空 addTitle 与 addContent 后打开新增弹框,确保每次打开都是空白状态而非上次的残留输入。openEdit 接收 index 参数,先做边界校验(index >= 0 && index < this.batchList.length),通过后将目标批次的 name/level/stage 分别拷贝到 editName/editStyle/editNote,再打开编辑弹框。这种"拷贝至编辑态"的模式避免了在弹框中直接修改原数据——只有用户点击"保存"才会通过 doEdit 将编辑态写回原数据,点击"取消"则原数据不变,是"编辑副本 → 提交或丢弃"的经典表单模式。

openDelA 与 openDelB 是删除弹框的两个入口,分别设置 delTarget 为 ‘works’(报废菌包)与 ‘course’(退课)。这两个方法共享同一个 delOpen 开关与 delModalBody 弹框,通过 delTarget 字段在弹框内区分文案与操作目标。这种"一个弹框多用途"的设计减少了重复代码,但要求 delModalBody 内部根据 delTarget 渲染不同文案与执行不同 doDel 分支。从开放-封闭原则看,新增第三种删除目标需要修改 delTarget 取值集合、delModalBody 文案分支与 doDel 执行分支三处,是典型的"开关判断"式设计的可扩展性短板。

3.20 数据操作方法 doAdd / doEdit / doDel

doAdd(): void {
  if (this.addTitle.length > 0) {
    this.postList.unshift(new PostItem(999, '我的菇语', '🍄', this.addTitle, '刚刚', 0));
  }
  this.addOpen = false;
}

doEdit(): void {
  if (this.editIdx >= 0 && this.editIdx < this.batchList.length) {
    this.batchList.splice(this.editIdx, 1, new BatchItem(this.batchList[this.editIdx].id, this.editName, this.editNote, this.editStyle, 40));
  }
  this.editOpen = false;
}

doDel(): void {
  if (this.delTarget === 'works' && this.batchList.length > 0) {
    this.batchList.splice(0, 1);
  } else if (this.delTarget === 'course' && this.postList.length > 0) {
    this.postList.splice(0, 1);
  }
  this.delOpen = false;
}

doAdd 方法在 addTitle 非空时向 postList 头部 unshift 一条新 PostItem,id 固定为 999、nick 为"我的菇语"、avatar 为 🍄、time 为"刚刚"、likes 为 0。这种"固定 id 999"的做法在多次新增时会产生重复 id,导致 ForEach 的键值冲突——这是教学示例中的简化处理,真实场景应使用自增 id 或 UUID。unshift 操作触发了 @State postList 的引用变更(数组被替换为新引用),框架会重新渲染 ForEach,但得益于 PostItem 是 @Observed,已有的列表项不会重建,只有新项会被插入渲染。最后无论是否添加,都将 addOpen 置为 false 关闭弹框。

doEdit 方法使用 splice 的三参数形式 splice(idx, 1, newItem),用新 BatchItem 替换指定位置的旧项。新 BatchItem 保留了原 id(保持 ForEach 键值稳定),但 name/level/stage 来自编辑态,progress 被硬编码为 40——这意味着任何编辑后的批次进度都会重置为 40%,是教学示例的简化。doDel 方法依据 delTarget 分支:‘works’ 删除 batchList 首项、‘course’ 删除 postList 首项。注意 delTarget === ‘course’ 实际删除的是 postList(菇友动态)而非真正的课程——这是应用的一个语义错位,"退课"按钮在菇菇课堂 tab 中,但删除操作作用于菇友动态列表,可能是教学示例的逻辑瑕疵或预留的功能占位。

3.21 特效层 Builder fxLayer

@Builder
fxLayer() {
  Stack({ alignContent: Alignment.TopStart }) {
    ForEach([0, 1, 2, 3, 4], (i: number) => {
      Column()
        .width(30)
        .height(30)
        .borderRadius(15)
        .backgroundColor(COLORS.accent)
        .opacity(lampA(this.tick, i))
        .translate({ x: lampX(this.tick, i), y: lampY(this.tick, i) })
    }, (i: number) => i.toString())
    ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], (i: number) => {
      Column()
        .width(sporeR(this.tick, i) * 2)
        .height(sporeR(this.tick, i) * 2)
        .borderRadius(sporeR(this.tick, i))
        .backgroundColor(COLORS.chalk)
        .opacity(sporeA(this.tick, i))
        .translate({ x: sporeX(this.tick, i), y: sporeY(this.tick, i) })
    }, (i: number) => i.toString())
  }
  .width('100%')
  .height('100%')
  .hitTestBehavior(HitTestMode.None)
}

fxLayer 是特效层的唯一 Builder,以 Stack 为容器,内含两个 ForEach:第一个渲染 5 个菌灯(30x30 圆形、菌绿色、依据 lampA/lampX/lampY 定位与透明),第二个渲染 12 个孢子(2-4 像素半径、粉笔色、依据 sporeR/sporeA/sporeX/sporeY 定位与透明)。两个 ForEach 的键值生成函数均返回 i.toString(),确保每次 tick 更新时框架能够通过稳定的键值复用既有节点,仅更新其属性(opacity、translate),而非销毁重建——这是动画流畅的关键。

hitTestBehavior(HitTestMode.None) 是特效层的"点击穿透"配置,使得特效层虽然覆盖全屏,但不拦截任何触摸事件,所有点击会穿透到下层的业务 UI。这一配置是特效层"位于底层、不遮挡交互"设计目标的关键实现。整个 fxLayer 没有任何外部依赖(除 COLORS 常量与纯函数),是一个纯展示组件。每次 tick 自增触发 fxLayer 重新求值,所有 17 个 Column 的 opacity 与 translate 属性被重新计算并应用,形成连续的飘浮与呼吸动画。这种"以 @State 驱动属性刷新"的动画模式,相比 animateTo 显式动画,更轻量但也缺少缓动函数,适合本场景的匀速飘浮效果。

3.22 头部 Builder header

@Builder
header() {
  Column({ space: 8 }) {
    Row() {
      Text('🍄')
        .fontSize(24)
      Column() {
        Text('菇语种植舱')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
        Text('养菌 · 催蕾 · 出菇 · 采收')
          .fontSize(10)
          .fontColor(COLORS.textHint)
      }
      .alignItems(HorizontalAlign.Start)
      .margin({ left: 8 })
      .layoutWeight(1)
      Text('今日采收')
        .fontSize(12)
        .fontWeight(FontWeight.Bold)
        .fontColor('#241A0E')
        .padding({ left: 10, right: 10, top: 6, bottom: 6 })
        .backgroundColor(COLORS.primary)
        .borderRadius(14)
    }
    .width('100%')

header Builder 渲染应用顶部头部,分为两行:第一行是品牌行,包含 🍄 emoji 图标(24 号字)、"菇语种植舱"主标题(20 号粗体、文本主色)、"养菌 · 催蕾 · 出菇 · 采收"副标题(10 号字、文本提示色),右侧是"今日采收"高亮按钮(12 号粗体、深棕文字色、主色背景、14 像素圆角,呈现为药丸状徽标)。这一行的视觉层级清晰:emoji 抓眼 → 主标题传达品牌 → 副标题传达定位 → 徽标传达当前操作入口。

"今日采收"徽标的文字色使用 #241A0E 而非 COLORS.white,这是一个细节——#241A0E 是比 bg 更深的棕黑色,配以 primary 暖棕背景,形成"深棕字 + 暖棕底"的高对比组合,比纯白字更柔和、更贴合应用的整体暖色调性。这种"以深色字而非白字配亮色底"的选择在应用的多个按钮(发布、保存、确认)中反复出现,形成了一致的按钮视觉语言。副标题"养菌 · 催蕾 · 出菇 · 采收"以中圆点分隔,恰好对应菌菇种植的四个核心阶段,既是品牌口号也是工艺流程的隐喻。

3.23 头部四宫格环境指标

Row({ space: 8 }) {
  Column({ space: 2 }) {
    Text('舱内温度')
      .fontSize(9)
      .fontColor(COLORS.textHint)
    Text('22.5 ℃')
      .fontSize(13)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.primary)
  }
  .layoutWeight(1)
  .padding(8)
  .backgroundColor(COLORS.cardBg)
  .borderRadius(10)
  .alignItems(HorizontalAlign.Center)
  // ...其余三个 Column 结构相同,分别为湿度、CO₂、在培批次
}
.width('100%')

头部第二行是四个等宽的环境指标卡片,每个卡片采用 Column 布局:顶部 9 号字的指标名称(文本提示色)、底部 13 号粗体的指标数值。四个指标的数值色分别为 primary(温度)、accent(湿度)、warning(CO₂)、danger(在培批次),这种"四色编码"使种植户一眼即可将指标与对应的菌菇生长要素关联——温度对应主色(暖棕,菌丝培养)、湿度对应菌绿(出菇必需)、CO₂ 对应警示黄(通风提醒)、在培批次对应危险红(工作量提醒)。

每个卡片使用 layoutWeight(1) 实现等宽分布,padding(8) 提供内边距,backgroundColor(COLORS.cardBg) 与 borderRadius(10) 营造卡片感。这种"四宫格指标"是行业应用的常见模式,其设计要点在于:指标名称用最小字号(9 号)以让位给数值、数值用粗体以突出可读性、色彩编码要与服务器的告警阈值关联(如 CO₂ 超过 1000ppm 应变红)。当前应用是静态数据演示,真实场景中这些数值应来自温湿度传感器与批次数据库的实时查询,并通过定时器或推送更新。

3.24 内容 Tab 横条 subNav

@Builder
subNav() {
  Scroll() {
    Row({ space: 8 }) {
      ForEach(SUB_NAV_LIST, (item: string, idx: number) => {
        Text(item)
          .fontSize(13)
          .fontWeight(this.subTab === idx ? FontWeight.Bold : FontWeight.Normal)
          .fontColor(this.subTab === idx ? '#241A0E' : COLORS.textSecondary)
          .backgroundColor(this.subTab === idx ? COLORS.primary : COLORS.cardBg)
          .padding({ left: 14, right: 14, top: 7, bottom: 7 })
          .borderRadius(16)
          .onClick(() => {
            this.subTab = idx;
          })
      }, (item: string) => item)
    }
    .width('100%')
  }
  .scrollable(ScrollDirection.Horizontal)
  .scrollBar(BarState.Off)
  .padding({ top: 4, bottom: 10 })
}

subNav Builder 渲染首页的内容 tab 横条,以横向 Scroll 包裹 Row,Row 内 ForEach 渲染 7 个胶囊式 Text。每个 Text 的样式依据 this.subTab === idx 二态切换:激活态为粗体、深棕字色、主色背景;非激活态为常规字重、文本次色、卡片背景色。padding 的水平 14、垂直 7 与 borderRadius(16) 共同构成胶囊外形。这种"胶囊式 tab"相比传统的下划线 tab,视觉更圆润、触摸面积更大,适合手指操作。

scrollable(ScrollDirection.Horizontal) 使内容超出屏幕宽度时可横向滚动,scrollBar(BarState.Off) 隐藏滚动条以保持视觉整洁。7 个胶囊的总宽度在多数手机上会超出屏幕宽度(每个胶囊约 60-80 像素 × 7 = 420-560 像素,加间距约 500-600 像素),因此滚动是必要的。onClick 回调将 subTab 设为 idx,触发 mainContent 中的 if-else 分支重新求值,渲染对应的内容页面。键值生成函数返回 item 字符串本身,由于 SUB_NAV_LIST 元素唯一,这保证了 ForEach 的稳定键值。整个 subNav 是一个轻量、自包含、状态驱动的导航组件,是应用内"胶囊 tab"设计的标准实现。

3.25 精选页面 pageFeatured 之采收季横幅与生长记录

@Builder
pageFeatured() {
  Column({ space: 12 }) {
    Row({ space: 14 }) {
      Text('🍄')
        .fontSize(44)
      Column({ space: 6 }) {
        Text('采收季 · P-07 第三潮')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
        Text('累计 800 克 · 建议间歇养菌 7 天')
          .fontSize(12)
          .fontColor('#F0E2CC')
        Text('记一笔')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor('#241A0E')
          .padding({ left: 10, right: 10, top: 4, bottom: 4 })
          .backgroundColor(COLORS.primaryLight)
          .borderRadius(10)
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
    }
    .width('100%')
    .padding(16)
    .backgroundColor(COLORS.primaryDark)
    .borderRadius(16)

pageFeatured 是精选页 Builder,内容最为丰富,由采收季横幅、生长记录时间线、周采收柱状图、菌种占比、在培批次双列、菇友动态六部分组成。采收季横幅以 primaryDark 深棕色为背景,左侧 44 号字的 🍄 emoji 作为视觉锚点,右侧三行文案:标题"采收季 · P-07 第三潮"(白色粗体)、说明"累计 800 克 · 建议间歇养菌 7 天"(浅麦色)、操作按钮"记一笔"(深棕字、浅麦背景、药丸状)。横幅的视觉重量在精选页中最重,用于突出当前最重要的采收事件。

"记一笔"按钮的点击行为在代码中未绑定(无 onClick),可能预留了打开"记录采收"弹框的入口。横幅的 #F0E2CC 文本色是 COLORS 中未定义的临时色值,介于 white 与 primaryLight 之间,用于在深棕背景上提供次级文字的可读性——这种"临时色值"的做法在大段 UI 代码中常见,但理想情况下应纳入 ColorPalette 统一管理。生长记录时间线使用 ForEach 渲染 LOG_LIST,每条记录是一个 Row:左侧 52 像素宽的日期+圆点列、右侧的标题+描述列,背景为 cardBg。圆点的颜色与日期文字色都来自 it.color,实现了"事件类型 → 颜色"的视觉编码。

3.26 精选页面之周采收柱状图与菌种占比

Row({ space: 10 }) {
  ForEach(HARVEST_CHART, (it: HarvestChartItem) => {
    Column({ space: 4 }) {
      Column()
        .width(20)
        .height(barH(it.value, 640))
        .borderRadius(5)
        .backgroundColor(kindColor(it.value))
      Text(it.label)
        .fontSize(10)
        .fontColor(COLORS.textSecondary)
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Center)
  }, (it: HarvestChartItem) => it.label + it.value.toString())
}
.width('100%')
.alignItems(VerticalAlign.Bottom)
.height(140)
.margin({ top: 12 })

周采收柱状图是精选页的核心数据可视化组件。ForEach 遍历 HARVEST_CHART 的 7 条数据,每条渲染一个 Column:内含一个 20 像素宽的柱子(高度由 barH(it.value, 640) 计算,颜色由 kindColor(it.value) 派生)与一个 10 号字的周次标签。父 Column 使用 layoutWeight(1) 等宽分布,使 7 根柱子均匀排列;父 Row 设置 alignItems(VerticalAlign.Bottom) 与 height(140),使所有柱子底部对齐——这是柱状图的标准布局模式。

柱状图下方的"累计 2910 克"汇总行以 Row 布局,"累计"标签为 11 号次色字、"2910 克"为 14 号粗体主色字。这种"小标签 + 大数值"的汇总模式在应用中反复出现(如在培批次 8 批、我的菇语 N 条等),是数据仪表盘的通用语言。菌种占比图则采用横向进度条形式:每条数据是一个 Row,左侧 64 像素宽的类别标签、中间 Stack 包裹的底色 Row + 前景 Row(宽度为 ${it.value}%)、右侧 44 像素宽的百分比数值。Stack 的 alignContent(Alignment.Start) 使前景 Row 从左侧对齐,形成"底色 + 前景"的进度条叠加效果,是 ArkUI 中实现进度条的常见技巧。

3.27 精选页面之在培批次双列与菇友动态

ForEach(this.batchList, (it: BatchItem, idx: number) => {
  if (idx % 2 === 0) {
    Row({ space: 10 }) {
      if (idx < this.batchList.length) {
        Column({ space: 6 }) {
          Row() {
            Text('🧺')
              .fontSize(22)
              .layoutWeight(1)
            Text(it.level)
              .fontSize(10)
              .fontWeight(FontWeight.Bold)
              .fontColor(levelColor(it.level))
              .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              .backgroundColor(COLORS.accentLight)
              .borderRadius(6)
          }
          .width('100%')
          Text(it.name)
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text(it.stage)
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)
        .padding(12)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
      }
      // ...idx + 1 的右列卡片结构相同
    }
    .width('100%')
  }
}, (it: BatchItem) => it.id.toString())

在培批次双列采用"手动双列"布局:ForEach 遍历 batchList,仅在 idx % 2 === 0 时渲染一行 Row,每个 Row 内包含 idx 与 idx+1 两张卡片。这种"手动双列"相比 Grid 布局的优势在于控制力更强——可以精确调整左右卡片的间距与样式;劣势是代码量翻倍、可读性下降。每张卡片是一个 Column,顶部 Row 放置 🧺 emoji 与 level 标签(levelColor 派生颜色、accentLight 深底、6 像素圆角),中部为批次名称(14 号粗体),底部为阶段说明(11 号次色字)。

菇友动态部分使用 this.postList.slice(0, 3) 取前 3 条动态,以 Row 布局:左侧 22 号字的 avatar emoji、右侧 Column 含昵称行(昵称 + 时间)与动态正文(12 号次色字、maxLines 2、textOverflow Ellipsis)。maxLines 与 textOverflow 的配置确保长动态不会撑破卡片,而是以省略号收尾。"发菇语"链接在右上角,点击触发 openAdd 打开新增弹框。整个精选页以 Column({ space: 12 }) 组织六个区块,每个区块以 cardBg 背景 + 12-14 像素圆角呈现,构成了信息密集但层次分明的仪表盘式首页。

3.28 菌种图鉴页面 pageStrains

@Builder
pageStrains() {
  Column({ space: 10 }) {
    Row() {
      Text('菌种图鉴')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .layoutWeight(1)
      Text('记录菌况')
        .fontSize(12)
        .fontColor(COLORS.primary)
        .onClick(() => {
          this.openAdd();
        })
    }
    .width('100%')
    ForEach(STRAIN_LIST, (it: StrainItem, idx: number) => {
      Column({ space: 6 }) {
        Row() {
          Column()
            .width(4)
            .height(38)
            .borderRadius(2)
            .backgroundColor(statusColor(it.status))
          // ...右侧内容列
        }
        .width('100%')
        Stack({ alignContent: Alignment.Start }) {
          Row()
            .width('100%')
            .height(6)
            .borderRadius(3)
            .backgroundColor(COLORS.border)
          Row()
            .width(`${workProgress(it.status)}%`)
            .height(6)
            .borderRadius(3)
            .backgroundColor(statusColor(it.status))
        }
        .width('100%')

pageStrains 渲染菌种图鉴列表,每条菌种卡片包含三部分:顶部 Row(左侧 4x38 像素的 statusColor 色条 + 右侧的名称/状态/品类/周期信息)、中部 Stack 进度条(底色 border + 前景 statusColor 色、宽度为 workProgress(it.status)%)、底部 Row(生长进度百分比 + tag 标签)。色条是菌种卡片的视觉标识,其颜色与状态标签、进度条前景色保持一致,形成了"状态 → 颜色"的三重映射,使每张卡片在视觉上即代表其当前所处的生命周期阶段。

ForEach 的键值生成函数返回 it.id.toString(),确保菌种 ID 唯一时键值稳定。菌种图鉴底部的"报废菌包"按钮采用了"Button + 叠加 Text"的特殊模式:Button 无文本,而是在其上方以负 margin 叠加一个 Text"报废菌包"。这种模式是 ArkUI 中 Button 组件不支持直接设置文字颜色的变通做法——通过分离 Button(提供背景色与点击)与 Text(提供文字样式),实现了"危险红背景 + 白色文字"的按钮效果。点击触发 openDelA,打开 delTarget 为 ‘works’ 的删除弹框。这种"按钮 + 文字叠加"的模式在 pageCourse 的"退课"按钮中也有出现,是应用内的一致设计。

3.29 种植舱页面 pagePod

@Builder
pagePod() {
  Column({ space: 10 }) {
    Text('种植舱')
      .fontSize(16)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.textPrimary)
      .width('100%')
    Row({ space: 8 }) {
      Column({ space: 4 }) {
        Text('🌡️')
          .fontSize(28)
        Text('培菌舱')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
        Text('22.5℃ · 黑暗')
          .fontSize(10)
          .fontColor(COLORS.textSecondary)
      }
      .layoutWeight(1)
      .padding(12)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(12)
      .alignItems(HorizontalAlign.Center)
      // ...出菇舱、低温库两个 Column 结构相同
    }
    .width('100%')
    ForEach(this.batchList.slice(0, 5), (it: BatchItem, idx: number) => {
      Row({ space: 10 }) {
        Text(`${idx + 1}`)
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(idx < 3 ? COLORS.primary : COLORS.textHint)
          .width(20)
        Column({ space: 3 }) {
          Text(it.name)
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text(`${it.stage} · ${it.level}`)
            .fontSize(10)
            .fontColor(COLORS.textSecondary)
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)
        Text(`+${it.progress}%`)
          .fontSize(11)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.primary)
      }
      .width('100%')
      .padding(10)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(10)
    }, (it: BatchItem) => it.id.toString())
  }
  .width('100%')
}

pagePod 渲染种植舱页面,分为"三舱概览"与"批次排行"两部分。三舱概览是顶部 Row 内三个等宽 Column:培菌舱(🌡️、22.5℃ · 黑暗)、出菇舱(💧、86% · 散光)、低温库(❄️、12℃ · 催蕾)。这三个舱室恰好对应了菌菇种植的三种核心环境:培菌舱用于菌丝暗培养、出菇舱用于成熟期散光出菇、低温库用于金针菇等品种的催蕾胁迫。每张卡片的 emoji + 舱名 + 参数三行布局简洁明了,是种植现场"看一眼即知舱况"的数字化映射。

批次排行使用 this.batchList.slice(0, 5) 取前 5 条批次,以 Row 布局:左侧序号(前 3 名为主色、其余为提示色,模拟"领奖台"视觉)、中部批次名称与阶段/管理方式、右侧进度百分比。这种"排名 + 进度"的列表是种植户快速把握"哪些批次领先、哪些滞后"的常见形式。序号颜色 idx < 3 ? COLORS.primary : COLORS.textHint 是一个简单的条件渲染,前三名高亮以激励种植户关注头部批次。整个 pagePod 是一个"环境 + 批次"的双视图页面,是菌舱主 tab 的内容延伸。

3.30 菌包批次页面 pageBatch

@Builder
pageBatch() {
  Column({ space: 10 }) {
    Text('菌包批次')
      .fontSize(16)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.textPrimary)
      .width('100%')
    ForEach(this.batchList, (it: BatchItem, idx: number) => {
      Column({ space: 8 }) {
        Row({ space: 10 }) {
          Text('🧺')
            .fontSize(24)
          Column({ space: 4 }) {
            Row() {
              Text(it.name)
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.textPrimary)
                .layoutWeight(1)
              Text(it.level)
                .fontSize(10)
                .fontWeight(FontWeight.Bold)
                .fontColor(levelColor(it.level))
                .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                .backgroundColor(COLORS.accentLight)
                .borderRadius(6)
            }
            .width('100%')
            Text(`🔁 ${it.stage}`)
              .fontSize(11)
              .fontColor(COLORS.textSecondary)
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)
        }
        .width('100%')
        Stack({ alignContent: Alignment.Start }) {
          Row()
            .width('100%')
            .height(6)
            .borderRadius(3)
            .backgroundColor(COLORS.border)
          Row()
            .width(`${it.progress}%`)
            .height(6)
            .borderRadius(3)
            .backgroundColor(stageColor(it.stage))
        }
        .width('100%')
        Row() {
          Text(`完成度 ${it.progress}%`)
            .fontSize(10)
            .fontColor(COLORS.textHint)
            .layoutWeight(1)
          Text('编辑批次')
            .fontSize(11)
            .fontColor(COLORS.primary)
            .onClick(() => {
              this.openEdit(idx);
            })
        }
        .width('100%')
      }
      .width('100%')
      .padding(12)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(12)
    }, (it: BatchItem) => it.id.toString())
  }
  .width('100%')
}

pageBatch 渲染菌包批次列表,每张卡片包含:顶部 Row(🧺 emoji + 批次名称 + level 标签 + 🔁 stage 说明)、中部 Stack 进度条(底色 border + 前景 stageColor 色、宽度为 it.progress%)、底部 Row(完成度百分比 + "编辑批次"链接)。与 pageStrains 不同,pageBatch 的进度条直接使用数据中的 it.progress 字段(而非 workProgress 函数派生),因为批次数据本身携带了精确的进度数值。stageColor 函数则用于进度条前景色,依据 stage 字段映射颜色:养菌中/养菌期 → 菌绿色、出菇中/已采收 → 主色、其余 → 警示黄。

"编辑批次"链接的 onClick 调用 this.openEdit(idx),将当前卡片的索引传入 openEdit,后者将批次数据拷贝到 editName/editStyle/editNote 编辑态并打开 editOpen 弹框。这种"列表项 → 编辑弹框"的交互模式是 CRUD 应用的标准范式。pageBatch 同时被首页 subTab=3 与菌舱主 tab(mainTab=1)复用——在 mainContent 的 if-else 中,mainTab === 1 分支直接渲染 pageBatch。这种"一个 Builder 多处复用"是 @Builder 的核心价值,避免了代码重复。

3.31 菇菇课堂页面 pageCourse

@Builder
pageCourse() {
  Column({ space: 10 }) {
    Text('菇菇课堂')
      .fontSize(16)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.textPrimary)
      .width('100%')
    ForEach(COURSE_LIST, (it: CourseItem) => {
      Column({ space: 8 }) {
        Row() {
          Text(it.title)
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
          Text(it.week)
            .fontSize(11)
            .fontColor(COLORS.textHint)
        }
        .width('100%')
        Stack({ alignContent: Alignment.Start }) {
          Row()
            .width('100%')
            .height(8)
            .borderRadius(4)
            .backgroundColor(COLORS.border)
          Row()
            .width(`${it.progress}%`)
            .height(8)
            .borderRadius(4)
            .backgroundColor(it.color)
        }
        .width('100%')
        Row() {
          Text('已学')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .layoutWeight(1)
          Text(`${it.progress}%`)
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primary)
        }
        .width('100%')
      }
      .width('100%')
      .padding(12)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(12)
    }, (it: CourseItem) => it.title)
    Button()
      .width('100%')
      .height(42)
      .backgroundColor(COLORS.danger)
      .borderRadius(10)
      .opacity(0.9)
      .onClick(() => {
        this.openDelB();
      })
    Text('退课')
      .fontSize(14)
      .fontColor(COLORS.white)
      .margin({ left: -28, top: -32 })
  }
  .width('100%')
}

pageCourse 渲染菇菇课堂列表,每张课程卡片包含:顶部 Row(课程标题 + 周次)、中部 Stack 进度条(8 像素高、底色 border + 前景 it.color 色、宽度为 progress%)、底部 Row("已学"标签 + 进度百分比)。与 pageBatch 的进度条相比,pageCourse 的进度条略高(8 vs 6 像素),且前景色直接使用数据携带的 it.color 字段,而非通过函数派生——这是因为课程的颜色是语义性的(每门课程有自己的标识色),不应由进度数值派生。

列表底部的"退课"按钮采用了与 pageStrains 的"报废菌包"按钮相同的"Button + 叠加 Text"模式:Button 设置 danger 背景色与 0.9 透明度(略微柔和),上方叠加的 Text"退课"使用白色字、负 margin 向上偏移 32 像素以覆盖在 Button 中心。left: -28 的负值是为了水平居中——Button 占满全宽,Text 需要向左偏移以补偿其自身宽度的一半,实现视觉居中。这种"手动居中"的做法略显粗糙,真实场景中应使用 Column({ space: 0 }) 或 Stack 包裹 Button 与 Text 实现自动居中。点击触发 openDelB,打开 delTarget 为 ‘course’ 的删除弹框。

3.32 菇友圈页面 pageCircle

@Builder
pageCircle() {
  Column({ space: 10 }) {
    Row() {
      Text('菇友圈')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .layoutWeight(1)
      Text('发菇语')
        .fontSize(12)
        .fontColor(COLORS.primary)
        .onClick(() => {
          this.openAdd();
        })
    }
    .width('100%')
    ForEach(this.postList, (it: PostItem) => {
      Row({ space: 10 }) {
        Text(it.avatar)
          .fontSize(26)
        Column({ space: 4 }) {
          Row() {
            Text(it.nick)
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
              .layoutWeight(1)
            Text(it.time)
              .fontSize(10)
              .fontColor(COLORS.textHint)
          }
          .width('100%')
          Text(it.text)
            .fontSize(13)
            .fontColor(COLORS.textSecondary)
          Text(`👍 ${it.likes} · 💬 回复`)
            .fontSize(11)
            .fontColor(COLORS.textHint)
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)
      }
      .width('100%')
      .padding(12)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(12)
    }, (it: PostItem) => it.id.toString())
  }
  .width('100%')
}

pageCircle 渲染菇友圈完整动态列表,每条动态是一个 Row:左侧 26 号字的 avatar emoji、右侧 Column 含昵称行(昵称 + 时间)、动态正文(13 号次色字)、互动行(👍 点赞数 + 💬 回复)。与精选页的菇友动态(仅显示前 3 条、avatar 22 号字、正文 12 号字)相比,pageCircle 显示全部 8 条动态、avatar 更大(26 号)、正文更可读(13 号),体现了"摘要视图 vs 完整视图"的设计区分。pageCircle 的 ForEach 键值返回 it.id.toString(),在 doAdd 新增动态时,新 PostItem 的 id 固定为 999,可能与既有动态的 id 冲突——这是前文提到的简化设计瑕疵,真实场景应使用自增 id。

“发菇语"链接位于右上角,与 pageStrains 的"记录菌况”、pageBatch 的"编辑批次"采用相同的"主色 12 号字 + onClick"模式,是应用内"列表头操作入口"的标准设计。整个 pageCircle 是一个纯粹的社交动态流,无图表、无进度条、无标签,视觉上最为"轻盈",与其他内容页的"信息密集"形成对比,为种植户提供了从数据管理切换到社群交流的节奏调节。

3.33 设备库页面 pageGear

@Builder
pageGear() {
  Column({ space: 10 }) {
    Text('设备库')
      .fontSize(16)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.textPrimary)
      .width('100%')
    ForEach(GEAR_LIST, (it: GearItem, idx: number) => {
      if (idx % 2 === 0) {
        Row({ space: 10 }) {
          if (idx < GEAR_LIST.length) {
            Column({ space: 6 }) {
              Text(GEAR_LIST[idx].icon)
                .fontSize(26)
              Text(GEAR_LIST[idx].name)
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.textPrimary)
              Text(GEAR_LIST[idx].desc)
                .fontSize(11)
                .fontColor(COLORS.textSecondary)
              Text('去添置')
                .fontSize(11)
                .fontColor(COLORS.primary)
                .margin({ top: 4 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)
            .padding(12)
            .backgroundColor(COLORS.cardBg)
            .borderRadius(12)
          }
          // ...idx + 1 的右列卡片结构相同
        }
        .width('100%')
      }
    }, (it: GearItem) => it.id.toString())
  }
  .width('100%')
}

pageGear 渲染设备库,采用与精选页"在培批次"相同的"手动双列"布局模式:ForEach 仅在 idx % 2 === 0 时渲染一行 Row,每行包含 idx 与 idx+1 两张设备卡片。每张卡片是一个 Column:顶部 26 号字的 emoji 图标、中部设备名称(14 号粗体)、设备用途(11 号次色字)、底部"去添置"链接(11 号主色字)。值得注意的是 ForEach 内部访问的是 GEAR_LIST[idx]GEAR_LIST[idx + 1] 而非回调参数 it——因为回调参数 it 在 idx % 2 === 0 的过滤下,只代表偶数索引的元素,要获取奇数索引的元素必须直接访问 GEAR_LIST。

"去添置"链接未绑定 onClick,预留了跳转电商或采购流程的入口。从商业化角度看,设备库是应用变现的自然切入点——种植户在管理菌包过程中发现设备不足,点击"去添置"即可进入采购流程。整个设备库页面是应用中唯一与"消费"而非"管理"相关的页面,其设计简洁(无进度条、无状态标签),聚焦于设备信息展示,为后续的电商集成留下了清晰的扩展点。

3.34 我的页面 pageMine

@Builder
pageMine() {
  Column({ space: 12 }) {
    Row({ space: 12 }) {
      Text('🦊')
        .fontSize(40)
      Column({ space: 4 }) {
        Text('菇语种植员')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
        Text('Lv.5 · 在培 8 批 · 累计 2910 克')
          .fontSize(11)
          .fontColor(COLORS.textSecondary)
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
    }
    .width('100%')
    .padding(14)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(14)
    Column({ space: 10 }) {
      Row() {
        Text('我的批次')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
          .layoutWeight(1)
        Text(`${this.batchList.length}`)
          .fontSize(12)
          .fontColor(COLORS.primary)
      }
      .width('100%')
      ForEach(this.batchList.slice(0, 3), (it: BatchItem) => {
        Row() {
          Text(it.name)
            .fontSize(13)
            .fontColor(COLORS.textSecondary)
            .layoutWeight(1)
          Text(it.stage)
            .fontSize(11)
            .fontColor(stageColor(it.stage))
        }
        .width('100%')
      }, (it: BatchItem) => it.id.toString())
    }
    .width('100%')
    .padding(14)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(14)
    // ..."我的菇语"卡片结构类似
  }
  .width('100%')
}

pageMine 渲染我的页面,由个人资料卡、我的批次、我的菇语三张卡片组成。个人资料卡使用 🦊 狐狸 emoji 作为头像(与菌菇种植的"菇"谐音,是应用的吉祥物隐喻),右侧 Column 展示"菇语种植员"用户名与"Lv.5 · 在培 8 批 · 累计 2910 克"等级信息。等级信息中的"8 批"与"2910 克"都是动态数据(batchList.length 与 HARVEST_CHART 累计值),但当前代码硬编码了字符串,未与状态联动——这是教学示例的简化,真实场景应使用模板字符串 `Lv.5 · 在培 ${this.batchList.length} 批 · 累计 ${total} 克`

"我的批次"卡片使用 this.batchList.slice(0, 3) 取前 3 条批次,以简洁的 Row 列表呈现:左侧批次名称(次色字)、右侧阶段(stageColor 派生色)。与 pageBatch 的完整批次卡片相比,这里的批次呈现是"摘要式"的,无进度条、无标签、无编辑入口,仅作为个人中心的快捷概览。“我的菇语"卡片结构类似,展示 postList 前 3 条动态的昵称与时间。整个 pageMine 是一个"个人仪表盘”,聚合了用户身份、批次摘要、动态摘要三类信息,是"我的"主 tab 的内容承载。

3.35 模态遮罩层 modalOverlay

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

modalOverlay 是三个弹框共用的半透明遮罩层,以 Stack 包裹一个全屏 Column,背景色为纯黑、透明度 0.6。onClick 回调一次性将 addOpen、editOpen、delOpen 全部置为 false,实现了"点击遮罩关闭任意弹框"的统一行为。这种"共用遮罩 + 统一关闭"的设计简洁高效,避免了为每个弹框单独实现遮罩。透明度 0.6 是经过权衡的值——足够暗以突出弹框主体,又不至于完全遮挡下层内容,保留上下文感知。

值得注意的是 modalOverlay 的 onClick 会同时将三个布尔状态置为 false,即使只有一个弹框是打开的。这种"全量重置"不会引发问题(关闭已关闭的弹框是无操作),但体现了"宁可冗余也要确保关闭"的防御性编程思路。在真实场景中,如果弹框数量增加(如设置弹框、筛选弹框),这种"全量重置"会变得冗长,可以考虑引入一个 closeAllModals() 方法统一处理。整个 modalOverlay 是一个极简的、无依赖的、可复用的遮罩组件,是应用弹框体系的基础设施。

3.36 新增弹框体 addModalBody

@Builder
addModalBody() {
  Column({ space: 12 }) {
    Text('发布菇语')
      .fontSize(17)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.textPrimary)
    TextInput({ placeholder: '一句话菌况…', text: this.addTitle })
      .fontSize(13)
      .fontColor(COLORS.textPrimary)
      .backgroundColor(COLORS.bg)
      .borderRadius(8)
      .height(40)
      .onChange((v: string) => {
        this.addTitle = v;
      })
    TextArea({ placeholder: '补充细节:菌种、批次、环境、采收…', text: this.addContent })
      .fontSize(13)
      .fontColor(COLORS.textPrimary)
      .backgroundColor(COLORS.bg)
      .borderRadius(8)
      .height(90)
      .onChange((v: string) => {
        this.addContent = v;
      })
    Row({ space: 10 }) {
      Button()
        .layoutWeight(1)
        .height(38)
        .backgroundColor(COLORS.bg)
        .borderRadius(8)
        .onClick(() => {
          this.addOpen = false;
        })
      Text('取消')
        .fontSize(13)
        .fontColor(COLORS.textSecondary)
        .margin({ left: -52 })
      Button()
        .layoutWeight(1)
        .height(38)
        .backgroundColor(COLORS.primary)
        .borderRadius(8)
        .onClick(() => {
          this.doAdd();
        })
      Text('发布')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor('#241A0E')
        .margin({ left: -44 })
    }
    .width('100%')
  }
  .width('100%')
  .padding(16)
  .backgroundColor(COLORS.cardBg)
}

addModalBody 是新增菇语弹框的主体,以 Column 组织:标题"发布菇语"(17 号粗体)、TextInput 单行输入框(placeholder"一句话菌况…“、bg 背景、40 像素高、onChange 同步到 addTitle)、TextArea 多行输入框(placeholder"补充细节:菌种、批次、环境、采收…”、bg 背景、90 像素高、onChange 同步到 addContent)、底部双按钮 Row(取消 + 发布)。TextInput 与 TextArea 的 text 参数绑定到 this.addTitle 与 this.addContent,实现了"状态 → 输入框"的单向数据流;onChange 回调则实现"输入框 → 状态"的逆向流,构成了双向数据绑定。

按钮 Row 采用"Button + 叠加 Text"模式:左侧取消按钮(bg 背景、点击关闭弹框)+ 叠加的"取消"文字(次色字、负 margin 向左偏移以居中);右侧发布按钮(primary 背景、点击调用 doAdd)+ 叠加的"发布"文字(深棕字、粗体、负 margin 居中)。负 margin 的值 -52 与 -44 是根据按钮宽度与文字宽度手动计算的,这种"手动居中"的精度有限,在不同字体渲染下可能出现轻微偏移。真实场景中,应使用 Button('取消') 直接设置文本,或以 Stack 包裹 Button 与 Text 实现自动居中。整个弹框体以 cardBg 背景填充、16 像素 padding,呈现为一张沉浸式的卡片表单。

3.37 编辑弹框体 editModalBody

@Builder
editModalBody() {
  Column({ space: 12 }) {
    Text('编辑种植批次')
      .fontSize(17)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.textPrimary)
    TextInput({ placeholder: '批次名称', text: this.editName })
      .fontSize(13)
      .fontColor(COLORS.textPrimary)
      .backgroundColor(COLORS.bg)
      .borderRadius(8)
      .height(40)
      .onChange((v: string) => {
        this.editName = v;
      })
    TextInput({ placeholder: '管理方式(如 低温)', text: this.editStyle })
      .fontSize(13)
      .fontColor(COLORS.textPrimary)
      .backgroundColor(COLORS.bg)
      .borderRadius(8)
      .height(40)
      .onChange((v: string) => {
        this.editStyle = v;
      })
    TextArea({ placeholder: '当前阶段与环境参数说明', text: this.editNote })
      .fontSize(13)
      .fontColor(COLORS.textPrimary)
      .backgroundColor(COLORS.bg)
      .borderRadius(8)
      .height(70)
      .onChange((v: string) => {
        this.editNote = v;
      })
    Row({ space: 10 }) {
      Button()
        .layoutWeight(1)
        .height(38)
        .backgroundColor(COLORS.bg)
        .borderRadius(8)
        .onClick(() => {
          this.editOpen = false;
        })
      Text('取消')
        .fontSize(13)
        .fontColor(COLORS.textSecondary)
        .margin({ left: -52 })
      Button()
        .layoutWeight(1)
        .height(38)
        .backgroundColor(COLORS.primary)
        .borderRadius(8)
        .onClick(() => {
          this.doEdit();
        })
      Text('保存')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor('#241A0E')
        .margin({ left: -44 })
    }
    .width('100%')
  }
  .width('100%')
  .padding(16)
  .backgroundColor(COLORS.cardBg)
}

editModalBody 是编辑种植批次弹框的主体,结构与 addModalBody 高度一致:标题"编辑种植批次"、三个输入框(批次名称 → editName、管理方式 → editStyle、当前阶段与环境参数说明 → editNote)、底部双按钮(取消 + 保存)。三个输入框分别对应 BatchItem 的 name/level/stage 三个可编辑字段,在 openEdit 时已从原数据拷贝到编辑态,用户修改后通过 doEdit 的 splice 写回原数据。这种"编辑副本 → 提交写回"的模式保证了用户在编辑过程中取消不会影响原数据。

三个输入框的 placeholder 文案值得注意:“批次名称”、“管理方式(如 低温)”、“当前阶段与环境参数说明”——这些 placeholder 不仅提示输入内容,还通过括号补充示例(“如 低温”),降低了种植户的输入认知成本。TextArea 的高度为 70 像素(比 addModalBody 的 90 像素略矮),因为编辑弹框有三个输入框(新增只有两个),需要压缩高度以适应 maxHeight 80% 的约束。整个 editModalBody 与 addModalBody 的结构高度同构,理想情况下可以抽取为一个通用的"表单弹框"Builder,通过参数差异化配置——这是应用在后续重构中可优化的方向。

3.38 删除弹框体 delModalBody

@Builder
delModalBody() {
  Column({ space: 12 }) {
    Text(this.delTarget === 'works' ? '报废菌包' : '退出菇菇课堂')
      .fontSize(17)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.textPrimary)
    Text(this.delTarget === 'works' ? '将报废首个菌包批次并记录归档,确认执行?' : '将退出首门进行中的课程,确认执行?')
      .fontSize(13)
      .fontColor(COLORS.textSecondary)
    Row({ space: 10 }) {
      Button()
        .layoutWeight(1)
        .height(38)
        .backgroundColor(COLORS.bg)
        .borderRadius(8)
        .onClick(() => {
          this.delOpen = false;
        })
      Text('取消')
        .fontSize(13)
        .fontColor(COLORS.textSecondary)
        .margin({ left: -52 })
      Button()
        .layoutWeight(1)
        .height(38)
        .backgroundColor(COLORS.danger)
        .borderRadius(8)
        .onClick(() => {
          this.doDel();
        })
      Text('确认')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.white)
        .margin({ left: -44 })
    }
    .width('100%')
  }
  .width('100%')
  .padding(16)
  .backgroundColor(COLORS.cardBg)
}

delModalBody 是删除确认弹框的主体,通过 delTarget 字段二态切换文案:‘works’ 时标题为"报废菌包"、说明为"将报废首个菌包批次并记录归档,确认执行?“;‘course’ 时标题为"退出菇菇课堂”、说明为"将退出首门进行中的课程,确认执行?“。这种"一个弹框双文案"的设计复用了弹框结构与交互逻辑,仅需差异化文案。底部双按钮中,确认按钮使用 danger 红色背景(区别于新增/编辑弹框的 primary 暖棕背景),从视觉上警示用户"此操作不可逆”。

确认按钮的文字色为 COLORS.white(白色),与新增/编辑弹框的 #241A0E(深棕)不同——因为 danger 红色背景比 primary 暖棕色更深,白色文字在红色背景上对比度更高。这种"依据背景明度自适应文字色"的细节体现了应用对可读性的关注。整个 delModalBody 是应用中唯一使用条件表达式渲染文案的弹框,是"配置驱动 UI"的轻量实践。doDel 方法在用户点击确认后执行,依据 delTarget 分支删除 batchList 或 postList 的首项,并关闭弹框。

3.39 底部导航栏 bottomBar

@Builder
bottomBar() {
  Row() {
    ForEach(NAV_LIST, (it: NavItem, idx: number) => {
      Column({ space: 2 }) {
        Text(it.icon)
          .fontSize(20)
          .opacity(this.mainTab === idx ? 1 : 0.55)
        Text(it.label)
          .fontSize(10)
          .fontWeight(this.mainTab === idx ? FontWeight.Bold : FontWeight.Normal)
          .fontColor(this.mainTab === idx ? COLORS.primary : COLORS.textHint)
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      .onClick(() => {
        this.mainTab = idx;
      })
    }, (it: NavItem) => it.label)
  }
  .width('100%')
  .height(56)
  .backgroundColor(COLORS.cardBg)
  .borderRadius({ topLeft: 16, topRight: 16 })
}

bottomBar 是底部 4 主 tab 导航栏,以 Row 包裹 ForEach 渲染 4 个 NavItem。每个 tab 是一个 Column:顶部 20 号字的 emoji 图标、底部 10 号字的标签。激活态(mainTab === idx)的样式为:图标 opacity 1(完全不透明)、标签粗体 + primary 主色;非激活态为:图标 opacity 0.55(半透明)、标签常规字重 + textHint 提示色。这种"透明度 + 字重 + 颜色"三重对比是 tab 激活态的常见表达,使当前 tab 在视觉上明显突出。

每个 tab 的 Column 使用 layoutWeight(1) 等宽分布,onClick 回调将 mainTab 设为 idx,触发 mainContent 的 if-else 分支切换。键值生成函数返回 it.label,由于 NAV_LIST 的 label 唯一(首页/菌舱/课堂/我的),保证了 ForEach 的稳定键值。整个 bottomBar 以 cardBg 为背景、56 像素高、顶部 16 像素圆角,呈现为悬浮于底部的导航条。顶部圆角是应用"卡片化设计"的延伸——底部导航条并非平铺,而是以圆角"卡片"形态呈现,与整体的设计语言一致。

3.40 主内容区 mainContent

@Builder
mainContent() {
  Column() {
    this.header()
    if (this.mainTab === 0) {
      this.subNav()
      Scroll() {
        Column() {
          if (this.subTab === 0) {
            this.pageFeatured()
          } else if (this.subTab === 1) {
            this.pageStrains()
          } else if (this.subTab === 2) {
            this.pagePod()
          } else if (this.subTab === 3) {
            this.pageBatch()
          } else if (this.subTab === 4) {
            this.pageCourse()
          } else if (this.subTab === 5) {
            this.pageCircle()
          } else {
            this.pageGear()
          }
        }
        .width('100%')
      }
      .scrollable(ScrollDirection.Vertical)
      .scrollBar(BarState.Off)
      .layoutWeight(1)
      .padding({ left: 14, right: 14, bottom: 20 })
    } else if (this.mainTab === 1) {
      Scroll() {
        Column() {
          this.pageBatch()
        }
        .width('100%')
      }
      .scrollable(ScrollDirection.Vertical)
      .scrollBar(BarState.Off)
      .layoutWeight(1)
      .padding({ left: 14, right: 14, bottom: 20 })
    } else if (this.mainTab === 2) {
      Scroll() {
        Column() {
          this.pageCourse()
        }
        .width('100%')
      }
      // ...菌舱/课堂/我的的结构相同
    } else {
      Scroll() {
        Column() {
          this.pageMine()
        }
        .width('100%')
      }
      // ...
    }
  }
  .width('100%')
  .height('100%')
}

mainContent 是主内容区的组织者,以 Column 包裹 header 与条件分支的内容区。mainTab === 0(首页)时,先渲染 subNav 胶囊横条,再渲染一个垂直 Scroll,Scroll 内的 Column 依据 subTab 的值(0-6)通过 if-else-if 链渲染 7 个内容 Builder 之一。其他主 tab(菌舱、课堂、我的)则直接渲染对应的单一 Builder 于垂直 Scroll 内。这种"主 tab 决定页面、内容 tab 决定首页子页面"的双层导航是应用的核心交互架构。

Scroll 组件的配置在四个分支中高度一致:scrollable(ScrollDirection.Vertical) 允许纵向滚动、scrollBar(BarState.Off) 隐藏滚动条、layoutWeight(1) 占满剩余高度、padding({ left: 14, right: 14, bottom: 20 }) 提供左右与底部内边距。这种重复的配置是代码可优化的方向——理想情况下应抽取为一个"内容滚动容器"Builder,通过参数传入子内容。但当前的四分支结构在可读性上更直观,每个分支独立完整,便于维护。整个 mainContent 以 height(‘100%’) 占满高度,与 bottomBar 一起构成 Column 的两个子项,实现了"头部固定 + 内容滚动 + 底部固定"的经典移动端布局。

3.41 build 主布局函数

build() {
  Stack() {
    Column()
      .width('100%')
      .height('100%')
      .backgroundColor(COLORS.bg)
    this.fxLayer()
    Column() {
      this.mainContent()
      this.bottomBar()
    }
    .width('100%')
    .height('100%')
    if (this.addOpen) {
      Stack() {
        this.modalOverlay()
        Column() {
          this.addModalBody()
        }
        .width('88%')
        .constraintSize({ maxHeight: '80%' })
        .borderRadius(16)
        .zIndex(999)
      }
      .width('100%')
      .height('100%')
    }
    if (this.editOpen) {
      Stack() {
        this.modalOverlay()
        Column() {
          this.editModalBody()
        }
        .width('88%')
        .constraintSize({ maxHeight: '80%' })
        .borderRadius(16)
        .zIndex(999)
      }
      .width('100%')
      .height('100%')
    }
    if (this.delOpen) {
      Stack() {
        this.modalOverlay()
        Column() {
          this.delModalBody()
        }
        .width('88%')
        .constraintSize({ maxHeight: '80%' })
        .borderRadius(16)
        .zIndex(999)
      }
      .width('100%')
      .height('100%')
    }
  }
  .width('100%')
  .height('100%')
}

build 是组件的入口渲染函数,以 Stack 为根容器,分层堆叠:最底层是全屏 bg 背景色 Column(提供统一的暗棕背景);第二层是 fxLayer 特效层(孢子飘浮 + 菌灯呼吸,hitTestBehavior 穿透);第三层是主内容 Column(mainContent + bottomBar,占据全屏);最顶层是三个条件弹框(addOpen/editOpen/delOpen 为 true 时渲染)。这种"背景 → 特效 → 内容 → 弹框"的四层 Stack 堆叠是应用的整体架构骨架,每一层各司其职、互不干扰。

三个弹框的结构高度一致:Stack 包裹 modalOverlay 遮罩与一个 88% 宽、maxHeight 80% 的 Column(承载弹框主体),borderRadius(16) 圆角、zIndex(999) 置顶。这种"条件渲染弹框"的模式使得弹框仅在对应状态为 true 时挂载,关闭时即从组件树移除,避免了隐藏态的资源占用。zIndex 999 确保弹框在所有内容之上。整个 build 函数是应用架构的"总装车间",将 16 个状态变量、17 个 Builder、4 类弹框统一组织为一个响应式组件树,是声明式 UI"状态驱动 UI"理念的集中体现。

四、Mermaid 流程图

mainTab = 0 首页

subTab = 0

subTab = 1

subTab = 2

subTab = 3

subTab = 4

subTab = 5

subTab = 6

mainTab = 1 菌舱

mainTab = 2 课堂

mainTab = 3 我的

addOpen = true

editOpen = true

delOpen = true

works

course

应用启动

aboutToAppear 生命周期

启动 setInterval 定时器
90ms 间隔

tick 自增 / glow 翻转

build 函数执行

Stack 根容器

第一层: bg 背景列

第二层: fxLayer 特效层

第三层: mainContent + bottomBar

弹框状态判断

菌灯 ForEach: 5 个 Column

孢子 ForEach: 12 个 Column

lampA/lampX/lampY 纯函数计算

sporeX/sporeY/sporeA/sporeR 纯函数计算

header 头部渲染

mainTab 判断

bottomBar 底部导航

subNav 胶囊横条

subTab 判断

pageFeatured 精选

pageStrains 菌种图鉴

pagePod 种植舱

pageBatch 菌包批次

pageCourse 菇菇课堂

pageCircle 菇友圈

pageGear 设备库

pageBatch 菌包批次

pageCourse 菇菇课堂

pageMine 我的

addModalBody 新增弹框

editModalBody 编辑弹框

delModalBody 删除弹框

用户输入 addTitle/addContent

点击发布 → doAdd

postList.unshift 新动态

addOpen = false 关闭弹框

用户修改 editName/editStyle/editNote

点击保存 → doEdit

batchList.splice 替换批次

editOpen = false 关闭弹框

用户点击确认

点击确认 → doDel

delTarget 判断

batchList.splice 删除首项

postList.splice 删除首项

delOpen = false 关闭弹框

用户点击 tab

mainTab = idx

用户点击胶囊

subTab = idx

上述流程图完整展示了从应用启动(aboutToAppear)到渲染(build)到交互(Tab 切换 / 弹窗 / 数据更新)的全链路。定时器每 90ms 驱动 tick 自增,触发 fxLayer 特效层的局部刷新;用户点击底部 tab 或内容胶囊触发 mainTab/subTab 变更,mainContent 的 if-else 分支重新求值渲染对应 Builder;用户点击"发菇语 / 编辑批次 / 报废菌包 / 退课"触发对应 open* 方法打开弹框;弹框内的输入与点击触发 do* 方法修改 postList/batchList,列表数据变更触发 ForEach 增量更新。整个流程闭环于 build 函数,任何状态变更都通过 ArkUI 的依赖追踪系统精确触发最小化的 UI 刷新。

五、技术对比表格

技术维度实现方式设计特点性能考量
色彩管理ColorPalette 接口 + COLORS 常量全局唯一色彩源,类型安全编译期检查,零运行时开销
数据模型@Observed class + 构造函数字段默认值 + 构造赋值双保险@Observed 追踪属性级变更,局部刷新
静态数据const 常量数组 + @State 拷贝常量作为初始源,状态承接变更避免直接修改常量,便于测试回滚
纯函数计算function 声明 + 取模运算无状态、无副作用、确定性强零对象创建,无 GC 压力
状态管理16 个 @State 变量分组导航 / 特效 / 弹框 / 数据四组细粒度依赖追踪,避免全量重绘
生命周期aboutToAppear / aboutToDisappear对称的资源获取与释放定时器及时清理,避免泄漏
特效层ForEach + 纯函数 + hitTestBehavior孢子飘浮 + 菌灯呼吸双效90ms 间隔,17 元素,低 CPU
头部布局Row + Column + layoutWeight品牌行 + 四宫格指标行轻量布局,无滚动开销
内容导航Scroll + Row + 胶囊 Text横向滚动,二态样式scrollBar 隐藏,视觉整洁
列表渲染ForEach + 稳定键值id.toString() 保证唯一增量更新,避免全量重建
进度条Stack + 底色 Row + 前景 Row双 Row 叠加,宽度百分比原生组件,无动画开销
柱状图ForEach + barH 纯函数归一化高度,kindColor 派生色整数像素,避免亚像素模糊
双列布局ForEach + idx % 2 过滤手动双列,控制力强代码量翻倍,可读性略低
弹框体系共用 modalOverlay + 三 Body状态驱动显隐,zIndex 999条件渲染,关闭即移除
按钮文案Button + 叠加 Text + 负 margin绕过 Button 文本色限制手动居中,精度有限
图标策略emoji 字符全量替代图标零资源加载,天然彩色无网络请求,无字体加载
按钮文案Button + 叠加 Text + 负 margin绕过 Button 文本色限制手动居中,精度有限
状态色映射statusColor / stageColor / levelColor条件分支返回色值O(1) 查找,无哈希开销
进度估算workProgress 依据状态返回预设值状态机式进度,无需日期简化但损失同状态内差异
数据操作unshift / splice 数组方法原生数组 API + @State 追踪引用变更触发 ForEach 增量

六、详细总结

6.1 架构设计

菇语·菌菇种植舱采用了"常量数据层 + 纯函数层 + Builder 组件层 + 状态层"的四层架构,层次清晰、职责分明。常量数据层(POST_LIST、STRAIN_LIST、BATCH_LIST、GEAR_LIST 及各类图表数据)作为应用的"数据底座",以 const 修饰确保不可变,通过 @State 拷贝到组件内承接运行时变更。纯函数层(barH、kindColor、sporeX 等 11 个函数)负责所有派生计算——高度归一化、颜色映射、孢子位置、状态进度等,保持组件逻辑的纯净与可测试性。Builder 组件层以 17 个 @Builder 声明可复用 UI 片段,从特效层到各类页面到弹框体,每个 Builder 自包含、可组合。状态层在 PageMushroomPod 内集中维护 16 个 @State 变量,按导航/特效/弹框/数据四组分类,构成了响应式网络的核心。这种四层架构使应用在功能扩展时只需新增对应 Builder 与状态字段,而无需扰动既有结构,体现了良好的开放-封闭原则。

6.2 状态管理

应用的状态管理充分利用了 ArkTS 的 @State 与 @Observed 双层响应式能力。@State 负责引用级监听——数组被 unshift/splice 后引用变更触发 ForEach 重新求值;@Observed 负责字段级监听——对象属性被修改后触发依赖该属性的 UI 片段局部刷新。两者协同构成了"引用级 + 字段级"的双层响应网络,使得 postList.unshift 新动态时整个列表不会重建,仅新项被插入渲染;batchList.splice 替换批次时仅对应卡片更新,其他卡片不动。弹框状态采用"三个布尔开关 + 目标标识 + 编辑态文本"的组合模式,open* 方法负责"拷贝原数据到编辑态 + 打开开关",do* 方法负责"将编辑态写回原数据 + 关闭开关",形成了"编辑副本 → 提交或丢弃"的经典表单模式,保证了用户取消编辑不影响原数据。

6.3 性能优化

性能优化是本应用的亮点之一,尤其在特效层的设计上体现得淋漓尽致。孢子飘浮与菌灯呼吸特效通过 11 个纯函数(sporeX/sporeY/sporeA/sporeR/lampA/lampX/lampY)以取模运算生成周期性的位置与外观参数,每次 tick 自增触发 17 个 Column 的 opacity 与 translate 属性刷新,全程零对象创建、零 GC 压力。90ms 的定时器间隔(约 11 FPS)是经过权衡的选择——足够慢以降低 CPU 开销,又足够快使孢子飘浮看起来连续。特效层的 hitTestBehavior(HitTestMode.None) 设置为点击穿透,确保特效不遮挡业务交互。ForEach 的键值生成函数全部返回稳定唯一的字符串(id.toString()、item.label 等),确保 tick 更新时框架能通过键值复用既有节点,仅更新属性而非销毁重建。弹框采用条件渲染(if (this.addOpen)),关闭时即从组件树移除,避免了隐藏态的资源占用。

6.4 交互设计

应用的交互设计围绕"种植户作业流程"展开,从底部 4 主 tab 到首页 7 内容胶囊,构成了"看 → 管 → 学 → 聊 → 备"的完整作业链路。首页精选页以采收季横幅、生长记录时间线、周采收柱状图、菌种占比、在培批次双列、菇友动态六区块构成了信息密集的仪表盘,让种植户一屏掌握全局。菌种图鉴以"色条 + 状态标签 + 进度条"三重颜色映射,使每条菌种的视觉调性与其生命周期阶段高度一致。菌包批次列表的"编辑批次"链接、菌种图鉴的"报废菌包"按钮、菇菇课堂的"退课"按钮,分别对应 CRUD 中的 Update 与 Delete,构成了完整的数据操作入口。所有弹框共用半透明遮罩,点击遮罩可关闭,提供了宽容的撤销路径。整个交互设计在"信息密度"与"操作便捷"之间取得了良好的平衡。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:



6.5 总结:

在这里插入图片描述

应用的可扩展性体现在多个层面。色彩体系通过 ColorPalette 接口约束 16 个 token,新增主题只需替换 COLORS 常量即可全局生效。数据模型通过 @Observed class + export 导出,可在未来拆分为多文件模块化结构。Builder 组件以 @Builder 声明,可被多处复用——pageBatch 同时被首页 subTab=3 与菌舱主 tab 复用,pageCourse 同时被首页 subTab=4 与课堂主 tab 复用,这种"一个 Builder 多处复用"减少了代码重复。纯函数层无状态、无副作用,新增一种状态色映射只需新增一个 function,无需修改既有函数。弹框体系通过 delTarget 字段实现了"一个弹框双用途",新增第三种删除目标只需扩展 delTarget 取值集合与 doDel 分支。这些扩展点使应用能够以最小改动适配未来的功能迭代。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐