开篇:从声明式UI范式到菌菇培育社区的技术全景

在这里插入图片描述

在 HarmonyOS 6.1.1 生态体系中,ArkTS 声明式UI开发范式正在重新定义跨端应用的构建方式。与传统命令式UI框架不同,ArkUI 采用"描述界面状态而非操作DOM"的核心思想,开发者通过声明式语法描述UI在不同状态下的呈现形态,由框架底层的渲染管线自动完成差异计算与视图更新。这种范式不仅大幅降低了UI状态管理的心智负担,更使得复杂动效与多层布局的组合实现变得异常简洁。

菌语应用正是这一技术范式下的典型实践案例。作为一款聚焦城市菌菇房培育与野生菌鉴别社区的垂直应用,它以菌绿色(#5E8C61)为核心主色调,橙棕色(#C97B4A)为强调色,构建了一套完整的菌菇主题设计语言。应用覆盖菌菇市集、菌房培育、采摘预约、鉴别课堂、菌友社区等核心场景,通过底部4主Tab与首页7内容Tab的双层导航架构,将丰富的功能模块有机组织在统一的视觉体系之中。

@Observed 装饰器是 HarmonyOS ArkTS API 24 中状态管理体系的核心基石之一。当一个类被 @Observed 装饰后,其属性的变化能够被UI框架精确追踪,从而触发关联视图的局部刷新。与 @State 装饰器配合使用时,@Observed 类的数组对象可以实现列表级别的细粒度更新——当数组中某个对象的属性发生变化时,只有依赖该属性的具体UI节点会被重新渲染,而非整个列表全部重建。这种细粒度的状态追踪机制,是菌语应用能够流畅承载大量动态数据的关键技术保障。

@Builder 装饰器则为UI复用提供了轻量级的解决方案。在菌语应用中,从特效层 fxLayer 到头部 header,从子导航 subNav 到各个页面的 pageBuilder,再到弹框组件 modalOverlay 与各类 modalBody,所有可复用的UI片段均通过 @Builder 封装为独立的构建函数。这种方式既保持了声明式代码的可读性,又避免了组件化拆分带来的额外性能开销。@Builder 函数可以接收参数、访问组件实例的状态变量,并支持链式调用属性方法,是 ArkUI 中实现UI模块化的重要手段。

在布局系统层面,Stack、Column、Row 三大基础容器构成了菌语应用的布局骨架。Stack 负责层叠布局,用于实现背景层、特效层、内容层、弹框层的多层叠加;Column 负责垂直方向的线性布局,承载页面内容的自上而下排列;Row 负责水平方向的线性布局,实现横向排列的导航、卡片、数据格等元素。三者的灵活组合,配合 layoutWeight 权重分配与 padding/margin 间距控制,构建了应用中所有复杂的布局形态。这种基于弹性盒模型的声明式布局方式,使得多端适配变得自然而直观。

ArkUI 渲染管线的高效运作是这一切的底层支撑。从组件树构建、属性计算、布局测量、绘制指令生成到最终的GPU渲染,每一个环节都经过了深度优化。菌语应用中的孢子漂浮特效与菌雾光晕呼吸特效,正是通过 setInterval 定时更新 tick 状态变量,触发渲染管线的增量更新,从而在不影响整体性能的前提下实现丝滑流畅的动画效果。而 hitTestBehavior(HitTestMode.None) 的巧妙运用,则确保了特效层虽然在视觉上覆盖全屏,但不会拦截任何用户交互事件,实现了"看得见、摸不着"的视觉增强效果。

菌菇绿色彩设计系统是菌语应用视觉识别的核心灵魂。主色 #5E8C61 取自森林中菌菇生长环境的自然绿色,既传达了生态、健康、自然的品牌理念,又在视觉上营造出舒适宁静的氛围。强调色 #C97B4A 则来源于成熟菌盖的橙棕色调,用于突出关键操作与重要信息,形成鲜明的视觉对比。而特殊色 spore(孢子色 #A3B18A)更是设计系统中的点睛之笔,它介于主色与浅色之间,既保留了菌绿的自然质感,又增添了一抹柔和的灰度,广泛应用于次级标签、背景装饰与特效元素之中。

菌菇培育社区的交互模式设计同样值得深入探讨。应用通过菌友动态(PostItem)构建了类社交的内容社区,用户可以发布"菌语"分享菌房日常;通过培育订单(GrowItem)实现了培育过程的全周期管理,从拌料、养菌到出菇、采收,每个阶段都有对应的状态标识与进度可视化;通过菌菇市集(FungiItem)连接了生产者与消费者,实现了从菌房到餐桌的直连链路。这种"社区+交易+培育管理"的三合一模式,在垂直农业应用中具有很强的代表性。


一、ColorPalette 接口设计:菌绿橙棕主题下的十六色全解析

1.1 接口定义的设计思想

在这里插入图片描述

ColorPalette 接口是菌语应用整个设计系统的基石。它通过 TypeScript 接口的形式,定义了一套完整的设计令牌(Design Tokens)规范,将所有颜色资源统一管理、集中配置。这种做法的优势在于:当需要调整主题色或适配深色模式时,只需修改 COLORS 常量对象中的色值,整个应用的配色方案便会同步更新,极大地提升了主题定制的效率与一致性。

接口共定义了16个颜色字段,涵盖主色系、辅助色、背景色、文本色、边框色、功能色与特殊色七大类别。每个字段的命名都遵循语义化原则——通过字段名即可直观判断其用途,而非使用抽象的 color1、color2 等编号方式。这种命名约定在团队协作中尤为重要,设计师与开发者可以基于同一套语义体系进行沟通,减少理解偏差。

1.2 主色系:菌绿三色阶的渐变美学

primary(主色 #5E8C61):这是整个应用的核心品牌色。取色于森林深处菌菇生长环境的自然绿色,RGB 分量约为 R:94, G:140, B:97。这是一种饱和度适中、明度偏高的灰调绿,既不会过于鲜艳刺眼,又能清晰传达生态、自然、健康的品牌调性。在应用中,primary 色被广泛用于导航选中态、主要按钮、重要标签、进度条主色等关键视觉元素,是用户对品牌识别的第一触点。

primaryLight(主色浅色 #E4EEDD):主色的极浅版本,RGB 约为 R:228, G:238, B:221。明度高达 92% 左右,几乎接近白色,但仍保留了微弱的绿色倾向。该色主要用于浅色背景填充、卡片悬浮态背景、分割线装饰等场景,能够在不抢夺视觉焦点的前提下,微妙地强化品牌色彩的渗透感。当用户浏览页面时,primaryLight 作为大面积的背景底色,营造出清新柔和的视觉氛围。

primaryDark(主色深色 #43683F):主色的深色调版本,RGB 约为 R:67, G:104, B:63。相比 primary 色,它的明度降低了约 20%,饱和度也略有下降,呈现出深沉稳重的森林墨绿质感。该色主要用于文字在彩色背景上的反白效果(如菌房环境卡中的按钮文字)、深色图标与装饰元素等场景。primaryDark 与 primary 形成了明暗对比,增强了界面的层次感与深度。

1.3 强调色系:橙棕菌盖的温暖点缀

在这里插入图片描述

accent(强调色 #C97B4A):取自成熟菌盖的橙棕色调,RGB 约为 R:201, G:123, B:74。这是一种温暖而富有食欲感的橙棕色,与主色的冷调绿形成鲜明的色相对比。在色彩心理学中,橙棕色能够唤起温暖、收获、美味的联想,与菌菇食材的属性高度契合。accent 色被用于次级按钮、重要标签、价格标识、弹框确认按钮等需要吸引用户注意但又不喧宾夺主的场景。

accentLight(强调色浅色 #F6E8DD):强调色的浅色版本,RGB 约为 R:246, G:232, B:221。明度极高,呈现出温暖的米杏色调。该色主要用于状态标签的背景填充(如"已采收"、"出菇中"等标签的底色)、次级卡片背景等场景。accentLight 与 primaryLight 在界面中形成冷暖对比的浅色基底,既丰富了视觉层次,又保持了整体的柔和感。

1.4 背景与文本色:五层灰度的信息层级

bg(页面背景 #F1F4EC):全局页面的背景色,RGB 约为 R:241, G:244, B:236。这是一种带微绿倾向的极浅灰白色,比纯白色更柔和,长时间浏览不易产生视觉疲劳。bg 色构成了应用的底色基调,所有内容卡片都浮于其上。

cardBg(卡片背景 #FFFFFF):纯白色卡片背景,与 bg 形成微妙的层次区分。在菌语应用中,所有功能模块均以白色卡片的形式呈现,通过圆角与阴影(此处通过背景色差异实现层级感)营造出"漂浮"在页面背景之上的视觉效果。

textPrimary(主文本 #33402F):主要正文文字颜色,RGB 约为 R:51, G:64, B:47。这是一种偏绿的深灰黑色,比纯黑更柔和,与整体菌绿主题相呼应。用于标题、正文等重要文字内容,确保最佳的可读性。

textSecondary(次文本 #64715E):次级文字颜色,RGB 约为 R:100, G:113, B:94。明度高于主文本,用于描述性文字、辅助信息等场景,与 textPrimary 形成清晰的信息层级。

textHint(提示文本 #A8B2A0):提示性文字颜色,RGB 约为 R:168, G:178, B:160。明度更高,用于占位符、时间戳、辅助标签等最次要的文字信息。三种文本色与背景色共同构成了完整的文字可读性体系。

1.5 边框与功能色:标准化的状态语义

在这里插入图片描述

border(边框色 #E3E9DB):统一的边框与分割线颜色,RGB 约为 R:227, G:233, B:219。带微弱绿调的浅灰色,与整体主题保持一致。用于卡片边框、输入框边框、分割线等场景。

success(成功色 #7BA05B):功能色中的成功态,RGB 约为 R:123, G:160, B:91。一种偏黄的草绿色,用于"已采收"状态、成功提示、正向数据等场景。

warning(警告色 #D9A05B):功能色中的警告态,RGB 约为 R:217, G:160, B:91。金黄色调,用于"拌料中"等中间状态、提醒信息等场景。

danger(危险色 #C0604F):功能色中的危险/错误态,RGB 约为 R:192, G:96, B:79。红棕色调,用于删除操作、退课按钮、购物车红点徽标等场景。

white(纯白 #FFFFFF):纯白色,用于反白文字、纯白背景覆盖等特殊场景。

1.6 特殊色:孢子色的独特定位

spore(孢子色 #A3B18A):这是整个调色板中最具特色的颜色。RGB 约为 R:163, G:177, B:138。它是一种灰调的橄榄绿,介于主色与浅色之间,既有菌绿的自然感,又带有灰调的高级质感。"孢子"这个命名本身就充满了菌菇主题的意象——孢子是菌菇繁殖的微小颗粒,轻盈而无处不在。在应用中,spore 色被用于:特效层中菌雾光晕的填充色、菌房环境卡中的标签背景、次级装饰元素等。它是连接主色与背景色的桥梁色,也是菌语应用视觉辨识度的重要组成部分。


二、@Observed 数据模型:四大核心实体的字段级剖析

在这里插入图片描述

2.1 @Observed 装饰器的工作原理

在深入各个数据模型之前,有必要先理解 @Observed 装饰器的技术本质。在 HarmonyOS ArkTS 中,@Observed 是一个类装饰器,它的作用是标记一个类为"可观察类"。当 @Observed 类的实例被 @State、@Link、@ObjectLink 等状态装饰器引用时,框架会自动为该实例创建一个代理对象(Proxy),拦截所有属性的读取与写入操作。

这种机制意味着:当你修改一个 @Observed 对象的某个属性时,只有依赖该属性的UI节点会被重新渲染,而不是整个组件树全部重建。对于列表场景而言,这带来了显著的性能提升——即使列表中有上百个条目,修改其中某一条的点赞数,也只会触发对应条目中点赞数字段的局部刷新。

2.2 PostItem:菌友动态数据模型

在这里插入图片描述

PostItem 是"菌友圈"与"菌友动态"模块的核心数据结构,代表一条用户发布的动态内容(在菌语中称为"菌语")。

id(number):动态的唯一标识符。在新增动态时,临时ID被设为 999,用于区分预置数据与用户生成内容。id 字段同时作为 ForEach 的键值生成依据(it.id.toString()),确保列表渲染时的复用正确性。

nick(string):发布者昵称,如"菌房值班员"、“野生菌猎人”、"培养基调配师"等。这些昵称充满了菌菇行业的特色,每个昵称对应一个特定的角色身份,增强了社区的沉浸感。

avatar(string):用户头像,使用 emoji 字符表示,如 🍄、⛰️、🧫、🧺、🪵 等。这种纯 emoji 的头像方案既简化了资源管理(无需加载图片资源),又与菌菇主题高度契合,每个 emoji 都对应着昵称中的角色特征。

text(string):动态正文内容,通常是一句简短的菌房日常或行业心得。预置数据中的文案都经过精心设计,既展示了菌菇培育的专业知识(如"麦粒培养基灭菌要足压,杂菌零容忍"),又带有社区分享的人情味(如"第一包平菇出菇了,激动到拍九张")。

time(string):发布时间,采用相对时间格式,如"6分钟前"、“24分钟前”、"昨天"等。相对时间的展示方式增强了内容的时效性感知,让用户能够快速判断动态的新鲜程度。

likes(number):点赞数,在UI中以 🍄 + 数字 的形式展示。用蘑菇 emoji 替代传统的"赞"图标,是菌语应用的一处巧妙设计,既强化了主题辨识度,又为社交互动增添了趣味性。

2.3 FungiItem:菌菇市集数据模型

FungiItem 代表菌菇市集中的一个商品条目,涵盖家种与野采两大类菌菇产品。

id(number):商品唯一标识,同样作为 ForEach 列表渲染的键值依据。

name(string):菌菇名称,包含品种与规格信息,如"榆黄蘑 · 头茬"、“羊肚菌 · 干品”、"猴头菇 · 白毛尖"等。名称的命名规范统一为"品种名 · 规格/等级"的格式,便于用户快速识别产品特征。

craft(string):工艺/来源描述,说明菌菇的栽培方式或采集方式,如"菌房现采 · 当日达"、“云贵山地 · 烘干”、"椴木栽培 · 鲜品"等。该字段传递了产品的品质感与稀缺性信息。

price(string):价格字符串,包含单位,如"¥18/斤"、“¥268/斤”、"¥148/盒"等。使用字符串类型而非数字类型,是因为价格展示需要包含货币符号与计量单位,直接以字符串形式存储更为便捷。

level(string):等级标签,取值包括"家种"、“野采”、“珍稀”、"稀有"等。等级字段通过 levelColor() 函数映射为不同的标签颜色,家种为草绿、野采为橙棕、稀有为金黄、珍稀为深绿,形成直观的等级视觉区分体系。

hot(number):热度值(关注度),范围从 58 到 95 不等。该字段有两个用途:一是在市集列表中作为关注度进度条的百分比值直接展示(因为 hot 值恰好与百分比形式契合),二是作为人气排序的参考依据。热度值的设计巧妙地将数据与可视化合二为一。

2.4 GrowItem:培育订单数据模型

GrowItem 代表"我的菌房"中的一个培育订单,记录了菌菇培育的全过程状态。

id(number):培育订单唯一标识。

name(string):培育项目名称,格式为"品种名 · 位置/编号",如"榆黄蘑 · 三号菌房"、“香菇 808 · 菌棒架”、"猴头菇 · 椴木区"等。名称中包含了品种信息与具体位置,便于用户快速识别不同的培育单元。

item(string):培育内容详情,描述当前培育阶段的具体工作内容与规模,如"640 菌包 · 头茬中"、“200 棒 · 转色完成”、"30 根 · 开口催蕾"等。该字段为用户提供了培育进度的详细说明。

status(string):当前培育状态,取值包括"已采收"、“出菇中”、“养菌期”、“拌料中”、"待接种"等。状态字段是培育订单模型的核心,它通过 statusColor() 函数映射为不同的状态标签颜色,通过 statusProgress() 函数映射为进度百分比,实现了"一值多用"的设计模式。

progress(number):培育进度数值,0-100 之间的整数。虽然 status 字段已经可以映射出默认的进度值,但 progress 字段的存在允许更细粒度的进度控制——不同的培育订单即使处于同一状态,也可以有不同的具体进度值。

2.5 GearItem:器具数据模型

GearItem 代表菌菇培育过程中使用的各种工具与器具。

id(number):器具唯一标识。

name(string):器具名称,如"菌种 · 母种试管"、“接种箱 · 双人”、"加湿器 · 超声波"等。名称中包含了器具类型与规格/特性信息。

desc(string):器具描述/功能说明,通常为简短的功能概括,如"PDA 培养基"、“紫外灭菌”、"湿度恒定"等。

icon(string):器具图标,同样使用 emoji 字符表示,如 🧫、📦、💧、🌡️、🚿、🔪、🔥、🛍️ 等。每个 emoji 都直观地对应器具的功能特征,降低了用户的认知成本。

2.6 辅助接口:图表与课程的数据契约

除了四个主要的 @Observed 类之外,代码中还定义了四个辅助接口,用于规范图表与课程等模块的数据结构。

WeekChartItem:周采收柱状图的数据项,包含 label(星期标签)和 value(采收量数值)两个字段。该接口配合 WEEK_CHART 常量数组使用,为柱状图组件提供标准化的数据输入。

FungiChartItem:品类构成条形图的数据项,在 WeekChartItem 的基础上增加了 color 字段,用于指定每个品类对应的条形颜色。这种设计允许每个数据项拥有独立的颜色配置,增强了图表的信息密度。

CourseItem:鉴别课堂课程的数据项,包含 title(课程标题)、week(学习周次)、progress(学习进度)、color(进度条颜色)四个字段。color 字段使得每门课程可以拥有独特的进度条颜色,形成视觉区分。

PlayTypeItem:玩法分类项,包含 name(名称)、desc(描述)、icon(图标)、hot(是否热门)四个字段。该接口用于"在菌语能玩什么"四格模块,描述应用的四大核心玩法。


三、静态数据数组:预置内容的结构化设计

在这里插入图片描述

3.1 POST_LIST:八条精选菌友动态

POST_LIST 数组包含 8 条预置的菌友动态,按照时间倒序排列(最新的在前)。每条动态都精心设计了角色人设与内容文案,构建出一个生机勃勃的菌菇培育社区氛围:

  • 第1条来自"菌房值班员",头像 🍄,内容是"榆黄蘑今晚又冒一茬,金黄一片",发布于6分钟前,获赞122。这条动态展示了菌房日常的喜悦时刻。
  • 第2条来自"野生菌猎人",头像 ⛰️,分享羊肚菌采摘窗口期的专业知识,体现了野采爱好者的经验传承。
  • 第3条来自"培养基调配师",头像 🧫,强调麦粒培养基灭菌的重要性,传递专业培育技术。
  • 第4条来自"菌语主理人",头像 🧺,发布采摘场加场通知,兼具运营与社区属性。
  • 第5条来自"猴头培育者",头像 🪵,记录椴木猴头转色成功的里程碑事件。
  • 第6条来自"新手小白菇",头像 🌱,分享第一包平菇出菇的激动心情,代表新手成长路径。
  • 第7条来自"鉴别课代表",头像 🔍,提醒见手青烹饪安全注意事项,体现科普价值。
  • 第8条来自"赤松茸农户",头像 🌾,推荐稻草覆土栽培技术,展示农户经验分享。

这8条动态覆盖了不同角色、不同主题、不同情绪,构成了一个多元而真实的社区内容生态。

3.2 FUNGI_LIST:八种菌菇商品矩阵

在这里插入图片描述

FUNGI_LIST 包含 8 种菌菇商品,覆盖家种、野采、珍稀等多个品类,形成了完整的商品矩阵:

  • 榆黄蘑(家种,热度95):头茬现采,价格亲民,热度最高
  • 羊肚菌(珍稀,热度92):云贵山地干品,价格高昂,稀缺性强
  • 猴头菇(家种,热度86):椴木栽培白毛尖,中高端价位
  • 赤松茸(家种,热度81):稻草覆土栽培,性价比之选
  • 鸡油菌(野采,热度77):松林混交手挑,中高端野采
  • 香菇(家种,热度70):808菌株花菇率40%,大众品类
  • 平菇(家种,热度63):墙式栽培现摘,最经济的选择
  • 牛肝菌(野采,热度58):雨季限时限量冻干,高端野采

热度值从 58 到 95 呈现梯度分布,与价格、稀缺性大致正相关,符合商业逻辑。

3.3 GROW_LIST:八条培育订单的全周期覆盖

GROW_LIST 包含 8 条培育订单,覆盖了从拌料、养菌、出菇到采收的全生命周期状态:

  • 榆黄蘑三号菌房:已采收(100%),代表完成态
  • 香菇808菌棒架:出菇中(74%),代表活跃产出态
  • 猴头菇椴木区:养菌期(52%),代表中期培育态
  • 平菇墙式二区:拌料中(36%),代表前期准备态
  • 赤松茸露天畦:养菌期(44%),另一中期案例
  • 羊肚菌试验田:待接种(12%),代表最早期
  • 灵芝观赏盆:出菇中(66%),观赏类品种
  • 木耳吊袋区:拌料中(20%),另一前期案例

这种多状态、多品种的组合,使得"我的菌房"页面能够展示丰富的培育进度视觉效果。

3.4 GEAR_LIST:八大器具分类展示

GEAR_LIST 包含 8 种培育器具,覆盖接种、环境控制、采收等多个环节:

  1. 菌种·母种试管(PDA培养基)🧫
  2. 接种箱·双人(紫外灭菌)📦
  3. 加湿器·超声波(湿度恒定)💧
  4. 温湿度计·双显(菌房必备)🌡️
  5. 喷雾壶·两升(水帘补水)🚿
  6. 采收刀·弯头(不伤菌柄)🔪
  7. 灭菌锅·高压(麦粒培养基)🔥
  8. 菌包袋·聚丙烯(耐高压透汽)🛍️

器具的排列遵循培育工艺流程的顺序:从菌种制备→接种→环境控制→采收→耗材,逻辑清晰,符合用户认知。

3.5 图表数据:可视化的支撑

WEEK_CHART:一周七天的采收量数据,周一到周日分别为 2、3、2、4、6、12、9 kg。可以明显看出周末采收量远高于工作日(周六周日合计 21 kg,占全周 67%),这与应用中"周末采收占比 67%"的展示数据完全吻合。这种数据一致性体现了严谨的数据设计。

FUNGI_CHART:本月采收品类构成数据,平菇30%、香菇26%、榆黄蘑18%、猴头菇14%、赤松茸12%,合计恰好100%。每个品类都配有独立的颜色值,与主题色系统保持一致。

3.6 玩法与课程:内容体系的骨架

TYPE_LIST:四大玩法分类——采摘活动、培育工坊、菌菇市集、鉴别课堂。其中前两个标记为"热门"(hot: true),体现了产品的重点推荐方向。每个分类都有简短的功能描述(desc字段),帮助用户快速理解。

COURSE_LIST:五周鉴别课程体系,从菌种分离→培养基配制→出菇管理→杂菌防治→野生菌鉴别安全。进度依次递减(100%→82%→57%→29%→6%),模拟了用户循序渐进的学习路径。每门课程配有独特的主题色,形成彩虹渐变的视觉效果。


四、导航配置:双层Tab架构的信息组织

4.1 NAV_LIST:底部四主Tab

NAV_LIST 定义了底部导航栏的四个主Tab,构成了应用的一级导航架构:

  1. 首页(图标 🍄):应用的核心入口,集成了搜索、环境监测、数据概览、玩法入口、图表分析、人气推荐、社区动态等丰富内容
  2. 市集(图标 🛒):菌菇交易市场,展示所有在售菌菇商品
  3. 课堂(图标 📚):鉴别学习课堂,提供菌菇鉴别与培育知识课程
  4. 我的(图标 👤):个人中心,展示用户档案、培育档案、菌语记录

四个Tab分别对应"逛→买→学→管"四个用户核心诉求,信息架构清晰合理。底部Tab使用 emoji 作为图标,保持了与整体设计风格的一致性。

4.2 SUB_NAV_LIST:首页七内容Tab的双排块状设计

SUB_NAV_LIST 定义了首页的7个内容分类Tab,采用双排块状式布局:

第一排(4个):精选、菌菇市集、我的菌房、培育工坊
第二排(3个):鉴别课堂、菌友圈、器具库

双排块状式布局是一种比较有特色的导航设计。与传统的横向滚动Tab不同,双排块状将所有选项一次性展示在用户面前,无需滑动即可看到全部入口,降低了发现成本。同时,块状按钮的形式比纯文字Tab更有点击感,视觉上也更加饱满。

第一排的4个Tab使用 primary(菌绿)作为选中态背景色,第二排的3个Tab使用 accent(橙棕)作为选中态背景色。这种分排用色的设计既增加了视觉层次,又巧妙地强化了双排的分组感——上排是"绿色系"的核心功能,下排是"橙色系"的延伸功能。

7个内容Tab的排列顺序也经过了精心考量:从精选推荐→市集交易→菌房管理→培育工具→课堂学习→社区互动→器具购物,遵循了用户从"浏览"到"消费"到"管理"到"学习"到"社交"再到"装备"的完整行为链路。


五、纯函数工具库:计算逻辑与特效算法的模块化

5.1 barH:柱状图高度计算函数

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

barH 函数用于计算周采收柱状图中每个柱子的高度。它接收当前值 v 和最大值 max,返回按比例计算的像素高度。基准高度为 118px,即当值等于最大值时,柱子高度为 118px。使用 Math.round 取整确保像素值为整数,避免出现模糊渲染。该函数在 pageFeatured 中的"本周采收量"柱状图模块被调用,配合 WEEK_CHART 数据与 fungiColor 颜色函数,实现了数据到视觉的完整映射。

5.2 fungiColor:热度值的颜色映射

function fungiColor(v: number): string {
  if (v > 8) return '#5E8C61';
  else if (v > 5) return '#C97B4A';
  return '#D9A05B';
}

fungiColor 函数根据数值大小返回不同的颜色,用于柱状图等场景的数据可视化。阈值分为三级:大于8为菌绿主色(高值),5-8之间为橙棕强调色(中值),5以下为金黄警告色(低值)。这种颜色分级让用户可以通过颜色快速判断数值的相对高低,增强了数据的可读性。函数名中的"fungi"(菌菇)呼应了主题,将颜色功能与菌菇生长状态相关联——高值如同茁壮成长的菌菇(绿色健康),低值则如待成熟的状态(金黄预警)。

5.3 sporeX/Y/A:孢子漂浮特效算法

孢子漂浮特效由三个函数协同实现,分别控制孢子的X坐标、Y坐标和透明度:

function sporeA(tick: number, i: number): number {
  return 0.12 + ((tick + i * 2) % 5) * 0.06;
}

function sporeX(tick: number, i: number): number {
  return 30 + ((tick * 5 + i * 229) % 610);
}

function sporeY(tick: number, i: number): number {
  return 40 + ((tick * 7 + i * 151) % 640);
}

这三个函数都接收两个参数:tick(时间帧计数器)和 i(孢子索引,0-5)。

sporeX 控制水平位移:基础偏移 30px,水平运动范围 610px。每个孢子有不同的水平速度(通过 i * 229 的质数偏移实现),tick * 5 决定了基础运动速率。模运算 % 610 使得孢子在水平方向上循环移动。

sporeY 控制垂直位移:基础偏移 40px,垂直运动范围 640px。垂直方向的运动速率为 tick * 7,比水平方向更快,模拟了孢子在空气中缓慢下沉的漂浮感。i * 151 的质数偏移确保每个孢子有不同的运动相位。

sporeA 控制透明度:基础透明度 0.12(很低,几乎不可见),然后以 5 帧为一个周期,在 0.12 到 0.36 之间呼吸变化。这种微妙的透明度变化,配合位置移动,产生了孢子闪烁漂移的视觉效果。

使用质数(229、151)作为偏移系数是一个精妙的设计——质数的数学特性确保了多个孢子之间不会出现周期性的位置重合,从而产生更自然、更随机的漂浮效果。

5.4 mistX/Y/A:菌雾光晕呼吸特效

菌雾光晕特效同样由三个函数驱动,与孢子特效形成双层视觉叠加:

function mistA(tick: number, i: number): number {
  return 0.08 + ((tick + i) % 6) * 0.03;
}

function mistX(tick: number, i: number): number {
  return 40 + ((tick * 3 + i * 197) % 540);
}

function mistY(tick: number, i: number): number {
  return 160 + ((tick * 2 + i * 231) % 460);
}

菌雾光晕的运动速度比孢子更慢(tick * 3 和 tick * 2,对比孢子的 tick * 5 和 tick * 7),营造出更加舒缓飘逸的氛围。透明度基础值为 0.08,变化范围为 0.08 到 0.23,比孢子更加朦胧。

菌雾使用的是较大的圆角矩形(110px × 34px,圆角 17px),呈现出椭圆形光晕的形态。它们使用 spore 孢子色填充,与背景的菌绿主题融为一体,仿佛是菌房中弥漫的雾气与孢子的结合体。

菌雾的Y轴基础偏移为 160px,运动范围 460px,主要集中在屏幕中下部区域,符合"雾气下沉"的物理直觉。

5.5 statusColor / statusProgress:培育状态的视觉映射

function statusColor(s: string): string {
  if (s === '已采收') return '#7BA05B';    // 成功绿
  else if (s === '出菇中') return '#5E8C61'; // 主色绿
  else if (s === '养菌期') return '#C97B4A'; // 橙棕
  else if (s === '拌料中') return '#D9A05B'; // 金黄
  return '#A8B2A0';                         // 默认灰绿
}

statusColor 将培育状态文本映射为对应的颜色值,形成了从"拌料中(金黄)→养菌期(橙棕)→出菇中(菌绿)→已采收(草绿)"的颜色渐变序列,这个序列恰好对应了菌菇从准备到成熟的生长过程色彩变化——从代表准备阶段的暖色调,逐渐过渡到代表成熟收获的绿色调。

function statusProgress(s: string): number {
  if (s === '已采收') return 100;
  else if (s === '出菇中') return 74;
  else if (s === '养菌期') return 52;
  else if (s === '拌料中') return 36;
  return 10;
}

statusProgress 则将状态映射为默认进度百分比,与 statusColor 配合使用。进度值的选取也经过了考量:已采收是完成态(100%),出菇中约四分之三(74%),养菌期约过半(52%),拌料中约三分之一(36%)。这些数值与 GROW_LIST 中的 progress 字段值大致对应,确保了状态标签与进度条的视觉一致性。

5.6 fungiTagColor:菌菇名称的颜色分类

function fungiTagColor(g: string): string {
  if (g.indexOf('平菇') >= 0 || g.indexOf('香菇') >= 0) return '#5E8C61';
  else if (g.indexOf('榆黄') >= 0 || g.indexOf('猴头') >= 0) return '#C97B4A';
  else if (g.indexOf('赤松茸') >= 0 || g.indexOf('羊肚') >= 0) return '#7BA05B';
  return '#D9A05B';
}

fungiTagColor 是一个基于名称关键字匹配的颜色分类函数。它根据菌菇名称中包含的关键字,将菌菇分为四个颜色组别:

  • 平菇/香菇组 → 菌绿主色 #5E8C61(最常见的家种菌菇)
  • 榆黄蘑/猴头菇组 → 橙棕强调色 #C97B4A(特色菌菇)
  • 赤松茸/羊肚菌组 → 草绿成功色 #7BA05B(高端珍稀菌菇)
  • 其他 → 金黄警告色 #D9A05B(默认分类)

这种基于关键字匹配的设计虽然简单,但足够灵活——新增菌菇品种时,只要名称中包含对应的关键字,就能自动归入正确的颜色分类。函数在市集页面的左侧色条、热度进度条等位置被调用,为每种菌菇赋予独特的色彩标识。

5.7 levelColor:等级标签的色彩体系

function levelColor(s: string): string {
  if (s === '家种') return '#7BA05B';
  else if (s === '野采') return '#C97B4A';
  else if (s === '稀有') return '#D9A05B';
  else if (s === '珍稀') return '#5E8C61';
  return '#C0604F';
}

levelColor 函数为菌菇的等级标签提供颜色映射。四个等级对应的颜色各有含义:

  • 家种 → 草绿色,代表安全、可靠、常见
  • 野采 → 橙棕色,代表自然、珍贵、特色
  • 稀有 → 金黄色,代表稀缺、珍贵、值得关注
  • 珍稀 → 深菌绿色,代表最高等级,与主色同色,彰显尊贵

这种颜色编码体系使得用户可以通过颜色快速判断菌菇的等级与价值,增强了信息的获取效率。


六、组件状态管理与生命周期

6.1 组件级状态变量全览

PageMushroomGrowing 组件共定义了 20 个 @State 状态变量,可分为以下几类:

导航状态

  • mainTab: number = 0:底部主Tab索引,0为首页,1为市集,2为课堂,3为我的
  • subTab: number = 0:首页子Tab索引,0-6对应7个内容分类

特效状态

  • tick: number = 0:特效动画帧计数器,每 90ms 自增1,驱动孢子与菌雾的位置更新
  • glow: number = 0:光晕呼吸状态,每帧在 0 和 1 之间切换(模2运算)
  • fxTimer: number = -1:特效定时器ID,用于在组件销毁时清除定时器

弹框状态

  • addOpen: boolean = false:新增(发菌语)弹框开关
  • editOpen: boolean = false:编辑(编辑培育单)弹框开关
  • delOpen: boolean = false:删除(取消订单/退课)弹框开关
  • pickOpen: boolean = false:预约(采摘场次)弹框开关

弹框数据

  • editIdx: number = 0:当前编辑的培育订单索引
  • delTarget: string = 'grow':删除操作目标类型('grow’培育订单 / 'course’课程)
  • editName: string = '':编辑弹框-培育名称输入值
  • editStyle: string = '':编辑弹框-状态输入值
  • editNote: string = '':编辑弹框-备注输入值
  • addTitle: string = '':新增弹框-标题输入值
  • addContent: string = '':新增弹框-内容输入值
  • pickBatch: string = '':预约弹框-场次输入值
  • pickDate: string = '':预约弹框-日期输入值

列表数据

  • postList: PostItem[] = POST_LIST:菌友动态列表(可变,支持新增/删除)
  • growList: GrowItem[] = GROW_LIST:培育订单列表(可变,支持编辑/删除)

使用 @State 装饰器的变量都会触发UI更新。当变量的值发生变化时,框架会自动重新渲染所有依赖该变量的UI节点。这种声明式的状态驱动模式,使得开发者无需手动操作DOM,只需关注数据状态的变化即可。

6.2 生命周期:特效定时器的管理

aboutToAppear 生命周期钩子在组件即将显示时被调用。菌语应用在此处启动特效定时器:

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

定时器每 90 毫秒触发一次,tick 值自增 1,glow 值在 0 和 1 之间循环切换。90ms 的间隔约等于每秒 11 帧,这个帧率对于简单的位移+透明度动画来说已经足够流畅,同时不会消耗过多的计算资源。

aboutToDisappear 生命周期钩子在组件即将销毁时被调用,用于清除定时器,防止内存泄漏:

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

这种"创建-销毁"对称的资源管理模式,是前端开发中的最佳实践之一。在 ArkUI 中,由于组件的生命周期由框架管理,开发者需要特别注意在适当的时机释放资源。

6.3 交互方法:增删改查的业务逻辑

组件中定义了 8 个交互方法,构成了完整的业务操作闭环:

打开弹框类方法

  • openAdd():打开发菌语弹框,先清空输入框内容再显示弹框
  • openEdit(index):打开编辑培育单弹框,根据索引加载待编辑的数据
  • openDelA():打开删除培育订单确认弹框,设置 delTarget 为 ‘grow’
  • openDelB():打开退课确认弹框,设置 delTarget 为 ‘course’
  • openPick():打开采摘预约弹框,清空输入内容

执行操作类方法

  • doAdd():发布菌语,将新动态插入到 postList 数组头部
  • doEdit():保存编辑,使用 splice 方法替换对应索引位置的培育订单
  • doDel():执行删除,根据 delTarget 删除不同列表的首条数据
  • doPick():提交预约,将预约信息转化为菌语动态发布到列表

这些方法都遵循"先修改数据,后关闭弹框"的操作流程。数据修改通过数组方法(unshift、splice)完成,由于 postList 和 growList 是 @State 装饰的数组,数组内容的变化会自动触发UI更新。

值得注意的是,doEdit 方法中使用了 splice 来替换数组元素,而不是直接修改对象属性。这是因为虽然 GrowItem 是 @Observed 类,但在实际开发中,通过 splice 替换整个对象可以确保更可靠的视图更新触发,同时也简化了状态管理逻辑。


七、fxLayer 特效层:孢子漂浮与菌雾光晕的双重视觉叠加

7.1 Stack 层叠布局与穿透点击

fxLayer 是整个应用视觉效果的点睛之笔。它使用 Stack 容器承载两种特效元素——菌雾光晕与孢子颗粒,并通过 .hitTestBehavior(HitTestMode.None) 实现了点击穿透。

hitTestBehavior 是 ArkUI 中一个非常重要的属性,它控制着组件的命中测试行为。默认情况下,所有可见组件都会参与命中测试——当用户点击屏幕时,最上层的可见组件会优先响应点击事件。但对于特效层这样的装饰性元素,我们希望它"看得见、摸不着",即视觉上可见但不拦截任何用户交互。

HitTestMode.None 就是为此而生的。设置该模式后,该组件及其所有子组件都不参与命中测试,点击事件会直接穿透到下方的内容层。这种设计使得全屏特效不会影响用户的正常操作,实现了视觉增强与功能完整的完美平衡。

7.2 菌雾光晕层:朦胧的氛围感

菌雾光晕由 3 个椭圆形光晕元素组成(ForEach 遍历 [0, 1, 2])。每个光晕是一个 110px × 34px 的圆角矩形(borderRadius: 17 即高度的一半,形成完全圆角),使用孢子色 #A3B18A 填充。

通过 mistX、mistY、mistA 三个函数的计算,每个光晕都有独立的运动轨迹与呼吸节奏。它们在屏幕中下部区域缓慢漂移,透明度在 0.08 到 0.23 之间微妙变化,营造出菌房中弥漫着薄雾的氛围。

7.3 孢子颗粒层:灵动的生命力

孢子颗粒由 6 个小圆点组成(ForEach 遍历 [0, 1, 2, 3, 4, 5])。每个孢子是一个 4px × 4px 的圆点(borderRadius: 2),使用强调色 #C97B4A(橙棕色)填充。

橙棕色的孢子在菌绿色的背景上形成了鲜明的色彩对比,成为视觉上的焦点。它们以比菌雾更快的速度在屏幕上漂移,透明度在 0.12 到 0.36 之间闪烁变化,仿佛无数菌菇孢子在空中翩翩起舞,充满了生命的律动。

7.4 特效性能考量

特效层的实现方案在视觉效果与性能之间取得了良好的平衡:

  1. 元素数量有限:菌雾3个 + 孢子6个,总共仅9个动画元素,对渲染管线的压力很小
  2. 仅使用 transform 与 opacity:动画仅通过 translate(位移)和 opacity(透明度)实现,这两个属性不触发布局重排(reflow),渲染效率极高
  3. 90ms 低帧率:每秒约 11 帧的更新频率,远低于 60fps 的满帧标准,CPU 占用极低
  4. 低透明度:所有特效元素的透明度都低于 0.4,即使叠加多层也不会造成过度绘制(overdraw)的性能问题

这种"轻量特效"的设计思路,在移动端应用中尤为重要——既增强了视觉体验,又不会牺牲流畅度与续航。


八、header 头部:信息密度与视觉层次的平衡

8.1 搜索栏与购物车

header 的最顶层是搜索栏与购物车图标组成的操作区。搜索栏采用圆角胶囊造型(高度 36px,圆角 18px),白色背景,内含放大镜图标与占位符文字"搜菌菇、菌包、器具…"。这种设计是移动应用中的标准模式,用户学习成本极低。

购物车图标使用 🛒 emoji,并在右上角叠加了一个红色圆形徽标(badge),显示数字"3"表示购物车中有 3 件商品。徽标通过 Stack 布局的 alignContent: Alignment.TopEnd 定位在右上角,再通过 translate 微调位置(x: 6, y: -4),实现了像素级的精确对齐。这种"Stack + translate"的微定位技巧,在 ArkUI 开发中非常实用。

8.2 菌房环境卡

菌房环境卡是头部最具特色的模块,它使用 primary 主色(菌绿 #5E8C61)作为背景,白色文字,营造出沉浸式的信息展示效果。

卡片内容分为三层:

  • 主标题:“榆黄蘑出菇房 · 湿度 88%”,加粗白色大号字体,传递最核心的环境信息
  • 副标题:“温度 19℃ · 通风中 · 每两小时喷雾一次”,小号浅色文字,补充环境细节
  • 状态行:包含"头茬进度"标签(孢子色背景,深绿文字)和"今晚可采 约 14 斤"提示文字

右侧的"预约采摘"按钮使用孢子色背景 + 深绿文字,与主色背景形成明暗对比,按钮圆角 14px,点击后触发 openPick() 方法打开预约弹框。

这张环境卡将菌房的实时状态以可视化的方式呈现给用户,是"城市菌菇房"概念的核心具象化表达。

8.3 采摘季横幅

采摘季横幅是一个运营推广模块,采用白色卡片背景,左侧 🧺 图标 + 双行文案,右侧"去逛逛"行动按钮。文案设计包含主标题(“雨后头茬上市 · 会员 9 折”)与副标题(“下单即送菌菇清洗刷 · 满 ¥88 包邮”),信息层次清晰。

"去逛逛"按钮使用 accent 强调色(橙棕 #C97B4A),在白色背景上非常醒目。点击后将 subTab 设置为 1,即跳转到"菌菇市集"分类页面。这种 Banner + 跳转的设计,是电商类应用促进转化的标准手段。

8.4 数据四格

数据四格模块以 2×2 的网格形式展示了四项核心数据指标:

  • 在售菌菇:36 kg(主色绿)
  • 菌包在育:128 包(强调橙)
  • 采收次数:64 次(成功绿)
  • 菌币:¥45(警告黄)

每个数据格都是一个独立的白色卡片,包含标签文字(小号浅灰)和数值文字(大号彩色)。四个数值分别使用不同的功能色(primary、accent、success、warning),既增强了视觉丰富度,又通过颜色编码传递了数据的类别信息。

数据四格以高度浓缩的方式呈现了用户最关心的核心指标,起到了"数据仪表盘"的作用。


九、subNav 子导航:双排块状式的信息架构

9.1 双排布局的实现逻辑

subNav 子导航采用了独特的双排块状式布局,7个Tab分为上下两排:上排4个,下排3个。实现方式是通过 SUB_NAV_LIST.slice(0, 4) 获取前4个元素渲染第一排,通过 SUB_NAV_LIST.slice(4, 7) 获取后3个元素渲染第二排。

每排内部使用 Row + ForEach 的组合,每个 Tab 都是一个 Text 组件,通过 layoutWeight(1) 实现等分宽度。Tab 的高度为 18px(上下 padding 各 9px),圆角 10px,带有 1px 的边框。

9.2 选中态的差异化设计

选中态的设计有两个值得关注的细节:

颜色差异:第一排(索引 0-3)的选中态使用 primary 主色(菌绿),第二排(索引 4-6)的选中态使用 accent 强调色(橙棕)。这种分排变色的设计,不仅增加了视觉层次,更暗示了两排Tab之间的功能分组——上排是核心功能(精选/市集/菌房/工坊),下排是辅助功能(课堂/社区/器具)。

文字与背景的反色:选中态的文字为白色(COLORS.white),字重加粗;未选中态的文字为次级灰(COLORS.textSecondary),常规字重。这种文字颜色与背景色的同步反转,确保了选中态的最高对比度与最佳可读性。

9.3 索引映射的巧妙处理

第二排Tab的索引映射需要特别注意。由于第二排使用 SUB_NAV_LIST.slice(4, 7),在 ForEach 回调中 idx 从 0 开始,但实际对应的 subTab 值应该是 idx + 4。代码中通过 this.subTab === idx + 4 的判断和 this.subTab = idx + 4 的赋值来处理这个偏移。

这种基于 slice + 索引偏移的多排布局方案,虽然实现起来略微繁琐,但逻辑清晰、性能优良,是 ArkUI 中实现不规则网格布局的常用技巧。


十、页面 Builder:七大内容模块的组件化实现

10.1 pageFeatured:精选首页的信息聚合

pageFeatured 是首页"精选"Tab 的内容构建器,包含了最丰富的功能模块,从上到下依次为:玩法四格、本周采收柱状图、采收构成条形图、人气菌菇双列、菌友动态。

玩法四格:以 4 列等分布局展示"在菌语能玩什么",每个玩法包含图标(24px emoji)、名称、描述、热门标签四个信息层级。热门玩法显示红色"热门"标签,非热门则显示空白占位(保持布局对齐)。

本周采收柱状图:自定义实现的柱状图,不依赖任何图表库。通过 Row + ForEach 渲染 7 根柱子,每根柱子是一个 Column 组件,高度由 barH 函数动态计算,颜色由 fungiColor 函数动态决定。柱子下方显示数值与星期标签。底部还有一行"周末采收占比 67%"的汇总信息。这种纯声明式的图表实现方式,轻量且灵活。

采收构成条形图:水平条形图展示品类构成占比。使用 Stack 叠放两层 Row 实现——底层为灰色背景条(100% 宽度),上层为彩色进度条(百分比宽度)。每条数据包含品类名称、进度条、百分比数值三部分,布局清晰直观。

人气菌菇双列:两列瀑布流式的菌菇卡片展示。通过 ForEach + idx % 2 === 0 的条件判断实现双列布局——偶数索引时渲染一行,该行包含 idx 和 idx+1 两个卡片。每张卡片包含菌菇图标、等级标签、名称、价格与关注度信息。双列布局在有限的屏幕空间内展示了更多商品,提升了信息密度。

菌友动态:展示最新的 3 条菌友动态(postList.slice(0, 3))。每条动态采用左侧头像 + 右侧内容的经典布局,内容区包含昵称+时间行、正文文字、点赞数三行。右上角的"发菌语"按钮点击后打开发布弹框。

10.2 pageMarket:菌菇市集的列表展示

pageMarket 是市集页面的构建器,展示完整的菌菇商品列表。每个商品卡片包含以下信息层级:

  1. 左侧色条:4px 宽的彩色竖条,颜色由 fungiTagColor 根据菌菇名称决定,形成视觉索引
  2. 名称行:菌菇名称(加粗)+ 等级标签(右侧)
  3. 信息行:价格 + 工艺/来源,用 emoji 图标前缀
  4. 热度进度条:Stack 叠放的双层进度条,以热度值作为百分比
  5. 操作行:左侧关注度数值,右侧"加入购物车"按钮

列表项的设计遵循"左信息、右操作"的经典电商列表范式,用户可以快速浏览商品信息并执行加购操作。

10.3 pageCrafts:我的菌房的进度可视化

pageCrafts 展示用户的培育订单列表,核心是每个订单的状态与进度可视化。

每张培育卡片包含:

  • 头部行:菌菇图标 + 名称(加粗)+ 状态标签(右侧,彩色背景)
  • 进度条:Stack 叠放的进度条,颜色由 statusColor 决定,进度百分比由 statusProgress 决定
  • 底部行:左侧进度百分比文字,右侧"编辑培育单"操作链接

状态标签使用 accentLight 浅色背景 + statusColor 彩色文字的组合,既保持了视觉的柔和感,又通过颜色传递了状态信息。

10.4 pageWorkshop:培育工坊的工具聚合

pageWorkshop 是培育工坊页面,整合了菌房状态、菌菇排行和器具速购三个模块:

菌房状态四格:一行四列的数据卡片,展示当前菌房、培养基含水、工序、今日巡检四项指标,每一项使用不同的功能色强调数值。

菌菇排行榜:展示关注度前 5 的菌菇,带有序号(前3名主色绿,后2名灰色)、名称工艺信息、关注度数值(fungiTagColor 着色)。排行榜的设计激发了用户的竞争心理与探索欲。

器具速购双列:与"人气菌菇双列"类似的双列布局,展示 8 种培育器具,每种器具包含图标、名称、描述和"加购"链接。

10.5 pageCourse:鉴别课堂的学习路径

pageCourse 展示鉴别课堂的课程列表与学习进度。每门课程卡片包含:

  • 标题行:课程名称(加粗)+ 学习周次(右侧,灰色小字)
  • 进度条:彩色进度条,每门课程有独立的主题色
  • 底部行:"已学"标签 + 进度百分比

课程列表下方有一个醒目的"退课"按钮,使用 danger 危险色(红棕 #C0604F),点击触发 openDelB() 打开退课确认弹框。危险色的使用向用户传递了"此操作不可逆"的视觉警示。

10.6 pageCircle:菌友圈的社区互动

pageCircle 是完整的菌友动态列表,与精选页中"菌友动态"模块的区别在于:

  • 展示全部动态(而非仅 3 条)
  • 每条动态的文字更大(13px vs 12px),点赞行增加了"💬 回复"入口
  • 头像更大(26px vs 22px)

页面顶部右侧有"发菌语"按钮,点击后打开发布弹框。菌友圈是社区互动的核心阵地,完整的动态流为用户提供了沉浸式的内容消费体验。

10.7 pageGear:器具库的分类与列表

pageGear 展示器具库页面,上方为三大功能分区(接种区、加湿区、采收区),以三列等宽的卡片形式展示,每个分区包含图标(28px)、分区名称和功能描述。

下方为完整的器具列表,采用水平排列的列表项样式:左侧图标 + 中间名称描述 + 右侧"加购"按钮。列表底部有一个"取消培育订单"的危险操作按钮,与课程页的"退课"按钮样式一致。

10.8 pageMine:个人中心的档案聚合

pageMine 是个人中心页面,展示用户的综合信息:

用户信息卡:大头像(40px)+ 昵称(“菌语培育人”,18px 加粗)+ 用户等级与统计数据(黄金会员、采收次数、在育菌包、打卡天数)。这张卡片是用户身份的核心展示。

菌房档案:展示培育订单数量与前 3 条订单的简要信息(名称 + 状态)。状态文字使用 statusColor 着色,保持视觉一致性。

菌语档案:展示菌语动态数量与前 3 条动态的简要信息(昵称 + 时间)。

个人中心采用了"信息聚合 + 快捷入口"的设计思路,让用户在一个页面内快速了解自己的核心数据概览。


十一、弹框组件:四种交互模态的统一范式

11.1 modalOverlay:遮罩层的统一实现

modalOverlay 是所有弹框共用的半透明遮罩层。它使用 Stack + Column 实现全屏黑色遮罩,透明度为 0.6。点击遮罩层会关闭所有弹框(将四个弹框开关全部设为 false)。

遮罩层的实现方式值得注意:它是一个全屏的 Column 组件,设置了黑色背景与 0.6 的透明度,而非使用传统的 opacity 属性对整个容器做透明处理。这种方式的优势在于遮罩层的透明度不会影响其内部的内容(因为内容并不在遮罩层内部),从而确保弹框内容保持完全不透明的清晰显示。

11.2 addModalBody:发布菌语弹框

“发布菌语"弹框包含标题、单行输入框(标题)、多行输入框(内容)、取消/发布双按钮。标题输入框的 placeholder 为"一句话菌房日常…”,引导用户发布简短动态。多行文本框的 placeholder 为"补充细节:菌菇、温湿度、采收心得…",引导用户补充更多专业内容。

底部按钮采用等宽双按钮布局:左侧"取消"为浅色背景灰色文字,右侧"发布"为主色背景白色文字。按钮使用 Button 组件 + Text 叠加的方式实现,通过 margin 负值调整文字位置,使其精确居中于按钮之上。

11.3 editModalBody:编辑培育单弹框

“编辑培育单"弹框包含三个输入字段:培育名称(单行)、状态(单行,如"出菇中”)、培育内容与备注(多行)。

打开弹框时(openEdit 方法),会根据索引从 growList 中加载对应培育订单的当前数据,填充到输入框中。用户修改后点击"保存",通过 doEdit 方法将修改后的数据写回数组。

这种"读取-修改-写回"的编辑模式,是 CRUD 应用中最经典的交互模式之一。

11.4 delModalBody:删除确认弹框

删除确认弹框是一个通用的二次确认组件,通过 delTarget 状态变量区分两种场景:

  • delTarget === ‘grow’:取消培育订单,提示文案为"将取消首笔培育订单并按工序退费,确认执行?"
  • delTarget === ‘course’:退出鉴别课堂,提示文案为"将退出首门进行中的课程,确认执行?"

确认按钮使用 danger 危险色(红棕 #C0604F),强化了"危险操作"的视觉警示。取消按钮为浅色背景。这种双按钮的确认弹框,可以有效防止用户的误操作。

11.5 pickModalBody:采摘预约弹框

“预约采摘场次"弹框包含两个输入字段:采摘场次和到访日期。下方有一行提示文字"现采现称 · 场内品尝 · 每场限 20 人”,传达采摘活动的特色与限制。

提交按钮使用 accent 强调色(橙棕 #C97B4A),与采摘活动的"收获"主题相契合。提交后(doPick 方法),预约信息会被格式化为一条菌语动态,发布到菌友圈中,形成社交化的预约记录。

11.6 弹框的架构模式

四种弹框遵循统一的架构模式:

  1. 每个弹框有独立的开关状态变量(addOpen/editOpen/delOpen/pickOpen)
  2. 每个弹框有独立的 open 方法用于打开并初始化数据
  3. 每个弹框有独立的 do 方法用于执行业务逻辑
  4. 每个弹框有独立的 body Builder 用于构建内容
  5. 在 build() 中通过 if 条件判断是否渲染弹框
  6. 弹框统一使用 Stack + modalOverlay + 内容 Column 的三层结构

这种统一的弹框架构模式,保证了代码的一致性与可维护性。新增弹框类型时,只需按照相同的模式添加状态、方法和 Builder 即可。


十二、bottomBar 与 mainContent:导航与内容的组织枢纽

12.1 bottomBar:底部导航栏

bottomBar 底部导航栏采用 Row + ForEach 的布局,四个 Tab 等分宽度(layoutWeight: 1)。每个 Tab 由图标(emoji,20px)和标签文字(10px)垂直排列组成。

选中态的视觉反馈包含两个维度:

  • 图标:选中态不透明度为 1,未选中为 0.55
  • 文字:选中态为主色 + 加粗,未选中为提示灰 + 常规字重

导航栏高度为 56px,白色背景,顶部左右各 16px 圆角。顶部圆角的设计让导航栏看起来像一个"浮"在内容之上的托盘,增加了界面的精致感。

12.2 mainContent:主内容区的路由分发

mainContent 是应用内容的组织枢纽,它根据 mainTab 的值渲染不同的页面内容。整体结构为垂直布局:顶部 header + 内容区 + 底部导航栏(导航栏在 bottomBar 中,不在 mainContent 内)。

首页(mainTab === 0):内容最为复杂,包含 subNav 子导航和可滚动的内容区。内容区根据 subTab 的值切换 7 个不同的页面 Builder。内容区使用 Scroll 组件包裹,支持垂直滚动,隐藏滚动条,左右 padding 14px,底部 padding 20px。

市集(mainTab === 1):直接渲染 pageMarket 页面,同样使用 Scroll 包裹。

课堂(mainTab === 2):直接渲染 pageCourse 页面。

我的(mainTab === 3):直接渲染 pageMine 页面。

mainContent 的设计体现了"条件渲染 + 组件组合"的声明式UI思想——通过 if/else 条件语句,根据状态变量的值选择渲染不同的组件组合,整个过程无需手动操作 DOM,完全由框架驱动。

值得注意的是,每个分支中的 Scroll 组件都设置了 .layoutWeight(1),这使得滚动区域会自动填充 header 与 bottomBar 之间的剩余空间,确保在不同屏幕尺寸下都能正确布局。


十三、build() 方法:Stack 多层叠加的页面骨架

13.1 四层Stack架构

build() 方法是整个组件的渲染入口,它使用 Stack 容器构建了四层叠加的页面结构:

第一层(最底层):背景层
一个全屏的 Column 组件,设置 bg 背景色(#F1F4EC)。这是整个应用的底色基底。

第二层:特效层
调用 fxLayer() Builder,渲染孢子漂浮与菌雾光晕特效。通过 hitTestBehavior(HitTestMode.None) 确保点击穿透。

第三层:内容层
Column 垂直布局,包含 mainContent(主内容区)和 bottomBar(底部导航栏)。这是用户交互的主要区域。

第四层(最上层):弹框层
四个条件渲染的弹框(addOpen/editOpen/delOpen/pickOpen),每个弹框都是一个全屏 Stack,包含半透明遮罩和居中的弹框内容。弹框内容宽度为 88%,最大高度为 80%,圆角 16px,zIndex 设为 999 确保显示在最上层。

13.2 弹框的条件渲染机制

四个弹框分别通过四个独立的 if 语句控制显示与隐藏。当对应的开关变量为 true 时,弹框被渲染到 Stack 中;为 false 时,弹框不被渲染。

这种"条件渲染"的方式与传统命令式框架中"显示/隐藏"的方式有本质区别:在声明式UI中,弹框不是"被隐藏了",而是"根本不存在于组件树中"。当状态改变时,框架会自动在组件树中添加或移除弹框节点。

每个弹框内部使用 Stack 布局,modalOverlay(遮罩层)占满全屏,弹框内容 Column 居中显示。由于 Stack 的子组件默认居中对齐,弹框内容会自动显示在屏幕中央。

13.3 架构设计的分层思想

整个 build() 方法的四层架构,体现了清晰的分层设计思想:

  1. 背景层:负责最基础的视觉底色
  2. 特效层:负责视觉增强,不参与交互
  3. 内容层:负责核心功能与用户交互
  4. 弹框层:负责模态交互,浮于所有内容之上

每一层都有明确的职责边界,层与层之间通过 Stack 的层叠特性自然组合。这种分层架构不仅代码结构清晰,也便于后续的功能扩展——新增底部浮层、增加全局通知等功能,都可以在对应的层级中添加,而不影响其他层级的代码。


十四、应用架构流程图

以下是菌语应用的整体架构流程图,展示了从数据层到视图层的完整链路:

渲染错误: Mermaid 渲染失败: Parse error on line 15: ...odel Layer] PI[@Observed PostIte ----------------------^ Expecting 'SEMI', 'NEWLINE', 'SPACE', 'EOF', 'subgraph', 'end', 'acc_title', 'acc_descr', 'acc_descr_multiline_value', 'AMP', 'COLON', 'STYLE', 'LINKSTYLE', 'CLASSDEF', 'CLASS', 'CLICK', 'DOWN', 'DEFAULT', 'NUM', 'COMMA', 'NODE_STRING', 'BRKT', 'MINUS', 'MULT', 'UNICODE_TEXT', 'direction_tb', 'direction_bt', 'direction_rl', 'direction_lr', 'direction_td', got 'LINK_ID'

十五、数据模型对比表

以下是四个核心数据模型的字段级对比,帮助理解各模型之间的差异与联系:

对比维度 PostItem 菌友动态 FungiItem 菌菇市集 GrowItem 培育订单 GearItem 器具
核心身份 社区内容单元 交易商品单元 生产订单单元 工具物品单元
唯一标识 id (number) id (number) id (number) id (number)
名称字段 nick(昵称) name(菌菇名) name(项目名) name(器具名)
图像字段 avatar(emoji头像) 无独立字段(用emoji表示) 无独立字段(用emoji表示) icon(emoji图标)
描述字段 text(动态正文) craft(工艺/来源) item(培育内容) desc(功能描述)
状态字段 time(发布时间) level(等级标签) status(培育状态)
数值字段 likes(点赞数) hot(热度值) + price(价格) progress(进度值)
状态映射 levelColor() statusColor() + statusProgress()
颜色关联 无(列表统一色) fungiTagColor()(名称匹配) statusColor()(状态匹配)
可否编辑 可新增(unshift) 只读展示 可编辑(splice替换) 只读展示
可否删除 可删除(splice) 只读展示 可删除(splice) 只读展示
出现页面 精选页、菌友圈、我的页 精选页、市集页、工坊页 菌房页、我的页 工坊页、器具库
列表数量 8条预置 + 用户新增 8条 8条 8条
装饰器 @Observed @Observed @Observed @Observed

十六、结尾总结:声明式UI范式下的垂直应用范本

16.1 技术架构的整体性回顾

菌语应用是 HarmonyOS ArkTS 声明式UI开发范式的一次完整实践。从数据模型到视图渲染,从状态管理到交互动效,整个应用构建在一套统一的技术体系之上,展现了声明式UI开发的核心优势——描述而非操作,数据驱动视图,组件化复用,以及分层架构的清晰性。

在数据层面,应用通过 @Observed 装饰器构建了四个核心业务模型,并通过静态数据数组提供了丰富的预置内容。ColorPalette 接口定义了完整的设计令牌体系,将菌绿+橙棕的主题色系统一管理、集中配置。纯函数工具库则将颜色映射、进度计算、特效算法等逻辑从UI代码中抽离,提升了代码的可测试性与可维护性。

在视图层面,应用以 Stack/Column/Row 三大基础容器为骨架,通过 @Builder 装饰器将UI片段模块化封装,实现了高度的代码复用。从 fxLayer 特效层到 header 头部,从 subNav 子导航到七个页面 Builder,从四个弹框到 bottomBar 底部栏,每个模块都有明确的职责边界,组合灵活而有序。

在状态层面,20个 @State 变量构成了完整的状态机,涵盖导航状态、特效状态、弹框状态、输入状态和列表数据。状态的变化会自动触发UI的增量更新,开发者无需关心"如何更新",只需关心"何时变化"。这种心智模型的转变,是声明式UI带来的最大价值之一。

16.2 设计系统的品牌一致性

菌语应用在设计系统层面的表现同样可圈可点。十六色的 ColorPalette 调色板不仅覆盖了主色、辅助色、背景色、文本色、功能色等常规类别,更创造性地引入了 spore 孢子色这一主题特色色。孢子色的存在,让整个应用的视觉语言多了一层"菌菇主题"的隐喻与韵味,它出现在特效层、标签背景、装饰元素等各个角落,潜移默化地强化着品牌认知。

双排块状式的子导航设计、emoji 图标的大量使用、菌菇主题的文案风格、孢子+菌雾的双特效体系——这些设计元素共同构建了一个完整而统一的菌菇文化视觉体系。用户在使用应用的过程中,不仅能获得功能上的满足,更能感受到一种沉浸式的主题体验。

16.3 特效设计的性能智慧

孢子漂浮与菌雾光晕特效是应用的视觉亮点,而其背后的性能考量更值得深思。在移动端应用中,动效设计往往面临"效果与性能不可兼得"的困境。菌语应用通过以下几个策略巧妙地平衡了二者:

一是轻量元素——特效层总共只有9个元素,对渲染管线的压力微乎其微。二是属性优选——仅使用 translate 和 opacity 这两个高性能动画属性,完全避免触发布局重排。三是降频运行——90ms的更新间隔(约11fps)远低于满帧标准,CPU占用极低。四是穿透点击——hitTestBehavior(HitTestMode.None) 确保特效层不影响用户交互,实现了"纯装饰"的定位。

这种"做减法"的特效设计思路,在移动应用开发中具有普遍的参考价值。好的动效不是越复杂越好,而是恰到好处——既增强了视觉体验,又不牺牲性能与可用性。

16.4 对垂直领域应用的启示

菌语应用作为一款垂直领域的菌菇培育社区产品,其技术实现方案对其他垂直领域应用也具有借鉴意义:

第一,主题化设计增强品牌记忆。 通过色彩、图标、文案、动效等多维度的主题化设计,可以让应用在用户心中建立独特的品牌形象,提升用户粘性与情感连接。

第二,社区+交易+管理的三合一模式。 将内容社区、电商交易、生产管理三者融合在一个应用中,形成"内容引流→交易变现→管理留存"的完整闭环,是垂直领域应用的有效商业模式。

第三,声明式UI加速垂直应用开发。 ArkUI 的声明式开发范式使得开发者可以快速构建复杂的界面与交互,大幅缩短开发周期。对于资源有限的垂直领域创业团队而言,这种高效的开发工具尤为重要。

第四,数据可视化增强专业感。 通过柱状图、条形图、进度条等数据可视化手段,将培育进度、采收数据、学习进度等信息直观呈现,既提升了用户体验,又增强了应用的专业感。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// 场景:菌菇市集 / 菌房培育 / 采摘预约 / 鉴别课堂社区
// 底部4主tab(首页/市集/课堂/我的)+ 首页7内容tab(双排块状式)
// 特效:孢子漂浮 + 菌雾光晕呼吸(特效层位于底层,不遮挡点击)
// 弹框:新增(发菌语) / 编辑(编辑培育单) / 删除(取消培育订单·退课) / 预约(采摘场次预约)

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

const COLORS: ColorPalette = {
  primary: '#5E8C61',
  primaryLight: '#E4EEDD',
  primaryDark: '#43683F',
  accent: '#C97B4A',
  accentLight: '#F6E8DD',
  bg: '#F1F4EC',
  cardBg: '#FFFFFF',
  textPrimary: '#33402F',
  textSecondary: '#64715E',
  textHint: '#A8B2A0',
  border: '#E3E9DB',
  success: '#7BA05B',
  warning: '#D9A05B',
  danger: '#C0604F',
  white: '#FFFFFF',
  spore: '#A3B18A'
};

// ============ 菌友动态数据模型 ============
@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
  }
}

// ============ 菌菇市集数据模型 ============
@Observed
export class FungiItem {
  id: number = 0
  name: string = ''
  craft: string = ''
  price: string = ''
  level: string = ''
  hot: number = 0
  constructor(id: number, name: string, craft: string, price: string, level: string, hot: number) {
    this.id = id; this.name = name; this.craft = craft; this.price = price
    this.level = level; this.hot = hot
  }
}

// ============ 培育订单数据模型 ============
@Observed
export class GrowItem {
  id: number = 0
  name: string = ''
  item: string = ''
  status: string = ''
  progress: number = 0
  constructor(id: number, name: string, item: string, status: string, progress: number) {
    this.id = id; this.name = name; this.item = item; this.status = status
    this.progress = progress
  }
}

// ============ 器具数据模型 ============
@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 WeekChartItem {
  label: string;
  value: number;
}

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

interface CourseItem {
  title: string;
  week: string;
  progress: number;
  color: string;
}

interface PlayTypeItem {
  name: string;
  desc: string;
  icon: string;
  hot: boolean;
}

// ============ 顶层静态数据 ============
const POST_LIST: PostItem[] = [
  new PostItem(1, '菌房值班员', '🍄', '榆黄蘑今晚又冒一茬,金黄一片。', '6分钟前', 122),
  new PostItem(2, '野生菌猎人', '⛰️', '雨后 48 小时是羊肚菌窗口,别错过。', '24分钟前', 105),
  new PostItem(3, '培养基调配师', '🧫', '麦粒培养基灭菌要足压,杂菌零容忍。', '39分钟前', 91),
  new PostItem(4, '菌语主理人', '🧺', '周六采摘场加开一场,还剩 8 个位。', '1小时前', 84),
  new PostItem(5, '猴头培育者', '🪵', '椴木猴头转色成功,就等开口。', '2小时前', 76),
  new PostItem(6, '新手小白菇', '🌱', '第一包平菇出菇了,激动到拍九张。', '3小时前', 68),
  new PostItem(7, '鉴别课代表', '🔍', '见手青务必炒透,别拿舌头赌。', '5小时前', 61),
  new PostItem(8, '赤松茸农户', '🌾', '稻草覆土栽培省地又高产,推荐。', '昨天', 54)
];

const FUNGI_LIST: FungiItem[] = [
  new FungiItem(1, '榆黄蘑 · 头茬', '菌房现采 · 当日达', '¥18/斤', '家种', 95),
  new FungiItem(2, '羊肚菌 · 干品', '云贵山地 · 烘干', '¥268/斤', '珍稀', 92),
  new FungiItem(3, '猴头菇 · 白毛尖', '椴木栽培 · 鲜品', '¥36/斤', '家种', 86),
  new FungiItem(4, '赤松茸 · 大球盖', '稻草覆土 · 鲜品', '¥22/斤', '家种', 81),
  new FungiItem(5, '鸡油菌 · 野采', '松林混交 · 手挑', '¥58/斤', '野采', 77),
  new FungiItem(6, '香菇 · 808 菌株', '菌棒架栽 · 花菇率 40%', '¥15/斤', '家种', 70),
  new FungiItem(7, '平菇 · 黑鲍', '墙式栽培 · 现摘', '¥8/斤', '家种', 63),
  new FungiItem(8, '牛肝菌 · 冻干', '雨季限时 · 锁鲜', '¥148/盒', '野采', 58)
];

const GROW_LIST: GrowItem[] = [
  new GrowItem(1, '榆黄蘑 · 三号菌房', '640 菌包 · 头茬中', '已采收', 100),
  new GrowItem(2, '香菇 808 · 菌棒架', '200 棒 · 转色完成', '出菇中', 74),
  new GrowItem(3, '猴头菇 · 椴木区', '30 根 · 开口催蕾', '养菌期', 52),
  new GrowItem(4, '平菇 · 墙式二区', '150 包 · 搅拌装袋', '拌料中', 36),
  new GrowItem(5, '赤松茸 · 露天畦', '40 ㎡ · 覆土完毕', '养菌期', 44),
  new GrowItem(6, '羊肚菌 · 试验田', '20 ㎡ · 外援营养袋', '待接种', 12),
  new GrowItem(7, '灵芝 · 观赏盆', '12 盆 · 芝盖成形', '出菇中', 66),
  new GrowItem(8, '木耳 · 吊袋区', '300 袋 · 待刺孔', '拌料中', 20)
];

const GEAR_LIST: GearItem[] = [
  new GearItem(1, '菌种 · 母种试管', 'PDA 培养基', '🧫'),
  new GearItem(2, '接种箱 · 双人', '紫外灭菌', '📦'),
  new GearItem(3, '加湿器 · 超声波', '湿度恒定', '💧'),
  new GearItem(4, '温湿度计 · 双显', '菌房必备', '🌡️'),
  new GearItem(5, '喷雾壶 · 两升', '水帘补水', '🚿'),
  new GearItem(6, '采收刀 · 弯头', '不伤菌柄', '🔪'),
  new GearItem(7, '灭菌锅 · 高压', '麦粒培养基', '🔥'),
  new GearItem(8, '菌包袋 · 聚丙烯', '耐高压透汽', '🛍️')
];

const WEEK_CHART: WeekChartItem[] = [
  { label: '周一', value: 2 },
  { label: '周二', value: 3 },
  { label: '周三', value: 2 },
  { label: '周四', value: 4 },
  { label: '周五', value: 6 },
  { label: '周六', value: 12 },
  { label: '周日', value: 9 }
];

const FUNGI_CHART: FungiChartItem[] = [
  { label: '平菇', value: 30, color: '#5E8C61' },
  { label: '香菇', value: 26, color: '#C97B4A' },
  { label: '榆黄蘑', value: 18, color: '#D9A05B' },
  { label: '猴头菇', value: 14, color: '#A3B18A' },
  { label: '赤松茸', value: 12, color: '#7BA05B' }
];

const TYPE_LIST: PlayTypeItem[] = [
  { name: '采摘活动', desc: '菌房现采 · 当场称重', icon: '🧺', hot: true },
  { name: '培育工坊', desc: '装袋 · 接种 · 养菌', icon: '🧫', hot: true },
  { name: '菌菇市集', desc: '家种野采 · 冷链', icon: '🍄', hot: false },
  { name: '鉴别课堂', desc: '毒菌图鉴 · 实物', icon: '🔍', hot: false }
];

const COURSE_LIST: CourseItem[] = [
  { title: '菌种分离与接种', week: '第 1 周', progress: 100, color: '#5E8C61' },
  { title: '培养基配制与灭菌', week: '第 2 周', progress: 82, color: '#C97B4A' },
  { title: '出菇管理温湿度', week: '第 3 周', progress: 57, color: '#D9A05B' },
  { title: '杂菌污染防治', week: '第 4 周', progress: 29, color: '#A3B18A' },
  { title: '野生菌鉴别与安全', week: '第 5 周', progress: 6, color: '#C0604F' }
];

// ============ 导航配置 ============
interface NavItem {
  icon: string;
  label: string;
}

const NAV_LIST: NavItem[] = [
  { icon: '🍄', label: '首页' },
  { icon: '🛒', label: '市集' },
  { icon: '📚', label: '课堂' },
  { icon: '👤', label: '我的' }
];

const SUB_NAV_LIST: string[] = ['精选', '菌菇市集', '我的菌房', '培育工坊', '鉴别课堂', '菌友圈', '器具库'];

// ============ 顶层纯函数 ============
function barH(v: number, max: number): number {
  return Math.round(118 * v / max);
}

function fungiColor(v: number): string {
  if (v > 8) {
    return '#5E8C61';
  } else if (v > 5) {
    return '#C97B4A';
  }
  return '#D9A05B';
}

function sporeA(tick: number, i: number): number {
  return 0.12 + ((tick + i * 2) % 5) * 0.06;
}

function sporeX(tick: number, i: number): number {
  return 30 + ((tick * 5 + i * 229) % 610);
}

function sporeY(tick: number, i: number): number {
  return 40 + ((tick * 7 + i * 151) % 640);
}

function mistA(tick: number, i: number): number {
  return 0.08 + ((tick + i) % 6) * 0.03;
}

function mistX(tick: number, i: number): number {
  return 40 + ((tick * 3 + i * 197) % 540);
}

function mistY(tick: number, i: number): number {
  return 160 + ((tick * 2 + i * 231) % 460);
}

function statusColor(s: string): string {
  if (s === '已采收') {
    return '#7BA05B';
  } else if (s === '出菇中') {
    return '#5E8C61';
  } else if (s === '养菌期') {
    return '#C97B4A';
  } else if (s === '拌料中') {
    return '#D9A05B';
  }
  return '#A8B2A0';
}

function statusProgress(s: string): number {
  if (s === '已采收') {
    return 100;
  } else if (s === '出菇中') {
    return 74;
  } else if (s === '养菌期') {
    return 52;
  } else if (s === '拌料中') {
    return 36;
  }
  return 10;
}

function fungiTagColor(g: string): string {
  if (g.indexOf('平菇') >= 0 || g.indexOf('香菇') >= 0) {
    return '#5E8C61';
  } else if (g.indexOf('榆黄') >= 0 || g.indexOf('猴头') >= 0) {
    return '#C97B4A';
  } else if (g.indexOf('赤松茸') >= 0 || g.indexOf('羊肚') >= 0) {
    return '#7BA05B';
  }
  return '#D9A05B';
}

function levelColor(s: string): string {
  if (s === '家种') {
    return '#7BA05B';
  } else if (s === '野采') {
    return '#C97B4A';
  } else if (s === '稀有') {
    return '#D9A05B';
  } else if (s === '珍稀') {
    return '#5E8C61';
  }
  return '#C0604F';
}

@Entry
@Component
struct PageMushroomGrowing {
  @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 pickOpen: boolean = false
  @State editIdx: number = 0
  @State delTarget: string = 'grow'
  @State editName: string = ''
  @State editStyle: string = ''
  @State editNote: string = ''
  @State addTitle: string = ''
  @State addContent: string = ''
  @State pickBatch: string = ''
  @State pickDate: string = ''
  @State postList: PostItem[] = POST_LIST
  @State growList: GrowItem[] = GROW_LIST
  @State fxTimer: number = -1

  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;
    }
  }

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

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

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

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

  openPick(): void {
    this.pickBatch = '';
    this.pickDate = '';
    this.pickOpen = true;
  }

  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.growList.length) {
      this.growList.splice(this.editIdx, 1, new GrowItem(this.growList[this.editIdx].id, this.editName, this.editNote, this.editStyle, 40));
    }
    this.editOpen = false;
  }

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

  doPick(): void {
    if (this.pickBatch.length > 0) {
      this.postList.unshift(new PostItem(998, '我的菌语', '🧺', `已预约采摘:${this.pickBatch} ${this.pickDate}`, '刚刚', 0));
    }
    this.pickOpen = false;
  }

  @Builder
  fxLayer() {
    Stack({ alignContent: Alignment.TopStart }) {
      ForEach([0, 1, 2], (i: number) => {
        Column()
          .width(110)
          .height(34)
          .borderRadius(17)
          .backgroundColor(COLORS.spore)
          .opacity(mistA(this.tick, i))
          .translate({ x: mistX(this.tick, i), y: mistY(this.tick, i) })
      }, (i: number) => i.toString())
      ForEach([0, 1, 2, 3, 4, 5], (i: number) => {
        Column()
          .width(4)
          .height(4)
          .borderRadius(2)
          .backgroundColor(COLORS.accent)
          .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)
  }

  @Builder
  header() {
    Column({ space: 10 }) {
      // 搜索条 + 购物车
      Row({ space: 10 }) {
        Row({ space: 6 }) {
          Text('🔍')
            .fontSize(14)
          Text('搜菌菇、菌包、器具…')
            .fontSize(12)
            .fontColor(COLORS.textHint)
        }
        .layoutWeight(1)
        .height(36)
        .padding({ left: 12, right: 12 })
        .backgroundColor(COLORS.cardBg)
        .borderRadius(18)
        .alignItems(VerticalAlign.Center)
        Stack({ alignContent: Alignment.TopEnd }) {
          Text('🛒')
            .fontSize(22)
          Text('3')
            .fontSize(9)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .textAlign(TextAlign.Center)
            .width(14)
            .height(14)
            .borderRadius(7)
            .backgroundColor(COLORS.danger)
            .translate({ x: 6, y: -4 })
        }
        .width(34)
        .height(30)
      }
      .width('100%')

      // 菌房环境卡
      Row({ space: 12 }) {
        Column({ space: 4 }) {
          Text('榆黄蘑出菇房 · 湿度 88%')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
          Text('温度 19℃ · 通风中 · 每两小时喷雾一次')
            .fontSize(10)
            .fontColor('#E7F0E4')
          Row({ space: 6 }) {
            Text('头茬进度')
              .fontSize(9)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.primaryDark)
              .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              .backgroundColor(COLORS.spore)
              .borderRadius(6)
            Text('今晚可采 约 14 斤')
              .fontSize(9)
              .fontColor('#E7F0E4')
          }
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Text('预约采摘')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.primaryDark)
          .padding({ left: 12, right: 12, top: 8, bottom: 8 })
          .backgroundColor(COLORS.spore)
          .borderRadius(14)
          .onClick(() => {
            this.openPick();
          })
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.primary)
      .borderRadius(16)

      // 采摘季横幅
      Row({ space: 10 }) {
        Text('🧺')
          .fontSize(22)
        Column({ space: 2 }) {
          Text('雨后头茬上市 · 会员 9 折')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('下单即送菌菇清洗刷 · 满 ¥88 包邮')
            .fontSize(10)
            .fontColor(COLORS.textSecondary)
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Text('去逛逛')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .padding({ left: 14, right: 14, top: 7, bottom: 7 })
          .backgroundColor(COLORS.accent)
          .borderRadius(14)
          .onClick(() => {
            this.subTab = 1;
          })
      }
      .width('100%')
      .padding(12)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)

      // 数据四格
      Row({ space: 8 }) {
        Column({ space: 2 }) {
          Text('在售菌菇')
            .fontSize(9)
            .fontColor(COLORS.textHint)
          Text('36 kg')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primary)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
        Column({ space: 2 }) {
          Text('菌包在育')
            .fontSize(9)
            .fontColor(COLORS.textHint)
          Text('128 包')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.accent)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
        Column({ space: 2 }) {
          Text('采收次数')
            .fontSize(9)
            .fontColor(COLORS.textHint)
          Text('64 次')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.success)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
        Column({ space: 2 }) {
          Text('菌币')
            .fontSize(9)
            .fontColor(COLORS.textHint)
          Text('¥45')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.warning)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
      }
      .width('100%')
    }
    .width('100%')
    .padding({ top: 10, bottom: 4 })
  }

  @Builder
  subNav() {
    Column({ space: 6 }) {
      Row({ space: 6 }) {
        ForEach(SUB_NAV_LIST.slice(0, 4), (item: string, idx: number) => {
          Text(item)
            .fontSize(11)
            .fontWeight(this.subTab === idx ? FontWeight.Bold : FontWeight.Normal)
            .fontColor(this.subTab === idx ? COLORS.white : COLORS.textSecondary)
            .maxLines(1)
            .layoutWeight(1)
            .padding({ top: 9, bottom: 9 })
            .backgroundColor(this.subTab === idx ? COLORS.primary : COLORS.cardBg)
            .borderRadius(10)
            .textAlign(TextAlign.Center)
            .border({ width: 1, color: COLORS.border })
            .onClick(() => {
              this.subTab = idx;
            })
        }, (item: string) => item)
      }
      .width('100%')
      Row({ space: 6 }) {
        ForEach(SUB_NAV_LIST.slice(4, 7), (item: string, idx: number) => {
          Text(item)
            .fontSize(11)
            .fontWeight(this.subTab === idx + 4 ? FontWeight.Bold : FontWeight.Normal)
            .fontColor(this.subTab === idx + 4 ? COLORS.white : COLORS.textSecondary)
            .maxLines(1)
            .layoutWeight(1)
            .padding({ top: 9, bottom: 9 })
            .backgroundColor(this.subTab === idx + 4 ? COLORS.accent : COLORS.cardBg)
            .borderRadius(10)
            .textAlign(TextAlign.Center)
            .border({ width: 1, color: COLORS.border })
            .onClick(() => {
              this.subTab = idx + 4;
            })
        }, (item: string) => item)
      }
      .width('100%')
    }
    .width('100%')
    .padding({ top: 6, bottom: 10 })
  }

  @Builder
  pageFeatured() {
    Column({ space: 12 }) {
      // 玩法四格
      Column() {
        Text('在菌语能玩什么')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
          .width('100%')
          .margin({ bottom: 10 })
        Row({ space: 8 }) {
          ForEach(TYPE_LIST, (it: PlayTypeItem) => {
            Column({ space: 4 }) {
              Text(it.icon)
                .fontSize(24)
              Text(it.name)
                .fontSize(11)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.textPrimary)
              Text(it.desc)
                .fontSize(9)
                .fontColor(COLORS.textHint)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
              Text(it.hot ? '热门' : ' ')
                .fontSize(9)
                .fontWeight(FontWeight.Bold)
                .fontColor(it.hot ? COLORS.danger : COLORS.border)
            }
            .layoutWeight(1)
            .padding({ top: 10, bottom: 8, left: 4, right: 4 })
            .backgroundColor(COLORS.cardBg)
            .borderRadius(12)
            .alignItems(HorizontalAlign.Center)
          }, (it: PlayTypeItem) => it.name)
        }
        .width('100%')
      }
      .width('100%')

      // 本周采收柱状图
      Column() {
        Row() {
          Text('本周采收量')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
          Text('单位 kg')
            .fontSize(10)
            .fontColor(COLORS.textHint)
        }
        .width('100%')
        Row({ space: 10 }) {
          ForEach(WEEK_CHART, (it: WeekChartItem) => {
            Column({ space: 4 }) {
              Column()
                .width(20)
                .height(barH(it.value, 12))
                .borderRadius(5)
                .backgroundColor(fungiColor(it.value))
              Text(`${it.value}`)
                .fontSize(9)
                .fontColor(COLORS.textSecondary)
              Text(it.label)
                .fontSize(9)
                .fontColor(COLORS.textHint)
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
          }, (it: WeekChartItem) => it.label + it.value.toString())
        }
        .width('100%')
        .alignItems(VerticalAlign.Bottom)
        .height(140)
        .margin({ top: 12 })
        Row({ space: 6 }) {
          Text('周末采收占比')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
          Text('67%')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.accent)
        }
        .width('100%')
        .margin({ top: 10 })
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)

      // 采收构成
      Column() {
        Text('本月采收品类构成')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
          .width('100%')
        ForEach(FUNGI_CHART, (it: FungiChartItem) => {
          Row({ space: 8 }) {
            Text(it.label)
              .fontSize(12)
              .fontColor(COLORS.textSecondary)
              .width(70)
            Stack({ alignContent: Alignment.Start }) {
              Row()
                .width('100%')
                .height(8)
                .borderRadius(4)
                .backgroundColor(COLORS.border)
              Row()
                .width(`${it.value}%`)
                .height(8)
                .borderRadius(4)
                .backgroundColor(it.color)
            }
            .layoutWeight(1)
            Text(`${it.value}%`)
              .fontSize(11)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
              .width(44)
              .textAlign(TextAlign.End)
          }
          .width('100%')
          .margin({ top: 8 })
        }, (it: FungiChartItem) => it.label)
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)

      // 人气菌菇双列
      Column() {
        Row() {
          Text('人气菌菇橱窗')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
          Text('查看全部')
            .fontSize(11)
            .fontColor(COLORS.primary)
        }
        .width('100%')
        .margin({ bottom: 10 })
        ForEach(FUNGI_LIST, (it: FungiItem, idx: number) => {
          if (idx % 2 === 0) {
            Row({ space: 10 }) {
              if (idx < FUNGI_LIST.length) {
                Column({ space: 6 }) {
                  Row() {
                    Text('🍄')
                      .fontSize(22)
                      .layoutWeight(1)
                    Text(FUNGI_LIST[idx].level)
                      .fontSize(10)
                      .fontWeight(FontWeight.Bold)
                      .fontColor(levelColor(FUNGI_LIST[idx].level))
                      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                      .backgroundColor(COLORS.accentLight)
                      .borderRadius(6)
                  }
                  .width('100%')
                  Text(FUNGI_LIST[idx].name)
                    .fontSize(14)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  Text(`${FUNGI_LIST[idx].price} · 关注 ${FUNGI_LIST[idx].hot}`)
                    .fontSize(11)
                    .fontColor(COLORS.textSecondary)
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Start)
                .padding(12)
                .backgroundColor(COLORS.cardBg)
                .borderRadius(12)
              }
              if (idx + 1 < FUNGI_LIST.length) {
                Column({ space: 6 }) {
                  Row() {
                    Text('🧺')
                      .fontSize(22)
                      .layoutWeight(1)
                    Text(FUNGI_LIST[idx + 1].level)
                      .fontSize(10)
                      .fontWeight(FontWeight.Bold)
                      .fontColor(levelColor(FUNGI_LIST[idx + 1].level))
                      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                      .backgroundColor(COLORS.accentLight)
                      .borderRadius(6)
                  }
                  .width('100%')
                  Text(FUNGI_LIST[idx + 1].name)
                    .fontSize(14)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  Text(`${FUNGI_LIST[idx + 1].price} · 关注 ${FUNGI_LIST[idx + 1].hot}`)
                    .fontSize(11)
                    .fontColor(COLORS.textSecondary)
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Start)
                .padding(12)
                .backgroundColor(COLORS.cardBg)
                .borderRadius(12)
              }
            }
            .width('100%')
          }
        }, (it: FungiItem) => it.id.toString())
      }
      .width('100%')

      // 菌友动态
      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.slice(0, 3), (it: PostItem) => {
          Row({ space: 10 }) {
            Text(it.avatar)
              .fontSize(22)
            Column({ space: 3 }) {
              Row() {
                Text(it.nick)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                  .layoutWeight(1)
                Text(it.time)
                  .fontSize(10)
                  .fontColor(COLORS.textHint)
              }
              .width('100%')
              Text(it.text)
                .fontSize(12)
                .fontColor(COLORS.textSecondary)
                .maxLines(2)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
              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%')
    }
    .width('100%')
  }

  @Builder
  pageMarket() {
    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(FUNGI_LIST, (it: FungiItem) => {
        Column({ space: 6 }) {
          Row() {
            Column()
              .width(4)
              .height(38)
              .borderRadius(2)
              .backgroundColor(fungiTagColor(it.name))
            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.bg)
                  .borderRadius(6)
              }
              .width('100%')
              Row({ space: 8 }) {
                Text(`💰 ${it.price}`)
                  .fontSize(11)
                  .fontColor(COLORS.textSecondary)
                Text(`🍄 ${it.craft}`)
                  .fontSize(11)
                  .fontColor(COLORS.textSecondary)
              }
              .width('100%')
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)
          }
          .width('100%')
          Stack({ alignContent: Alignment.Start }) {
            Row()
              .width('100%')
              .height(6)
              .borderRadius(3)
              .backgroundColor(COLORS.border)
            Row()
              .width(`${it.hot}%`)
              .height(6)
              .borderRadius(3)
              .backgroundColor(fungiTagColor(it.name))
          }
          .width('100%')
          Row({ space: 10 }) {
            Text(`关注度 ${it.hot}`)
              .fontSize(10)
              .fontColor(COLORS.textHint)
              .layoutWeight(1)
            Text('加入购物车')
              .fontSize(11)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.white)
              .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              .backgroundColor(COLORS.primary)
              .borderRadius(8)
              .onClick(() => {
                this.openPick();
              })
          }
          .width('100%')
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
      }, (it: FungiItem) => it.id.toString())
    }
    .width('100%')
  }

  @Builder
  pageCrafts() {
    Column({ space: 10 }) {
      Text('我的菌房')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .width('100%')
      ForEach(this.growList, (it: GrowItem, 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.status)
                  .fontSize(10)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(statusColor(it.status))
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                  .backgroundColor(COLORS.accentLight)
                  .borderRadius(6)
              }
              .width('100%')
              Text(`🧾 ${it.item}`)
                .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(`${statusProgress(it.status)}%`)
              .height(6)
              .borderRadius(3)
              .backgroundColor(statusColor(it.status))
          }
          .width('100%')
          Row() {
            Text(`培育进度 ${statusProgress(it.status)}%`)
              .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: GrowItem) => it.id.toString())
    }
    .width('100%')
  }

  @Builder
  pageWorkshop() {
    Column({ space: 10 }) {
      Text('培育工坊')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .width('100%')
      Row({ space: 8 }) {
        Column({ space: 2 }) {
          Text('当前菌房')
            .fontSize(10)
            .fontColor(COLORS.textHint)
          Text('榆黄蘑三号')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primary)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
        Column({ space: 2 }) {
          Text('培养基含水')
            .fontSize(10)
            .fontColor(COLORS.textHint)
          Text('63%')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.accent)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
        Column({ space: 2 }) {
          Text('工序')
            .fontSize(10)
            .fontColor(COLORS.textHint)
          Text('接种')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.warning)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
        Column({ space: 2 }) {
          Text('今日巡检')
            .fontSize(10)
            .fontColor(COLORS.textHint)
          Text('3 次')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.success)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
      }
      .width('100%')
      ForEach(FUNGI_LIST.slice(0, 5), (it: FungiItem, 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.craft} · ${it.price}`)
              .fontSize(10)
              .fontColor(COLORS.textSecondary)
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)
          Text(`关注 ${it.hot}`)
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(fungiTagColor(it.name))
        }
        .width('100%')
        .padding(10)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
      }, (it: FungiItem) => it.id.toString())
      Text('器具速购')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .width('100%')
        .margin({ top: 4 })
      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)
            }
            if (idx + 1 < GEAR_LIST.length) {
              Column({ space: 6 }) {
                Text(GEAR_LIST[idx + 1].icon)
                  .fontSize(26)
                Text(GEAR_LIST[idx + 1].name)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                Text(GEAR_LIST[idx + 1].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)
            }
          }
          .width('100%')
        }
      }, (it: GearItem) => it.id.toString())
    }
    .width('100%')
  }

  @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%')
  }

  @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%')
  }

  @Builder
  pageGear() {
    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('菌种 · 培养基')
            .fontSize(10)
            .fontColor(COLORS.textSecondary)
        }
        .layoutWeight(1)
        .padding(12)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
        .alignItems(HorizontalAlign.Center)
        Column({ space: 4 }) {
          Text('💧')
            .fontSize(28)
          Text('加湿区')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('喷雾 · 水帘')
            .fontSize(10)
            .fontColor(COLORS.textSecondary)
        }
        .layoutWeight(1)
        .padding(12)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
        .alignItems(HorizontalAlign.Center)
        Column({ space: 4 }) {
          Text('🧺')
            .fontSize(28)
          Text('采收区')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('篮筐 · 刀剪')
            .fontSize(10)
            .fontColor(COLORS.textSecondary)
        }
        .layoutWeight(1)
        .padding(12)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
        .alignItems(HorizontalAlign.Center)
      }
      .width('100%')
      ForEach(GEAR_LIST, (it: GearItem) => {
        Row({ space: 10 }) {
          Text(it.icon)
            .fontSize(20)
          Column({ space: 3 }) {
            Text(it.name)
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text(it.desc)
              .fontSize(10)
              .fontColor(COLORS.textSecondary)
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)
          Text('加购')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .padding({ left: 8, right: 8, top: 3, bottom: 3 })
            .backgroundColor(COLORS.primary)
            .borderRadius(8)
        }
        .width('100%')
        .padding(10)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
      }, (it: GearItem) => it.id.toString())
      Button()
        .width('100%')
        .height(42)
        .backgroundColor(COLORS.danger)
        .borderRadius(10)
        .opacity(0.9)
        .onClick(() => {
          this.openDelA();
        })
      Text('取消培育订单')
        .fontSize(14)
        .fontColor(COLORS.white)
        .margin({ left: -84, top: -32 })
    }
    .width('100%')
  }

  @Builder
  pageMine() {
    Column({ space: 12 }) {
      Row({ space: 12 }) {
        Text('🍄')
          .fontSize(40)
        Column({ space: 4 }) {
          Text('菌语培育人')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('黄金会员 · 采收 64 次 · 在育 128 包 · 打卡 95 天')
            .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.growList.length}`)
            .fontSize(12)
            .fontColor(COLORS.primary)
        }
        .width('100%')
        ForEach(this.growList.slice(0, 3), (it: GrowItem) => {
          Row() {
            Text(it.name)
              .fontSize(13)
              .fontColor(COLORS.textSecondary)
              .layoutWeight(1)
            Text(it.status)
              .fontSize(11)
              .fontColor(statusColor(it.status))
          }
          .width('100%')
        }, (it: GrowItem) => it.id.toString())
      }
      .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.postList.length}`)
            .fontSize(12)
            .fontColor(COLORS.primary)
        }
        .width('100%')
        ForEach(this.postList.slice(0, 3), (it: PostItem) => {
          Row() {
            Text(it.nick)
              .fontSize(13)
              .fontColor(COLORS.textSecondary)
              .layoutWeight(1)
            Text(it.time)
              .fontSize(11)
              .fontColor(COLORS.textHint)
          }
          .width('100%')
        }, (it: PostItem) => it.id.toString())
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)
    }
    .width('100%')
  }

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

  @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(COLORS.white)
          .margin({ left: -44 })
      }
      .width('100%')
    }
    .width('100%')
    .padding(16)
    .backgroundColor(COLORS.cardBg)
  }

  @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(COLORS.white)
          .margin({ left: -44 })
      }
      .width('100%')
    }
    .width('100%')
    .padding(16)
    .backgroundColor(COLORS.cardBg)
  }

  @Builder
  delModalBody() {
    Column({ space: 12 }) {
      Text(this.delTarget === 'grow' ? '取消培育订单' : '退出鉴别课堂')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
      Text(this.delTarget === 'grow' ? '将取消首笔培育订单并按工序退费,确认执行?' : '将退出首门进行中的课程,确认执行?')
        .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)
  }

  @Builder
  pickModalBody() {
    Column({ space: 12 }) {
      Text('预约采摘场次')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
      TextInput({ placeholder: '采摘场次(如 榆黄蘑头茬场)', text: this.pickBatch })
        .fontSize(13)
        .fontColor(COLORS.textPrimary)
        .backgroundColor(COLORS.bg)
        .borderRadius(8)
        .height(40)
        .onChange((v: string) => {
          this.pickBatch = v;
        })
      TextInput({ placeholder: '到访日期(如 周六上午)', text: this.pickDate })
        .fontSize(13)
        .fontColor(COLORS.textPrimary)
        .backgroundColor(COLORS.bg)
        .borderRadius(8)
        .height(40)
        .onChange((v: string) => {
          this.pickDate = v;
        })
      Text('现采现称 · 场内品尝 · 每场限 20 人')
        .fontSize(11)
        .fontColor(COLORS.textHint)
        .width('100%')
      Row({ space: 10 }) {
        Button()
          .layoutWeight(1)
          .height(38)
          .backgroundColor(COLORS.bg)
          .borderRadius(8)
          .onClick(() => {
            this.pickOpen = false;
          })
        Text('取消')
          .fontSize(13)
          .fontColor(COLORS.textSecondary)
          .margin({ left: -52 })
        Button()
          .layoutWeight(1)
          .height(38)
          .backgroundColor(COLORS.accent)
          .borderRadius(8)
          .onClick(() => {
            this.doPick();
          })
        Text('提交预约')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .margin({ left: -56 })
      }
      .width('100%')
    }
    .width('100%')
    .padding(16)
    .backgroundColor(COLORS.cardBg)
  }

  @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 })
  }

  @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.pageMarket()
            } else if (this.subTab === 2) {
              this.pageCrafts()
            } else if (this.subTab === 3) {
              this.pageWorkshop()
            } 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.pageMarket()
          }
          .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%')
        }
        .scrollable(ScrollDirection.Vertical)
        .scrollBar(BarState.Off)
        .layoutWeight(1)
        .padding({ left: 14, right: 14, bottom: 20 })
      } else {
        Scroll() {
          Column() {
            this.pageMine()
          }
          .width('100%')
        }
        .scrollable(ScrollDirection.Vertical)
        .scrollBar(BarState.Off)
        .layoutWeight(1)
        .padding({ left: 14, right: 14, bottom: 20 })
      }
    }
    .width('100%')
    .height('100%')
  }

  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%')
      }
      if (this.pickOpen) {
        Stack() {
          this.modalOverlay()
          Column() {
            this.pickModalBody()
          }
          .width('88%')
          .constraintSize({ maxHeight: '80%' })
          .borderRadius(16)
          .zIndex(999)
        }
        .width('100%')
        .height('100%')
      }
    }
    .width('100%')
    .height('100%')
  }
}


16.5 总结:

在这里插入图片描述

当前版本的菌语应用采用单文件架构,所有代码(数据、模型、工具、组件、视图)都集中在一个文件中。这种方式对于小型应用或原型验证来说非常高效——所有代码一目了然,无需跨文件跳转。

但随着功能的不断迭代,单文件架构会逐渐暴露出可维护性方面的问题。未来可以考虑按照以下路径进行工程化演进:

  1. 数据层抽离:将 ColorPalette、数据模型、静态数据抽取到独立的 model/ 目录中,按业务模块拆分文件
  2. 工具层抽离:将纯函数工具抽取到 utils/ 目录中,按功能分类(颜色工具、特效工具、通用工具等)
  3. 组件化拆分:将复用率高的UI模块(如菌菇卡片、进度条、弹框等)抽取为独立的自定义组件
  4. 页面级拆分:将7个页面 Builder 拆分为独立的页面组件文件
  5. 状态管理升级:对于更复杂的业务场景,可以引入 AppStorage 或全局状态管理方案,实现跨组件的状态共享
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐