一、技术背景

1.1 HarmonyOS 声明式 UI 框架

HarmonyOS 声明式 UI 框架是华为面向分布式全场景智慧生活打造的新一代 UI 开发框架,它摒弃了传统命令式 UI 开发中繁琐的视图更新逻辑,转而采用「状态驱动视图」的声明式编程范式。开发者只需描述 UI 在不同状态下的呈现方式,框架会自动处理状态变化到视图更新的映射关系,大幅降低了 UI 开发的复杂度。

在声明式 UI 框架下,每一个页面或组件都由 @Component 装饰的结构体来定义,build() 方法则以声明式的语法描述组件的渲染结果。这种范式与传统 Android/iOS 开发的最大区别在于:开发者不再需要手动获取控件引用、调用 setText/setImage 等方法来更新界面,而是通过状态变量的变化触发整个视图树的差异更新。

声明式 UI 的核心优势在于开发效率和代码可维护性。以本蓝染平台应用为例,首页包含6个内容Tab、4个主Tab、3种弹框以及动态特效,如果采用命令式开发,需要手动管理大量控件引用和状态同步逻辑。而在声明式框架下,只需维护少数几个 @State 变量,所有 UI 变化都由状态自动驱动,代码量和出错概率都大幅降低。

此外,HarmonyOS 声明式 UI 框架还内置了丰富的内置组件(Text、Column、Row、Scroll、Button、Stack 等)和属性方法(fontSize、backgroundColor、borderRadius、padding 等),支持链式调用,使得 UI 代码简洁直观。配合 ArkTS 语言的类型安全特性,开发者可以在编译期发现大量潜在错误,提升应用质量。
在这里插入图片描述

1.2 ArkTS 语言特性

ArkTS 是 HarmonyOS 生态的核心编程语言,它在 TypeScript 的基础上进行了扩展和优化,专门适配声明式 UI 开发和分布式应用场景。ArkTS 保留了 TypeScript 的类型系统和现代语法特性,同时新增了装饰器、状态管理、生命周期等面向 UI 开发的专属能力。

装饰器是 ArkTS 最具特色的语言特性之一。@Component 装饰器将一个结构体标记为自定义组件,使其具备独立渲染和复用的能力;@Entry 装饰器标记页面入口组件,作为页面的根节点;@State 装饰器声明组件内的响应式状态变量,当状态变化时自动触发组件重新渲染;@Observed 装饰器则用于标记可观察的数据类,使其内部属性变化能被框架感知。这些装饰器共同构成了 ArkTS 声明式开发的基石。

在状态管理方面,ArkTS 提供了多层次的状态管理机制。@State 用于组件内部状态,@Prop 用于父组件向子组件的单向数据传递,@Link 用于双向数据绑定,@Provide/@Consume 用于跨层级数据共享,@Observed/@ObjectLink 用于对象级别的细粒度观察。这种分层设计使得开发者可以根据实际场景选择最合适的状态管理方式,既保证了响应式更新的效率,又避免了不必要的性能开销。

ArkTS 还支持 @Builder 装饰器定义可复用的 UI 片段,类似于 React 中的自定义渲染函数。本蓝染应用大量使用了 @Builder 来封装头部、底部导航、各 Tab 内容、弹框等 UI 模块,使得主 build() 方法保持清晰的结构,每个 @Builder 方法职责单一,便于维护和复用。这种模块化的代码组织方式在复杂页面开发中尤为重要。

1.3 HarmonyOS 生态与跨设备能力

HarmonyOS 是面向全场景智慧生活的分布式操作系统,其核心设计理念是「一次开发,多端部署」,即同一套代码可以运行在手机、平板、折叠屏、智慧屏、穿戴设备等多种终端上。这种跨设备能力对于非遗文化数字化类应用尤为重要,因为蓝染这类传统工艺的展示和交互需要在不同尺寸的设备上提供一致的体验。

在本蓝染平台应用中,大量使用了百分比布局(width('100%')height('100%'))和 layoutWeight 弹性布局,这正是 HarmonyOS 跨设备适配的典型实践。相比硬编码像素值,百分比和弹性布局能够根据设备屏幕尺寸自动调整界面元素的大小和位置,确保在手机、平板等不同设备上都有良好的显示效果。结合 constraintSize 等约束属性,可以进一步控制组件在极端尺寸下的行为。

分布式能力是 HarmonyOS 的另一大特色。通过分布式数据管理和分布式任务调度,应用可以实现跨设备的数据同步和能力流转。虽然本示例代码主要聚焦于单设备的 UI 展示,但其数据模型设计(如 DyeItemOrderItemClassItem 等)已经为后续的分布式扩展奠定了基础。未来可以轻松地将染物数据、订单状态等同步到其他设备,实现「手机下单、平板展示、智慧屏教学」的全场景体验。

此外,HarmonyOS 还提供了原子化服务(Atomic Service)能力,应用可以以卡片的形式直接展示在服务中心、负一屏等位置,用户无需安装即可使用核心功能。对于蓝染平台这样的文化类应用,可以将「今日开缸」「热门染物」等信息做成服务卡片,让用户更便捷地获取信息,提升用户粘性和活跃度。

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

@Observed 装饰器是 ArkTS 状态管理体系中的重要组成部分,它用于标记一个类为「可观察类」,使得该类实例的属性变化能够被 UI 框架感知并触发相应的视图更新。与 @State 装饰基本类型变量不同,@Observed 主要用于对象类型的细粒度状态观察。

在本蓝染应用中,DyeItemOrderItemClassItemDyeFriendItem 四个数据模型类都使用了 @Observed 装饰器。这意味着当这些类的实例被放置在 @State 装饰的数组中时,不仅数组本身的变化(如 push、splice)会触发 UI 更新,数组中某个对象的单个属性变化(如修改订单名称、更改染物状态)也会被精确捕捉并更新对应的 UI 片段。这种细粒度的观察机制大大提升了渲染性能,避免了不必要的全量重绘。

@Observed 的工作原理是在类的属性访问器中植入观察者模式的逻辑。当框架首次渲染一个使用了 @Observed 对象属性的组件时,会记录下该属性与组件之间的依赖关系。后续当该属性发生变化时,框架会精确地通知依赖该属性的组件进行更新,而不会影响其他不相关的组件。这种依赖追踪是自动完成的,开发者无需手动编写订阅/取消订阅的代码。

值得注意的是,@Observed 装饰的类需要配合 @ObjectLink@State 使用才能发挥完整效果。在本应用中,@State 装饰的数组(如 @State orders: OrderItem[])中的元素是 @Observed 类的实例,因此数组元素的属性变化也能触发 UI 更新。这种设计模式非常适合列表类页面,每个列表项对应一个数据对象,对象属性的修改只会影响对应列表项的渲染,性能开销极小。

1.5 非遗文化数字化的技术特点与挑战

非遗文化数字化是近年来文化与科技融合的重要方向,蓝染作为国家级非物质文化遗产,其数字化呈现具有独特的技术特点和挑战。首先是色彩还原的准确性,蓝染的靛蓝色系层次丰富,从浅月白到深铁绀多达数十种色阶,如何在不同设备屏幕上准确呈现这些微妙的色彩差异是设计和技术层面都需要解决的问题。本应用通过建立完善的色彩体系(ColorPalette 接口和 COLORS 常量),统一管理靛蓝、藏蓝、绀青、月白等多种蓝色调,确保色彩在全应用中的一致性。

其次是工艺过程的可视化表达。蓝染的制作过程包括扎花、浸染、氧化、晾晒等多个环节,传统的图文展示难以直观呈现这些动态过程。本应用通过「染料气泡上浮」和「靛蓝波纹扩散」等特效,在视觉上模拟染缸中的物理现象,为用户带来沉浸式的体验。虽然当前实现使用 setInterval 驱动的简单动画,但已经体现了将传统工艺数字化、动态化的设计思路。

第三个挑战是社区互动的设计。非遗传承离不开人的参与,如何让染友之间形成良好的互动社区是非遗数字化平台的重要课题。本应用的「染友圈」功能提供了动态发布、点赞、评论等基础社交能力,让染师、学员和爱好者能够分享经验、交流心得。从技术实现来看,动态列表的增删操作通过 @State 数组和 @Observed 数据模型实现了高效的状态管理。

最后,非遗文化类应用还面临内容专业性和用户体验之间的平衡问题。蓝染涉及大量专业术语(如缬、靛花、养缸、建蓝等),如何在保持专业性的同时不让普通用户感到晦涩,需要在产品设计和技术实现上精心打磨。本应用通过「染物图鉴」「靛蓝色谱」「工坊课程」等模块,将专业知识以直观的表格、图表、卡片形式呈现,兼顾了专业性和易用性。

二、项目概览

2.1 功能模块全景

本蓝染平台应用是一个集内容展示、社区互动、在线课程、电商交易于一体的综合型非遗文化数字化平台。应用整体采用「4主Tab + 6内容Tab」的双层导航架构,主Tab包括首页、染坊、市集、我的四个核心模块,首页下又细分为精选、染物图鉴、靛蓝色谱、染缸日历、工坊课程、染友圈六个内容子Tab。

首页是应用的信息汇聚中心,顶部为手作工坊风格的头部区域,包含定位信息、搜索购物入口、今日开缸大卡和热门染物横滚推荐。头部下方是六个内容Tab的横向滚动导航,用户可以快速切换不同的内容维度。精选Tab以横幅轮播、工艺四宫格、染物大卡列表等形式展示平台核心内容;染物图鉴Tab以表格式列表呈现染物的工艺、浸染次数、色阶等专业信息;靛蓝色谱Tab通过柱状图和进度条可视化展示色阶体系和工艺占比;染缸日历Tab以周历形式展示染缸排期和老缸档案;工坊课程Tab展示不同等级的课程信息;染友圈Tab则是社区互动模块。

染坊Tab专注于订单管理,展示当前用户在染坊中的所有订单,包括染物名称、工艺类型、当前工序、费用、工期、染师等信息,并支持订单调整和取消操作。市集Tab是蓝染产品的展示和交易入口,以列表形式呈现各类染物的详细信息和价格,支持下架操作。我的Tab是用户个人中心,展示用户头像、昵称、染龄等基本信息,以及染物件数、在染订单、完成课程、圈内积分等数据统计,下方是我的订单、课程记录、染物图鉴等功能入口。

应用还设计了三种弹框:发布染友动态弹框、调整染物订单弹框、删除/下架确认弹框,分别对应社区发布、订单编辑、危险操作确认等场景。弹框采用半透明遮罩 + 居中卡片的经典设计,通过 if 条件渲染和 zIndex 层级控制实现弹出效果。此外,内容区域还有染料气泡上浮和靛蓝波纹扩散的装饰性特效,营造沉浸感。

2.2 整体架构设计

从架构层面来看,本应用采用了典型的单组件分层设计,所有 UI 和逻辑都集中在一个 Page 组件中,通过 @Builder 装饰器将不同功能模块的 UI 代码拆分为独立的构建方法。这种设计在中小型应用中具有结构清晰、开发效率高的优势,所有状态和方法都在同一作用域内,调用方便。

应用的状态管理采用集中式设计,所有状态变量都定义在 Page 组件顶层,包括主Tab索引(mainTab)、内容Tab索引(subTab)、特效计时器(tickglow)、弹框状态(addModaleditModaldelModal 等)以及各类数据列表(ordersdyesfriends)。这种集中式状态管理的优势在于状态流向清晰,任何状态变化都能追溯到唯一的数据源,避免了多组件间状态同步的复杂性。

数据层由四个 @Observed 装饰的数据模型类(DyeItemOrderItemClassItemDyeFriendItem)和全局常量数据组成。数据模型类定义了业务实体的结构和构造方法,全局常量则提供了初始数据。在实际生产环境中,这些数据通常来自后端 API,但在本示例中采用写死数据的方式,便于演示和调试。值得注意的是,组件内部也维护了一份数据副本(如 this.ordersthis.dyes),用于支持增删改操作。

视图层由一系列 @Builder 方法构成,按照职责可以分为:头部构建器(header)、内容Tab导航(subNav)、各子Tab内容(tabPicktabDyetabShadetabVattabClasstabFriends)、主Tab页面(pageWorkshoppageMarketpageMine)、底部导航(bottomBar)、特效层(fxLayer)、弹框相关(modalOverlayaddModalBodyeditModalBodydelModalBody)。主 build() 方法以 Stack 布局组织主内容区、特效层和弹框层,形成清晰的视觉层级。

三、逐段代码分析

3.1 色彩体系定义:ColorPalette 接口与 COLORS 常量

interface ColorPalette {
  bg: string;
  card: string;
  card2: string;
  title: string;
  sub: string;
  line: string;
  indigo: string;
  indigoDeep: string;
  indigoLight: string;
  sky: string;
  white: string;
  clay: string;
  red: string;
  gold: string;
  mask: string;
  ink: string;
}

const COLORS: ColorPalette = {
  bg: '#F2F4F8',
  card: '#FFFFFF',
  card2: '#E8EDF6',
  title: '#1D2A44',
  sub: '#6B7A99',
  line: '#D8E0EE',
  indigo: '#3352A0',
  indigoDeep: '#23386E',
  indigoLight: '#7B96CE',
  sky: '#AFC4E8',
  white: '#FFFFFF',
  clay: '#B0653A',
  red: '#C94F4F',
  gold: '#C9A24B',
  mask: '#1D2A4499',
  ink: '#141C33'
};

色彩体系是整个应用视觉设计的基石。代码首先定义了 ColorPalette 接口,规定了应用中所有颜色的命名规范,然后通过 COLORS 常量提供具体的色值实现。这种「接口 + 常量」的设计模式确保了颜色使用的类型安全和一致性。

该色彩体系围绕靛蓝色系展开,定义了四个层次的蓝色:indigo(标准靛蓝 #3352A0)作为主色调,indigoDeep(深藏蓝 #23386E)用于强调和深色背景,indigoLight(浅靛蓝 #7B96CE)用于辅助元素和特效,sky(天蓝色 #AFC4E8)用于最浅的蓝色层次。这种多层次蓝色设计精确还原了蓝染工艺中从月白到铁绀的色阶变化,体现了非遗文化数字化的专业性。

除了蓝色系,色彩体系还包含了功能色和中性色:clay(陶土色 #B0653A)作为暖色调点缀,呼应手作工坊的质朴感;red(朱砂红 #C94F4F)用于警示和重要状态;gold(金色 #C9A24B)用于特殊标识;ink(墨色 #141C33)用于最深的文字和背景。中性色方面则从 bg 背景灰到 title 标题色、sub 辅助色、line 分割线色,构建了完整的灰度层次。

这种集中管理颜色的做法有诸多优势:一是确保全局颜色一致,避免同一语义的颜色在不同页面出现色差;二是便于后续主题切换,如果需要增加深色模式或其他主题,只需替换 COLORS 常量即可;三是提高了代码的可维护性,修改颜色时只需改一处,不必在全代码中搜索替换。mask 颜色使用了带透明度的十六进制表示(#1D2A4499),最后两位 99 表示约 60% 的不透明度,用于弹框遮罩层。

3.2 染物数据模型:DyeItem 类

@Observed
export class DyeItem {
  id: number = 0;
  name: string = '';
  craft: string = '';
  dips: string = '';
  color: string = '';
  price: string = '';
  maker: string = '';
  status: string = '';
  constructor(id: number, name: string, craft: string, dips: string, color: string, price: string, maker: string, status: string) {
    this.id = id; this.name = name; this.craft = craft; this.dips = dips;
    this.color = color; this.price = price; this.maker = maker; this.status = status;
  }
}

DyeItem 是染物的数据模型类,使用 @Observed 装饰器标记,使其成为可观察对象。这意味着当 DyeItem 实例的任何属性发生变化时,依赖该属性的 UI 组件都会自动更新。这是 ArkTS 响应式状态管理的核心机制之一。

该类包含八个属性,全面描述了一件染物的信息:id 是唯一标识符,用于列表渲染的 key 和数据操作定位;name 是染物名称,如「苍山云纹方巾」「冰裂纹桌旗」等;craft 描述工艺类型,如「扎染·捆扎」「蜡染·冰裂」等;dips 表示浸染次数,如「8 次浸染」,这是蓝染工艺的重要参数,直接影响颜色深浅;color 是色阶名称,如「绀青」「铁绀」「月白」等;price 是价格字符串;maker 是匠人名称,体现了手作工坊的匠人属性;status 是状态标识,如「在展」「可售」「洽藏中」「新品」等。

构造函数采用了参数全量赋值的简洁写法,将八个参数在两行代码中完成赋值。这种写法虽然紧凑,但也牺牲了一定的可读性。在实际项目中,对于参数较多的构造函数,可以考虑使用对象参数或建造者模式来提高代码的可维护性。不过对于示例项目而言,这种简洁写法是可以接受的。

@Observed 装饰器的使用是这段代码的技术亮点。在传统的状态管理方案中,如果要监听对象属性变化,通常需要手动使用 getter/setter 或 Proxy 来实现,而 ArkTS 通过装饰器语法将这一过程自动化。当 DyeItem 的实例被放入 @State 装饰的数组中时,框架会自动建立属性级别的依赖追踪,实现细粒度的 UI 更新。

3.3 订单数据模型:OrderItem 类

@Observed
export class OrderItem {
  id: number = 0;
  item: string = '';
  craft: string = '';
  stage: string = '';
  fee: string = '';
  days: string = '';
  dyer: string = '';
  status: string = '';
  constructor(id: number, item: string, craft: string, stage: string, fee: string, days: string, dyer: string, status: string) {
    this.id = id; this.item = item; this.craft = craft; this.stage = stage;
    this.fee = fee; this.days = days; this.dyer = dyer; this.status = status;
  }
}

OrderItem 是染物订单的数据模型,同样使用 @Observed 装饰器以支持响应式更新。订单模型与染物模型在结构上有相似之处,但关注点不同:染物模型关注产品本身的属性,订单模型则关注交易和生产过程的状态。

订单模型包含八个属性:id 是订单唯一标识;item 是染物名称,描述订单对应的具体物品,如「真丝长巾·私人订制」;craft 是工艺类型,与染物模型中的 craft 字段含义相同;stage 是当前工序阶段,如「扎花中」「点蜡中」「刮糊中」「养缸中」「浸染中」「排图中」等,反映了蓝染制作的工艺流程;fee 是订单费用;days 是预计工期,如「约 14 天」;dyer 是负责的染师;status 是订单状态,如「进行中」「已收件」「待排期」「候单」等。

从数据建模的角度来看,这个订单模型设计得相当完整,覆盖了从下单到完成的全流程信息。stage 字段特别体现了蓝染行业的特点,将制作过程拆分为多个具体工序,让用户能够实时了解自己的染物处于哪个阶段,这种透明化的生产过程展示能够有效提升用户信任感。

在实际应用中,订单状态的流转通常由后端服务控制,前端负责展示。但本示例中订单数据存储在前端 @State 变量中,并支持编辑操作,这为演示状态管理和 UI 响应式更新提供了很好的场景。当用户修改订单信息时,@Observed 装饰的 OrderItem 对象属性变化会自动触发对应列表项的重新渲染。

3.4 课程与染友数据模型

@Observed
export class ClassItem {
  id: number = 0;
  name: string = '';
  level: string = '';
  price: string = '';
  quota: string = '';
  teacher: string = '';
  mat: string = '';
  status: string = '';
  constructor(id: number, name: string, level: string, price: string, quota: string, teacher: string, mat: string, status: string) {
    this.id = id; this.name = name; this.level = level; this.price = price;
    this.quota = quota; this.teacher = teacher; this.mat = mat; this.status = status;
  }
}

ClassItem 是工坊课程的数据模型,描述了蓝染课程的各项属性。level 字段表示课程难度等级,包括「体验」「入门」「进阶」「理论」「亲子」「研修」等多个级别,满足不同层次学员的需求。quota 字段以「余 X/Y」的格式表示名额情况,让用户直观了解课程的热门程度。teacher 是授课老师,mat 是包含的材料,这些信息直接影响用户的报名决策。

课程等级的多样化设计反映了蓝染教学体系的完整性。从零基础的体验课到专业的设计师研修班,覆盖了从入门到精通的完整学习路径。这种分层课程设计在非遗传承数字化中非常重要,它既降低了普通人接触传统文化的门槛,也为深度爱好者提供了进阶通道。

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

DyeFriendItem 是染友圈动态的数据模型,是社区功能的核心数据结构。nickavatar 分别是用户昵称和头像(这里用 emoji 表示),text 是动态正文内容,likesreplies 是点赞数和评论数,tag 是动态标签(如「缸况日记」「手艺传承」「工艺笔记」等),time 是发布时间。

染友圈动态的标签设计很有特色,它不是传统的话题标签,而是按照内容性质分类的垂直标签。「缸况日记」记录染缸状态,「手艺传承」分享传统技艺,「工艺笔记」总结技术经验,「祖纹故事」讲述文化背景,「作品上新」展示新品发布,「老铺手记」记录匠人日常,「翻车惊喜」分享意外收获,「开放日」发布活动信息。这些标签既丰富了社区内容的维度,也体现了蓝染文化的深厚底蕴。

3.5 图表数据结构与全局数据

interface ShadeChartItem {
  name: string;
  val: number;
  color: string;
}

const shadeCharts: ShadeChartItem[] = [
  { name: '蓝白', val: 24, color: '#AFC4E8' },
  { name: '月白', val: 38, color: '#7B96CE' },
  { name: '绀青', val: 62, color: '#3352A0' },
  { name: '藏蓝', val: 82, color: '#23386E' },
  { name: '铁绀', val: 96, color: '#141C33' }
];

ShadeChartItem 接口定义了色阶柱状图的数据结构,name 是色阶名称,val 是数值(用于计算柱高),color 是对应的颜色值。shadeCharts 常量数组包含五个传统靛蓝色阶,从浅到深依次为蓝白、月白、绀青、藏蓝、铁绀,数值从 24 递增到 96,形成一个完整的色阶体系。

这五个色阶的命名都来源于中国传统染色工艺的术语,具有深厚的文化内涵。「蓝白」是最浅的色调,接近本色;「月白」是微微泛蓝的浅色,因月色而得名;「绀青」是标准的靛蓝色,也是最具代表性的颜色;「藏蓝」是较深的蓝色,因常用于藏地服饰而得名;「铁绀」是最深的颜色,因色深如铁而得名。这些传统色名的使用,体现了非遗数字化应用对文化准确性的追求。

interface CraftItem {
  label: string;
  pct: number;
  color: string;
}

const craftList: CraftItem[] = [
  { label: '扎染·捆扎防染', pct: 86, color: '#3352A0' },
  { label: '蜡染·点蜡封色', pct: 64, color: '#23386E' },
  { label: '夹染·木板夹防', pct: 47, color: '#7B96CE' },
  { label: '型糊染·刮糊显花', pct: 29, color: '#B0653A' }
];

CraftItem 接口定义了工艺占比的数据结构,label 是工艺名称和简要说明,pct 是百分比数值,color 是进度条颜色。craftList 常量列出了四大蓝染工艺:扎染、蜡染、夹染、型糊染,它们的占比分别为 86%、64%、47%、29%,反映了不同工艺的普及程度。

这四大工艺是中国传统蓝染的核心技法,每种工艺都有独特的艺术效果和技术特点。扎染通过捆扎布料形成防染区,染出的花纹灵动自然;蜡染用蜡液绘制图案后浸染,蜡裂开形成的冰裂纹是其标志性特色;夹染用木板夹住布料染色,产生规整的几何图案;型糊染用防染糊刮过雕版印花,图案精细且可批量生产。这些工艺知识通过可视化的进度条呈现,既美观又有教育意义。

3.6 全局写死数据:染物列表

const dyeList: DyeItem[] = [
  new DyeItem(1, '苍山云纹方巾', '扎染·捆扎', '8 次浸染', '绀青', '¥168', '白族阿嬢', '在展'),
  new DyeItem(2, '冰裂纹桌旗', '蜡染·冰裂', '12 次浸染', '铁绀', '¥298', '染师老和', '可售'),
  new DyeItem(3, '鱼子缬抱枕套', '扎染·鱼子缬', '6 次浸染', '藏蓝', '¥138', '白族阿嬢', '可售'),
  new DyeItem(4, '蝴蝶妈妈挂画', '蜡染·点蜡', '15 次浸染', '铁绀', '¥588', '苗寨阿雅', '洽藏中'),
  new DyeItem(5, '蓝白条纹围巾', '夹染·条纹', '5 次浸染', '月白', '¥128', '染师阿月', '可售'),
  new DyeItem(6, '型糊染茶席', '型糊染', '10 次浸染', '绀青', '¥368', '老铺周师傅', '在展'),
  new DyeItem(7, '渐变晕染连衣裙', '吊染·渐变', '9 次浸染', '月白-绀青', '¥468', '设计师小林', '新品'),
  new DyeItem(8, '小孩虎头肚兜', '扎染·缝扎', '7 次浸染', '藏蓝', '¥98', '白族阿嬢', '可售')
];

dyeList 是全局染物列表数据,包含 8 件精选染物。这些染物的命名都非常有意境,如「苍山云纹方巾」「冰裂纹桌旗」「蝴蝶妈妈挂画」等,每一件都像一件艺术品。匠人一栏出现了「白族阿嬢」「染师老和」「苗寨阿雅」「染师阿月」「老铺周师傅」「设计师小林」等多个角色,体现了蓝染传承的多元背景——有少数民族传统艺人,有资深染师,也有年轻设计师。

从数据设计的角度来看,这些模拟数据非常用心。每件染物的工艺、浸染次数、色阶之间都有合理的对应关系:浸染次数少的颜色浅(如 5 次浸染的月白),次数多的颜色深(如 15 次浸染的铁绀)。价格也根据工艺复杂度和浸染次数有所差异,从 98 元到 588 元不等。状态字段则包括「在展」「可售」「洽藏中」「新品」等多种情况,丰富了列表的视觉层次。

在实际项目中,这些数据通常从后端 API 获取,但在前端开发阶段使用模拟数据是常见的做法。将模拟数据定义为全局常量有几个好处:一是便于在多个组件中共享,二是数据与组件解耦,后续替换为真实接口时只需修改数据来源即可,三是常量数据在编译期确定,运行时不可变,有利于性能优化。

值得注意的是,代码中既定义了全局的 dyeList 常量,又在 Page 组件中定义了 @State dyes: DyeItem[] 状态数组并赋了相同的初始值。这种设计模式下,全局常量作为初始数据源,组件内部的状态数组用于支持增删改等交互操作。两者分离确保了全局数据不会被意外修改,同时组件可以自由维护自己的状态。

3.7 订单与课程列表数据

const orderList: OrderItem[] = [
  new OrderItem(1, '真丝长巾·私人订制', '扎染·缝扎', '扎花中', '¥288', '约 14 天', '染师阿月', '进行中'),
  new OrderItem(2, '棉麻桌旗·冰裂蜡染', '蜡染·冰裂', '点蜡中', '¥398', '约 21 天', '染师老和', '进行中'),
  new OrderItem(3, '帆布包·型糊染字', '型糊染', '刮糊中', '¥158', '约 10 天', '老铺周师傅', '已收件'),
  new OrderItem(4, '旧衣翻染·牛仔外套', '吊染·渐变', '养缸中', '¥198', '约 7 天', '染师阿月', '待排期'),
  new OrderItem(5, '亲子 T 恤一对', '扎染·捆扎', '浸染中', '¥168', '约 10 天', '白族阿嬢', '进行中'),
  new OrderItem(6, '和风门帘·大版蜡染', '蜡染·大版', '排图中', '¥688', '约 30 天', '苗寨阿雅', '候单')
];

orderList 是订单列表的全局模拟数据,包含 6 个不同状态的订单。这些订单展示了蓝染定制服务的多样性:有真丝长巾的私人订制,有棉麻桌旗的冰裂蜡染,有帆布包的型糊染字,还有旧衣翻染、亲子 T 恤、和风门帘等。每个订单处于不同的工序阶段(扎花中、点蜡中、刮糊中、养缸中、浸染中、排图中),状态也各不相同(进行中、已收件、待排期、候单),真实反映了染坊订单的实际情况。

订单数据中的「工期」字段特别有意思,从 7 天到 30 天不等,反映了不同工艺的复杂度差异。简单的吊染只需 7 天,而大版蜡染门帘需要 30 天。这种透明的工期展示在定制类服务中非常重要,它帮助用户建立合理的预期,减少因等待时间不确定而产生的焦虑。

const classList: ClassItem[] = [
  new ClassItem(1, '扎染体验·半日', '体验', '¥168', '余 8/12', '染师阿月', '含白坯方巾×2', '报名中'),
  new ClassItem(2, '蜡染入门·点蜡笔法', '入门', '¥298', '余 5/8', '染师老和', '含蜡刀+棉布', '报名中'),
  new ClassItem(3, '型糊染·雕版与刮糊', '进阶', '¥398', '余 3/6', '老铺周师傅', '送自家雕版', '即将满员'),
  new ClassItem(4, '养缸与建蓝·理论', '理论', '¥98', '余 15/20', '周师傅', '菌种+石灰配比', '热抢'),
  new ClassItem(5, '亲子扎染周末场', '亲子', '¥258', '余 6/10', '白族阿嬢', '一大一小材料', '报名中'),
  new ClassItem(6, '蓝染设计师研修', '研修', '¥1,980', '余 2/6', '设计师小林', '五天沉浸+成品', '最后名额')
];

classList 是工坊课程列表,包含 6 门不同等级的课程。课程等级从「体验」到「研修」逐步进阶,价格从 98 元到 1980 元跨度很大,满足了从好奇体验到专业研修的不同需求。每门课程都明确标注了包含的材料,如「含白坯方巾×2」「含蜡刀+棉布」「送自家雕版」等,这种透明化的材料说明有助于用户判断课程的性价比。

课程的名额信息(如「余 8/12」)采用了「剩余/总名额」的格式,让用户直观了解课程的热门程度和紧迫感。状态字段如「即将满员」「热抢」「最后名额」等则起到了营销促进的作用,刺激用户尽快报名。这些细节设计体现了电商运营思维在非遗课程推广中的应用。

3.8 导航常量与染友圈数据

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

const NAV_LIST: NavItem[] = [
  { icon: '🌀', label: '首页' },
  { icon: '🏛️', label: '染坊' },
  { icon: '🛍️', label: '市集' },
  { icon: '🧑‍🎨', label: '我的' }
];

NavItem 接口定义了底部导航项的数据结构,icon 是图标(使用 emoji),label 是文字标签。NAV_LIST 常量定义了四个主导航项:首页、染坊、市集、我的。使用 emoji 作为图标是一种轻量级的实现方式,不需要额外的图标资源文件,开发效率高,且在支持 emoji 的设备上都能正常显示。

这种「数据驱动导航」的设计模式是声明式 UI 的典型实践。导航的配置数据(图标和标签)与导航的渲染逻辑分离,通过 ForEach 遍历数据数组来生成导航项。如果后续需要调整导航项的顺序或增加新的导航项,只需修改 NAV_LIST 数组即可,无需改动渲染代码。这种设计提高了代码的可维护性和扩展性。

const friendList: DyeFriendItem[] = [
  new DyeFriendItem(1, '染师阿月', '🌀', '养了三年的老缸今天状态极佳,缸面泛着靛花,浸下去三分钟就上色,8 缸的绀青稳了。', 368, 29, '缸况日记', '1小时前'),
  new DyeFriendItem(2, '白族阿嬢', '🌸', '周城的老规矩:扎花的手劲决定蓝白的锋利,机器永远扎不出手上这种活口的灵气。', 412, 35, '手艺传承', '3小时前'),
  new DyeFriendItem(3, '染师老和', '🕯️', '冰裂纹的秘密是蜡裂开后让靛蓝钻进去,裂纹的走向全凭手感,世上没有两幅相同的冰裂。', 356, 28, '工艺笔记', '5小时前'),
  // ... 更多动态
];

friendList 是染友圈动态列表,包含 8 条精心撰写的动态内容。每条动态都以第一人称的口吻讲述蓝染相关的故事或知识,内容质量很高,读起来像是真实染师的分享。「染师阿月」分享养缸心得,「白族阿嬢」讲述传统规矩,「染师老和」揭秘冰裂纹工艺,「苗寨阿雅」讲述祖纹故事,「设计师小林」展示作品创意……这些角色塑造得非常立体,各自有不同的视角和风格。

染友圈的内容设计是这个应用的一大亮点。它不是简单的社交功能,而是通过 UGC(用户生成内容)的形式传递蓝染文化知识。每条动态都蕴含着专业内容,用户在浏览动态的过程中自然而然地了解了蓝染的工艺、历史和文化。这种「内容即社区」的设计思路非常适合文化类应用,它让社区不仅仅是交流的场所,更是知识传播的渠道。

3.9 顶层纯函数:色彩辅助与特效计算

function shadeBarH(val: number): string {
  return (val * 0.78).toString() + '%';
}

function dyeStatusColor(s: string): string {
  if (s === '洽藏中' || s === '新品') { return COLORS.red; }
  if (s === '在展') { return COLORS.indigo; }
  return COLORS.clay;
}

function orderStatusColor(s: string): string {
  if (s === '进行中') { return COLORS.indigo; }
  if (s === '已收件') { return COLORS.gold; }
  if (s === '候单') { return COLORS.red; }
  return COLORS.sub;
}

这三个纯函数是视图渲染的辅助工具。shadeBarH 函数将色阶数值转换为柱状图的高度百分比,乘以 0.78 的系数是为了让最高的柱子也不会超出容器高度,留出适当的顶部空间。这种将数据映射到视觉尺寸的函数是数据可视化中的常见模式。

dyeStatusColororderStatusColor 是状态颜色映射函数,分别根据染物状态和订单状态返回对应的颜色值。染物状态中,「洽藏中」和「新品」用红色表示特殊/热门,「在展」用靛蓝表示正常展示,其他状态(如「可售」)用陶土色表示。订单状态中,「进行中」用靛蓝表示正常,「已收件」用金色表示完成,「候单」用红色表示等待,其他用灰色表示。

状态颜色映射是 UI 开发中非常常见的需求,将其封装为纯函数有诸多好处:一是避免在模板代码中出现大量的条件判断逻辑,保持 UI 代码的简洁性;二是集中管理状态与颜色的映射关系,便于统一修改和维护;三是纯函数易于测试,可以独立验证各种状态对应的颜色是否正确。

function bubbleX(seed: number): number {
  return (seed * 61) % 93 + 4;
}

function bubbleY(seed: number, tick: number): number {
  return 92 - ((seed * 23 + tick * 7) % 86);
}

function rippleSize(glow: boolean): number {
  return glow ? 46 : 22;
}

function dyeIconBg(seed: number): string {
  const arr: string[] = ['#E8EDF6', '#DFE7F3', '#EDF1F9', '#E3EAF5'];
  return arr[seed % 4];
}

这四个函数是特效和视觉变化的计算函数。bubbleXbubbleY 计算气泡的位置坐标,使用简单的数学公式生成伪随机分布的效果。seed 参数是气泡的种子值,不同的种子产生不同的位置,tick 参数是计时器的节拍数,随时间递增,驱动气泡向上浮动。bubbleY92 - (...) 的计算方式使得气泡从底部向顶部移动,模拟气泡上浮的物理现象。

rippleSize 函数根据 glow 状态返回波纹的大小,当 glow 为 true 时返回 46(扩散状态),为 false 时返回 22(收缩状态)。配合 glow 状态的周期性切换,形成波纹扩散和收缩的动画效果。dyeIconBg 函数则根据种子值从四个浅蓝灰色中选取一个,为染物图标提供略有差异的背景色,增加视觉层次感。

这些函数的设计体现了「用计算代替资源」的优化思路。传统的动画效果通常需要使用 GIF、序列帧或 Lottie 等资源文件,而这里通过简单的数学公式和状态变化就能生成动态效果,无需额外的资源文件,包体更小,性能更优。这种思路在轻量级特效场景中非常值得借鉴。

3.10 主组件定义与状态变量

@Entry
@Component
struct Page {
  @State mainTab: number = 0;
  @State subTab: number = 0;
  @State tick: number = 0;
  @State glow: boolean = false;
  // 弹框状态
  @State addModal: boolean = false;
  @State editModal: boolean = false;
  @State delModal: boolean = false;
  @State editIdx: number = -1;
  @State delTarget: string = 'dye';
  @State editItem: string = '';
  @State editCraft: string = '';
  @State addNick: string = '';
  @State addText: string = '';

Page 组件是整个应用的根组件,使用 @Entry 装饰器标记为页面入口,使用 @Component 装饰器标记为自定义组件。@Entry 装饰的组件会作为页面的根节点被框架渲染,一个页面只能有一个 @Entry 组件。

组件中定义了大量 @State 装饰的状态变量,可以分为几类:导航状态(mainTabsubTab)分别控制主Tab和内容Tab的切换;特效状态(tickglow)驱动气泡上浮和波纹扩散动画;弹框状态(addModaleditModaldelModal)控制三种弹框的显示与隐藏;弹框数据(editIdxdelTargeteditItemeditCraftaddNickaddText)存储弹框操作所需的上下文数据。

所有状态变量都集中定义在组件顶部,这种设计使得组件的状态一目了然,便于理解和维护。@State 装饰器是 ArkTS 中最基础也是最常用的状态管理方式,它将变量标记为响应式状态,当变量值发生变化时,所有依赖该变量的 UI 组件都会自动重新渲染。@State 变量必须初始化,且只能在当前组件内部访问。

状态变量的命名非常清晰,通过名称就能理解其用途。mainTabsubTab 使用数字索引来标识当前选中的 Tab,这种方式在 Tab 切换场景中简单高效。弹框相关的布尔变量采用 xxxModal 的命名约定,弹框数据变量则采用 editXxxaddXxxdelXxx 的前缀区分,代码可读性很好。

3.11 列表状态数据与特效计时器

  // 列表
  @State orders: OrderItem[] = [
    new OrderItem(1, '真丝长巾·私人订制', '扎染·缝扎', '扎花中', '¥288', '约 14 天', '染师阿月', '进行中'),
    new OrderItem(2, '棉麻桌旗·冰裂蜡染', '蜡染·冰裂', '点蜡中', '¥398', '约 21 天', '染师老和', '进行中'),
    new OrderItem(3, '帆布包·型糊染字', '型糊染', '刮糊中', '¥158', '约 10 天', '老铺周师傅', '已收件'),
    new OrderItem(4, '旧衣翻染·牛仔外套', '吊染·渐变', '养缸中', '¥198', '约 7 天', '染师阿月', '待排期'),
    new OrderItem(5, '亲子 T 恤一对', '扎染·捆扎', '浸染中', '¥168', '约 10 天', '白族阿嬢', '进行中'),
    new OrderItem(6, '和风门帘·大版蜡染', '蜡染·大版', '排图中', '¥688', '约 30 天', '苗寨阿雅', '候单')
  ];
  @State dyes: DyeItem[] = [
    new DyeItem(1, '苍山云纹方巾', '扎染·捆扎', '8 次浸染', '绀青', '¥168', '白族阿嬢', '在展'),
    // ... 更多染物
  ];
  @State friends: DyeFriendItem[] = [
    new DyeFriendItem(1, '染师阿月', '🌀', '养了三年的老缸今天状态极佳...', 368, 29, '缸况日记', '1小时前'),
    // ... 更多动态
  ];
  fxTimer: number = -1;

三个列表状态变量 ordersdyesfriends 分别存储订单、染物和染友圈动态的数据。这些数组使用 @State 装饰,意味着数组本身的变化(如 push、splice、重新赋值)会触发 UI 更新。同时,由于数组元素是 @Observed 装饰的类实例,元素属性的变化也能触发对应 UI 的更新,形成了双层响应式机制。

fxTimer 变量存储特效计时器的 ID,类型为 number,初始值为 -1 表示计时器未启动。这个变量没有使用 @State 装饰,因为它只是一个普通的引用变量,不需要触发 UI 更新,仅用于在生命周期回调中启动和清除计时器。这是一种良好的实践——只有真正需要驱动 UI 变化的变量才需要 @State,普通变量保持原样即可,避免不必要的渲染开销。

数据初始化采用了内联方式,直接在 @State 变量声明时填充初始数据。对于小型示例项目来说,这种方式简单直接。但在实际生产环境中,通常会将数据请求放在 aboutToAppear 生命周期回调中进行,通过异步请求从后端获取数据,然后赋值给 @State 变量触发渲染。

值得关注的是,ordersdyes 的初始数据与全局常量 orderListdyeList 的内容完全相同。这种冗余设计的原因是:全局常量是不可变的参考数据,而组件内的 @State 数组是可变的业务数据,支持增删改操作。两者分离确保了数据源的纯净性,同时为组件提供了独立的状态空间。

3.12 生命周期:aboutToAppear 与 aboutToDisappear

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

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

aboutToAppearaboutToDisappear 是 ArkTS 组件的两个核心生命周期回调函数。aboutToAppear 在组件即将出现(即将执行 build() 方法之前)时被调用,适合进行数据初始化、事件订阅、启动定时器等准备工作。aboutToDisappear 在组件即将销毁时被调用,用于清理资源,如取消订阅、清除定时器、释放内存等。

在本应用中,aboutToAppear 用于启动特效定时器。setInterval 每隔 700 毫秒执行一次回调,将 tick 加 1 并切换 glow 的布尔值。tick 的递增驱动气泡位置的变化(通过 bubbleY 函数计算),glow 的切换驱动波纹大小的变化(通过 rippleSize 函数计算)。700 毫秒的间隔是一个经过精心选择的数值——既不会太快导致视觉闪烁,也不会太慢让动画显得迟钝,营造出染缸中气泡缓缓上浮的闲适感。

aboutToDisappear 中调用 clearInterval 清除定时器,这是非常重要的资源清理操作。如果不清除定时器,即使组件已经销毁,定时器回调仍然会继续执行,导致内存泄漏和性能问题。正确配对使用 aboutToAppearaboutToDisappear 是 ArkTS 开发的最佳实践之一。

定时器的实现方式也值得讨论。本应用使用原生的 setInterval 来驱动动画,这是一种简单直接的方式。但在 HarmonyOS 中,也可以使用 animateTo 显式动画 API 来实现更流畅的动画效果。setInterval 的优势是简单灵活,可以精确控制每一帧的状态;劣势是性能相对较低,且动画不够平滑。对于本应用这种装饰性的轻量特效,setInterval 已经足够满足需求。

3.13 弹框工具方法:打开弹框

  openAdd(): void {
    this.addNick = '';
    this.addText = '';
    this.addModal = true;
    this.editModal = false;
    this.delModal = false;
  }

  openEdit(idx: number): void {
    this.editIdx = idx;
    this.editItem = this.orders[idx].item;
    this.editCraft = this.orders[idx].craft;
    this.addModal = false;
    this.editModal = true;
    this.delModal = false;
  }

  openDelOrder(idx: number): void {
    this.delTarget = 'order';
    this.editIdx = idx;
    this.addModal = false;
    this.editModal = false;
    this.delModal = true;
  }

  openDelDye(idx: number): void {
    this.delTarget = 'dye';
    this.editIdx = idx;
    this.addModal = false;
    this.editModal = false;
    this.delModal = true;
  }

这四个方法是弹框的打开函数,分别对应发布动态弹框、编辑订单弹框、删除订单确认弹框、下架染物确认弹框。每个方法都遵循相同的模式:设置弹框所需的数据、将目标弹框的显示状态设为 true、将其他弹框的显示状态设为 false。最后两步确保了同一时间只有一个弹框显示,避免了弹框叠加的问题。

openAdd 方法在打开弹框前先清空 addNickaddText 字段,确保每次打开弹框都是空白的初始状态。这种「重置状态」的操作在弹框场景中很常见,因为用户可能上次输入了内容但取消了,下次打开时不应保留上次的内容。

openEdit 方法接收一个索引参数 idx,先将索引保存到 editIdx 中(供后续保存操作使用),然后从 orders 数组中取出对应订单的名称和工艺,赋值给 editItemeditCraft。这相当于将编辑数据「加载」到弹框的表单中,用户可以在此基础上修改。

openDelOrderopenDelDye 都是打开删除确认弹框,但操作的目标不同(一个是订单,一个是染物)。通过 delTarget 变量来区分操作目标,后续的删除操作 doDel 会根据这个变量决定操作哪个数组。这种设计复用了同一个删除确认弹框,减少了重复代码。

3.14 弹框工具方法:确认操作

  doAdd(): void {
    if (this.addText.length > 0) {
      const n = this.friends.length + 1;
      this.friends.push(new DyeFriendItem(n, this.addNick.length > 0 ? this.addNick : '新染友', '🌀', this.addText, 0, 0, '新动态', '刚刚'));
      this.addModal = false;
    }
  }

  doEdit(): void {
    if (this.editIdx >= 0 && this.editIdx < this.orders.length) {
      this.orders[this.editIdx].item = this.editItem;
      this.orders[this.editIdx].craft = this.editCraft;
      this.editModal = false;
    }
  }

  doDel(): void {
    if (this.delTarget === 'order') {
      if (this.editIdx >= 0 && this.editIdx < this.orders.length) {
        this.orders.splice(this.editIdx, 1);
        this.delModal = false;
      }
    } else {
      if (this.editIdx >= 0 && this.editIdx < this.dyes.length) {
        this.dyes.splice(this.editIdx, 1);
        this.delModal = false;
      }
    }
  }

这三个方法是弹框的确认操作函数,分别处理发布动态、编辑订单、删除/下架的逻辑。每个方法都包含数据校验、数据操作和关闭弹框三个步骤,体现了「先验证再操作」的防御式编程思想。

doAdd 方法首先检查 addText 是否有内容(正文是必填项),如果有内容则创建一个新的 DyeFriendItem 对象并 push 到 friends 数组中。昵称 addNick 是可选的,如果为空则使用默认值「新染友」。新动态的 ID 取当前数组长度 + 1,点赞数和评论数初始为 0,标签为「新动态」,时间为「刚刚」。这些默认值的设计符合社交产品的常规交互逻辑。

doEdit 方法先校验 editIdx 的合法性(在数组有效索引范围内),然后通过索引找到对应的订单对象,修改其 itemcraft 属性。由于 OrderItem 类使用了 @Observed 装饰器,修改对象属性会自动触发对应列表项的 UI 更新,无需手动操作视图。

doDel 方法根据 delTarget 的值决定操作哪个数组:如果是 'order',则从 orders 数组中删除对应索引的元素;如果是 'dye'(或其他值,作为默认分支),则从 dyes 数组中删除。删除操作使用 splice 方法,它会修改原数组并触发 UI 更新。删除成功后关闭弹框。

这三个方法都体现了 ArkTS 声明式开发的特点:操作数据,UI 自动更新。开发者不需要关心 DOM 操作或视图刷新,只需专注于数据逻辑的正确性,框架会自动处理状态到视图的映射。

3.15 特效层构建器:fxLayer

  @Builder
  fxLayer() {
    Stack({ alignContent: Alignment.TopStart }) {
      ForEach([0, 1, 2, 3, 4, 5], (s: number) => {
        Column() {
          Text('°')
            .fontSize(s % 2 === 0 ? 14 : 9)
            .fontColor(COLORS.indigoLight)
            .opacity(this.glow ? 0.8 : 0.3)
        }
        .position({ x: bubbleX(s + 1), y: bubbleY(s + 2, this.tick) })
      }, (s: number) => s.toString())

fxLayer 是特效层的构建器,使用 @Builder 装饰器定义。@Builder 是 ArkTS 提供的一种轻量级 UI 复用机制,它可以将一段 UI 描述封装为可复用的函数,在 build() 方法中像调用普通函数一样调用。@Builder 方法可以访问组件的状态变量和其他方法,非常适合封装页面内的 UI 片段。

特效层使用 Stack 布局作为容器,Stack 的子组件会堆叠在一起,配合 position 定位可以实现绝对定位效果。这是实现自由定位元素(如气泡、波纹)的常用手法。alignContent: Alignment.TopStart 设置对齐方式为左上角,为绝对定位提供统一的坐标原点。

气泡部分使用 ForEach 遍历 0 到 5 的数组,生成 6 个气泡。每个气泡是一个包含 Text('°')Column,通过 position 属性定位到具体坐标。气泡的 X 坐标通过 bubbleX(s + 1) 计算,Y 坐标通过 bubbleY(s + 2, this.tick) 计算。由于 tick 状态随时间递增,气泡的 Y 坐标会不断变化,形成上浮的动画效果。

气泡的大小通过 fontSize 控制,偶数索引的气泡为 14px,奇数索引的为 9px,形成大小交替的视觉效果。颜色使用 COLORS.indigoLight(浅靛蓝),透明度通过 this.glow 状态切换:glow 为 true 时 0.8 不透明,为 false 时 0.3 透明。这种透明度的呼吸效果配合气泡上浮,模拟了染缸中气泡上升时的光影变化。

      ForEach([6, 7], (s: number) => {
        Column() {
          Column()
            .width(rippleSize(this.glow))
            .height(rippleSize(this.glow))
            .borderRadius(rippleSize(this.glow) / 2)
            .border({ width: 1, color: COLORS.indigoLight })
            .opacity(this.glow ? 0.6 : 0.2)
        }
        .position({ x: s === 6 ? 22 : 62, y: 42 })
      }, (s: number) => s.toString())
    }
    .width('100%')
    .height('100%')
    .clip(true)
  }

波纹部分生成两个圆形波纹,分别位于 X 坐标 22 和 62 的位置,Y 坐标都是 42。波纹的实现方式是一个正方形 Column,设置宽高相等,borderRadius 设为边长的一半,从而变成圆形。只设置边框(border)而不设置背景色,形成圆环效果。

波纹的大小通过 rippleSize(this.glow) 函数动态计算:当 glow 为 true 时,波纹半径为 46/2 = 23(扩散状态);当 glow 为 false 时,半径为 22/2 = 11(收缩状态)。配合 700ms 的切换频率,波纹会周期性地扩大和收缩,模拟靛蓝滴入水中扩散的视觉效果。

波纹的透明度也随 glow 状态变化:扩散时 0.6 不透明度,收缩时 0.2 不透明度,增强了波纹扩散的视觉层次感。整个特效层设置 clip(true),确保超出容器范围的气泡和波纹被裁剪,不会溢出到其他区域。

3.16 头部构建器:定位与搜索栏

  @Builder
  header() {
    Column({ space: 10 }) {
      Row({ space: 8 }) {
        Column() { Text('📍').fontSize(15) }
        .width(32).height(32).borderRadius(16).backgroundColor(COLORS.card)
        .justifyContent(FlexAlign.Center)
        Column({ space: 2 }) {
          Text('大理·周城蓝染老铺').fontSize(11).fontColor(COLORS.sub)
          Text('晴 23°C · 湿度58%·宜晾晒布料').fontSize(9).fontColor(COLORS.indigo)
        }
        .alignItems(HorizontalAlign.Start)
        Column().layoutWeight(1)
        Row({ space: 6 }) {
          Column() { Text('🔍').fontSize(14) }
          .width(30).height(30).borderRadius(15).backgroundColor(COLORS.card)
          .justifyContent(FlexAlign.Center)
          Column() { Text('🛒').fontSize(14) }
          .width(30).height(30).borderRadius(15).backgroundColor(COLORS.card)
          .justifyContent(FlexAlign.Center)
        }
      }
      .width('100%')

header 构建器定义了页面顶部的头部区域,采用手作工坊电商风格设计。整个头部使用 Column 纵向布局,包含三部分内容:定位与搜索栏(第一行)、今日开缸大卡、热门染物横滚列表。各部分之间通过 space: 10 设置 10dp 的间距。

第一行是定位信息与功能按钮的组合。左侧是定位图标加店铺位置和天气信息:📍图标在一个 32x32 的圆形白底卡片中,旁边是两行文字,第一行是店铺名称「大理·周城蓝染老铺」,第二行是天气信息「晴 23°C · 湿度58%·宜晾晒布料」。天气信息使用靛蓝色,既是点缀色,又与蓝染主题呼应。「宜晾晒布料」的文案很有特色,将日常天气与蓝染工艺结合,增添了行业特色。

中间的空白区域使用 Column().layoutWeight(1) 填充,layoutWeight 类似于 Android 的 layout_weight 或 CSS 的 flex: 1,会占据剩余的所有空间,从而将左右两侧的内容推开。这是弹性布局中常用的「撑开」技巧。

右侧是搜索和购物车两个圆形按钮,分别使用 🔍 和 🛒 emoji 作为图标,放置在 30x30 的白色圆形卡片中。按钮尺寸略小于左侧的定位图标,形成视觉上的主次关系。整行布局紧凑而有序,信息密度适中。

3.17 头部构建器:今日开缸大卡

在这里插入图片描述

      // 今日开缸大卡
      Row({ space: 12 }) {
        Column({ space: 3 }) {
          Text('今日老缸浸染').fontSize(10).fontColor('#D8E2F8')
          Text('62').fontSize(30).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
          Text('件 · 靛花状态极佳').fontSize(9).fontColor('#D8E2F8')
        }
        .alignItems(HorizontalAlign.Start)
        Column().layoutWeight(1)
        Column({ space: 5 }) {
          Row({ space: 4 }) {
            ForEach([0, 1, 2, 3, 4, 5], (b: number) => {
              Column()
                .width(9)
                .height(b < 4 ? 8 + b * 7 : 0)
                .height(b < 3 ? 8 + b * 7 : 0)
                .borderRadius(4)
                .backgroundColor(b < 3 ? COLORS.sky : COLORS.white)
            }, (b: number) => b.toString())
          }
          .alignItems(VerticalAlign.Bottom)
          Text('缸液活性 · 优').fontSize(8).fontColor('#D8E2F8')
        }
        .alignItems(HorizontalAlign.End)
      }
      .width('100%')
      .padding(14)
      .borderRadius(16)
      .backgroundColor(COLORS.indigo)
      .linearGradient({
        angle: 135,
        colors: [['#23386E', 0], ['#3352A0', 1]]
      })

「今日开缸大卡」是头部最醒目的元素,这是一个深蓝色渐变背景的大卡片,展示当日染缸的运作状态。卡片左侧显示今日浸染件数(62 件)和靛花状态,右侧是一个简易的柱状图和缸液活性评级。

大数字「62」使用 30px 的白色粗体字,是整个卡片的视觉焦点,直观展示了当日的工作量。上下两行小字(「今日老缸浸染」和「件 · 靛花状态极佳」)使用浅蓝灰色(#D8E2F8),作为辅助信息。「靛花」是蓝染工艺中的专业术语,指养缸良好时缸面出现的蓝色泡沫,是缸液活性好的标志。

右侧的柱状图由 6 个小柱子组成,通过 ForEach 遍历生成。柱子的宽度都是 9px,高度根据索引计算:前 4 个柱子有高度(b < 4),后 2 个高度为 0(即不显示)。前 3 个柱子使用天蓝色(COLORS.sky),第 4 个使用白色(COLORS.white),高度计算公式为 8 + b * 7,即从左到右逐渐升高,形成递增的柱状图效果。柱状图底部对齐(alignItems: VerticalAlign.Bottom),模拟柱状图从底部生长的视觉效果。

卡片背景使用了 linearGradient 线性渐变,从 135 度角(即从左上到右下)由深藏蓝(#23386E)过渡到标准靛蓝(#3352A0)。渐变背景比纯色背景更有层次感,也更符合「染缸」的视觉意象。卡片整体设置了 14dp 的内边距和 16dp 的圆角,营造出柔和圆润的卡片风格。

3.18 头部构建器:热门染物横滚

      // 热门染物横滚
      Scroll() {
        Row({ space: 10 }) {
          ForEach(dyeList, (d: DyeItem) => {
            Column({ space: 4 }) {
              Text('🌀').fontSize(18)
              Text(d.name.length > 6 ? d.name.slice(0, 6) : d.name).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
              Text(d.color).fontSize(8).fontColor(COLORS.indigo)
            }
            .width(88).padding({ top: 8, bottom: 8 }).borderRadius(10)
            .backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
          }, (d: DyeItem) => d.id.toString())
        }
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .width('100%')
      .height(72)
    }
    .width('100%')
    .padding({ left: 12, right: 12, top: 8, bottom: 8 })
    .backgroundColor(COLORS.bg)
  }

热门染物横滚区域是头部的第三个模块,使用 Scroll 组件包裹一个横向排列的 Row,实现水平滚动效果。scrollable(ScrollDirection.Horizontal) 设置滚动方向为水平,scrollBar(BarState.Off) 隐藏滚动条,让界面更加简洁。这种横向滚动的卡片列表是移动端常见的内容展示形式,适合在有限的空间内展示更多内容。

每个染物卡片的宽度固定为 88dp,竖向排列图标、名称和色阶三行内容。图标使用 🌀 emoji 代替染物图片,名称使用 slice(0, 6) 进行截断,确保超过 6 个字符的名称不会撑破卡片布局。色阶名称使用靛蓝色显示,与主题色呼应。卡片使用白色背景和 10dp 圆角,整体风格简洁明快。

ForEach 的第二个参数 (d: DyeItem) => d.id.toString() 是 key 生成函数,它使用染物的 id 作为列表项的唯一标识。这是列表渲染的最佳实践:为每个列表项提供稳定的 key,帮助框架高效地进行差异更新,避免不必要的组件重建和重排。使用数据的唯一 ID 作为 key 比使用数组索引更可靠,因为索引会随着数组元素的增删而变化。

整个头部区域设置了 12dp 的左右内边距和 8dp 的上下内边距,背景色为 COLORS.bg(浅灰蓝色)。这种「卡片在背景上」的分层设计通过不同的背景色和圆角来营造层次感,是现代移动端 UI 设计的常用手法。

3.19 内容Tab导航构建器:subNav

  @Builder
  subNav() {
    Scroll() {
      Row({ space: 8 }) {
        ForEach(['精选', '染物图鉴', '靛蓝色谱', '染缸日历', '工坊课程', '染友圈'], (t: string, i: number) => {
          Column() {
            Text(t)
              .fontSize(11)
              .fontColor(this.subTab === i ? COLORS.white : COLORS.sub)
              .fontWeight(this.subTab === i ? FontWeight.Bold : FontWeight.Normal)
          }
          .padding({ left: 16, right: 16, top: 7, bottom: 7 })
          .borderRadius(16)
          .backgroundColor(this.subTab === i ? COLORS.indigo : COLORS.card)
          .alignItems(HorizontalAlign.Center)
          .onClick(() => { this.subTab = i; })
        }, (t: string) => t)
      }
      .padding({ left: 12, right: 12 })
    }
    .scrollable(ScrollDirection.Horizontal)
    .scrollBar(BarState.Off)
    .width('100%')
  }

subNav 构建器定义了首页下方的内容 Tab 导航条,包含 6 个 Tab:精选、染物图鉴、靛蓝色谱、染缸日历、工坊课程、染友圈。这 6 个 Tab 以横向滚动的方式排列,用户可以左右滑动查看更多 Tab,也可以直接点击切换。

Tab 项采用「胶囊」样式,未选中时为白底灰字,选中时为靛蓝底白字并加粗。选中状态通过 this.subTab === i 的条件判断来动态切换样式:文字颜色、字重、背景色都会根据选中状态变化。这种设计符合移动端 Tab 导航的常见模式,用户学习成本低。

每个 Tab 通过 .onClick(() => { this.subTab = i; }) 绑定点击事件,点击时将 subTab 状态设置为对应的索引值。由于 subTab@State 变量,赋值操作会触发 UI 更新,Tab 的选中样式和下方的内容区域都会相应变化,整个过程由框架自动完成。

ForEach 的 key 生成函数使用 Tab 文本本身((t: string) => t),因为 Tab 名称是唯一的,可以作为稳定的标识。这种实现方式简单直接,但如果后续需要支持国际化或修改 Tab 名称,可能会影响 key 的稳定性。在生产环境中,建议使用枚举值或固定 ID 作为 key。

3.20 精选Tab:横幅轮播与工艺四宫格

  @Builder
  tabPick() {
    Scroll() {
      Column({ space: 10 }) {
        Scroll() {
          Row({ space: 10 }) {
            ForEach([0, 1, 2], (b: number) => {
              Column({ space: 6 }) {
                Text(b === 0 ? '🌀 周城蓝染开放日' : (b === 1 ? '👗 渐变新品首发' : '🎓 零基础扎染课'))
                  .fontSize(16).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
                Text(b === 0 ? '两口百年老缸等你来' : (b === 1 ? '把黄昏穿在身上' : '半日做出传家方巾'))
                  .fontSize(9).fontColor('#D8E2F8')
                Row({ space: 4 }) {
                  Text(b === 0 ? '去预约' : (b === 1 ? '去抢购' : '去报名')).fontSize(9)
                    .fontColor(COLORS.indigo).padding({ left: 10, right: 10, top: 4, bottom: 4 })
                    .borderRadius(10)
                    .backgroundColor(COLORS.white)
                }
              }
              .width(200).padding(14).borderRadius(14).alignItems(HorizontalAlign.Start)
              .backgroundColor(b === 0 ? COLORS.indigo : (b === 1 ? COLORS.indigoDeep : COLORS.clay))
            }, (b: number) => b.toString())
          }
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .width('100%')

tabPick 构建器定义了「精选」Tab 的内容,是首页内容最丰富的一个 Tab。它采用纵向滚动布局,从上到下依次包含:横幅轮播、工艺四宫格、染物大卡列表。

横幅轮播区域使用横向滚动的 Scroll 实现,包含三张横幅卡片。每张卡片宽度 200dp,高度自适应,卡片之间有 10dp 的间距。三张横幅分别推广不同的内容:「周城蓝染开放日」使用标准靛蓝色背景,「渐变新品首发」使用深藏蓝色背景,「零基础扎染课」使用陶土色背景。不同的背景色既区分了内容类型,也增加了视觉丰富度。

每张横幅卡片包含标题、副标题和行动按钮三行内容。标题使用 16px 白色粗体,副标题使用 9px 浅蓝灰色,行动按钮使用白底靛蓝字的圆角小按钮。按钮文案分别为「去预约」「去抢购」「去报名」,与横幅内容对应,具有明确的行动指引。

这种使用 Scroll + Row 实现的横向轮播虽然不如专业轮播组件功能丰富(没有自动播放、没有指示器),但实现简单、性能优秀,适合展示数量不多的推广卡片。用户可以手动滑动查看,符合移动端的交互习惯。

        Text('— 四大蓝染工艺 —').fontSize(11).fontColor(COLORS.sub).width('100%').textAlign(TextAlign.Center)

        Row({ space: 8 }) {
          Column({ space: 4 }) {
            Text('🌀').fontSize(22)
            Text('扎染').fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Text('捆扎防染·灵动的花').fontSize(7).fontColor(COLORS.sub)
          }
          .layoutWeight(1).padding(10).borderRadius(10).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
          Column({ space: 4 }) {
            Text('🕯️').fontSize(22)
            Text('蜡染').fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Text('点蜡封色·冰裂之美').fontSize(7).fontColor(COLORS.sub)
          }
          .layoutWeight(1).padding(10).borderRadius(10).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
          Column({ space: 4 }) {
            Text('🪵').fontSize(22)
            Text('夹染').fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Text('木板夹防·几何秩序').fontSize(7).fontColor(COLORS.sub)
          }
          .layoutWeight(1).padding(10).borderRadius(10).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
          Column({ space: 4 }) {
            Text('🖌️').fontSize(22)
            Text('型糊染').fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Text('刮糊显花·精工细作').fontSize(7).fontColor(COLORS.sub)
          }
          .layoutWeight(1).padding(10).borderRadius(10).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
        }
        .width('100%')

工艺四宫格区域展示四大蓝染工艺的入口,标题「— 四大蓝染工艺 —」居中显示,使用灰色文字和分隔线样式,营造出传统与现代结合的设计感。

四个工艺卡片使用 Row + layoutWeight(1) 的方式实现等分排列,每个卡片占据相同的宽度。卡片内竖向排列图标、工艺名称和简短描述三个元素。图标使用 22px 的 emoji,工艺名称使用 11px 的粗体标题字,描述使用 7px 的极小字体——这种字号在实际设备上可能会比较小,但作为分类入口的辅助信息,简洁即可。

四宫格的设计既展示了平台的核心内容分类,又起到了导航入口的作用。用户点击不同的工艺卡片可以进入对应的分类页面(虽然本示例中没有实现点击跳转,但从产品设计角度看,这是合理的交互逻辑)。这种「分类入口 + 内容列表」的页面结构是电商和内容类应用的经典模式。

3.21 精选Tab:染物大卡列表

在这里插入图片描述

        // 左色块大卡列表
        ForEach(dyeList, (d: DyeItem) => {
          Row({ space: 12 }) {
            Column()
              .width(8).height(64).borderRadius(4)
              .backgroundColor(d.color === '铁绀' ? COLORS.ink : (d.color === '藏蓝' ? COLORS.indigoDeep : (d.color === '绀青' ? COLORS.indigo : COLORS.indigoLight)))
            Column({ space: 4 }) {
              Text(d.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
              Text(d.craft + ' · ' + d.dips).fontSize(8).fontColor(COLORS.sub)
              Row({ space: 6 }) {
                Text(d.color).fontSize(8).fontColor(COLORS.indigo)
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                  .borderRadius(6).backgroundColor(COLORS.card2)
                Text(d.maker).fontSize(8).fontColor(COLORS.clay)
              }
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Column({ space: 2 }) {
              Text(d.price).fontSize(13).fontColor(COLORS.indigo).fontWeight(FontWeight.Bold)
              Text(d.status).fontSize(8).fontColor(dyeStatusColor(d.status))
            }
            .alignItems(HorizontalAlign.End)
          }
          .width('100%').padding(12).borderRadius(12)
          .backgroundColor(COLORS.card)
        }, (d: DyeItem) => d.id.toString())
      }
      .width('100%')
      .padding(12)
    }
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
    .width('100%')
    .height('100%')
  }

染物大卡列表是精选 Tab 的主要内容区域,使用 ForEach 遍历 dyeList 数组生成染物卡片。每张卡片采用「左侧色块 + 中间信息 + 右侧价格」的三栏布局,结构清晰,信息层次分明。

左侧色块是这个列表设计的亮点——一个 8dp 宽、64dp 高的彩色竖条,颜色根据染物的色阶动态变化:铁绀用墨色(COLORS.ink)、藏蓝用深藏蓝(COLORS.indigoDeep)、绀青用标准靛蓝(COLORS.indigo)、其他用浅靛蓝(COLORS.indigoLight)。这个色块既是装饰元素,也是色阶的可视化标识,用户通过色块颜色就能快速判断染物的颜色深浅。

中间信息区域占据剩余空间(layoutWeight(1)),包含染物名称、工艺与浸染次数、色阶标签和匠人名称四行内容。色阶标签使用浅蓝灰色背景的小胶囊样式,匠人名称使用陶土色(COLORS.clay)显示,突出手作工坊的匠人属性。

右侧价格区域右对齐显示,价格使用 13px 的靛蓝色粗体,是卡片中最醒目的信息之一。状态文字使用 dyeStatusColor(d.status) 函数动态计算颜色,不同状态显示不同颜色,帮助用户快速识别。

整个卡片使用白色背景、12dp 圆角和 12dp 内边距,卡片之间通过外层 Columnspace: 10 产生间距。列表整体采用垂直滚动(ScrollDirection.Vertical),隐藏滚动条,提供沉浸式的浏览体验。

3.22 染物图鉴Tab:表格式列表

  @Builder
  tabDye() {
    Scroll() {
      Column({ space: 10 }) {
        Text('本铺染物图鉴 TOP8').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')

        // 表头
        Row({ space: 8 }) {
          Text('染物').fontSize(9).fontColor(COLORS.sub).width(112)
          Text('工艺').fontSize(9).fontColor(COLORS.sub).width(72)
          Text('浸染').fontSize(9).fontColor(COLORS.sub).width(56)
          Text('色阶').fontSize(9).fontColor(COLORS.sub).width(48)
        }
        .width('100%').padding({ left: 12, right: 12, top: 8, bottom: 8 })
        .borderRadius(10)
        .backgroundColor(COLORS.card2)

tabDye 构建器定义了「染物图鉴」Tab 的内容,采用表格式列表展示染物的专业信息。与精选 Tab 的卡片式设计不同,图鉴 Tab 使用表格形式,将染物的各项参数以列的形式整齐排列,便于对比和查阅。

标题「本铺染物图鉴 TOP8」使用 13px 的粗体字,「TOP8」的表达方式带有排行榜的意味,增加了内容的吸引力。表格分为四列:染物、工艺、浸染、色阶,每列有固定的宽度(112dp、72dp、56dp、48dp),确保内容对齐整齐。

表头使用浅蓝灰色(COLORS.card2)背景,文字为灰色,与下方的数据行形成区分。这种「表头深色/浅灰背景 + 数据行白色背景」的表格样式是信息展示类页面的经典设计,清晰易读。

固定列宽的表格在移动端有一定的局限性——如果列数过多或文字过长,可能会出现截断或换行的问题。但在本应用中只有 4 列,且内容经过精心设计(名称长度适中),在手机屏幕上应该能够正常显示。如果后续需要支持更多列,可以考虑使用横向滚动的表格来解决空间问题。

        ForEach(dyeList, (d: DyeItem, i: number) => {
          Row({ space: 8 }) {
            Text((i + 1).toString() + '. ' + d.name).fontSize(10).fontColor(COLORS.title).width(112)
            Text(d.craft).fontSize(9).fontColor(COLORS.sub).width(72)
            Text(d.dips).fontSize(9).fontColor(COLORS.indigo).width(56)
            Text(d.color).fontSize(9).fontColor(COLORS.indigoDeep).fontWeight(FontWeight.Bold).width(48)
          }
          .width('100%').padding({ left: 12, right: 12, top: 10, bottom: 10 })
          .borderRadius(10)
          .backgroundColor(COLORS.card)
          .border({ width: i < 3 ? 1 : 0, color: COLORS.indigo })
        }, (d: DyeItem) => d.id.toString())

数据行使用 ForEach 遍历 dyeList 生成,每行包含四列数据,列宽与表头对应。第一列显示序号和染物名称(如「1. 苍山云纹方巾」),第二列是工艺类型,第三列是浸染次数(使用靛蓝色突出显示),第四列是色阶名称(使用深藏蓝粗体,最醒目)。

色阶列使用深藏蓝粗体是一个很好的设计决策,因为色阶是染物图鉴的核心信息,也是用户最关心的参数之一。将其用最深的蓝色加粗显示,既符合「颜色越深字越重」的视觉逻辑,也引导用户关注关键信息。

前三行数据额外添加了靛蓝色边框(border({ width: i < 3 ? 1 : 0, color: COLORS.indigo })),形成「TOP3 高亮」的效果。这种设计常见于排行榜或推荐列表中,通过视觉强调突出前几名的重要性,也增加了列表的层次感。

        Text('浸染次数与色深').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
        Row({ space: 8 }) {
          Column({ space: 4 }) {
            Text('2 次').fontSize(10).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Text('月白').fontSize(9).fontColor(COLORS.sub)
          }
          .layoutWeight(1).padding(10).borderRadius(10).backgroundColor('#D5E0F2').alignItems(HorizontalAlign.Center)
          Column({ space: 4 }) {
            Text('5 次').fontSize(10).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
            Text('浅绀').fontSize(9).fontColor('#D8E2F8')
          }
          .layoutWeight(1).padding(10).borderRadius(10).backgroundColor('#7B96CE').alignItems(HorizontalAlign.Center)
          Column({ space: 4 }) {
            Text('10 次').fontSize(10).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
            Text('绀青').fontSize(9).fontColor('#D8E2F8')
          }
          .layoutWeight(1).padding(10).borderRadius(10).backgroundColor('#3352A0').alignItems(HorizontalAlign.Center)
          Column({ space: 4 }) {
            Text('15 次').fontSize(10).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
            Text('铁绀').fontSize(9).fontColor('#D8E2F8')
          }
          .layoutWeight(1).padding(10).borderRadius(10).backgroundColor('#141C33').alignItems(HorizontalAlign.Center)
        }
        .width('100%')

「浸染次数与色深」模块是染物图鉴 Tab 的下半部分,用四个等分卡片直观展示浸染次数与颜色深浅的对应关系。从左到右依次是 2 次月白(最浅)、5 次浅绀、10 次绀青、15 次铁绀(最深),卡片的背景色也从浅到深变化,直观地表达了「浸染次数越多,颜色越深」的规律。

这种将知识可视化的设计非常巧妙,用户不需要阅读枯燥的文字说明,通过四个色块的对比就能直观理解浸染次数与色深的关系。浅色背景的卡片使用深色文字,深色背景的卡片使用白色文字,确保了良好的可读性。这既是信息展示,也是知识科普,体现了文化类应用的教育属性。

3.23 靛蓝色谱Tab:色阶柱状图

  @Builder
  tabShade() {
    Scroll() {
      Column({ space: 12 }) {
        Text('传统靛蓝色阶体系').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
        Row({ space: 12 }) {
          ForEach(shadeCharts, (c: ShadeChartItem) => {
            Column({ space: 4 }) {
              Column()
                .width(30)
                .height(shadeBarH(c.val))
                .borderRadius(8)
                .backgroundColor(c.color)
              Text(c.name).fontSize(8).fontColor(COLORS.sub)
            }
            .alignItems(HorizontalAlign.Center)
          }, (c: ShadeChartItem) => c.name)
        }
        .width('100%')
        .height(150)
        .padding(12)
        .borderRadius(12)
        .backgroundColor(COLORS.card)
        .alignItems(VerticalAlign.Bottom)

tabShade 构建器定义了「靛蓝色谱」Tab 的内容,这是一个以数据可视化为核心的 Tab,包含色阶柱状图、工艺占比图和建蓝小知识三个模块。

色阶柱状图是本 Tab 的核心组件,使用 Row 横向排列 5 个柱状条,每个柱子对应一个传统色阶。柱子的宽度固定为 30dp,高度通过 shadeBarH(c.val) 函数计算,将数值(24-96)转换为百分比高度。由于外层 Row 设置了 alignItems(VerticalAlign.Bottom),所有柱子从底部向上生长,形成标准的柱状图效果。

每个柱子的颜色使用对应的色阶颜色(c.color),柱子下方是色阶名称。这种「柱子本身就是颜色样本」的设计非常直观,用户一眼就能看到每个色阶的实际颜色和相对深浅关系。相比传统的「色卡 + 文字说明」,柱状图的形式增加了数据感和专业性。

整个图表区域放在一张白色大卡片中,卡片高度固定为 150dp,设置了 12dp 的内边距和圆角。固定高度确保了柱状图有足够的纵向空间展示高度差异,同时也避免了因柱子高度变化导致的布局抖动。

        Text('四大工艺作品占比').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
        ForEach(craftList, (c: CraftItem) => {
          Column({ space: 6 }) {
            Row({ space: 8 }) {
              Text('🌀').fontSize(14)
              Text(c.label).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
              Column().layoutWeight(1)
              Text(c.pct.toString() + '%').fontSize(10).fontColor(c.color).fontWeight(FontWeight.Bold)
            }
            .width('100%')
            Row() {
              Column().height(8).borderRadius(4).backgroundColor(c.color).width(c.pct.toString() + '%')
            }
            .width('100%')
          }
          .width('100%').padding(12).borderRadius(12)
          .backgroundColor(COLORS.card2)
        }, (c: CraftItem) => c.label)

「四大工艺作品占比」模块使用横向进度条的形式展示四大蓝染工艺的作品占比。每条数据包含图标、工艺名称、百分比数字和进度条四个元素,布局层次清晰。

进度条的实现方式很简洁:一个 Row 容器中放置一个 Column,通过设置 Column 的宽度百分比(width(c.pct.toString() + '%'))来表示进度比例。进度条高度为 8dp,圆角 4dp,背景色使用对应工艺的主题色。这种纯布局实现的进度条虽然功能简单,但性能优秀,无需额外的组件依赖。

每个工艺条目使用浅蓝灰色(COLORS.card2)背景,与上方色阶图的白色卡片形成区分,也增加了页面的节奏感。工艺名称和百分比数字分别左右对齐,百分比使用对应工艺的颜色加粗显示,形成视觉呼应。

        Text('建蓝小知识').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
        Row({ space: 8 }) {
          Column({ space: 4 }) {
            Text('🌿').fontSize(20)
            Text('蓝靛草').fontSize(10).fontColor(COLORS.title)
            Text('板蓝根叶发酵制靛').fontSize(8).fontColor(COLORS.sub)
          }
          .layoutWeight(1).padding(10).borderRadius(10).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
          Column({ space: 4 }) {
            Text('🫧').fontSize(20)
            Text('养缸').fontSize(10).fontColor(COLORS.title)
            Text('靛蓝还原成隐色体').fontSize(8).fontColor(COLORS.sub)
          }
          .layoutWeight(1).padding(10).borderRadius(10).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
          Column({ space: 4 }) {
            Text('💨').fontSize(20)
            Text('氧化').fontSize(10).fontColor(COLORS.title)
            Text('出缸遇空气变蓝').fontSize(8).fontColor(COLORS.sub)
          }
          .layoutWeight(1).padding(10).borderRadius(10).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
        }
        .width('100%')

「建蓝小知识」模块以三张小卡片的形式科普蓝染的三个核心步骤:蓝靛草(原料)、养缸(发酵还原)、氧化(显色反应)。每张卡片包含图标、标题和简要说明三个元素,使用 layoutWeight(1) 等分排列。

这三段小知识简洁准确地概括了蓝染的化学原理:从板蓝根叶等植物中提取靛蓝(制靛),通过发酵还原将靛蓝转化为可溶于水的隐色体(养缸),布料浸染后取出,隐色体遇到空气氧化变回不溶性的靛蓝,从而固着在布料上(氧化显色)。将这些专业知识用简洁的图标和文字呈现,降低了用户的理解门槛。

3.24 染缸日历Tab:周历格布局

  @Builder
  tabVat() {
    Scroll() {
      Column({ space: 12 }) {
        Text('本周染缸排期').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
        // 周历表头
        Row({ space: 4 }) {
          ForEach(['一', '二', '三', '四', '五', '六', '日'], (d: string) => {
            Text(d).fontSize(9).fontColor(COLORS.sub)
              .layoutWeight(1).textAlign(TextAlign.Center)
          }, (d: string) => d)
        }
        .width('100%').padding({ top: 8, bottom: 6 })
        .borderRadius(10).backgroundColor(COLORS.card2)

        // 3行7列日历格
        ForEach([0, 1, 2], (w: number) => {
          Row({ space: 4 }) {
            ForEach([0, 1, 2, 3, 4, 5, 6], (d: number) => {
              Column({ space: 3 }) {
                Text((w * 7 + d + 1).toString()).fontSize(9)
                  .fontColor(d >= 5 ? COLORS.indigo : COLORS.sub)

tabVat 构建器定义了「染缸日历」Tab 的内容,以周历形式展示染缸排期,这是一个非常有行业特色的功能模块。蓝染的制作周期与染缸的使用安排密切相关,通过日历可视化排期,用户可以直观了解染坊的工作节奏。

日历表头使用 ForEach 遍历「一」到「日」七个汉字,使用 layoutWeight(1) 等分宽度,文字居中对齐。表头使用浅蓝灰色背景,与日历格子的白色背景形成区分。

日历主体由 3 行 7 列共 21 个格子组成,通过双层 ForEach 嵌套实现。外层遍历周数(0-2,共3周),内层遍历星期几(0-6,共7天)。日期通过 w * 7 + d + 1 计算得出,即第一周从 1 号开始,依次递增。

日期数字的颜色根据是否为周末而变化:周六和周日(d >= 5)使用靛蓝色,工作日使用灰色。这种设计符合传统日历的配色习惯,也帮助用户快速识别周末。每个格子高度为 52dp,使用 layoutWeight(1) 等分宽度,确保 7 列均匀分布。

                if (w === 0 && d >= 4) {
                  Text('浸染').fontSize(6).fontColor(COLORS.white)
                    .padding({ left: 3, right: 3, top: 1, bottom: 1 }).borderRadius(4)
                    .backgroundColor(COLORS.indigo)
                }
                if (w === 1 && d < 3) {
                  Text('养缸').fontSize(6).fontColor(COLORS.white)
                    .padding({ left: 3, right: 3, top: 1, bottom: 1 }).borderRadius(4)
                    .backgroundColor(COLORS.clay)
                }
                if (w === 1 && d >= 4) {
                  Text('晾晒').fontSize(6).fontColor(COLORS.indigo)
                    .padding({ left: 3, right: 3, top: 1, bottom: 1 }).borderRadius(4)
                    .backgroundColor(COLORS.card2)
                }
                if (w === 2 && d < 2) {
                  Text('开放').fontSize(6).fontColor(COLORS.white)
                    .padding({ left: 3, right: 3, top: 1, bottom: 1 }).borderRadius(4)
                    .backgroundColor(COLORS.red)
                }
              }
              .layoutWeight(1).height(52).borderRadius(8)
              .backgroundColor(d >= 5 ? '#EDF2FA' : COLORS.card)
              .alignItems(HorizontalAlign.Center)
              .justifyContent(FlexAlign.Center)
            }, (d: number) => 'w' + w.toString() + 'd' + d.toString())
          }
          .width('100%')
        }, (w: number) => 'week' + w.toString())

每个日历格子中,根据日期位置的不同,通过 if 条件渲染显示不同的工序标签:第一周的周五到周日(w === 0 && d >= 4)显示「浸染」标签(靛蓝色背景白字);第二周的周一到周三(w === 1 && d < 3)显示「养缸」标签(陶土色背景白字);第二周的周五到周日(w === 1 && d >= 4)显示「晾晒」标签(浅蓝灰背景靛蓝字);第三周的周一到周二(w === 2 && d < 2)显示「开放」标签(红色背景白字)。

这种硬编码的排期数据仅用于演示目的,在实际应用中,排期数据应该从后端动态获取。但从设计角度来看,将不同工序用不同颜色的标签展示在日历上,让用户一目了然地了解染坊的工作节奏,是一种非常直观的信息呈现方式。

日历格子的 key 生成函数使用了 'w' + w.toString() + 'd' + d.toString(),将周索引和日索引拼接成唯一标识,确保每个格子有稳定的 key。这是嵌套 ForEach 中生成唯一 key 的常用技巧。

        // 图例
        Row({ space: 12 }) {
          Row({ space: 4 }) {
            Column().width(8).height(8).borderRadius(2).backgroundColor(COLORS.indigo)
            Text('浸染').fontSize(8).fontColor(COLORS.sub)
          }
          Row({ space: 4 }) {
            Column().width(8).height(8).borderRadius(2).backgroundColor(COLORS.clay)
            Text('养缸').fontSize(8).fontColor(COLORS.sub)
          }
          Row({ space: 4 }) {
            Column().width(8).height(8).borderRadius(2).backgroundColor(COLORS.card2)
            Text('晾晒').fontSize(8).fontColor(COLORS.sub)
          }
          Row({ space: 4 }) {
            Column().width(8).height(8).borderRadius(2).backgroundColor(COLORS.red)
            Text('开放日').fontSize(8).fontColor(COLORS.sub)
          }
        }
        .width('100%').justifyContent(FlexAlign.Center)

日历下方是图例说明,用四个小色块加文字的形式解释四种工序标签的含义。图例居中显示,每个图例项由一个 8x8 的小色块和文字组成,色块颜色与对应标签的背景色一致。图例是信息可视化设计中不可或缺的辅助元素,帮助用户快速理解图表中的符号含义。

3.25 工坊课程Tab与染友圈Tab

  @Builder
  tabClass() {
    Scroll() {
      Column({ space: 10 }) {
        Text('工坊课程').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
        ForEach(classList, (c: ClassItem, i: number) => {
          Row({ space: 12 }) {
            Column({ space: 2 }) {
              Text(c.level).fontSize(10).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
            }
            .width(52).height(52).borderRadius(14)
            .backgroundColor(c.level === '体验' ? COLORS.indigoLight : (c.level === '入门' ? COLORS.indigo : (c.level === '理论' ? COLORS.clay : COLORS.indigoDeep)))
            .justifyContent(FlexAlign.Center)
            .alignItems(HorizontalAlign.Center)

tabClass 构建器定义了「工坊课程」Tab 的内容,以列表形式展示各类蓝染课程。每个课程卡片采用「左侧等级色块 + 右侧课程信息」的布局,与精选 Tab 的染物大卡有相似之处,但设计侧重点不同。

左侧的等级方块是课程列表的设计亮点:一个 52x52dp 的方形卡片,圆角 14dp,中间显示课程等级(如「体验」「入门」「进阶」等)。方块的背景色根据等级动态变化:体验级用浅靛蓝,入门级用标准靛蓝,理论课用陶土色,进阶/研修等用深藏蓝。这种「等级越高颜色越深」的色彩编码方式,让用户通过颜色就能快速判断课程的难度级别。

课程等级的色彩设计遵循了一定的逻辑:入门级使用标准品牌色(靛蓝),体验级用更浅更友好的颜色,高阶课程用更深沉的颜色表示专业度,理论课则用暖色调(陶土色)与实践类课程区分。这种设计既美观又有信息传达功能。

            Column({ space: 4 }) {
              Text(c.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
              Text('导师:' + c.teacher).fontSize(8).fontColor(COLORS.sub)
              Text('材料:' + c.mat).fontSize(8).fontColor(COLORS.sub)
              Row({ space: 8 }) {
                Text('💰 ' + c.price).fontSize(10).fontColor(COLORS.indigo).fontWeight(FontWeight.Bold)
                Text('🎫 ' + c.quota).fontSize(8).fontColor(COLORS.clay)
                Text(c.status).fontSize(8).fontColor(dyeStatusColor(c.status === '热抢' ? '洽藏中' : '在展'))
              }
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
          }
          .width('100%').padding(12).borderRadius(12)
          .backgroundColor(COLORS.card)
          .border({ width: i < 2 ? 1 : 0, color: COLORS.indigo })
        }, (c: ClassItem) => c.id.toString())

右侧课程信息区域包含课程名称、导师、材料、价格、名额、状态等信息。课程名称用 12px 粗体,是最醒目的信息。导师和材料信息用 8px 灰色小字,属于辅助信息。底部一行用 emoji 加文字的方式展示价格、名额和状态,信息密度高但层次分明。

状态颜色的处理有一个小技巧:dyeStatusColor(c.status === '热抢' ? '洽藏中' : '在展'),通过将课程状态映射到染物状态来复用已有的颜色映射函数。「热抢」对应「洽藏中」(红色),其他对应「在展」(靛蓝色)。这种复用现有函数的做法减少了重复代码,是一种实用的编程技巧。

前两门课程添加了靛蓝色边框高亮(border({ width: i < 2 ? 1 : 0, color: COLORS.indigo })),与染物图鉴的 TOP3 高亮类似,起到推荐和引导的作用。在实际应用中,可以根据课程的热门程度或推荐优先级来动态设置高亮。

  @Builder
  tabFriends() {
    Scroll() {
      Column({ space: 10 }) {
        Row({ space: 8 }) {
          Button().width(110).height(32).borderRadius(16).backgroundColor(COLORS.indigo)
            .onClick(() => { this.openAdd(); })
          Text('📝 发布动态').fontSize(10).fontColor(COLORS.white).margin({ left: -102 })
          Column().layoutWeight(1)
          Text('按最新排序').fontSize(9).fontColor(COLORS.sub)
        }
        .width('100%')

tabFriends 构建器定义了「染友圈」Tab 的内容,是社区互动模块。顶部是操作栏,左侧是「发布动态」按钮,右侧是排序方式说明。

「发布动态」按钮的实现方式比较特别:使用一个 Button 组件作为底(设置尺寸和背景色),然后用一个 Text 组件通过负边距覆盖在按钮上方显示文字。这种做法可能是因为 ArkTS 的 Button 组件对自定义内容的支持有限,需要用文字组件来精确控制显示内容和样式。在实际开发中,如果 Button 组件支持子组件,应该优先使用嵌套子组件的方式。

按钮点击调用 this.openAdd() 方法,打开发布动态的弹框。这种「按钮 + 弹框」的交互模式是移动端常见的内容发布方式,弹框中包含表单输入,用户填写后提交。

        ForEach(this.friends, (f: DyeFriendItem) => {
          Column({ space: 8 }) {
            Row({ space: 10 }) {
              Column() { Text(f.avatar).fontSize(24) }
              .width(44).height(44).borderRadius(12)
              .backgroundColor(COLORS.card2)
              .justifyContent(FlexAlign.Center)
              Column({ space: 2 }) {
                Text(f.nick).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
                Text(f.time + ' · ' + f.tag).fontSize(8).fontColor(COLORS.sub)
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              Text('🪪 染坊认证').fontSize(7).fontColor(COLORS.indigo)
                .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                .borderRadius(8).backgroundColor(COLORS.card2)
            }
            .width('100%')
            Text(f.text).fontSize(10).fontColor(COLORS.sub).width('100%').lineHeight(16)
            Row({ space: 16 }) {
              Text('❤️ ' + f.likes.toString()).fontSize(9).fontColor(COLORS.indigo)
              Text('💬 ' + f.replies.toString()).fontSize(9).fontColor(COLORS.sub)
              Text('⤴ 分享').fontSize(9).fontColor(COLORS.sub)
              Column().layoutWeight(1)
              Button().width(48).height(20).borderRadius(10).backgroundColor(COLORS.card2)
                .onClick(() => { this.openDelDye(-1); this.editIdx = f.id - 1; this.delTarget = 'friend'; })
              Text('删').fontSize(8).fontColor(COLORS.red).margin({ left: -38 })
            }
            .width('100%')
          }
          .width('100%').padding(12).borderRadius(12)
          .backgroundColor(COLORS.card)
        }, (f: DyeFriendItem) => f.id.toString())

动态列表是染友圈的主体部分。每条动态采用标准的社交卡片布局:顶部是用户信息行(头像、昵称、时间、标签、认证标识),中间是正文内容,底部是互动操作行(点赞、评论、分享、删除)。

用户信息行中,头像使用 44dp 的圆形浅灰底卡片,里面放置 emoji 头像。昵称用 11px 粗体,时间和标签用 8px 灰色小字。右侧的「染坊认证」标签是一个小胶囊,使用靛蓝色文字和浅蓝灰色背景,增加了用户身份的可信度。这种认证标识在社区产品中很常见,有助于建立社区的专业氛围。

正文内容使用 10px 灰色文字,行高 16dp,确保良好的可读性。底部互动栏中,点赞数使用靛蓝色突出显示,评论和分享使用灰色。删除按钮同样采用「Button + 负边距文字」的实现方式,点击后打开删除确认弹框。

删除按钮的点击处理中有一个值得注意的细节:this.openDelDye(-1) 先调用了打开染物删除弹框的方法(传入 -1 作为索引),然后立即修改 editIdxdelTarget 为正确的值。这种写法虽然功能上能工作(因为 openDelDye 内部设置的状态会被立即覆盖),但逻辑上不够清晰。更好的做法是增加一个专门的 openDelFriend 方法,直接设置正确的目标类型和索引。

3.26 染坊页:订单管理

  @Builder
  pageWorkshop() {
    Scroll() {
      Column({ space: 12 }) {
        Text('🏛️ 染坊工位与服务').fontSize(15).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
        ForEach(this.orders, (o: OrderItem, i: number) => {
          Column({ space: 8 }) {
            Row({ space: 10 }) {
              Column() { Text('🫙').fontSize(20) }
              .width(40).height(40).borderRadius(12)
              .backgroundColor(COLORS.card2)
              .justifyContent(FlexAlign.Center)
              Column({ space: 3 }) {
                Text(o.item).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
                Text(o.craft + ' · 当前:' + o.stage).fontSize(8).fontColor(COLORS.sub)
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              Text(o.status).fontSize(9).fontColor(orderStatusColor(o.status))
                .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                .borderRadius(8).backgroundColor(COLORS.card2)
            }
            .width('100%')

pageWorkshop 构建器定义了「染坊」主 Tab 的内容,核心是订单管理功能。页面标题「🏛️ 染坊工位与服务」使用 15px 的大字号,比内容 Tab 的标题更大,体现了主 Tab 页面的层级感。

订单卡片采用「主信息行 + 操作行」的双层结构。主信息行左侧是染缸图标(🫙),中间是订单名称和当前工序,右侧是订单状态标签。图标使用 40x40dp 的浅灰底圆角卡片,大小适中,既起到视觉锚点的作用,又不会过于抢眼。

订单状态标签使用 orderStatusColor(o.status) 函数动态着色,不同状态显示不同颜色:「进行中」为靛蓝色(正常)、「已收件」为金色(完成)、「候单」为红色(等待)、其他为灰色。状态标签放置在浅蓝灰色背景的小胶囊中,整体风格简洁专业。

「当前工序」的展示是染坊页面的特色,它将蓝染制作的中间过程透明化。用户可以随时查看自己的订单处于哪个阶段(扎花中、点蜡中、刮糊中、养缸中、浸染中、排图中等),这种过程透明化的设计能够有效提升用户的参与感和信任感。

            Row({ space: 10 }) {
              Text('💰 ' + o.fee).fontSize(10).fontColor(COLORS.indigo).fontWeight(FontWeight.Bold)
              Text('🗓️ ' + o.days).fontSize(9).fontColor(COLORS.sub)
              Text('🎨 ' + o.dyer).fontSize(9).fontColor(COLORS.sub)
              Column().layoutWeight(1)
              Button().width(64).height(26).borderRadius(13).backgroundColor(COLORS.card2)
                .onClick(() => { this.openEdit(i); })
              Text('调整').fontSize(9).fontColor(COLORS.sub).margin({ left: -52 })
              Button().width(64).height(26).borderRadius(13).backgroundColor(COLORS.card2)
                .onClick(() => { this.openDelOrder(i); })
              Text('取消').fontSize(9).fontColor(COLORS.red).margin({ left: -52 })
            }
            .width('100%')
          }
          .width('100%').padding(12).borderRadius(12)
          .backgroundColor(COLORS.card)
        }, (o: OrderItem) => o.id.toString())

操作行展示了订单的费用、工期、染师等关键信息,以及「调整」和「取消」两个操作按钮。费用使用靛蓝色粗体突出显示,工期和染师使用灰色普通字体。操作按钮采用浅灰底圆角矩形,「调整」按钮文字为灰色,「取消」按钮文字为红色(警示色),符合危险操作用红色标识的设计规范。

「调整」按钮点击后调用 openEdit(i) 打开编辑订单弹框,用户可以修改订单的染物名称和工艺类型。「取消」按钮点击后调用 openDelOrder(i) 打开取消订单确认弹框,用户确认后订单被移除。这两个操作覆盖了订单管理的核心功能——修改和取消。

从交互设计的角度来看,将操作按钮直接放在订单卡片中(而不是进入详情页才能操作)减少了用户的操作步骤,提升了操作效率。但这种设计也需要注意避免误触,确保按钮尺寸适中且有足够的间距。

3.27 市集页与我的页

  @Builder
  pageMarket() {
    Scroll() {
      Column({ space: 12 }) {
        Text('🛍️ 蓝染市集').fontSize(15).fontColor(COLORS.title).fontWeight(FontWeight.Bold).width('100%')
        ForEach(this.dyes, (d: DyeItem) => {
          Row({ space: 12 }) {
            Column()
              .width(56).height(56).borderRadius(14)
              .backgroundColor(d.color === '铁绀' ? COLORS.ink : (d.color === '藏蓝' ? COLORS.indigoDeep : (d.color === '绀青' ? COLORS.indigo : COLORS.indigoLight)))
              .justifyContent(FlexAlign.Center)
            Column({ space: 4 }) {
              Text(d.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
              Text(d.craft + ' · ' + d.dips + ' · ' + d.color).fontSize(8).fontColor(COLORS.sub)
              Row({ space: 6 }) {
                Text('匠人 ' + d.maker).fontSize(8).fontColor(COLORS.clay)
                Text(d.status).fontSize(8).fontColor(dyeStatusColor(d.status))
              }
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Column({ space: 4 }) {
              Text(d.price).fontSize(13).fontColor(COLORS.indigo).fontWeight(FontWeight.Bold)
              Button().width(52).height(22).borderRadius(11).backgroundColor(COLORS.card2)
                .onClick(() => { this.openDelDye(this.dyes.indexOf(d)); })
              Text('下架').fontSize(8).fontColor(COLORS.red).margin({ top: -18 })
            }
            .alignItems(HorizontalAlign.End)
          }
          .width('100%').padding(12).borderRadius(12)
          .backgroundColor(COLORS.card)
        }, (d: DyeItem) => d.id.toString())

pageMarket 构建器定义了「市集」主 Tab 的内容,以列表形式展示蓝染商品。与精选 Tab 的染物大卡类似,但布局有所不同:左侧是方形色块(而不是细竖条),右侧信息区域更加紧凑,底部增加了「下架」操作按钮。

左侧的方形色块尺寸为 56x56dp,圆角 14dp,颜色根据染物色阶动态变化。这个色块相当于商品的主图占位,虽然只是纯色块,但通过颜色的变化已经能传达出染物的核心视觉特征。在实际应用中,这里应该替换为真实的商品图片,但用颜色块作为占位也是一种简洁有效的设计方案。

商品信息区域包含名称、工艺/浸染/色阶、匠人、状态等信息,信息密度比精选 Tab 的卡片更高。价格和下架按钮右对齐排列,价格在上、下架按钮在下,形成清晰的操作区。下架按钮使用红色文字,明确标识这是一个移除操作。

下架按钮的点击处理使用了 this.dyes.indexOf(d) 来获取索引,这种方式虽然能工作,但存在一定的问题:如果数组中有完全相同的元素(不太可能,但理论上存在),indexOf 可能返回错误的索引。更好的做法是在 ForEach 的回调中使用索引参数 i,直接传递索引值。

  @Builder
  pageMine() {
    Scroll() {
      Column({ space: 12 }) {
        Row({ space: 12 }) {
          Column() { Text('🧑‍🎨').fontSize(30) }
          .width(64).height(64).borderRadius(18)
          .backgroundColor(COLORS.indigo)
          .justifyContent(FlexAlign.Center)
          Column({ space: 4 }) {
            Text('拾蓝·阿蓝').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Text('扎染学员 · 染布 32 件').fontSize(9).fontColor(COLORS.sub)
            Text('染龄 1 年 · 已完成 3 门课程').fontSize(8).fontColor(COLORS.indigo)
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
        }
        .width('100%').padding(16).borderRadius(16)
        .backgroundColor(COLORS.card)

pageMine 构建器定义了「我的」主 Tab 的内容,即个人中心页面。顶部是用户信息卡片,展示用户头像、昵称、身份、染布数量、染龄和完成课程数等信息。头像是一个 64x64dp 的靛蓝色圆形卡片,里面放置 🧑‍🎨 emoji,尺寸较大,是页面的视觉焦点。

用户昵称「拾蓝·阿蓝」很有诗意,「拾蓝」既指从事蓝染工作,又有「拾起蓝色」的意象。用户身份标注为「扎染学员」,染布 32 件、染龄 1 年、完成 3 门课程,这些数据量化了用户的参与度和成长。最后一行「染龄 1 年 · 已完成 3 门课程」使用靛蓝色文字,与主题色呼应,也突出了用户的学习成果。

        Row({ space: 10 }) {
          Column({ space: 3 }) {
            Text('32').fontSize(18).fontColor(COLORS.indigo).fontWeight(FontWeight.Bold)
            Text('染物件数').fontSize(8).fontColor(COLORS.sub)
          }
          .layoutWeight(1).padding(12).borderRadius(12).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
          Column({ space: 3 }) {
            Text('6').fontSize(18).fontColor(COLORS.indigoDeep).fontWeight(FontWeight.Bold)
            Text('在染订单').fontSize(8).fontColor(COLORS.sub)
          }
          .layoutWeight(1).padding(12).borderRadius(12).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
          Column({ space: 3 }) {
            Text('3').fontSize(18).fontColor(COLORS.clay).fontWeight(FontWeight.Bold)
            Text('完成课程').fontSize(8).fontColor(COLORS.sub)
          }
          .layoutWeight(1).padding(12).borderRadius(12).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
          Column({ space: 3 }) {
            Text('96').fontSize(18).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
            Text('圈内积分').fontSize(8).fontColor(COLORS.sub)
          }
          .layoutWeight(1).padding(12).borderRadius(12).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
        }
        .width('100%')

用户信息卡片下方是四个数据统计卡片,横向等分排列,分别展示染物件数、在染订单、完成课程和圈内积分四项核心数据。每个数据卡片都有独立的颜色:染物件数用标准靛蓝、在染订单用深藏蓝、完成课程用陶土色、圈内积分用金色。不同的颜色既增加了视觉丰富度,也帮助用户快速区分不同类型的数据。

数据统计的设计是个人中心页面的常见模式,它将用户最关心的核心指标以醒目的方式展示出来,既满足了用户查看数据的需求,也能激励用户更多地参与(如赚取积分、完成更多课程)。这种「游戏化」的设计元素在社区和教育类应用中非常有效。

        ForEach(['我的订单', '课程记录', '染物图鉴', '匠人收藏', '养缸手册', '设置'], (s: string) => {
          Row({ space: 10 }) {
            Text('›').fontSize(16).fontColor(COLORS.indigo)
            Text(s).fontSize(11).fontColor(COLORS.title)
            Column().layoutWeight(1)
            Text('>').fontSize(12).fontColor(COLORS.sub)
          }
          .width('100%').padding(14).borderRadius(12)
          .backgroundColor(COLORS.card)
        }, (s: string) => s)

页面底部是功能菜单列表,包含「我的订单」「课程记录」「染物图鉴」「匠人收藏」「养缸手册」「设置」六个菜单项。每个菜单项采用「前缀图标 + 文字 + 右箭头」的经典列表样式,前缀使用靛蓝色的「›」符号作为装饰,右侧是灰色的「>」箭头表示可点击。

菜单列表使用 ForEach 遍历字符串数组生成,这种数据驱动的方式简洁高效。但与之前的导航 Tab 类似,使用字符串作为 key 在需要国际化或修改文案时可能会有问题。在生产环境中,建议使用枚举或常量对象来管理菜单项。

3.28 弹框构建器:遮罩与三种弹框

  @Builder
  modalOverlay(onClose: () => void) {
    Column()
      .width('100%')
      .height('100%')
      .backgroundColor(COLORS.mask)
      .onClick(() => { onClose(); })
  }

modalOverlay 是弹框遮罩层的构建器,它接收一个 onClose 回调函数作为参数,用于处理点击遮罩关闭弹框的逻辑。遮罩层是一个占满全屏的 Column,背景色为 COLORS.mask(带 60% 透明度的深蓝色),点击遮罩时调用传入的关闭回调。

遮罩层在弹框设计中有两个作用:一是视觉上弱化背景内容,突出弹框;二是拦截点击事件,点击遮罩关闭弹框是移动端的标准交互模式。将遮罩层抽象为独立的 @Builder 方法,可以被多个弹框复用,减少重复代码。

@Builder 方法支持参数传递是一个重要的特性,它使得构建器不仅仅是静态的 UI 片段,还可以根据传入的参数动态调整行为。在这个例子中,不同的弹框传入不同的关闭回调(() => { this.addModal = false }() => { this.editModal = false } 等),实现了逻辑的解耦。

  @Builder
  addModalBody() {
    Column({ space: 12 }) {
      Text('发布染友动态').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
      TextInput({ placeholder: '昵称(可留空)', text: this.addNick })
        .fontSize(11).height(38).borderRadius(10)
        .backgroundColor(COLORS.card2).fontColor(COLORS.title)
        .onChange((v: string) => { this.addNick = v; })
      TextInput({ placeholder: '分享你的染布心得或开缸见闻…', text: this.addText })
        .fontSize(11).height(60).borderRadius(10)
        .backgroundColor(COLORS.card2).fontColor(COLORS.title)
        .onChange((v: string) => { this.addText = v; })

addModalBody 构建器定义了发布动态弹框的内容。弹框标题为「发布染友动态」,使用 14px 粗体。表单包含两个输入框:昵称输入框(可选)和正文输入框(必填)。昵称输入框高度 38dp,正文输入框高度 60dp,都使用浅蓝灰色背景和圆角边框。

TextInput 组件的 text 属性绑定到对应的 @State 变量(this.addNickthis.addText),onChange 回调在输入内容变化时更新状态变量。这种「双向绑定」式的写法确保了输入框内容与状态变量的同步。

正文输入框的 placeholder 文案「分享你的染布心得或开缸见闻…」很有引导性,它提示用户可以分享哪些类型的内容,同时也呼应了蓝染主题。好的 placeholder 文案不仅能告诉用户输入什么,还能起到内容运营的作用。

      Row({ space: 10 }) {
        Button().layoutWeight(1).height(36).borderRadius(18).backgroundColor(COLORS.card2)
          .onClick(() => { this.addModal = false; })
        Text('取消').fontSize(11).fontColor(COLORS.sub).margin({ left: -66 })
        Button().layoutWeight(1).height(36).borderRadius(18).backgroundColor(COLORS.indigo)
          .onClick(() => { this.doAdd(); })
        Text('发布').fontSize(11).fontColor(COLORS.white).fontWeight(FontWeight.Bold).margin({ left: -66 })
      }
      .width('100%')
    }
    .width('86%')
    .padding(18)
    .borderRadius(16)
    .backgroundColor(COLORS.white)
  }

弹框底部是两个操作按钮:「取消」和「发布」。按钮使用 layoutWeight(1) 等分宽度,高度 36dp,圆角 18dp(高度的一半),形成完全圆角的胶囊形状。取消按钮使用浅灰底灰字,发布按钮使用靛蓝底白字粗体,视觉主次分明。

弹框整体宽度为屏幕的 86%,留出 7% 的左右边距,这种宽度比例在移动端弹框设计中很常见——既不会太宽显得压抑,也不会太窄内容放不下。弹框使用 18dp 的内边距和 16dp 的圆角,白色背景,整体风格简洁柔和。

  @Builder
  editModalBody() {
    Column({ space: 12 }) {
      Text('调整染物订单').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
      TextInput({ placeholder: '染物名称', text: this.editItem })
        .fontSize(11).height(38).borderRadius(10)
        .backgroundColor(COLORS.card2).fontColor(COLORS.title)
        .onChange((v: string) => { this.editItem = v; })
      TextInput({ placeholder: '工艺 如 扎染·捆扎', text: this.editCraft })
        .fontSize(11).height(38).borderRadius(10)
        .backgroundColor(COLORS.card2).fontColor(COLORS.title)
        .onChange((v: string) => { this.editCraft = v; })
      Text('提示:浸染次数越多色越深,工期相应延长').fontSize(8).fontColor(COLORS.sub).width('100%')

editModalBody 构建器定义了编辑订单弹框的内容。标题为「调整染物订单」,表单包含两个输入框:染物名称和工艺类型。两个输入框高度相同(38dp),都是单行输入。

输入框下方有一行提示文字「提示:浸染次数越多色越深,工期相应延长」,使用 8px 的灰色小字。这行提示既是温馨提示,也传递了蓝染的专业知识。在表单中添加适当的辅助说明文字,能够帮助用户更好地理解输入项的含义和影响,减少输入错误。

  @Builder
  delModalBody() {
    Column({ space: 12 }) {
      Text('确认操作').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
      Text(this.delTarget === 'order' ? '取消订单后已扎花的工序照常完成,收取 20% 工时费,确认吗?' : '下架该染物后将从市集列表移除,确认继续吗?')
        .fontSize(10).fontColor(COLORS.sub).textAlign(TextAlign.Center)
      Row({ space: 10 }) {
        Button().layoutWeight(1).height(36).borderRadius(18).backgroundColor(COLORS.card2)
          .onClick(() => { this.delModal = false; })
        Text('再想想').fontSize(11).fontColor(COLORS.sub).margin({ left: -82 })
        Button().layoutWeight(1).height(36).borderRadius(18).backgroundColor(COLORS.red)
          .onClick(() => { this.doDel(); })
        Text('确认').fontSize(11).fontColor(COLORS.white).fontWeight(FontWeight.Bold).margin({ left: -58 })
      }
      .width('100%')
    }
    .width('86%')
    .padding(18)
    .borderRadius(16)
    .backgroundColor(COLORS.white)
  }

delModalBody 构建器定义了删除/下架确认弹框的内容。标题为「确认操作」,正文根据 delTarget 的值显示不同的提示文案:取消订单时显示「取消订单后已扎花的工序照常完成,收取 20% 工时费,确认吗?」,下架染物时显示「下架该染物后将从市集列表移除,确认继续吗?」。

确认弹框的文案设计很用心,它不仅询问用户是否确认操作,还说明了操作的后果和影响。取消订单的文案特别详细——「已扎花的工序照常完成,收取 20% 工时费」,这既体现了手作工坊的经营规则,也让用户对取消的后果有清晰的预期,减少纠纷。

操作按钮的设计也符合确认弹框的规范:左侧的「再想想」按钮使用浅灰底灰字,语气比较温和,给用户一个「反悔」的出口;右侧的「确认」按钮使用红底白字粗体,明确标识这是危险操作。红色在交互设计中通常代表警告和危险,用在删除/取消等不可逆操作上是符合用户预期的。

3.29 底部导航构建器

  @Builder
  bottomBar() {
    Row({ space: 0 }) {
      ForEach(NAV_LIST, (ni: NavItem, i: number) => {
        Column({ space: 3 }) {
          Text(ni.icon).fontSize(18)
          Text(ni.label)
            .fontSize(9)
            .fontColor(this.mainTab === i ? COLORS.indigo : COLORS.sub)
            .fontWeight(this.mainTab === i ? FontWeight.Bold : FontWeight.Normal)
        }
        .layoutWeight(1)
        .padding({ top: 6, bottom: 6 })
        .alignItems(HorizontalAlign.Center)
        .onClick(() => { this.mainTab = i; this.subTab = 0; })
      }, (ni: NavItem) => ni.label)
    }
    .width('100%')
    .backgroundColor(COLORS.white)
    .border({ width: 1, color: COLORS.line })
  }

bottomBar 构建器定义了底部导航栏,这是应用的主导航组件。底部导航使用 Row 横向排列四个导航项,每个导航项通过 layoutWeight(1) 等分宽度。导航项数据来源于全局常量 NAV_LIST,包含图标(emoji)和标签文字。

每个导航项包含图标和文字两部分,图标在上、文字在下,竖向居中排列。图标使用 18px 的 emoji,文字使用 9px。选中状态(this.mainTab === i)下文字变为靛蓝色并加粗,未选中时为灰色常规字重。图标没有选中态变化(因为 emoji 无法改变颜色),这是使用 emoji 作为图标的一个局限。

点击导航项时,不仅设置 mainTab = i 切换主 Tab,还将 subTab = 0 重置内容 Tab 到第一个。这种「切换主 Tab 时重置子 Tab」的交互设计是合理的,因为不同主 Tab 的内容 Tab 是相互独立的,用户切换回来时应该从默认位置开始。

底部导航栏使用白色背景,顶部有一条 1dp 的分隔线(COLORS.line),与内容区域形成视觉分隔。这是移动端底部导航的标准设计——白色背景加顶部分隔线,简洁而经典。导航项上下各有 6dp 的内边距,确保点击区域足够大(符合移动端 44dp 最小点击区域的规范)。

3.30 主构建方法:build 与 Stack 布局

  build() {
    Stack() {
      Column() {
        this.header()
        if (this.mainTab === 0) {
          Column() {
            this.subNav()
            if (this.subTab === 0) {
              this.tabPick()
            } else if (this.subTab === 1) {
              this.tabDye()
            } else if (this.subTab === 2) {
              this.tabShade()
            } else if (this.subTab === 3) {
              this.tabVat()
            } else if (this.subTab === 4) {
              this.tabClass()
            } else {
              this.tabFriends()
            }
          }
          .layoutWeight(1)
          .width('100%')
          .alignItems(HorizontalAlign.Center)
          .backgroundColor(COLORS.bg)
        } else if (this.mainTab === 1) {
          this.pageWorkshop()
        } else if (this.mainTab === 2) {
          this.pageMarket()
        } else {
          this.pageMine()
        }
        this.bottomBar()
      }
      .width('100%')
      .height('100%')

build() 方法是组件的核心渲染函数,它以声明式的方式描述了整个页面的 UI 结构。最外层使用 Stack 布局,包含三层内容:主内容区(Column)、特效层、弹框层。Stack 布局的子组件会按顺序堆叠,后渲染的组件在上层,这正好满足「内容在最下、特效在中间、弹框在最上」的层级需求。

主内容区使用 Column 纵向布局,从上到下依次是头部(header)、内容区域、底部导航(bottomBar)。内容区域使用 if 条件渲染根据 mainTab 的值显示不同的主 Tab 内容。首页(mainTab === 0)比较特殊,它还包含了内容 Tab 导航(subNav)和嵌套的二级内容切换逻辑。

首页的内容切换使用 if / else if / else 的多分支条件渲染,根据 subTab 的值显示不同的内容 Tab。这种方式的优点是逻辑清晰直观,缺点是当 Tab 数量较多时代码会显得冗长。对于 6 个 Tab 来说,这种写法是可以接受的,如果 Tab 数量更多,可以考虑使用 @Builder 数组 + 索引的方式来简化代码。

内容区域的 Column 设置了 layoutWeight(1),使其占据头部和底部导航之间的所有剩余空间。这是典型的「头 + 内容 + 底」三段式布局,内容区域自适应填充剩余空间,确保在不同尺寸的设备上都有正确的布局。

      // 特效层仅覆盖内容区(头部之下)
      Column() {
        this.fxLayer()
      }
      .width('100%')
      .height('100%')
      .justifyContent(FlexAlign.End)
      .hitTestBehavior(HitTestMode.None)

特效层是 Stack 的第二个子组件,位于主内容区之上。特效层使用 Column 包裹 fxLayer(),设置 justifyContent(FlexAlign.End) 使特效层从底部开始,只覆盖内容区域而不遮挡头部。这样的设计确保了头部的交互元素(搜索、购物车、Tab 导航)不会被特效遮挡。

hitTestBehavior(HitTestMode.None) 是一个关键的属性设置,它让特效层不响应触摸事件,触摸事件会穿透到下层的主内容区。如果没有这个设置,特效层虽然视觉上是透明的,但会拦截所有触摸事件,导致下方的内容无法交互。这是覆盖层设计中的一个重要细节。

      // 弹框层
      if (this.addModal) {
        Column() {
          this.modalOverlay(() => { this.addModal = false; })
          Column() {
            this.addModalBody()
          }
          .constraintSize({ maxHeight: '80%' })
          .zIndex(999)
        }
        .width('100%')
        .height('100%')
        .justifyContent(FlexAlign.Center)
      }
      if (this.editModal) {
        // 编辑弹框...
      }
      if (this.delModal) {
        // 删除弹框...
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor(COLORS.bg)
  }
}

弹框层是 Stack 的最上层,包含三个条件渲染的弹框。每个弹框都是一个占满全屏的 Column,内部包含遮罩层和弹框内容两部分。遮罩层覆盖全屏处理点击关闭,弹框内容居中显示。

弹框内容使用 constraintSize({ maxHeight: '80%' }) 设置最大高度限制,防止弹框内容过多时超出屏幕。不同类型的弹框设置了不同的最大高度:新增弹框 80%、编辑弹框 70%、删除确认弹框 60%,根据内容量调整最大高度,避免弹框过大或过小。

zIndex(999) 设置弹框的层叠顺序,确保弹框始终在最上层。虽然 Stack 布局本身就是后渲染的在上层,但显式设置 zIndex 可以更明确地控制层级关系,特别是在复杂的堆叠场景中。

三种弹框各自独立使用 if 条件渲染,根据对应的状态变量(addModaleditModaldelModal)控制显示与隐藏。由于打开弹框的方法中会确保同时只有一个弹框为 true,所以不会出现弹框叠加的问题。这种「每个弹框一个状态变量」的设计模式简单直观,便于维护。

四、Mermaid 流程图

以下是从组件生命周期到渲染再到交互的完整流程图,展示了应用从启动到用户交互的全流程:

mainTab=0

0

1

2

3

4

5

mainTab=1

mainTab=2

mainTab=3

addModal=true

editModal=true

delModal=true

组件初始化

aboutToAppear 生命周期

启动 setInterval 计时器

每隔 700ms 执行

tick 自增 + glow 取反

触发气泡/波纹特效更新

首次执行 build 渲染

Stack 三层布局构建

第一层:主内容区 Column

header 头部构建

定位/搜索栏

今日开缸大卡

热门染物横滚

mainTab 条件渲染

首页内容区

subNav 内容 Tab 导航

subTab 条件渲染

tabPick 精选

tabDye 染物图鉴

tabShade 靛蓝色谱

tabVat 染缸日历

tabClass 工坊课程

tabFriends 染友圈

pageWorkshop 染坊

pageMarket 市集

pageMine 我的

bottomBar 底部导航

第二层:特效层

fxLayer 气泡+波纹

hitTestBehavior=None 穿透点击

第三层:弹框层

发布动态弹框

编辑订单弹框

删除确认弹框

用户交互事件

点击底部导航

mainTab 变更 + subTab 重置

触发 UI 重新渲染

点击内容 Tab

subTab 变更

点击发布/调整/取消/下架

调用 openXxx 方法

设置弹框状态 + 弹框数据

弹框显示

弹框表单输入

onChange 更新 addNick/addText/editItem/editCraft

弹框确认操作

调用 doAdd/doEdit/doDel

修改 orders/dyes/friends 数组

点击遮罩/取消

设置 xxxModal=false

弹框隐藏

ArkUI 框架差异更新

仅更新变化的 UI 片段

五、技术对比表格

技术维度 实现方式 设计特点 性能考量
状态管理 @State + @Observed 双层机制 组件内集中管理,数组变化与对象属性变化均能响应 @Observed 实现细粒度更新,避免全量重绘
UI 构建 @Builder 装饰器封装 按功能模块拆分,主 build 结构清晰 @Builder 为编译期内联,无运行时开销
列表渲染 ForEach + key 生成函数 数据驱动渲染,key 基于数据 ID 生成 稳定 key 确保最小化 DOM 操作,提升列表性能
导航架构 4主Tab + 6内容Tab 双层嵌套 首页内容丰富,其他主Tab独立页面 条件渲染按需构建,未选中的 Tab 不占用渲染资源
特效实现 setInterval + 纯计算函数 气泡上浮 + 波纹扩散,伪随机分布 纯 JS 计算 + 状态驱动,无需动画资源,包体小
弹框系统 if 条件渲染 + Stack 堆叠 遮罩层复用 + 三种弹框体 + zIndex 层级控制 弹框未显示时完全不构建,节省初始渲染成本
色彩体系 ColorPalette 接口 + COLORS 常量 统一管理,多蓝色阶 + 功能色 + 中性色 避免硬编码色值,主题切换只需替换常量
数据模型 @Observed class + 构造函数 字段完整,类型安全,可观察 对象属性级别的依赖追踪,精确更新
布局系统 Column/Row/Stack 组合 弹性布局为主,百分比 + layoutWeight 声明式布局,框架自动计算位置,性能优化
滚动容器 Scroll + scrollBar Off 横向滚/纵向滚灵活组合,隐藏滚动条 原生 Scroll 组件,滑动流畅,支持惯性滚动
表单输入 TextInput + onChange 双向绑定 状态变量驱动,输入实时同步 轻量级表单,无第三方依赖,性能优秀
生命周期 aboutToAppear / aboutToDisappear 配对使用,定时器精确管理 及时清理定时器,防止内存泄漏
图标方案 Emoji 字符图标 零资源依赖,跨平台一致 无需加载图片,渲染快,但无法改色
条件渲染 if/else if/else 多分支 逻辑清晰,直接映射状态到视图 未满足条件的分支完全不构建,节省性能
卡片设计 圆角 + 阴影感(通过背景色层次) 多层次背景色营造深度感 纯布局实现,无阴影绘制开销
进度条/图表 纯布局实现(Column 宽度百分比) 简单直接,数据到视觉的映射清晰 无额外图表库依赖,性能最优
键盘交互 弹框内 TextInput 原生支持 系统键盘,无需自定义 原生输入组件,性能和体验最佳
触摸事件 onClick + hitTestBehavior 覆盖层穿透点击,事件精准 事件分层处理,避免误触和拦截问题
渐变效果 linearGradient 属性 135度角双色渐变,增强视觉层次 原生渐变绘制,性能优于图片

六、详细总结

6.1 架构设计总结

本蓝染平台应用采用了单组件集中式架构,所有 UI 模块和业务逻辑都集中在一个 Page 组件中,通过 @Builder 装饰器将不同功能模块的 UI 代码拆分为独立的构建方法。这种架构在中小型应用中有明显的优势:结构简单直观,状态管理集中,方法调用方便,开发效率高。

从分层角度来看,应用可以分为数据层、逻辑层和视图层三个层次。数据层由 @Observed 装饰的数据模型类和全局常量数据组成,定义了业务实体的结构和初始数据;逻辑层由组件的状态变量和操作方法组成,负责处理用户交互和数据变更;视图层由 @Builder 构建器和主 build() 方法组成,负责将状态渲染为 UI。三层之间通过声明式的状态驱动联系在一起,形成了清晰的单向数据流。

Stack 三层布局的设计是架构上的一大亮点。最底层是主内容区,中间是特效层(穿透点击),最上层是弹框层。这种分层设计将不同性质的视觉元素在空间上进行了隔离,每层有各自的职责和交互规则。特别是特效层的 hitTestBehavior(HitTestMode.None) 设置,确保了装饰性的特效不会影响正常的内容交互,体现了对用户体验的细致考量。

架构上也存在一些可以优化的地方。随着功能的增加,单组件的代码量会持续增长,可能导致维护困难。未来可以考虑将独立的功能模块(如弹框、列表项等)拆分为子组件,通过 @Prop@Link 进行数据传递,进一步提升代码的模块化程度。

6.2 状态管理总结

应用的状态管理采用了「@State + @Observed」的双层响应式机制,这是 ArkTS 状态管理体系的经典组合。@State 用于管理基本类型和数组等顶层状态,如 Tab 索引、弹框显示状态、列表数组等;@Observed 用于标记数据模型类,使得对象属性的变化也能触发 UI 更新。两者配合,实现了从宏观到微观的全面响应式覆盖。

状态变量的组织方式值得称道。所有状态变量都集中定义在组件顶部,按功能分组(导航状态、特效状态、弹框状态、列表数据),命名清晰规范,通过变量名就能理解其用途。弹框状态和弹框数据的分离设计也很合理:布尔变量控制显示隐藏,字符串/数字变量存储弹框上下文数据,职责明确。

状态变更的方式遵循声明式编程的最佳实践——操作数据,UI 自动更新。无论是 Tab 切换、列表增删,还是对象属性修改,都只需修改对应的状态变量,框架会自动计算差异并更新 UI。开发者不需要手动操作视图控件,大大降低了心智负担,也减少了因状态与视图不同步而产生的 bug。

在状态管理的粒度上,应用也处理得相当不错。列表使用数组级别的 @State 管理整体增删,数组元素使用 @Observed 支持属性级别的细粒度更新。这种分层响应式设计确保了不同规模的状态变化都能以最优的方式触发 UI 更新,既不过度渲染也不遗漏更新。

6.3 性能优化总结

虽然本应用是一个示例项目,但其中蕴含了不少性能优化的思路和实践。首先是「按需渲染」的设计理念:通过 if 条件渲染,未选中的 Tab 内容完全不构建,弹框在未显示时也不构建,大大减少了初始渲染的工作量和内存占用。对于有 6 个内容 Tab 和 4 个主 Tab 的应用来说,这种按需构建的方式相比一次性构建所有 Tab,性能提升非常显著。

其次是列表渲染的优化。应用中大量使用 ForEach 进行列表渲染,并且都提供了稳定的 key 生成函数(基于数据 ID 或唯一标识)。稳定的 key 帮助框架在列表数据变化时进行最小化的 DOM 操作,避免不必要的组件销毁和重建,特别是在列表增删操作频繁的场景下,性能差异会非常明显。

特效实现的优化也很有代表性。应用没有使用 GIF、序列帧或 Lottie 等资源型动画,而是通过 setInterval + 纯函数计算 + 状态驱动的方式实现了气泡和波纹效果。这种「用计算代替资源」的思路不仅减少了应用包体大小,还避免了资源加载和解析的开销。虽然 setInterval 驱动的动画在流畅度上不如原生动画 API,但对于装饰性的轻量特效来说已经足够,且实现简单、可控性强。

色彩和布局层面的优化同样值得关注。应用使用纯布局实现图表(柱状图、进度条)和卡片效果,避免了图片资源的使用;使用 linearGradient 实现渐变背景,比使用渐变图片更轻量;使用 emoji 作为图标,零资源依赖。这些优化虽然单个效果有限,但积少成多,共同构成了应用轻量高效的特性。

6.4 交互设计总结

应用的交互设计整体遵循移动端的设计规范,用户学习成本低,操作流畅自然。Tab 导航采用底部主导航 + 内容区次级导航的双层架构,符合移动端用户的使用习惯。底部导航 4 个 Tab 是行业内的标准配置(通常 3-5 个),内容区 6 个 Tab 采用横向滚动的胶囊样式,既展示了全部分类,又不会占用太多空间。

弹框交互设计得相当完善。三种弹框(新增、编辑、确认)各自有明确的使用场景和视觉风格:新增弹框表单最丰富,编辑弹框简洁实用,确认弹框重点突出风险提示。弹框都支持点击遮罩关闭和点击取消按钮关闭,提供了多种关闭途径。确认弹框中的「再想想」按钮用温和的措辞降低了用户的心理压力,是一种人性化的交互设计。

列表交互方面,订单列表和市集列表直接在卡片上提供操作按钮(调整、取消、下架),减少了用户的操作步骤。虽然这种设计需要注意避免误触,但对于效率工具型的场景(如管理后台、商家端),直接操作的方式确实能提升工作效率。

特效层的穿透点击设计(hitTestBehavior(HitTestMode.None))是一个重要的交互细节。很多应用在实现装饰性覆盖层时会忽略穿透点击的处理,导致下方内容无法交互,严重影响用户体验。本应用对此处理得当,特效只负责视觉装饰,不干扰正常交互,体现了对交互细节的重视。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:



6.5 总结:

在这里插入图片描述

从代码结构来看,应用具备良好的可扩展性。数据驱动的设计模式使得增加新的列表项(新增染物、新增课程、新增动态)非常简单,只需添加数据对象即可,无需修改渲染逻辑。导航配置(底部 Tab、内容 Tab)也采用了数据驱动的方式,增加新的 Tab 只需修改对应的数据数组和增加一个 @Builder 方法。

色彩体系和状态颜色映射的集中管理为主题扩展打下了基础。如果未来需要支持深色模式或其他主题配色,只需替换 COLORS 常量和调整颜色映射函数,无需在全代码中搜索替换硬编码的色值。这种集中式的设计管理大大降低了维护成本和出错概率。

弹框系统的设计也具有良好的扩展性。新增弹框类型只需遵循「一个状态变量 + 一个 open 方法 + 一个 do 方法 + 一个 @Builder 构建器」的模式,在 build() 方法的弹框层添加条件渲染即可。遮罩层 modalOverlay 的复用设计减少了新增弹框时的重复代码量。

当然,应用在可扩展性方面也存在一些局限。单组件架构在功能模块较少时效率很高,但随着功能不断增加,组件会变得越来越臃肿,不利于团队协作和并行开发。未来如果功能持续扩展,可以考虑将页面拆分为多个子组件,每个子组件负责一个功能模块,通过 props 和 events 进行通信。此外,数据层目前是前端写死的,接入后端 API 时需要增加网络请求和数据持久化的逻辑,这也是扩展过程中需要重点考虑的部分。

Logo

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

更多推荐