开篇:在HarmonyOS ArkUI声明式范式下构建海洋美学应用

在这里插入图片描述

HarmonyOS ArkUI声明式开发范式为移动应用的界面构建提供了一种全新的思维方式。与传统命令式UI框架不同,声明式UI强调"描述目标状态而非操作过程"——开发者通过结构化的组件描述来定义界面在不同状态下的呈现形式,框架则负责在状态变迁时自动完成差异计算与界面刷新。这种范式在HarmonyOS 6.1.1及ArkTS API 24中得到了进一步的强化与完善,其核心优势体现在代码可读性强、状态与视图绑定清晰、渲染性能可预测等多个维度。

基于HarmonyOS API 24的"贝语·海边拾贝与贝壳标本工坊"应用,正是这一技术范式的典型实践案例。该应用以珊瑚粉与青色海贝为核心视觉主题,构建了一个集贝壳市集、潮汐赶海、标本拼装工坊、拾贝爱好者社区于一体的垂直兴趣平台。在不到两千行的ArkTS代码中,应用完整实现了底部四Tab导航、首页七内容Tab切换、海气泡上浮与沙粒闪烁双层特效、新增/编辑/删除三类弹框交互、以及丰富的数据可视化图表等功能模块,充分展现了声明式UI在复杂界面构建中的高效表达能力。

在状态管理层面,应用巧妙运用了@Observed装饰器与@State装饰器的组合拳。@Observed用于标记数据模型类,使得类实例的属性变化能够被框架精确追踪;而@State则用于组件内部的状态变量,驱动局部UI的重新渲染。这种分层的状态管理策略既保证了数据变化的可观测性,又避免了不必要的全局重绘,是ArkUI状态管理体系中的最佳实践之一。

在布局系统方面,应用深度使用了Stack、Column、Row三大基础容器组件。Stack用于实现层叠布局,如背景层、特效层、内容层与弹框层的纵向叠加;Column与Row则分别负责纵向与横向的线性排列,配合layoutWeight属性实现弹性分配。这种基于基础容器的组合式布局方法,既保持了结构的清晰性,又提供了足够的灵活性来应对复杂的界面需求。

在色彩设计系统上,应用采用了珊瑚粉(#D98880)与青色(#6BA8A9)的双主色方案,辅以潮汐色(#9CC8C8)作为点缀,构建出一套完整的海洋美学视觉语言。16个精心定义的颜色字段覆盖了主色、辅色、背景、文字、边框、功能色等全部视觉维度,为整个应用提供了统一且可扩展的色彩基础设施。

在交互模式方面,应用创新性地采用了"对勾确认式"子导航设计——选中的Tab项不仅在文字样式上发生变化,还会在右侧显示一个带有深色圆角背景的对勾图标,形成独特的视觉确认反馈。此外,拾贝标本社区的动态流交互、贝壳市集的关注度热度条、标本订单的工序进度追踪等功能模块,共同构成了丰富而立体的用户交互体验。

接下来,本文将从颜色系统设计、数据模型架构、静态数据组织、导航配置、纯函数工具、组件状态管理、特效层实现、页面构建器、弹框组件、底部导航、主内容区以及整体渲染管线等多个维度,对应用进行逐层深入的代码级技术分析。


一、ColorPalette接口设计与珊瑚粉海贝色彩系统

1.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;
  tide: string;
}

这个接口包含了16个颜色字段,覆盖了从主色调到功能色的完整色谱。通过接口约束颜色变量的结构,开发者能够在编译期就获得类型安全的保障——任何对颜色字段的拼写错误或类型不匹配都会被TypeScript编译器及时捕获,大大降低了运行时出现视觉异常的风险。

1.2 珊瑚粉主色系三态解析

主色系由primaryprimaryLightprimaryDark三个字段组成,形成了完整的明度阶梯:

  • primary: #D98880 —— 珊瑚粉主色,是整个应用的视觉锚点。这个饱和度适中、明度偏高的粉色调既保留了珊瑚的温暖质感,又不会过于艳丽刺眼,非常适合作为垂直兴趣社区的主色调。它被广泛应用于按钮背景、选中态文字、图标强调、进度条填充等关键交互元素。

  • primaryLight: #F7E1DE —— 珊瑚粉浅色,明度提升至接近粉色的柔和区域。这个颜色常用于标签背景、次级强调区域等需要主色存在感但又不宜过于抢眼的场景。它与主色之间形成了柔和的对比关系,使得界面层次更加丰富。

  • primaryDark: #B05F5C —— 珊瑚粉深色,明度降低、饱和度微升,呈现出更深沉的赤陶质感。这个颜色在对勾确认式导航的对勾图标背景中发挥了关键作用,与浅色主色形成鲜明的内外对比,强化了选中状态的视觉确认感。

1.3 青色辅色系与双主色平衡

在这里插入图片描述

辅色系由accentaccentLight组成,以青色海贝为灵感来源:

  • accent: #6BA8A9 —— 青色主辅色,是海洋主题的另一根色彩支柱。这种带有灰度的青色既传达了海水的清凉感,又不会过于冷冽,与珊瑚粉主色形成了温暖与清凉的完美平衡。它被应用于潮汐赶海卡片的背景、数据统计数字、部分选中态元素等场景。

  • accentLight: #DCEDED —— 青色浅色,明度大幅提升后呈现出一种近乎白的淡青色。它主要用作标签的背景色,与深色文字搭配时能提供良好的可读性,同时又保持了视觉上的轻盈感。

1.4 背景与卡片色彩层次

  • bg: #F7F1E8 —— 背景色是一种带有暖调的米白色,仿佛沙滩在阳光下的颜色。这种暖底色为整个应用奠定了温暖、舒适的基调,与冷色调的青色辅色形成了有趣的冷暖对比。

  • cardBg: #FFFFFF —— 纯白色卡片背景,在米白背景上形成清晰的层次区分。卡片式设计是本应用的核心布局范式,几乎所有内容模块都以圆角卡片的形式呈现,纯白背景确保了卡片内文字和图标的最佳可读性。

1.5 文字色彩三级体系

在这里插入图片描述

文字色彩采用了三级递进的灰度体系,每一级都对应不同的信息权重:

  • textPrimary: #4A3A34 —— 主文字色,深棕褐色,饱和度偏低但对比度充足。这种暖深褐色比纯黑色更柔和,与暖调背景相得益彰,长时间阅读也不易疲劳。

  • textSecondary: #7F6C63 —— 次级文字色,中灰调的棕褐色,用于描述性文字、辅助信息等次级内容。它与主文字色之间保持了足够的对比度差异,使得信息层级一目了然。

  • textHint: #B7A69B —— 提示文字色,浅灰棕色,用于时间戳、占位文字、弱化信息等场景。它的存在使得界面在视觉上有了呼吸感,不会因为信息密度过高而显得拥挤。

1.6 边框与功能色系统

  • border: #EDE1D4 —— 边框色,极浅的暖米色。它的明度非常高,仅比背景色略深一点,形成一种"若有若无"的微妙边界感。这种设计使得卡片之间的分隔既清晰又不生硬。

  • success: #7BA884 —— 成功色,柔和的鼠尾草绿。它被用于"已寄出"状态标记、赶海次数统计等正向反馈场景,传达出完成、顺利的语义。

  • warning: #D9A05B —— 警示色,温暖的琥珀金色。这种颜色与沙滩色调相呼应,被用于沙粒闪烁特效、贝币金额、"清洗中"状态等场景,既起到了警示作用又不破坏整体美感。

  • danger: #C76B5A —— 危险色,深沉的赤陶红。它的饱和度低于常规的警告红,更符合应用的海洋美学调性,主要用于删除确认按钮、退课按钮等破坏性操作。

1.7 潮汐特殊色与白色

  • white: #FFFFFF —— 纯白,与cardBg相同但语义不同。white字段专门用于文字颜色等需要白色的场景,保持了语义上的清晰区分。

  • tide: #9CC8C8 —— 潮汐色,这是整个色彩系统中最具特色的一个字段。它是一种灰调的青色,介于accent和accentLight之间,被专门用于海气泡上浮特效的渲染。这个颜色的命名和用途都与"潮汐"这一海洋主题深度绑定,体现了色彩设计与产品主题的高度融合。


二、@Observed数据模型架构深度解析

在这里插入图片描述

2.1 @Observed装饰器的核心作用

在ArkUI的状态管理体系中,@Observed装饰器扮演着至关重要的角色。它用于修饰类声明,使得该类的实例属性能够被UI框架精确观测。当被@Observed修饰的类的实例属性发生变化时,框架能够自动触发依赖该属性的UI组件进行重新渲染。这种细粒度的状态追踪机制是ArkUI高性能渲染的关键所在。

@State装饰器不同,@Observed修饰的是类本身而非具体的属性。这意味着只要类被@Observed标记,其实例的所有属性变化都可以被观测到——但前提是引用该实例的组件使用了正确的装饰器链(如@State@ObjectLink等)。在本应用中,@Observed类的实例通过@State数组的形式被持有,形成了完整的观测链路。

2.2 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是贝友圈动态的数据模型,包含6个字段:

  • id: number —— 动态唯一标识,作为ForEach的key函数参数,确保列表渲染的正确性和性能。在ArkUI中,ForEach的第二个参数是键生成函数,框架通过键值来判断哪些元素需要复用、哪些需要重建,因此id字段的唯一性至关重要。

  • nick: string —— 用户昵称,展示在动态卡片的头部位置,使用主文字色和加粗字重,是动态信息的身份标识。

  • avatar: string —— 头像字段,采用emoji字符形式表示(如"🐚"、“🖼️”、"🌙"等)。这种设计既简化了图片资源管理,又与海洋主题高度契合,每个emoji都暗示了用户的身份特征或兴趣偏好。

  • text: string —— 动态正文内容,是动态的核心信息载体。在首页精选页中限制为最多2行显示并添加省略号,而在贝友圈完整页面中则全部展示,体现了不同场景下的信息密度策略。

  • time: string —— 发布时间,采用相对时间格式(如"5分钟前"、“昨天”),使用提示文字色弱化显示,提供时间上下文但不干扰主要内容。

  • likes: number —— 点赞数,配合贝壳emoji图标显示,量化了动态的受欢迎程度。这个字段是数值型的,未来可以很容易地扩展点赞交互功能。

2.3 ShellItem:贝壳市集数据模型

在这里插入图片描述

@Observed
export class ShellItem {
  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
  }
}

ShellItem是贝壳市集商品的数据模型,包含6个字段:

  • id: number —— 商品唯一标识,同样承担着ForEach键值的重要职责。

  • name: string —— 贝壳名称,包含物种信息和特征描述(如"海菊蛤 · 完整刺"、“芋螺 · 帝王螺”),是商品的核心识别信息。

  • craft: string —— 产地/工艺信息(如"东海 · 大潮货"、“南海 · 深水”),传达了贝壳的来源和采集方式,对于收藏爱好者来说是重要的价值参考。

  • price: string —— 价格字符串(如"¥128/枚"),采用字符串类型而非数值类型,因为价格的展示格式是多样化的,可能包含单位、计价方式等信息。

  • level: string —— 稀有等级(如"收藏级"、“稀有”、“少见”、“常见”),用于标识贝壳的珍贵程度,配合levelColor函数动态映射不同的颜色标签。

  • hot: number —— 关注度数值,范围从64到96不等。这个数值有两个用途:一是直接显示为"关注XX"的热度指标,二是驱动热度进度条的填充宽度(以百分比形式呈现)。数值范围恰好落在0-100之间,天然适合作为百分比使用。

2.4 CraftItem:标本订单数据模型

在这里插入图片描述

@Observed
export class CraftItem {
  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
  }
}

CraftItem是标本制作订单的数据模型,包含5个字段:

  • id: number —— 订单唯一标识。

  • name: string —— 标本作品名称(如"星空螺旋画 · 120 壳"、“相框标本 · 混合贝”),描述了作品的主题和规模。

  • item: string —— 标本内容/规格说明(如"微壳拼贴 · 深框"、“30 枚 · 原木框”),提供了更具体的工艺和材料信息。

  • status: string —— 订单状态(如"已寄出"、“拼装中”、“研磨中”、“清洗中”、“设计稿”、“待付尾款”),这是一个非常关键的字段。它不仅决定了状态标签的显示文字,还通过statusColor函数映射为不同的颜色,通过statusProgress函数映射为进度条的百分比值。这种以状态字符串为核心的驱动方式,使得状态管理变得简洁直观。

  • progress: number —— 制作进度数值,存储了订单的完成百分比。虽然代码中主要通过状态字符串计算显示进度,但保留progress字段为未来的精细化进度追踪留下了扩展空间。

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

GearItem是拾贝工具的数据模型,包含4个字段:

  • id: number —— 工具唯一标识。

  • name: string —— 工具名称(如"赶海头灯"、“小耙子 · 三齿”)。

  • desc: string —— 工具功能描述(如"夜潮拾贝"、“翻沙寻宝”),简洁地说明了工具的用途。

  • icon: string —— 工具图标,同样采用emoji字符形式(如"🔦"、“⛏️”、“🧴”),与整体视觉风格保持一致。

2.6 辅助接口定义

除了四个主要的@Observed类之外,应用还定义了四个普通接口用于辅助数据结构:

  • WeekChartItem —— 周赶海柱状图的数据项,包含label(星期标签)和value(次数值)两个字段。

  • ShellChartItem —— 贝壳收藏纲目构成的数据项,包含label(纲目名称)、value(百分比数值)和color(进度条颜色)三个字段。每条数据自带颜色配置,使得图表渲染更加灵活。

  • CourseItem —— 课程数据项,包含title(课程标题)、week(周次标签)、progress(学习进度)和color(进度条颜色)四个字段。

  • BeachTypeItem —— 玩法分类数据项,包含name(分类名称)、desc(分类描述)、icon(图标emoji)和hot(是否热门标记)四个字段。

这些接口虽然没有使用@Observed装饰器,但它们在静态数据展示中扮演着重要角色。由于这些数据通常是只读的、不需要动态修改,因此不需要额外的观测开销,体现了"按需观测"的性能优化原则。


三、静态数据数组的组织与设计

3.1 POST_LIST:贝友动态初始数据

在这里插入图片描述

POST_LIST数组包含8条预设的贝友动态,构成了社区的初始内容生态。每条动态都经过精心设计,涵盖了不同类型的用户角色(赶海人、标本师、收藏家、主理人等)和不同主题的内容(捡宝分享、作品展示、活动通知、经验交流等)。这种多元化的初始数据设计使得应用首次加载时就能呈现出丰富活跃的社区氛围。

数据的时间字段按照"5分钟前"到"昨天"的顺序递减排列,模拟了真实社交动态的时间流分布。点赞数则从134递减到62,形成了自然的热度衰减曲线,符合真实社区中"越新越热"的内容分布规律。

3.2 SHELL_LIST:贝壳市集商品数据

SHELL_LIST数组包含8款贝壳商品,涵盖了从常见到珍稀的完整等级梯度。商品的选择覆盖了海菊蛤、芋螺、宝螺、东风螺、蝾螺、骨螺、扇贝、鹦鹉螺等多个物种,展示了贝壳收藏领域的丰富品类。

价格区间从¥26到¥880不等,跨度巨大,既满足了入门爱好者的需求,也为资深收藏家提供了高端选择。关注度(hot值)从96递减到64,与价格和稀有度大致呈正相关关系,合理反映了市场关注度的分布规律。

3.3 CRAFT_LIST:标本订单数据

CRAFT_LIST数组包含8个标本制作订单,展示了工坊业务的多样性。订单类型包括拼贴画、相框标本、风铃、婚礼伴手礼、耳环、灯罩、彩窗、企业礼品等多种形式,充分体现了贝壳标本工坊的创意边界。

订单状态涵盖了从"设计稿"到"已寄出"的完整工序列表,进度值从6%到100%均匀分布,使得进度条组件能够展示出丰富的状态变化。这种设计不仅具有视觉上的多样性,也完整呈现了订单生命周期的各个阶段。

3.4 GEAR_LIST:工具库数据

GEAR_LIST数组包含8款拾贝工具,按照使用场景可以分为赶海区(头灯、耙子)、清洗区(清洗液、研磨海绵)、拼贴区(标本框、渔线)和鉴赏收纳区(放大镜、收纳盒)四大类别。这种分类方式与工具库页面顶部的三格分类导航相呼应,为用户提供了清晰的工具查找路径。

3.5 WEEK_CHART:周赶海打卡柱状图数据

WEEK_CHART数组包含周一到周日七天的赶海打卡数据,呈现出明显的"周末高峰"特征——周六14次、周日12次,而工作日则在3-8次之间波动。这种数据分布既符合真实的生活规律,也使得柱状图的视觉呈现更加有看点。数据中的最大值14被用作柱状图高度计算的基准值(barH函数中的max参数)。

3.6 SHELL_CHART:贝壳纲目构成数据

SHELL_CHART数组包含5个软体动物纲目的收藏占比数据:腹足纲34%、双壳纲27%、掘足纲16%、多板纲13%、头足纲10%。每个数据项都自带颜色配置,分别对应珊瑚粉、青色、绿色、琥珀色、赤陶红五种主题色。这种设计使得横向进度条图表能够直接使用数据中的颜色值,无需额外的颜色映射逻辑。

3.7 TYPE_LIST:玩法分类数据

TYPE_LIST数组包含4个玩法分类:赶海团、标本定制、贝壳市集、微壳鉴赏。其中前两个标记为"热门"(hot: true),后两个为普通分类。每个分类都有对应的emoji图标和简短的功能描述,为新用户提供了快速了解应用核心玩法的入口。

3.8 COURSE_LIST:课程列表数据

COURSE_LIST数组包含5门贝类课程,按照学习周次递进排列,从"贝壳纲目分类入门"到"珍稀壳法律与收藏",内容由浅入深。课程进度从100%递减到5%,模拟了一个正在学习中的用户的真实进度状态。每门课程配有独立的主题色,与课程内容的气质相匹配。


四、导航配置系统

4.1 NAV_LIST:底部主导航

底部Tab导航是移动应用最常见的导航模式之一。本应用的底部导航包含4个主Tab:

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

每个Tab由一个emoji图标和文字标签组成,图标选择都与Tab功能高度相关:贝壳代表首页(应用核心主题)、珊瑚代表市集(海洋商品交易)、书本代表课堂(知识学习)、人像代表我的(个人中心)。

选中态通过三种视觉变化来强化:图标不透明度从0.55提升到1、文字字重从Normal变为Bold、文字颜色从提示灰变为珊瑚粉主色。这种多维度的状态反馈既清晰又优雅,符合移动端交互设计的最佳实践。

4.2 SUB_NAV_LIST:首页子导航(对勾确认式)

首页的子导航是本应用交互设计的一大亮点,采用了独特的"对勾确认式"设计:

const SUB_NAV_LIST: string[] = ['精选', '贝壳市集', '我的标本', '拾贝工坊', '贝类课堂', '贝友圈', '工具库'];

7个内容Tab涵盖了应用的所有核心功能模块。与常规的下划线指示器或纯文字变色不同,本应用的选中态采用了"胶囊背景+对勾图标"的确认式设计——选中的Tab项背景填充为珊瑚粉主色,文字变白加粗,右侧还会出现一个深珊瑚粉色的圆形对勾图标。

这种设计语言传达出一种"选定并确认"的心理暗示,比传统的下划线指示器具有更强的视觉确定性。对勾图标在非选中态时被一个透明占位元素替代,确保了所有Tab项的宽度一致性,避免了选中切换时的宽度跳动问题。

子导航支持横向滚动(Scroll组件 + Horizontal方向),在屏幕宽度不足以展示全部7个Tab时,用户可以通过左右滑动来查看和选择。滚动条被隐藏(BarState.Off),保持了界面的简洁美观。


五、纯函数工具集解析

纯函数是函数式编程的核心概念,它们不依赖外部状态、不产生副作用,相同的输入总是产生相同的输出。在ArkUI开发中,大量使用纯函数来处理UI逻辑可以有效降低组件的复杂度,提高代码的可测试性和可维护性。

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

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

barH函数用于计算周赶海柱状图中每个柱子的高度。它接受当前值v和最大值max两个参数,返回计算后的像素高度。118是柱状图的最大高度(像素),通过比例换算得到每个柱子的实际高度。Math.round确保了结果为整数像素,避免了亚像素渲染可能导致的模糊问题。

这个函数被应用在pageFeatured构建器的柱状图模块中,配合WEEK_CHART数据动态生成7根高度不一的柱子。最大值14对应118像素的完整高度,其他值按比例缩放。

5.2 shellColor:热度颜色映射函数

function shellColor(v: number): string {
  if (v > 9) {
    return '#D98880';
  } else if (v > 6) {
    return '#6BA8A9';
  }
  return '#D9A05B';
}

shellColor函数根据数值大小返回不同的主题色,用于柱状图柱子的颜色填充。数值大于9时返回珊瑚粉(最高热度),大于6时返回青色(中等热度),否则返回琥珀色(较低热度)。这种三段式颜色映射使得柱状图不仅展示了数量信息,还通过颜色传达了热度等级。

5.3 海气泡特效函数组:bubbleA/X/Y/S

function bubbleA(tick: number, i: number): number {
  return 0.1 + ((tick + i) % 5) * 0.05;
}
function bubbleX(tick: number, i: number): number {
  return 50 + ((tick * 4 + i * 193) % 590);
}
function bubbleY(tick: number, i: number): number {
  return 660 - ((tick * 8 + i * 217) % 620);
}
function bubbleS(tick: number, i: number): number {
  return 4 + ((tick + i * 2) % 3) * 3;
}

这四个函数共同构成了海气泡上浮特效的计算引擎,每个函数负责气泡的一个属性维度:

  • bubbleA —— 计算气泡的不透明度。基于tick和索引i的组合取模,产生0.1到0.3之间周期性变化的透明度值。每个气泡的变化相位因i值不同而错开,形成错落有致的闪烁效果。

  • bubbleX —— 计算气泡的水平位置。通过tick * 4 + i * 193的复合运算再取模590,产生水平方向的伪随机分布。i乘以193(一个较大的质数)确保了不同气泡的水平位置不会出现规律性重合。

  • bubbleY —— 计算气泡的垂直位置。Y值从660开始递减(660减去取模结果),模拟气泡从底部向上浮起的运动。tick乘以8的系数较大,使得垂直方向的运动速度快于水平方向的漂移,符合气泡上浮的物理直觉。

  • bubbleS —— 计算气泡的大小。在4到10像素之间变化,使得气泡大小有一定的随机性,更加自然。

这种基于时间tick和索引i的纯函数计算方式,避免了为每个气泡维护独立的状态变量,大大简化了特效实现的复杂度。所有的动画状态都隐含在tick这一个变量中,通过数学公式推导而出。

5.4 沙粒闪烁特效函数组:sandA/X/Y

function sandA(tick: number, i: number): number {
  return 0.12 + ((tick + i * 3) % 4) * 0.07;
}
function sandX(tick: number, i: number): number {
  return 20 + ((tick * 5 + i * 233) % 630);
}
function sandY(tick: number, i: number): number {
  return 40 + ((tick * 7 + i * 181) % 640);
}

沙粒闪烁特效的三个函数与海气泡函数采用了相同的设计模式,但参数有所不同:

  • sandA —— 沙粒不透明度,在0.12到0.33之间变化。i乘以3的系数使得沙粒的闪烁相位差更大,营造出星光点点的视觉效果。

  • sandX/Y —— 沙粒位置,同样基于tick和i的复合运算产生伪随机分布。沙粒的分布范围比气泡更广阔(X范围630、Y范围640),覆盖了整个屏幕区域。

沙粒使用warning色(琥珀金#D9A05B),在米白色的背景上形成金色光点闪烁的效果,仿佛沙滩上的云母碎片在阳光下闪闪发光。

5.5 statusColor:状态颜色映射函数

function statusColor(s: string): string {
  if (s === '已寄出') {
    return '#7BA884';
  } else if (s === '拼装中') {
    return '#6BA8A9';
  } else if (s === '研磨中') {
    return '#D98880';
  } else if (s === '清洗中') {
    return '#D9A05B';
  }
  return '#B7A69B';
}

statusColor函数将标本订单的状态字符串映射为对应的颜色。每种状态都有独特的颜色语义:

  • 已寄出 → 绿色(成功、完成)
  • 拼装中 → 青色(进行中、创作)
  • 研磨中 → 珊瑚粉(加工中、精细)
  • 清洗中 → 琥珀色(处理中、准备)
  • 其他状态 → 灰色(待定、未知)

这种颜色编码系统使得用户一眼就能识别订单所处的工序阶段,是信息可视化的一种轻量级实现。

5.6 statusProgress:状态进度映射函数

function statusProgress(s: string): number {
  if (s === '已寄出') {
    return 100;
  } else if (s === '拼装中') {
    return 76;
  } else if (s === '研磨中') {
    return 58;
  } else if (s === '清洗中') {
    return 40;
  }
  return 10;
}

statusProgress函数将状态字符串映射为对应的进度百分比。它定义了四个核心工序的进度基准值:清洗中40%、研磨中58%、拼装中76%、已寄出100%。这些数值并非随意设定——它们大致对应了订单制作流程中各阶段的完成度,形成了一条合理的进度曲线。

这个函数的存在使得进度条的显示完全由状态驱动,无需为每个订单单独维护进度数值,简化了数据模型和状态管理。

5.7 shellTagColor:贝壳标签颜色函数

function shellTagColor(g: string): string {
  if (g.indexOf('海菊') >= 0 || g.indexOf('蝾螺') >= 0) {
    return '#D98880';
  } else if (g.indexOf('宝螺') >= 0 || g.indexOf('芋螺') >= 0) {
    return '#6BA8A9';
  } else if (g.indexOf('扇贝') >= 0 || g.indexOf('东风') >= 0) {
    return '#7BA884';
  }
  return '#D9A05B';
}

shellTagColor函数根据贝壳名称中的关键词匹配来决定颜色标签。它将贝壳分为四个色系:

  • 海菊蛤/蝾螺 → 珊瑚粉(高端收藏品类)
  • 宝螺/芋螺 → 青色(经典收藏品类)
  • 扇贝/东风螺 → 绿色(常见食用/装饰品类)
  • 其他 → 琥珀色(其他品类)

这种基于名称关键词的颜色分类方法虽然简单,但对于固定品类的展示已经足够有效。它使得贝壳列表中的每一项都有了独特的颜色标识,增强了列表的视觉节奏感。

5.8 levelColor:稀有等级颜色函数

function levelColor(s: string): string {
  if (s === '常见') {
    return '#7BA884';
  } else if (s === '少见') {
    return '#6BA8A9';
  } else if (s === '稀有') {
    return '#D9A05B';
  } else if (s === '收藏级') {
    return '#D98880';
  }
  return '#C76B5A';
}

levelColor函数将贝壳的稀有等级映射为对应的颜色。等级从低到高分别对应绿→青→金→粉的颜色递进,形成了一条清晰的视觉等级梯度。用户可以通过颜色快速判断贝壳的珍贵程度,绿色表示易得,粉色则代表顶级收藏。


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

6.1 组件结构与@State变量总览

PageShellCollecting是应用的主入口组件,使用@Entry@Component装饰器标记。@Entry表示这是页面的根组件,@Component表示这是一个可复用的UI组件。

组件内部定义了丰富的@State状态变量,按照功能可以分为以下几类:

导航状态:

  • mainTab: number = 0 —— 底部主导航当前选中的Tab索引
  • subTab: number = 0 —— 首页子导航当前选中的Tab索引

特效状态:

  • tick: number = 0 —— 特效动画的时间计数器,每90毫秒递增1
  • glow: number = 0 —— 光晕状态,在0和1之间循环切换
  • fxTimer: number = -1 —— 特效定时器的句柄,用于资源清理

弹框状态:

  • addOpen: boolean = false —— 新增(发贝语)弹框是否打开
  • editOpen: boolean = false —— 编辑(编辑标本单)弹框是否打开
  • delOpen: boolean = false —— 删除(取消订单/退课)弹框是否打开
  • editIdx: number = 0 —— 当前编辑的标本单索引
  • delTarget: string = 'craft' —— 删除操作的目标类型(craft或course)

弹框表单数据:

  • editName: string = '' —— 编辑弹框中的标本名称
  • editStyle: string = '' —— 编辑弹框中的状态
  • editNote: string = '' —— 编辑弹框中的备注说明
  • addTitle: string = '' —— 新增弹框中的标题
  • addContent: string = '' —— 新增弹框中的内容

列表数据:

  • postList: PostItem[] = POST_LIST —— 贝友动态列表,初始值来自POST_LIST常量
  • craftList: CraftItem[] = CRAFT_LIST —— 标本订单列表,初始值来自CRAFT_LIST常量

这种分类清晰的状态变量设计,使得组件的数据流一目了然。每个状态变量都有明确的职责,遵循了单一职责原则。

6.2 生命周期钩子:aboutToAppear与aboutToDisappear

ArkUI组件提供了丰富的生命周期钩子,允许开发者在组件的不同阶段执行特定逻辑。本应用使用了两个关键的生命周期方法:

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

aboutToAppear在组件即将出现时被调用。在这里,应用启动了一个90毫秒间隔的定时器,用于驱动海气泡和沙粒闪烁的动画效果。定时器每触发一次,tick计数器加1,glow值在0和1之间循环切换。

90毫秒的间隔是一个精心选择的数值——它足够快以产生流畅的动画效果(约11fps,对于简单的位置和透明度变化已经足够),又不会因为频率过高而消耗过多的计算资源。

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

aboutToDisappear在组件即将消失时被调用。在这里,应用清理了特效定时器,防止组件销毁后定时器继续运行导致的内存泄漏和性能浪费。将fxTimer重置为-1是一个良好的防御性编程习惯,可以避免后续误操作。

这种"出现时创建、消失时销毁"的资源管理模式,是ArkUI开发中处理定时器、事件监听等资源的标准范式。

6.3 业务操作方法

组件定义了三组业务操作方法,分别对应新增、编辑、删除三种交互:

新增相关方法:

  • openAdd() —— 打开新增弹框,先清空表单数据再显示弹框
  • doAdd() —— 执行新增操作,将标题非空的新动态插入到postList的最前面(unshift),然后关闭弹框

编辑相关方法:

  • openEdit(index: number) —— 打开编辑弹框,根据索引从craftList中取出对应数据填充到表单
  • doEdit() —— 执行编辑操作,使用splice方法替换数组中的对应项,新创建一个CraftItem实例

删除相关方法:

  • openDelA() —— 打开删除弹框(标本订单模式)
  • openDelB() —— 打开删除弹框(课程退课模式)
  • doDel() —— 执行删除操作,根据delTarget判断删除目标类型,使用splice删除列表第一项

这些方法都遵循了"先验证后操作"的原则。例如doAdd会检查标题长度是否大于0,doEdit和doDel会检查索引是否在有效范围内。这种防御性编程有效避免了边界情况导致的运行时错误。


七、@Builder装饰器与页面构建器详解

@Builder是ArkUI中的一个核心装饰器,它允许开发者将UI片段封装为可复用的构建函数。与自定义组件不同,@Builder方法是组件内部的轻量级UI封装,不需要单独的文件和类定义,可以直接访问组件的状态变量和方法。本应用大量使用@Builder来组织UI代码,使得结构清晰、职责分明。

7.1 fxLayer:特效层构建器

@Builder
fxLayer() {
  Stack({ alignContent: Alignment.TopStart }) {
    ForEach([0, 1, 2, 3, 4], (i: number) => {
      Column()
        .width(bubbleS(this.tick, i))
        .height(bubbleS(this.tick, i))
        .borderRadius(bubbleS(this.tick, i) / 2)
        .backgroundColor(COLORS.tide)
        .opacity(bubbleA(this.tick, i))
        .translate({ x: bubbleX(this.tick, i), y: bubbleY(this.tick, i) })
    }, (i: number) => i.toString())
    ForEach([0, 1, 2, 3, 4, 5], (i: number) => {
      Column()
        .width(3)
        .height(3)
        .borderRadius(2)
        .backgroundColor(COLORS.warning)
        .opacity(sandA(this.tick, i))
        .translate({ x: sandX(this.tick, i), y: sandY(this.tick, i) })
    }, (i: number) => i.toString())
  }
  .width('100%')
  .height('100%')
  .hitTestBehavior(HitTestMode.None)
}

fxLayer是应用的特效层,包含了海气泡上浮和沙粒闪烁两种视觉效果。

海气泡特效使用5个Column元素(通过ForEach遍历[0,1,2,3,4]生成),每个元素的大小、位置、不透明度都通过对应的纯函数基于tick和索引计算得出。由于tick每90毫秒变化一次,这些气泡的属性也会随之更新,形成连续的上浮动画。

沙粒闪烁特效使用6个更小的Column元素(3x3像素),使用琥珀金色(COLORS.warning),同样通过tick驱动的纯函数计算位置和不透明度,产生金色光点闪烁的效果。

特效层最关键的设计是.hitTestBehavior(HitTestMode.None)属性。这个属性设置使得特效层完全不响应触摸事件,所有的点击事件都会穿透特效层传递到下方的内容层。这是一种非常优雅的分层设计——视觉效果和交互逻辑完全解耦,特效层只负责美观,不影响任何用户交互。

7.2 header:头部构建器

header构建器负责页面顶部的内容展示,包含四个主要模块:品牌行、潮汐赶海卡、新人礼横幅、数据四格。

品牌行展示应用logo(🐚emoji)、应用名称(“贝语拾贝”)、定位与潮汐信息(“金沙滩 · 今日退潮 16:40 · 赶海正当时”),以及一个"打卡"按钮。点击打卡按钮会打开发贝语弹框,巧妙地将签到功能与内容发布结合在一起。

潮汐赶海卡是header中最醒目的模块,使用青色(COLORS.accent)作为背景色,上面展示了今日潮汐状态、潮位数据、窗口期时间等关键赶海信息。右侧有一个"查看潮汐表"按钮,使用潮汐色(tide)作为背景,与青色背景形成柔和的对比。

新人礼横幅是一个营销模块,使用纯白卡片背景,展示首单优惠信息和"领取"按钮。

数据四格以四列等宽卡片的形式展示用户的核心数据:收藏贝壳数(486枚,珊瑚粉色)、标本件数(23件,青色)、赶海次数(92次,绿色)、贝币余额(¥58,琥珀色)。每个数据项都使用了不同的主题色,既丰富了视觉效果,又通过颜色建立了数据与功能模块之间的关联。

7.3 subNav:子导航构建器

subNav构建器实现了前面提到的"对勾确认式"子导航。核心设计亮点在于选中态的对勾图标与非选中态的透明占位元素之间的对称设计:

if (this.subTab === idx) {
  Text('✓')
    .fontSize(9)
    .fontWeight(FontWeight.Bold)
    .fontColor(COLORS.white)
    .textAlign(TextAlign.Center)
    .width(16)
    .height(16)
    .borderRadius(8)
    .backgroundColor(COLORS.primaryDark)
} else {
  Column()
    .width(16)
    .height(16)
    .opacity(0)
}

无论选中与否,Tab项右侧都有一个16x16的元素——选中时显示深色圆角背景的白色对勾,未选中时是完全透明的占位元素。这种设计保证了所有Tab项的总宽度一致,切换时不会出现文字位置的跳动。

7.4 页面构建器总览

应用共定义了7个页面级构建器,分别对应子导航的7个Tab:

构建器 对应Tab 核心内容
pageFeatured 精选 玩法四格 + 周赶海柱状图 + 收藏构成条形图 + 人气贝壳双列 + 贝友动态
pageShells 贝壳市集 贝壳列表(左侧色条 + 名称等级 + 价格产地 + 热度条 + 加购按钮)
pageCrafts 我的标本 标本订单列表(图标 + 名称状态 + 内容描述 + 进度条 + 编辑按钮)
pageWorkshop 拾贝工坊 工坊状态四格 + 热门贝壳榜 + 工具速购双列
pageCourse 贝类课堂 课程列表(标题周次 + 进度条 + 学习比例) + 退课按钮
pageCircle 贝友圈 完整动态列表(头像 + 昵称时间 + 正文 + 点赞回复)
pageGear 工具库 三区分类 + 工具列表 + 取消标本订单按钮

此外还有一个pageMine构建器用于"我的"页面,展示用户头像、会员信息、标本档案摘要、贝语摘要等内容。

每个页面构建器都遵循统一的设计规范:顶部有标题行、内容使用白色圆角卡片、卡片间距一致、文字层级分明。这种一致性保证了用户在不同页面间切换时的体验连贯性。

7.5 弹框构建器

应用定义了三种弹框构建器,对应三种弹框场景:

  • addModalBody —— 发布贝语弹框,包含标题输入框、内容文本域、取消/发布按钮
  • editModalBody —— 编辑标本单弹框,包含名称输入框、状态输入框、备注文本域、取消/保存按钮
  • delModalBody —— 删除确认弹框,包含标题、确认提示文字、取消/确认按钮

每种弹框都有对应的遮罩层(modalOverlay),遮罩层是半透明的黑色背景(opacity 0.6),点击遮罩层可以关闭弹框。

弹框的按钮布局采用了一种特殊的实现方式:使用两个Button组件配合负margin的Text组件来显示按钮文字。这是因为ArkUI的Button组件默认不支持自定义文字样式(如颜色、字重),通过Button+Text叠加的方式可以实现更灵活的按钮样式。

7.6 bottomBar:底部导航构建器

bottomBar构建器实现了底部四Tab导航栏。导航栏高度56像素,白色背景,顶部左右各16像素圆角,形成了一种"悬浮"在内容之上的视觉效果。

每个Tab项使用纵向排列的图标和文字,选中态通过不透明度、字重、颜色三重变化来强化。点击事件通过修改mainTab状态变量来触发页面切换。

7.7 mainContent:主内容区构建器

mainContent构建器是页面内容的中枢调度器,根据mainTab和subTab的状态来决定显示哪个页面构建器。它采用了嵌套的if-else结构:

  • 当mainTab为0(首页)时,显示header + subNav + 对应subTab的页面(可滚动)
  • 当mainTab为1(市集)时,显示pageShells页面(可滚动)
  • 当mainTab为2(课堂)时,显示pageCourse页面(可滚动)
  • 当mainTab为3(我的)时,显示pageMine页面(可滚动)

这种设计使得首页具有最丰富的交互(包含子导航和7个内容页),而其他三个主Tab则直接展示对应的核心功能页面,结构清晰且易于扩展。

7.8 build():根构建器

build方法是组件的根构建函数,它使用Stack容器将所有层叠在一起:

build() {
  Stack() {
    Column()
      .width('100%')
      .height('100%')
      .backgroundColor(COLORS.bg)
    this.fxLayer()
    Column() {
      this.mainContent()
      this.bottomBar()
    }
    .width('100%')
    .height('100%')
    // 三个弹框...
  }
  .width('100%')
  .height('100%')
}

Stack的子元素按照声明顺序从下到上排列,因此层级结构为:

  1. 最底层:米白色背景Column
  2. 第二层:fxLayer特效层(海气泡+沙粒,穿透点击)
  3. 第三层:主内容区 + 底部导航
  4. 最上层:弹框(按需显示)

这种清晰的分层设计是Stack布局的典型应用。每一层都有明确的职责,层与层之间通过z-index和hitTestBehavior来控制交互关系。特别是特效层的穿透设计,使得视觉效果与交互逻辑完全解耦,是一种非常优雅的架构实践。


八、应用架构流程图

以下是"贝语"应用的整体架构流程图,展示了从状态驱动到UI渲染的完整数据流:

渲染错误: Mermaid 渲染失败: Parse error on line 4: ...e
色彩系统] A2[@Observed 数据模型

九、数据模型对比分析

为了更清晰地理解四个核心数据模型的设计差异和适用场景,以下从多个维度进行对比分析:

对比维度 PostItem(贝友动态) ShellItem(贝壳市集) CraftItem(标本订单) GearItem(工具)
字段数量 6个 6个 5个 4个
核心标识 id + nick id + name id + name id + name
数值型字段 likes(点赞数) hot(关注度) progress(进度)
颜色映射方式 无直接映射 shellTagColor + levelColor statusColor + statusProgress
列表展示形式 横向卡片(头像+文字) 左侧色条+详细信息 图标+进度条 图标+文字列表
是否可新增 是(发贝语) 否(可编辑)
是否可编辑 是(编辑标本单)
是否可删除 是(退课联动) 是(取消订单)
驱动的页面数 2个(精选+贝友圈) 3个(精选+市集+工坊) 2个(标本+我的) 2个(工坊+工具库)
数据量级 8条初始 8条初始 8条初始 8条初始
@Observed装饰
典型交互 浏览/发布 浏览/加购 浏览/编辑/取消 浏览/加购

从上表可以看出,虽然四个模型都使用了@Observed装饰器,但它们的交互复杂度和状态变化频率是不同的。PostItem和CraftItem因为涉及到用户的增删改操作,所以状态变化更频繁,对观测性能的要求也更高。而ShellItem和GearItem主要是只读展示,虽然也标记了@Observed,但实际运行时的属性变化较少。

这种"统一观测、按需变化"的设计策略是一种务实的工程选择——统一使用@Observed可以保持代码风格的一致性,降低未来功能扩展时的改造成本;而实际运行时只有真正变化的数据才会触发重绘,因此也不会有明显的性能损耗。


十、ArkUI渲染管线与性能优化思考

10.1 声明式渲染管线的工作机制

在ArkUI的声明式渲染管线中,当状态变量发生变化时,框架会执行以下步骤:

  1. 状态变更检测:框架检测到@State变量的值发生了变化
  2. 依赖收集回溯:框架根据之前收集的依赖关系,确定哪些UI组件依赖于这个状态
  3. 差异计算:框架重新执行相关的@Builder函数,生成新的虚拟节点树
  4. 最小化更新:框架对比新旧虚拟节点树的差异,只更新真正变化的部分
  5. 原生渲染:框架将更新指令发送给原生渲染引擎,完成实际的绘制

在本应用中,tick变量的变化是最频繁的(每90毫秒一次),但由于它只影响fxLayer特效层中的少量元素,因此实际的渲染开销很小。这充分体现了ArkUI细粒度更新机制的优势——即使状态变化频率很高,只要影响的范围有限,性能就不会成为问题。

10.2 ForEach的键函数与列表性能

应用中大量使用了ForEach来渲染列表,并且每个ForEach都提供了键函数(第二个参数)。键函数对于列表性能至关重要:

  • 正确的键值使得框架能够复用已有的组件实例,而不是每次都销毁重建
  • 键值的唯一性确保了列表项不会错位
  • 键值的稳定性(不随顺序变化而变化)保证了动画的正确性

本应用中的键函数设计各有特点:使用id.toString()的方案依赖数据的唯一标识,使用label或name的方案则依赖内容的唯一性。不同的策略适用于不同的数据特征。

10.3 hitTestBehavior的性能意义

fxLayer特效层使用了.hitTestBehavior(HitTestMode.None)来实现点击穿透。这个属性不仅有交互层面的意义,也有性能层面的影响:

  • 当设置为None时,框架在进行命中测试时会完全跳过该组件,不需要遍历其子节点
  • 这减少了每次触摸事件的处理时间,提升了交互响应速度
  • 对于纯展示性质的装饰层来说,这是一个重要的性能优化手段

10.4 纯函数计算的性能优势

应用中的特效动画全部通过纯函数计算来实现,这种方式有几个性能优势:

  • 无状态开销:不需要为每个气泡/沙粒维护独立的状态对象,节省了内存
  • 计算即结果:每次tick变化时直接通过公式计算最新值,不需要逐步累加
  • 可预测性:纯函数的结果完全由输入决定,不会产生意外的副作用
  • 易于调试:给定具体的tick和i值,可以精确预测输出结果

十一、总结与展望

11.1 架构设计总结

"贝语·海边拾贝与贝壳标本工坊"应用在架构设计上展现了多方面的技术亮点:

首先,色彩系统的工程化是一个突出的优点。通过ColorPalette接口统一管理16个颜色字段,不仅保证了视觉风格的一致性,也为未来的主题切换功能预留了扩展空间。珊瑚粉与青色的双主色方案既符合海洋主题的美学诉求,又提供了足够的语义区分能力。

其次,状态管理的分层设计体现了对ArkUI状态体系的深刻理解。@Observed用于数据模型类、@State用于组件状态、静态数据使用常量数组——不同性质的数据使用不同的管理方式,形成了层次分明的状态架构。

第三,@Builder的模块化组织使得代码结构清晰可读。15个@Builder方法各司其职,从特效层到页面构建器,从弹框到底部导航,每个构建器都有明确的职责边界。这种"化整为零"的组织方式大大降低了单组件代码的认知复杂度。

第四,纯函数工具集的广泛应用展示了函数式编程思维在UI开发中的价值。颜色映射、特效计算、图表换算等逻辑全部封装为纯函数,既提高了代码的可测试性,又使得UI描述更加简洁 declarative。

第五,Stack分层架构的精妙设计令人印象深刻。背景层、特效层、内容层、弹框层各司其职,通过hitTestBehavior和zIndex精确控制交互和显示顺序,实现了视觉效果与交互逻辑的完美解耦。

11.2 技术实践启示

从这个应用的代码中,我们可以提炼出几条对HarmonyOS ArkUI开发具有普遍指导意义的实践原则:

原则一:数据先行,界面随后。 在开始写UI代码之前,先定义好数据模型、颜色系统、常量配置等基础设施。稳定的数据层是构建可靠UI的基石。

原则二:分层明确,职责单一。 无论是组件的分层设计(Stack的多层结构)还是代码的组织方式(@Builder的模块化拆分),都应该遵循"高内聚、低耦合"的设计原则。

原则三:纯函数优先,副作用隔离。 尽可能将UI计算逻辑封装为纯函数,将有副作用的操作(如定时器、状态变更)集中管理在有限的范围内。

原则四:性能从设计开始。 性能优化不是事后的补救措施,而是应该贯穿设计全过程。从ForEach的键函数选择,到hitTestBehavior的合理使用,再到状态变量的粒度控制,每一个设计决策都在影响着最终的性能表现。

原则五:美学与工程并重。 优秀的应用不仅要有美观的界面,还要有优雅的代码。珊瑚粉海贝主题的色彩系统、对勾确认式导航的交互设计,都体现了美学追求;而接口化的颜色管理、纯函数化的特效计算,则体现了工程素养。两者的结合才能产出真正优秀的产品。

鸽语·信鸽竞翔与赛鸽俱乐部应用虽然是一个垂直领域的产品,但它所采用的技术方案和设计思路,对整个HarmonyOS生态的应用开发都具有参考价值。


安装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;
  tide: string;
}

const COLORS: ColorPalette = {
  primary: '#D98880',
  primaryLight: '#F7E1DE',
  primaryDark: '#B05F5C',
  accent: '#6BA8A9',
  accentLight: '#DCEDED',
  bg: '#F7F1E8',
  cardBg: '#FFFFFF',
  textPrimary: '#4A3A34',
  textSecondary: '#7F6C63',
  textHint: '#B7A69B',
  border: '#EDE1D4',
  success: '#7BA884',
  warning: '#D9A05B',
  danger: '#C76B5A',
  white: '#FFFFFF',
  tide: '#9CC8C8'
};

// ============ 贝友动态数据模型 ============
@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 ShellItem {
  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 CraftItem {
  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 ShellChartItem {
  label: string;
  value: number;
  color: string;
}

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

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

// ============ 顶层静态数据 ============
const POST_LIST: PostItem[] = [
  new PostItem(1, '退潮赶海人', '🐚', '大潮日捡到一枚完整的海菊蛤,刺都齐。', '5分钟前', 134),
  new PostItem(2, '贝壳标本师', '🖼️', '芋螺拼了一幅螺旋画,强迫症狂喜。', '19分钟前', 112),
  new PostItem(3, '夜潮拾贝者', '🌙', '夜灯一开,沙滩上的宝螺会反光。', '36分钟前', 101),
  new PostItem(4, '贝语主理人', '🌊', '周六赶海团还有 4 个位,含潮汐课。', '1小时前', 93),
  new PostItem(5, '微壳收藏家', '🔍', '毫米级的微壳用放大镜挑,别有天地。', '2小时前', 85),
  new PostItem(6, '海边老住户', '🌅', '台风后第二天去捡宝,收获一桶碎片美。', '3小时前', 77),
  new PostItem(7, '标本框手作人', '🎨', '用 120 枚小壳拼了星空,越看越治愈。', '5小时前', 70),
  new PostItem(8, '产地直采党', '✈️', '北海带的虾夷扇贝,壳厚纹深。', '昨天', 62)
];

const SHELL_LIST: ShellItem[] = [
  new ShellItem(1, '海菊蛤 · 完整刺', '东海 · 大潮货', '¥128/枚', '收藏级', 96),
  new ShellItem(2, '芋螺 · 帝王螺', '南海 · 深水', '¥368/枚', '收藏级', 92),
  new ShellItem(3, '宝螺 · 黑星赤脚', '海南 · 退潮带', '¥58/枚', '常见', 88),
  new ShellItem(4, '东风螺 · 斑马纹', '北海 · 养殖', '¥38/枚', '常见', 82),
  new ShellItem(5, '蝾螺 · 帝王蝾', '南沙 · 采集', '¥298/枚', '稀有', 79),
  new ShellItem(6, '骨螺 · 维纳斯', '东海 · 拖网', '¥86/枚', '少见', 74),
  new ShellItem(7, '扇贝 · 虾夷厚壳', '北海 · 直采', '¥26/枚', '常见', 68),
  new ShellItem(8, '鹦鹉螺 · 切片标本', '进口 · 附证书', '¥880/件', '珍稀', 64)
];

const CRAFT_LIST: CraftItem[] = [
  new CraftItem(1, '星空螺旋画 · 120 壳', '微壳拼贴 · 深框', '已寄出', 100),
  new CraftItem(2, '相框标本 · 混合贝', '30 枚 · 原木框', '拼装中', 78),
  new CraftItem(3, '宝螺风铃 · 定制', '12 枚 · 渔线', '研磨中', 60),
  new CraftItem(4, '婚礼伴手礼 · 50 套', '小扇贝 · 刻字', '清洗中', 46),
  new CraftItem(5, '耳环 · 海菊蛤切片', '银钩 · 两对', '拼装中', 32),
  new CraftItem(6, '灯罩 · 荧光壳', '夜光涂层', '设计稿', 18),
  new CraftItem(7, '教堂风彩窗 · 贝片', '彩色壳片 200 枚', '清洗中', 12),
  new CraftItem(8, '企业礼品 · 100 套', '扇贝 · 印 logo', '待付尾款', 6)
];

const GEAR_LIST: GearItem[] = [
  new GearItem(1, '赶海头灯', '夜潮拾贝', '🔦'),
  new GearItem(2, '小耙子 · 三齿', '翻沙寻宝', '⛏️'),
  new GearItem(3, '贝壳清洗液', '除藻去腥', '🧴'),
  new GearItem(4, '研磨海绵', '抛光壳面', '✨'),
  new GearItem(5, '标本深框 · 原木', 'A4 加厚', '🖼️'),
  new GearItem(6, '渔线 · 透明', '风铃绑扎', '🧵'),
  new GearItem(7, '放大镜 · 10 倍', '微壳鉴赏', '🔍'),
  new GearItem(8, '分格收纳盒', '贝壳分类', '🗄️')
];

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

const SHELL_CHART: ShellChartItem[] = [
  { label: '腹足纲', value: 34, color: '#D98880' },
  { label: '双壳纲', value: 27, color: '#6BA8A9' },
  { label: '掘足纲', value: 16, color: '#7BA884' },
  { label: '多板纲', value: 13, color: '#D9A05B' },
  { label: '头足纲', value: 10, color: '#C76B5A' }
];

const TYPE_LIST: BeachTypeItem[] = [
  { 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: '#D98880' },
  { title: '潮汐表与赶海时机', week: '第 2 周', progress: 84, color: '#6BA8A9' },
  { title: '清洗研磨与保养', week: '第 3 周', progress: 55, color: '#7BA884' },
  { title: '拼贴构图与配色', week: '第 4 周', progress: 27, color: '#D9A05B' },
  { title: '珍稀壳法律与收藏', week: '第 5 周', progress: 5, color: '#C76B5A' }
];

// ============ 导航配置 ============
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 shellColor(v: number): string {
  if (v > 9) {
    return '#D98880';
  } else if (v > 6) {
    return '#6BA8A9';
  }
  return '#D9A05B';
}

function bubbleA(tick: number, i: number): number {
  return 0.1 + ((tick + i) % 5) * 0.05;
}

function bubbleX(tick: number, i: number): number {
  return 50 + ((tick * 4 + i * 193) % 590);
}

function bubbleY(tick: number, i: number): number {
  return 660 - ((tick * 8 + i * 217) % 620);
}

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

function sandA(tick: number, i: number): number {
  return 0.12 + ((tick + i * 3) % 4) * 0.07;
}

function sandX(tick: number, i: number): number {
  return 20 + ((tick * 5 + i * 233) % 630);
}

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

function statusColor(s: string): string {
  if (s === '已寄出') {
    return '#7BA884';
  } else if (s === '拼装中') {
    return '#6BA8A9';
  } else if (s === '研磨中') {
    return '#D98880';
  } else if (s === '清洗中') {
    return '#D9A05B';
  }
  return '#B7A69B';
}

function statusProgress(s: string): number {
  if (s === '已寄出') {
    return 100;
  } else if (s === '拼装中') {
    return 76;
  } else if (s === '研磨中') {
    return 58;
  } else if (s === '清洗中') {
    return 40;
  }
  return 10;
}

function shellTagColor(g: string): string {
  if (g.indexOf('海菊') >= 0 || g.indexOf('蝾螺') >= 0) {
    return '#D98880';
  } else if (g.indexOf('宝螺') >= 0 || g.indexOf('芋螺') >= 0) {
    return '#6BA8A9';
  } else if (g.indexOf('扇贝') >= 0 || g.indexOf('东风') >= 0) {
    return '#7BA884';
  }
  return '#D9A05B';
}

function levelColor(s: string): string {
  if (s === '常见') {
    return '#7BA884';
  } else if (s === '少见') {
    return '#6BA8A9';
  } else if (s === '稀有') {
    return '#D9A05B';
  } else if (s === '收藏级') {
    return '#D98880';
  }
  return '#C76B5A';
}

@Entry
@Component
struct PageShellCollecting {
  @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 = 'craft'
  @State editName: string = ''
  @State editStyle: string = ''
  @State editNote: string = ''
  @State addTitle: string = ''
  @State addContent: string = ''
  @State postList: PostItem[] = POST_LIST
  @State craftList: CraftItem[] = CRAFT_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.craftList.length) {
      this.editIdx = index;
      this.editName = this.craftList[index].name;
      this.editStyle = this.craftList[index].status;
      this.editNote = this.craftList[index].item;
    }
    this.editOpen = true;
  }

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

  openDelB(): void {
    this.delTarget = 'course';
    this.delOpen = 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.craftList.length) {
      this.craftList.splice(this.editIdx, 1, new CraftItem(this.craftList[this.editIdx].id, this.editName, this.editNote, this.editStyle, 40));
    }
    this.editOpen = false;
  }

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

  @Builder
  fxLayer() {
    Stack({ alignContent: Alignment.TopStart }) {
      ForEach([0, 1, 2, 3, 4], (i: number) => {
        Column()
          .width(bubbleS(this.tick, i))
          .height(bubbleS(this.tick, i))
          .borderRadius(bubbleS(this.tick, i) / 2)
          .backgroundColor(COLORS.tide)
          .opacity(bubbleA(this.tick, i))
          .translate({ x: bubbleX(this.tick, i), y: bubbleY(this.tick, i) })
      }, (i: number) => i.toString())
      ForEach([0, 1, 2, 3, 4, 5], (i: number) => {
        Column()
          .width(3)
          .height(3)
          .borderRadius(2)
          .backgroundColor(COLORS.warning)
          .opacity(sandA(this.tick, i))
          .translate({ x: sandX(this.tick, i), y: sandY(this.tick, i) })
      }, (i: number) => i.toString())
    }
    .width('100%')
    .height('100%')
    .hitTestBehavior(HitTestMode.None)
  }

  @Builder
  header() {
    Column({ space: 10 }) {
      // 品牌行 + 潮汐定位
      Row({ space: 10 }) {
        Text('🐚')
          .fontSize(24)
        Column({ space: 2 }) {
          Text('贝语拾贝')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('📍 金沙滩 · 今日退潮 16:40 · 赶海正当时')
            .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.openAdd();
          })
      }
      .width('100%')

      // 潮汐赶海卡
      Row({ space: 12 }) {
        Column({ space: 4 }) {
          Text('今日大潮 · 适宜赶海')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
          Text('潮位 -1.8m · 滩涂外扩 300 米')
            .fontSize(10)
            .fontColor('#E5F0EF')
          Row({ space: 6 }) {
            Text('窗口期')
              .fontSize(9)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.primaryDark)
              .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              .backgroundColor(COLORS.tide)
              .borderRadius(6)
            Text('15:20-18:00 · 3 小时')
              .fontSize(9)
              .fontColor('#E5F0EF')
          }
        }
        .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.tide)
          .borderRadius(14)
          .onClick(() => {
            this.openEdit(1);
          })
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.accent)
      .borderRadius(16)

      // 新人礼横幅
      Row({ space: 10 }) {
        Text('🎁')
          .fontSize(22)
        Column({ space: 2 }) {
          Text('首单标本制作立减 ¥20')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('送贝壳清洗液试用装 · 周六可自取')
            .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.primary)
          .borderRadius(14)
          .onClick(() => {
            this.openAdd();
          })
      }
      .width('100%')
      .padding(12)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)

      // 数据四格
      Row({ space: 8 }) {
        Column({ space: 2 }) {
          Text('收藏贝壳')
            .fontSize(9)
            .fontColor(COLORS.textHint)
          Text('486 枚')
            .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('23 件')
            .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('92 次')
            .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('¥58')
            .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() {
    Scroll() {
      Row({ space: 10 }) {
        ForEach(SUB_NAV_LIST, (item: string, idx: number) => {
          Row({ space: 5 }) {
            Text(item)
              .fontSize(this.subTab === idx ? 13 : 11)
              .fontWeight(this.subTab === idx ? FontWeight.Bold : FontWeight.Normal)
              .fontColor(this.subTab === idx ? COLORS.white : COLORS.textSecondary)
            if (this.subTab === idx) {
              Text('✓')
                .fontSize(9)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.white)
                .textAlign(TextAlign.Center)
                .width(16)
                .height(16)
                .borderRadius(8)
                .backgroundColor(COLORS.primaryDark)
            } else {
              Column()
                .width(16)
                .height(16)
                .opacity(0)
            }
          }
          .padding({ left: 10, right: 6, top: 7, bottom: 7 })
          .backgroundColor(this.subTab === idx ? COLORS.primary : COLORS.bg)
          .borderRadius(18)
          .onClick(() => {
            this.subTab = idx;
          })
        }, (item: string) => item)
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)
    }
    .scrollable(ScrollDirection.Horizontal)
    .scrollBar(BarState.Off)
    .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: BeachTypeItem) => {
            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: BeachTypeItem) => it.name)
        }
        .width('100%')
      }
      .width('100%')

      // 周赶海柱状图
      Column() {
        Row() {
          Text('本周赶海打卡次数')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
          Text('单位 次')
            .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, 14))
                .borderRadius(5)
                .backgroundColor(shellColor(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('59%')
            .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(SHELL_CHART, (it: ShellChartItem) => {
          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: ShellChartItem) => 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(SHELL_LIST, (it: ShellItem, idx: number) => {
          if (idx % 2 === 0) {
            Row({ space: 10 }) {
              if (idx < SHELL_LIST.length) {
                Column({ space: 6 }) {
                  Row() {
                    Text('🐚')
                      .fontSize(22)
                      .layoutWeight(1)
                    Text(SHELL_LIST[idx].level)
                      .fontSize(10)
                      .fontWeight(FontWeight.Bold)
                      .fontColor(levelColor(SHELL_LIST[idx].level))
                      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                      .backgroundColor(COLORS.accentLight)
                      .borderRadius(6)
                  }
                  .width('100%')
                  Text(SHELL_LIST[idx].name)
                    .fontSize(14)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  Text(`${SHELL_LIST[idx].price} · 关注 ${SHELL_LIST[idx].hot}`)
                    .fontSize(11)
                    .fontColor(COLORS.textSecondary)
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Start)
                .padding(12)
                .backgroundColor(COLORS.cardBg)
                .borderRadius(12)
              }
              if (idx + 1 < SHELL_LIST.length) {
                Column({ space: 6 }) {
                  Row() {
                    Text('🪸')
                      .fontSize(22)
                      .layoutWeight(1)
                    Text(SHELL_LIST[idx + 1].level)
                      .fontSize(10)
                      .fontWeight(FontWeight.Bold)
                      .fontColor(levelColor(SHELL_LIST[idx + 1].level))
                      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                      .backgroundColor(COLORS.accentLight)
                      .borderRadius(6)
                  }
                  .width('100%')
                  Text(SHELL_LIST[idx + 1].name)
                    .fontSize(14)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  Text(`${SHELL_LIST[idx + 1].price} · 关注 ${SHELL_LIST[idx + 1].hot}`)
                    .fontSize(11)
                    .fontColor(COLORS.textSecondary)
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Start)
                .padding(12)
                .backgroundColor(COLORS.cardBg)
                .borderRadius(12)
              }
            }
            .width('100%')
          }
        }, (it: ShellItem) => 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
  pageShells() {
    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(SHELL_LIST, (it: ShellItem) => {
        Column({ space: 6 }) {
          Row() {
            Column()
              .width(4)
              .height(38)
              .borderRadius(2)
              .backgroundColor(shellTagColor(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(shellTagColor(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)
          }
          .width('100%')
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
      }, (it: ShellItem) => it.id.toString())
    }
    .width('100%')
  }

  @Builder
  pageCrafts() {
    Column({ space: 10 }) {
      Text('我的标本')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .width('100%')
      ForEach(this.craftList, (it: CraftItem, 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: CraftItem) => 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('拼贴台 S3')
            .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('38℃')
            .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('5.0 h')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.success)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
      }
      .width('100%')
      ForEach(SHELL_LIST.slice(0, 5), (it: ShellItem, 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(shellTagColor(it.name))
        }
        .width('100%')
        .padding(10)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
      }, (it: ShellItem) => 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('珍珠会员 · 赶海 92 次 · 收藏 486 枚 · 打卡 84 天')
            .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.craftList.length}`)
            .fontSize(12)
            .fontColor(COLORS.primary)
        }
        .width('100%')
        ForEach(this.craftList.slice(0, 3), (it: CraftItem) => {
          Row() {
            Text(it.name)
              .fontSize(13)
              .fontColor(COLORS.textSecondary)
              .layoutWeight(1)
            Text(it.status)
              .fontSize(11)
              .fontColor(statusColor(it.status))
          }
          .width('100%')
        }, (it: CraftItem) => 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;
        })
    }
    .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 === 'craft' ? '取消标本订单' : '退出贝类课堂')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
      Text(this.delTarget === 'craft' ? '将取消首笔标本订单并按工序退费,确认执行?' : '将退出首门进行中的课程,确认执行?')
        .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
  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.pageShells()
            } 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.pageShells()
          }
          .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%')
      }
    }
    .width('100%')
    .height('100%')
  }
}


11.3 总结:

在这里插入图片描述

基于现有架构,应用未来可以在以下方向进行扩展:

数据层扩展:可以引入更多的数据模型,如用户模型、评论模型、收藏模型等,丰富应用的功能维度。同时可以考虑引入@StorageLink或@LocalStorageProp来实现跨组件的状态共享。

网络层接入:当前所有数据都是本地静态数据,未来可以接入HTTP请求或WebSocket,实现真正的动态数据交互。配合Loading状态和错误处理,可以打造更完整的用户体验。

动画增强:可以进一步利用ArkUI的动画系统(animateTo、transition等)为页面切换、列表操作、弹框出现等交互添加更丰富的动画效果,提升应用的品质感。

组件复用:可以将频繁出现的卡片组件、列表项组件、按钮组件等抽离为独立的自定义@Component,进一步提高代码的复用性和可维护性。

主题系统:基于现有的ColorPalette架构,可以轻松扩展出深色模式、多主题切换等功能。只需维护多套COLORS常量,即可实现整个应用的主题切换。

Logo

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

更多推荐