一、蜡染工艺的历史文化背景

在这里插入图片描述

蜡染,古称"蜡缬",是中国古代三大印花技艺之一,与绞缬(扎染)、夹缬并称为中国古代印染工艺的"三缬"。蜡染的历史可以追溯至秦汉时期,距今已有两千余年的悠久传承。据《南蛮西南夷传》记载,早在秦汉时代,南方少数民族就已掌握了以蜡画花、入染靛蓝的技艺,这种以蜂蜡为防染剂、以靛蓝为主染料的工艺,在漫长的历史长河中逐渐发展成为中国西南地区最具代表性的民族手工技艺之一。蜡染的工艺原理看似简单——用蜡在白布上绘制图案,再将布浸入靛蓝染缸,有蜡之处不沾染料,脱蜡之后便显出蓝白相间的花纹——然而这简单之中蕴含着深厚的智慧积累,每一步工序的温度、时间、手法都需经年累月的实践才能掌握。

蜡染在中国的传播与发展,与西南少数民族的生活密不可分。贵州丹寨、云南大理、湖南凤凰、四川珙县等地,都是蜡染工艺的重要传承区域,各地的蜡染作品在纹样、色彩、风格上各具特色。贵州丹寨的蜡染以蝴蝶妈妈纹、太阳鼓纹、铜鼓纹等图腾纹样为主,蓝白对比强烈,图案古朴庄重,承载着苗族先民对始祖的崇拜和对自然力量的敬畏。云南大理的扎染虽与蜡染工艺有别,但同属靛蓝染系统,风格清新淡雅,多用于日常生活用品。湖南凤凰的蜡染则融合了湘西土家族、苗族的文化元素,鱼纹、龙纹等纹样寄托着年年有余、护佑子孙的美好愿景。四川珙县的蜡染则受到僰人文化的影响,漩涡纹、蛙纹等图案象征着生生不息和多子多福的祈愿。

蜡染的纹样不仅仅是装饰图案,更是一部用蓝白两色书写的民族文化史诗。蝴蝶妈妈是苗族创世神话中的始祖,传说她由枫树心中孕育而生,诞下十二个蛋,孵化出人类和万物,因此蝴蝶纹在苗族蜡染中具有至高无上的地位,象征着万物之母和生命的起源。太阳鼓纹则代表着光明与昌盛,铜鼓纹象征着权力与祥瑞,这些纹样在蜡染布上的呈现,不仅是审美的表达,更是信仰的传承、族群的认同和历史的记忆。每一幅蜡染作品,都是一位染娘用蜡刀和靛蓝书写的文化叙事,是指尖上的蓝色诗篇。

蜡染工艺的核心在于"蜡"与"染"的精妙配合。蜂蜡在六十至七十度的温度下融化成液态,染娘用铜制蜡刀蘸取蜡液,在白坯布上细细描绘纹样,蜡液渗入布纤维后冷凝固化,形成一层防染保护膜。随后将点好蜡的布匹浸入靛蓝染缸,经过多次浸染、氧化、再浸染的循环,颜色由浅至深层层叠加。染毕,将布投入沸水中煮蜡,蜂蜡融化脱去,被蜡覆盖之处保持白底,其余之处则呈现出不同深浅的蓝色,蓝白花纹由此显现。在染布过程中,蜡层因揉搓、折叠而产生的自然裂纹,会让染液渗入形成独特的"冰纹"——这种无法完全人为控制的天然纹理,被视为蜡染的灵魂所在,有"冰纹天成"之美誉。

蜡染的价值不仅在于其成品之美,更在于其承载的工匠精神。一位蜡染大师从学徒到独当一面,往往需要十年以上的苦练。蜡刀的握法、蜡液的温度、下笔的力度、浸染的次数,每一个环节都需要手工把持、经验判断。在现代化工业印染技术高度发达的今天,手工蜡染依然具有不可替代的艺术价值和文化价值,它的每一道裂纹、每一处蓝晕都是独一无二的,是机器永远无法复制的"手的温度"。蜡染技艺已被列入国家级非物质文化遗产名录,成为中华民族优秀传统文化的重要组成部分,保护与传承这一古老工艺,不仅是对技艺本身的守护,更是对一种生活方式、一种文化基因、一种审美传统的延续。

在数字时代,如何将蜡染这一古老工艺以现代化的方式呈现和传播,是一个值得深思的课题。本文将以 HarmonyOS ArkTS 声明式开发框架为载体,深入剖析一个蜡染工艺主题的移动端应用,从色彩体系、数据模型、状态管理、UI 组件、布局样式等多个维度,逐行逐段地解析代码背后的设计思想和技术实现,探寻传统工艺与现代编程之间的跨界对话。通过这一技术解析,我们不仅能理解 ArkTS 的核心语法和开发范式,更能体会到如何用代码语言来描绘和传承一种古老的手工之美。


二、色彩体系:ColorPalette 接口与 COLORS 常量

在这里插入图片描述

2.1 ColorPalette 接口定义

interface ColorPalette {
  bg: string;
  cardBg: string;
  header1: string;
  header2: string;
  indigoA: string;
  indigoB: string;
  waxW: string;
  gold: string;
  title: string;
  sub: string;
  text1: string;
  text2: string;
  text3: string;
  accent: string;
  hot: string;
  cool: string;
  danger: string;
  tabBg: string;
  tabOn: string;
  mask: string;
}

在 HarmonyOS ArkTS 开发中,接口(interface)是一种用于定义对象形状的类型声明工具。这里定义的 ColorPalette 接口,为整个应用提供了一套完整的色彩类型约束体系。接口中声明了二十个字符串类型的字段,每一个字段对应应用中的一种色彩用途,从背景色、卡片色、标题色到强调色、危险色、遮罩色,涵盖了移动端界面设计所需的全部色彩角色。

这种将色彩集合抽象为接口的设计方式,具有几层重要的工程意义。首先,它实现了色彩的统一管理。在一个复杂的移动应用中,色彩的使用往往分散在数十个甚至上百个组件之中,如果每个组件各自定义颜色值,不仅容易产生不一致,还会使后续的主题切换或色彩调整变得极其困难。通过接口定义,所有色彩集中管理于一处,修改时只需调整一个源头,全局生效。这种集中管理的设计在项目规模增长时尤为关键,它使色彩体系成为一个可维护、可追溯的整体,而非散落在代码各处的零散值。

其次,接口提供了类型安全保障。在 TypeScript 和 ArkTS 中,接口定义的字段类型是编译期检查的依据。当开发者在使用 COLORS 常量时,如果拼写错误或引用了不存在的字段,编译器会立即报错,避免了运行时才发现的色彩引用错误。这种静态类型检查是 ArkTS 相比传统 JavaScript 的一大优势,它让错误在编写阶段就被发现,大幅提升了代码的可靠性和可维护性。在团队协作开发中,类型安全的价值更加突出——新成员可以通过接口定义快速了解项目使用了哪些色彩,而无需翻阅各处代码。

第三,接口中的字段命名遵循了语义化的设计原则。bg 代表页面背景,cardBg 代表卡片背景,header1header2 代表头部渐变色的起点和终点,indigoAindigoB 代表不同深浅的靛蓝色,waxW 代表蜡白色,gold 代表金色。这些命名不是简单的颜色描述(如"深蓝"、“浅蓝”),而是基于用途的语义命名,使得代码在阅读时就能理解每种色彩在设计中的角色和功能,提升了代码的可读性。语义化命名是一种"自文档化"的编程实践,优秀的命名本身就能传达设计意图,减少对额外注释的依赖。

2.2 COLORS 常量实例

在这里插入图片描述

const COLORS: ColorPalette = {
  bg: '#EEF3FA',
  cardBg: '#FFFFFF',
  header1: '#1B3B6F',
  header2: '#0A1B3A',
  indigoA: '#2E5FA3',
  indigoB: '#6A8FD0',
  waxW: '#F3EFE6',
  gold: '#D9B45B',
  title: '#E8F0FC',
  sub: '#A9C4E8',
  text1: '#1B2F52',
  text2: '#46608C',
  text3: '#8CA0BE',
  accent: '#1B3B6F',
  hot: '#D9B45B',
  cool: '#2E5FA3',
  danger: '#D9534F',
  tabBg: '#1B3B6F',
  tabOn: '#D9B45B',
  mask: 'rgba(0,0,0,0.45)'
};

COLORS 是一个被 const 关键字修饰的常量,它的类型被显式标注为 ColorPalette 接口。这意味着该常量的结构必须严格符合接口定义,字段名和字段类型都不能有偏差。这个常量对象以键值对的形式为二十个色彩字段赋予了具体的十六进制颜色值,是整个应用色彩体系的具体实现。每一个颜色值都经过精心调配,服务于蜡染工艺的视觉主题。

观察这些颜色值,可以发现一套精心设计的色彩方案。#EEF3FA 作为背景色,是一种极浅的蓝白色调,给人一种清晨靛蓝染坊的淡雅感,如同白坯布刚刚铺展开来的底色。#1B3B6F#0A1B3A 构成了头部渐变,分别是一种深沉的藏蓝和近乎墨蓝的色调,模拟的是靛蓝染缸中深层次浸染后布匹呈现的浓郁蓝。#2E5FA3#6A8FD0 是两种不同饱和度的靛蓝,前者浓重后者清浅,对应蜡染中不同浸染次数所呈现的蓝色层次。#F3EFE6 是一种微黄的米白色,模拟的是蜂蜡冷却后的蜡白色调,在界面中用于模拟蜡纹和气泡的视觉效果。#D9B45B 是金色,在蜡染文化中金色常用于点缀仪式服饰,在这里作为强调色和标签激活色使用。

文字色彩的层次设计也值得关注。text1#1B2F52)是最深的文字色,用于主要标题和重要信息,模拟蜡染布上最深处的靛蓝;text2#46608C)是中等深度的文字色,用于次要说明文字,模拟中等浸染的蓝色;text3#8CA0BE)是最浅的文字色,用于辅助信息和元数据,模拟浅染的淡蓝。这种三级文字色彩体系,使界面信息的层次感通过色彩明暗自然呈现,引导用户的视觉焦点从主到次有序流动。同时,titlesub 是专门用于头部区域的浅色文字,在深色背景上呈现,与主体内容的深色文字形成对比,这种"深底浅字"与"浅底深字"的对比搭配是视觉设计的基本功法。

mask 字段的值 'rgba(0,0,0,0.45)' 使用了 RGBA 格式而非十六进制,这是一种半透明黑色,用于弹窗出现时遮罩整个背景的视觉效果。RGBA 格式的优势在于可以直接指定透明度通道,而十六进制色值无法直接表达半透明效果(除非使用八位十六进制 #00000073,但可读性较差)。选择 RGBA 格式在这里是合理的,因为遮罩层的效果核心就是半透明,使用 RGBA 使意图更加清晰。这种根据具体场景灵活选择色彩格式的做法,体现了开发者在细节上的考量。

值得注意的是,accentheader1 都是 #1B3B6FhotgoldtabOn 都是 #D9B45BcoolindigoA 都是 #2E5FA3。这种"同色异名"的设计并非冗余,而是基于语义角色的分离。虽然当前它们指向同一颜色值,但 accent 语义上是"强调色"、header1 语义上是"头部色"、cool 语义上是"冷调色",它们在不同的上下文中使用,未来如果需要调整其中一者而不影响其他,只需修改对应的字段即可。这是一种面向未来的可扩展设计,体现了"语义与实现分离"的工程思想。


三、标签系统:TabMeta 接口与 TAB_LIST 常量

在这里插入图片描述

3.1 TabMeta 接口

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

TabMeta 接口定义了底部标签栏中每个标签项的元数据结构。接口结构极为简洁,仅包含两个字符串字段:label 用于存储标签的显示文字,icon 用于存储标签的图标符号。这种极简的接口设计体现了"单一职责原则"——一个接口只描述一个事物的核心属性,不添加任何多余的字段,保持了接口的纯粹性和可理解性。

在 ArkTS 的类型系统中,接口的简洁性不仅影响代码的清晰度,更影响编译期的类型推断效率。字段越少,类型检查的开销越低,编译器的反馈也越迅速。对于标签这种高频使用的数据结构,保持接口精简是合理的工程选择。此外,使用 string 类型而非枚举或联合类型来定义 icon,是因为这里的图标使用的是 Emoji 字符(如 🪁、🌀 等),Emoji 本质上是 Unicode 字符串,用 string 类型存储是最自然的选择。

label 字段同样使用 string 类型,标签文字如"蜡染布"、“纹样”、"蜡刀"等都是普通中文字符串。这里没有为 label 定义更具体的类型约束(如联合类型 '蜡染布' | '纹样' | ...),是因为标签内容可能在未来迭代中变化,使用 string 保持了灵活性。这种设计在小型应用中是常见的实践——在类型安全和开发效率之间取得平衡,不过度约束。如果标签集固定不变,使用联合类型可以获得更强的类型安全;但在快速迭代的开发环境中,联合类型的约束反而会降低灵活性。

3.2 TAB_LIST 常量

在这里插入图片描述

const TAB_LIST: TabMeta[] = [
  { label: '蜡染布', icon: '🪁' },
  { label: '纹样', icon: '🌀' },
  { label: '蜡刀', icon: '🔪' },
  { label: '染娘', icon: '🧕' },
  { label: '工序', icon: '🫧' },
  { label: '订单', icon: '📦' }
];

TAB_LIST 是一个 TabMeta 类型的数组常量,定义了底部标签栏的六个标签项。六个标签分别对应应用的六个功能页面:蜡染布(成品展示)、纹样(图案寓意)、蜡刀(工具介绍)、染娘(匠人名录)、工序(制作流程)、订单(订单管理)。这六个标签构成了应用的信息架构,涵盖了蜡染工艺从材料到成品、从工具到人、从工艺到商业的完整链条,体现了对蜡染工艺全流程的数字化呈现。

使用 Emoji 作为图标是一种巧妙的设计选择。传统移动应用通常使用矢量图标库(如 Material Icons、Font Awesome)或自定义 SVG 图形作为标签图标,但这种方式需要引入额外的图标资源或字体文件,增加了应用的体积和复杂度。Emoji 作为 Unicode 标准字符集的一部分,无需任何额外资源即可在任何支持 Unicode 的系统上显示,是一种零成本的图标方案。每个 Emoji 的选择也颇具匠心:🪁(风筝形似布匹)代表蜡染布,🌀(漩涡)代表纹样图案,🔪(刀)代表蜡刀,🧕(戴头巾的女性)代表染娘,🫧(气泡)代表工序中的染缸气泡,📦(包裹)代表订单。这些 Emoji 在视觉上与蜡染工艺的意象形成了巧妙的对应关系,既是图标也是文化符号。

将标签配置提取为数组常量,而非硬编码在每个组件内部,带来了显著的架构优势。首先,标签的增删改只需修改这一处数组,无需触及组件代码,实现了配置与逻辑的解耦。其次,数组结构天然支持遍历操作,后续在渲染标签栏时可以使用 ForEach 循环动态生成标签项,避免了重复编写相似的 UI 代码。第三,如果未来需要支持动态标签(如根据用户角色显示不同标签集),只需替换数据源即可,组件逻辑无需修改。这种"数据驱动 UI"的设计思想,是现代声明式 UI 框架的核心范式。


四、索引常量与渲染循环配置

在这里插入图片描述

const ROW1_IDX: number[] = [0, 1, 2];
const ROW2_IDX: number[] = [3, 4, 5];
const RING_IDX: number[] = [0, 1, 2];
const CRACK_IDX: number[] = [0, 1, 2, 3, 4, 5];
const TEXTILE_IDX: number[] = [0, 1, 2];
const BUBBLE_IDX: number[] = [0, 1, 2, 3];

这六个常量都是 number[] 类型的索引数组,用于控制 ForEach 循环中的渲染次数和序列。ROW1_IDXROW2_IDX 分别对应底部标签栏第一行和第二行的三个标签索引,将六个标签分为两行三列的布局。RING_IDX 控制头部装饰中同心圆环的渲染数量(三个)。CRACK_IDX 控制蜡纹裂纹的数量(六条)。TEXTILE_IDX 控制纺织布条的数量(三条)。BUBBLE_IDX 控制气泡的数量(四个)。每个数组都精确地定义了一种装饰元素的渲染规模。

使用索引数组而非简单的数字来控制循环次数,看似冗余,实则有其设计考量。在 ArkTS 的 ForEach 中,需要提供一个可迭代的集合作为数据源,同时提供一个键生成函数(key generator)用于 Diff 算法的元素追踪。使用索引数组作为数据源,可以精确控制每次循环渲染的元素数量和顺序,同时在键生成函数中可以直接使用索引值(如 'c' + i)作为唯一键,确保 Diff 算法在更新时能正确识别元素的增减和位置变化。这种做法虽然比直接传入一个数字多了些代码,但在类型安全性和可读性上有明显优势——数组的内容一目了然,开发者能立刻知道会渲染多少个元素。

这些常量将视觉效果参数化,使得调节数量变得便捷。例如,如果需要在头部增加更多裂纹效果,只需在 CRACK_IDX 数组中添加更多元素(如 [0, 1, 2, 3, 4, 5, 6, 7]),相关的 crackXcrackYcrackAngle 函数会根据索引自动计算新元素的位置和角度,无需修改任何组件代码。这种参数化设计使得视觉效果可配置、可扩展,体现了数据驱动 UI 的核心思想。在需要快速迭代视觉效果的场景下,这种设计能大幅减少代码修改量和测试成本。


五、辅助函数体系

在这里插入图片描述

5.1 圆环尺寸与透明度函数

function ringSize(i: number): number {
  return 60 + i * 34 + (i === 0 ? 0 : 0);
}

ringSize 函数根据索引 i 计算同心圆环的尺寸(直径,以 vp 为单位)。计算公式为 60 + i * 34,即基础尺寸 60vp,每增加一层索引,直径增加 34vp。这意味着三个圆环的尺寸分别为 60vp、94vp、128vp,形成了由内向外逐渐扩大的同心圆效果。公式末尾的 (i === 0 ? 0 : 0) 是一个条件表达式,但无论条件是否成立都返回 0,这是一个有意保留的占位表达式——它不影响计算结果,但在代码结构上为未来可能的特殊处理预留了位置,比如将来需要对第一层圆环做特殊尺寸调整时,只需修改这个条件表达式的 true 分支即可。

这种在公式中保留"无效但可扩展"的条件表达式的做法,虽然在严格的代码审查中可能被视为冗余,但在实际开发中是一种常见的防御性编程策略。它表明开发者在设计函数时已经考虑了未来可能的扩展需求,并预留了修改的接口。当需求变化时,修改集中在函数内部一处,而非散落在多个调用点。这种"开放-封闭原则"的体现——对扩展开放、对修改封闭——是面向对象设计的基本原则之一。

function ringOpacity(i: number): number {
  return 0.5 - i * 0.13;
}

ringOpacity 函数根据索引 i 计算圆环的透明度。公式为 0.5 - i * 0.13,三个圆环的透明度分别为 0.5、0.37、0.24。这种由内向外逐渐降低透明度的设计,模拟了水波纹或染液在布面上扩散时由浓至淡的效果——内层圆环最为清晰,外层圆环渐渐隐入背景,营造出一种层次感和空间纵深感。透明度从 0.5 起步而非 1.0,意味着即使是最内层的圆环也是半透明的,避免了遮挡下层内容,这在叠加渲染时尤为重要。

这两个函数共同控制了头部装饰区域同心圆环的视觉表现。尺寸递增与透明度递减的组合,形成了一种"扩散波纹"的视觉效果,暗喻了蜡染染缸中靛蓝染液扩散的动态过程。将这种视觉计算封装为独立函数,而非内联在组件模板中,使代码结构更加清晰,也便于在不同位置复用同样的计算逻辑。函数的命名也具有自文档化的效果——ringSizeringOpacity 一目了然地表达了它们的用途。

5.2 气泡位置函数

function bubbleX(i: number): number {
  return 64 + i * 22;
}

function bubbleY(i: number): number {
  return 74 + (i % 2) * 20;
}

bubbleXbubbleY 函数分别计算气泡元素的横向和纵向坐标。横向坐标公式 64 + i * 22 使四个气泡沿水平方向等间距排列,间距为 22vp,起始位置为 64vp。纵向坐标公式 74 + (i % 2) * 20 使用了取模运算 i % 2,使得奇偶索引的气泡在垂直方向上交替偏移 20vp——偶数索引(0, 2)的气泡在 y=74vp 位置,奇数索引(1, 3)的气泡在 y=94vp 位置。这种交错排列打破了气泡在一条水平线上排列的单调感,模拟了真实气泡在染液中上升时左右摇摆、高低不齐的自然状态。

这种使用取模运算来生成交错排列的手法,在 UI 动画编程中是一种常见的技巧。它避免了使用复杂的随机数或物理模拟,仅用简单的数学运算就实现了"看似随机实则有序"的排列效果。取模运算的周期性特性确保了即使增加更多气泡,排列模式也能自动延续,具有良好的可扩展性。如果未来需要在 BUBBLE_IDX 中添加更多索引,新的气泡会自动遵循同样的交错模式,无需修改函数。

5.3 裂纹位置与角度函数

function crackX(i: number): number {
  return 264 + i * 14;
}

function crackY(i: number): number {
  return 30 + (i % 3) * 26;
}

function crackAngle(i: number): number {
  return -30 + i * 15;
}

这三个函数共同控制了头部区域六条裂纹(模拟蜡染冰纹效果)的位置和旋转角度。crackX 使裂纹沿水平方向等间距分布,起始 x 坐标为 264vp,间距 14vp。crackY 使用 i % 3 实现三行交错排列——索引 0、3 在 y=30vp,索引 1、4 在 y=56vp,索引 2、5 在 y=82vp。crackAngle 使裂纹的旋转角度从 -30 度开始,每条递增 15 度,六条裂纹的角度分别为 -30、-15、0、15、30、45 度。

裂纹是蜡染工艺最具辨识度的视觉特征之一。在真实的蜡染工艺中,布面上的蜡层在折叠、揉搓过程中产生自然裂纹,染液沿裂缝渗入,形成不规则的蓝色细纹,这就是"冰纹"或"冰裂纹"。冰纹的随机性和不可预测性被视为蜡染的灵魂,有"冰纹天成"之说。这三个函数通过数学计算模拟了冰纹的排列和方向,虽然数学公式是确定性的,但通过交错排列和多角度旋转的组合,在视觉上产生了一种看似随机的自然效果。裂纹长短不一(通过 crackY 的交错实现)、角度各异(通过 crackAngle 的递增实现),模拟了真实冰纹的不规则性。

5.4 纺织品与蜡刀位置函数

function textileX(i: number): number {
  return 22 + i * 34;
}

function knifeY(i: number): number {
  return 60 + i * 26;
}

textileX 函数计算三条纺织布条的横向位置,起始 22vp,间距 34vp。knifeY 函数计算蜡刀的纵向位置,起始 60vp,间距 26vp。这些函数统一使用了"基础值加上索引乘以步进值"的线性排列模式,简洁而高效。在 UI 布局编程中,这种线性排列函数是最基础也最常用的定位方式,适用于一切等间距排列的场景。

两个函数虽然公式结构相同(base + i * step),但各自的 basestep 值不同,这是因为它们服务于不同的视觉元素和布局上下文。textileX 的步进 34vp 较大,是因为纺织布条宽度为 14vp,需要足够的间距避免重叠;knifeY 的步进 26vp 适中,是根据蜡刀卡片的高度和间距需求设定的。这种根据具体元素的尺寸特征定制不同步进值的做法,体现了对布局精度的重视。

5.5 条形图宽度计算函数

function blueBarW(b: number): number {
  return 18 + b * 2.2;
}

blueBarW 函数根据蓝度值 b 计算蜡染布列表中蓝度进度条的宽度。公式 18 + b * 2.2 意味着基础宽度为 18vp,蓝度每增加 1 个单位,进度条宽度增加 2.2vp。以数据中蓝度值范围 66 到 95 为例,进度条宽度范围约为 163vp 到 227vp。这个线性映射将抽象的数值(蓝度百分比)转化为了直观的视觉长度,使用户无需仔细阅读数字就能通过条形长度快速比较不同蜡染布的蓝度高低。

function complexBarW(c: number): number {
  return 16 + c * 1.2;
}

complexBarW 函数用于计算纹样繁复度和染娘技艺值对应的进度条宽度。公式 16 + c * 1.2 的基础宽度 16vp 和系数 1.2 与 blueBarW 不同,这是因为繁复度的数值范围(74 到 92)与蓝度值范围(66 到 95)不同,需要不同的系数来确保进度条在 UI 中的宽度处于合理的可视范围内。这种根据不同数据特征定制不同映射函数的做法,体现了对数据可视化精度的重视。

function craftBarW(d: number): number {
  return 16 + d * 1.6;
}

craftBarW 函数是另一个进度条宽度计算函数,系数为 1.6,介于 blueBarW(2.2)和 complexBarW(1.2)之间。三个进度条函数虽然公式结构相同(基础值加上数值乘以系数),但各自的基础值和系数不同,这是因为它们服务于不同的数据维度和 UI 上下文。将它们分别命名为独立的函数而非使用一个通用函数传入不同参数,是因为语义上的区分——函数名本身就是一种文档,blueBarWcomplexBarWcraftBarW 各自清楚地表达了其计算目的。

function orderBarH(a: number): number {
  return 24 + Math.min(a, 50000) / 500;
}

orderBarH 函数计算订单金额柱状图中每根柱子的高度。公式 24 + Math.min(a, 50000) / 500 的设计颇具巧思。首先,Math.min(a, 50000) 对金额值做了上限截断——当金额超过 50000 时,按 50000 计算,防止柱子过高溢出容器。其次,除以 500 是一个缩放因子,将金额值(通常在 12000 到 26400 的范围)压缩到 24 到 100vp 的可视高度范围内。最后,加上基础高度 24vp,确保即使金额为零,柱子也有一个最小可见高度。这种"截断加上缩放再加上基线"的三段式计算,是数据可视化中处理极端值的常用策略。

5.6 色彩判定函数

function blueColor(b: number): string {
  if (b >= 85) {
    return COLORS.header1;
  }
  if (b >= 60) {
    return COLORS.indigoA;
  }
  return COLORS.indigoB;
}

blueColor 函数根据蓝度值返回对应的颜色。逻辑分为三档:蓝度 85 及以上返回最深的 header1#1B3B6F),60 到 84 返回中等的 indigoA#2E5FA3),60 以下返回最浅的 indigoB#6A8FD0)。这种分级着色策略将数值映射为色彩深浅,模拟了真实蜡染中不同浸染次数呈现的蓝色层次——浸染次数越多,蓝色越深,价值越高。用户通过色彩直观感知蓝度等级,无需细读数字。

函数使用连续的 if 语句配合 return 实现早返回(early return),减少了嵌套层级,使代码更扁平。这种写法在简洁性上与 if-else if-else 链无异,但在可读性上略有差异——连续 if 配合 return 是一种"早返回"模式,是函数式编程中推崇的代码风格。三个阈值(85、60)的设定是有意义的:85 以上代表"深染"级别,需要多次浸染才能达到;60 到 84 代表"中染"级别,是一般蜡染布的常见蓝度;60 以下代表"浅染"级别,适合初学者作品或浅色用途。

function styleColor(s: string): string {
  if (s === '贵州丹寨') {
    return COLORS.indigoA;
  }
  if (s === '云南大理') {
    return COLORS.hot;
  }
  if (s === '湖南凤凰') {
    return COLORS.cool;
  }
  if (s === '四川珙县') {
    return COLORS.accent;
  }
  return COLORS.text3;
}

styleColor 函数根据产地字符串返回对应的标签背景色。四个产地各自映射到不同的色彩:贵州丹寨映射为靛蓝(indigoA),云南大理映射为金色(hot),湖南凤凰映射为冷蓝(cool),四川珙县映射为深蓝(accent)。最后的 return COLORS.text3 是默认分支,处理未匹配的产地(如数据中的"广西"和"贵州榕江")。这种"产地到色彩"的映射设计,使不同产地的纹样标签在视觉上具有辨识度,用户可以通过标签颜色快速识别纹样的地域来源。

使用字符串严格相等(===)进行产地匹配,在数据量小且固定的场景下是合理的。如果产地列表可能动态扩展,使用 Map 或对象查找表会更高效。但在当前场景下,四个 if 判断的代码清晰度高于查找表方案,且性能差异在可忽略范围内。这种"在简单场景下优先选择可读性更高的方案"的判断,是工程实践中的常见权衡。

function levelColor(l: string): string {
  if (l === '大师') {
    return COLORS.accent;
  }
  if (l === '名师') {
    return COLORS.hot;
  }
  return COLORS.cool;
}

levelColor 函数根据染娘的级别返回对应的标签色。大师级映射为深蓝(accent),名师级映射为金色(hot),匠娘和学徒等默认映射为冷蓝(cool)。这种分级着色与 blueColor 函数的设计理念一致,都是通过色彩层次来传达等级差异,使信息层次在视觉上一目了然。大师级使用最深的蓝色,暗示其技艺最为深沉厚重;名师级使用金色,暗示其成就如同金色般耀眼;匠娘和学徒使用冷蓝色,暗示其尚在成长阶段。

function stepColor(s: number): string {
  if (s <= 3) {
    return COLORS.indigoB;
  }
  if (s <= 6) {
    return COLORS.hot;
  }
  return COLORS.accent;
}

stepColor 函数根据工序序号返回对应的序号标记色。序号 1-3 返回浅蓝(indigoB),4-6 返回金色(hot),7-8 返回深蓝(accent)。这种按序号分段着色的设计,将八道工序分为三个色彩阶段,使用户能通过颜色快速感知工序所处的阶段——前期(准备阶段)为浅蓝,中期(核心工艺)为金色,后期(收尾阶段)为深蓝,色彩变化暗合了工艺流程从轻到重再归于沉稳的节奏。工序的色彩从浅到深再回到深,形成了一种色彩的"回环"效果,暗示了工艺从开始到结束是一个完整的过程。


六、数据模型:@Observed 类体系

6.1 BatikItem —— 蜡染布数据模型

@Observed
export class BatikItem {
  name: string;
  region: string;
  blue: number;
  size: string;
  price: number;

  constructor(name: string, region: string, blue: number, size: string, price: number) {
    this.name = name;
    this.region = region;
    this.blue = blue;
    this.size = size;
    this.price = price;
  }
}

BatikItem 是蜡染布成品的数据模型类,使用 @Observed 装饰器修饰。在 ArkTS 的状态管理框架中,@Observed 装饰器的作用是使类实例成为可观察对象——当实例的属性发生变化时,所有引用该实例的 @ObjectLink 组件都会收到通知并触发重新渲染。这是 ArkTS 实现细粒度状态追踪和 UI 响应的核心机制之一。@Observed 的本质是在类的每个属性上安装一个"监听器",当属性被赋新值时,监听器通知框架触发依赖该属性的 UI 更新。

类中定义了五个属性:name(布名)、region(产地)、blue(蓝度)、size(尺寸)、price(价格)。这五个属性完整地描述了一幅蜡染布成品的核心信息。属性的类型标注严格而明确:nameregionsize 为字符串类型,blueprice 为数值类型。blue 是一个 0 到 100 的数值,代表蜡染布的蓝色深度——在真实蜡染工艺中,蓝色深度取决于浸染次数和靛蓝染液的浓度,是衡量蜡染布品质和价值的重要指标。浸染次数越多,蓝色越深,价值越高,这一专业评判标准在数据模型中被转化为一个简洁的数值字段。

构造函数 constructor 接收五个参数并逐一赋值给对应属性。使用构造函数而非对象字面量来创建实例,带来了几个优势。首先,构造函数提供了参数的类型检查和顺序约束,确保每个实例在创建时都具备完整的属性集。其次,构造函数可以被复用——在 @State 数组的初始化中,通过连续调用 new BatikItem(...) 创建了十二个实例,代码结构统一而整洁。第三,如果未来需要为类添加方法(如计算每平方米单价),可以在类内部直接定义,而对象字面量无法做到这一点。

export 关键字使该类可以被其他文件导入使用,体现了模块化设计的思想。虽然当前应用可能将所有代码放在同一文件中,但 export 的存在意味着这些数据模型可以被提取到独立的模型文件中,按需导入。这种模块化设计在大型项目中尤为重要,它使数据模型与 UI 逻辑分离,提高了代码的组织性和可维护性。数据模型作为应用的核心资产,独立于 UI 层存在,是良好架构的基础。

6.2 BatikPatternItem —— 纹样数据模型

@Observed
export class BatikPatternItem {
  name: string;
  meaning: string;
  complex: number;
  use: string;
  style: string;

  constructor(name: string, meaning: string, complex: number, use: string, style: string) {
    this.name = name;
    this.meaning = meaning;
    this.complex = complex;
    this.use = use;
    this.style = style;
  }
}

BatikPatternItem 是蜡染纹样的数据模型类。五个属性分别描述了纹样的名称(name)、文化寓意(meaning)、繁复度(complex)、用途(use)和产地风格(style)。这个数据模型的设计体现了蜡染纹样不仅是装饰图案,更是文化符号的设计理念——meaning 字段记录了每个纹样的文化寓意,如"万物之母"、“年年有余”、"护佑子孙"等,使纹样数据不仅仅是视觉信息,更承载了文化内涵。这种将文化语义纳入数据模型的做法,使应用不仅是一个展示工具,更是一个文化数据库。

complex 属性是一个数值,表示纹样的繁复程度。这个值在应用中是可编辑的——用户可以通过编辑弹窗调整纹样的繁复度(每次加 5 或减 5),这正是使用 @Observed 装饰器的关键原因。当 complex 属性被修改时,引用该实例的 UI 组件会自动更新显示,无需手动触发刷新。这种响应式更新是 ArkTS 框架的核心能力之一,它极大地简化了状态与 UI 的同步逻辑。繁复度的范围被约束在 40 到 100 之间(通过 Math.maxMath.min),低于 40 的纹样过于简单缺乏艺术价值,高于 100 则过于复杂难以实现。

style 属性存储产地名(如"贵州丹寨"、"云南大理"等),这个值被传给 styleColor 函数来决定标签的背景色。将产地信息存储在纹样模型中而非单独关联,简化了数据结构——每个纹样自带产地信息,在渲染时直接读取即可,无需额外的关联查询。这种"宽表"设计在小型应用中是合理的,它以一定的数据冗余换取了查询的简便性。在大型应用中,可能需要将产地独立为一张表并通过外键关联,但在当前规模下无需过度设计。

6.3 WaxKnifeItem —— 蜡刀数据模型

@Observed
export class WaxKnifeItem {
  name: string;
  material: string;
  tip: string;
  len: string;
  price: number;

  constructor(name: string, material: string, tip: string, len: string, price: number) {
    this.name = name;
    this.material = material;
    this.tip = tip;
    this.len = len;
    this.price = price;
  }
}

WaxKnifeItem 是蜡刀工具的数据模型类。蜡刀是蜡染工艺中最核心的工具,染娘用蜡刀蘸取热蜡液在白布上描绘纹样,刀头的形状决定了线条的粗细和风格。类中的五个属性分别描述了蜡刀的名称(name)、材质(material,如黄铜、白铜、红铜、不锈钢)、刀头形状(tip,如圆头、尖头、斜口、排齿等)、长度(len)和价格(price)。这五个属性构成了蜡刀的完整产品规格描述。

蜡刀的材质多样性反映了蜡染工艺的工具丰富性。黄铜是最常见的蜡刀材质,导热性好且耐腐蚀;白铜硬度更高,适合描绘精细线条;红铜质地较软,适合特殊纹样;不锈钢是现代改良材质,耐用且不易氧化。不同材质和刀头形状的组合,构成了蜡刀从经济型到专业型的完整产品线。将这些工具信息结构化为数据模型,使应用能够以列表形式系统展示蜡刀知识,具有教育意义,让用户了解到蜡染不仅需要布和染料,还需要专业的工具。

虽然蜡刀数据在当前应用中是只读的(没有编辑功能),但仍然使用 @Observed 装饰器,这是一种前瞻性的设计。未来如果需要添加蜡刀的编辑功能(如修改价格、添加新刀具),@Observed 已经就位,无需重构数据模型。这种"提前投资"的做法在快速原型开发中是常见的权衡——用少量的额外开销换取未来的灵活性。在不确定数据模型是否需要可观察的场景下,加上 @Observed 是一种安全的默认选择,因为它的成本极低(仅增加属性监听的开销),但收益在需要时是显著的。

6.4 DyerItem —— 染娘数据模型

@Observed
export class DyerItem {
  name: string;
  title: string;
  age: number;
  pieces: number;
  skill: number;

  constructor(name: string, title: string, age: number, pieces: number, skill: number) {
    this.name = name;
    this.title = title;
    this.age = age;
    this.pieces = pieces;
    this.skill = skill;
  }
}

DyerItem 是染娘(蜡染匠人)的数据模型类。五个属性分别描述了染娘的姓名(name)、职称(title,如大师、名师、匠娘、学徒)、年龄(age)、累计作品数(pieces)和技艺评分(skill,0 到 100 的数值)。这个数据模型描绘了蜡染匠人的成长路径——从学徒起步,经年累月后晋升为匠娘,再到名师,最终成为大师,年龄和作品数随着时间增长,技艺评分也随之提升。

title 属性的值被传给 levelColor 函数来决定职称标签的背景色,使不同级别的染娘在列表中具有视觉区分度。skill 属性被传给 complexBarW 函数计算技艺进度条宽度,将抽象的技艺评分转化为可视的条形图。pieces 属性以"幅"为单位,记录了染娘的累计产出量,是衡量经验丰富度的重要指标。年龄、作品数和技艺评分三者共同构成了一个染娘的"专业画像",使用户能从多维度了解每位染娘的水平。

将匠人信息数字化并结构化展示,是对蜡染工艺中"人的因素"的尊重和记录。在传统工艺的传承中,匠人的名字往往被隐没在作品背后,而通过数据模型将匠人的信息显性化,使每一位染娘的技艺、经验和地位都得到呈现,这是数字化手段对传统工艺传承的一种贡献。让染娘的名字和技艺被看见、被记住,本身就是对非遗传承人的一种尊重。

6.5 WaxStepItem —— 工序数据模型

@Observed
export class WaxStepItem {
  name: string;
  days: number;
  tool: string;
  note: string;
  seq: number;

  constructor(name: string, days: number, tool: string, note: string, seq: number) {
    this.name = name;
    this.days = days;
    this.tool = tool;
    this.note = note;
    this.seq = seq;
  }
}

WaxStepItem 是蜡染工序步骤的数据模型类。五个属性分别描述了工序名称(name,如选布、画蜡、入染等)、耗时天数(days)、使用工具(tool)、工序说明(note)和工序序号(seq)。这八个工序步骤构成了蜡染从白坯布到成品的完整工艺流程:选布、画蜡、冷固、入染、煮蜡、漂洗、冰纹、晾晒,每一步都不可或缺,构成了完整的工艺链条。

seq 属性是工序的序号(1 到 8),在 UI 中用于显示步骤编号和决定编号标记的颜色(通过 stepColor 函数)。days 属性表示该工序的耗时天数,画蜡是最耗时的步骤(3 天),因为需要手工描绘全部纹样;入染次之(2 天),需要多次浸染和氧化;其余步骤各需 1 天。总耗时约 11 天,反映了手工蜡染的周期之长。tool 属性用"·“分隔多个工具或材料,如"蜡刀 · 蜂蜡”、“靛蓝缸"等,简洁地传达了每步所需的工具信息。note 属性是工序的简要说明,如"热蜡绘纹”、"脱蜡显花"等,四字短语凝练地概括了每步的核心动作。

将工艺流程结构化为数据模型,使复杂的蜡染工艺以有序的、可理解的方式呈现给用户。每一步的名称、耗时、工具和说明都有据可查,用户可以按序了解蜡染从布到花的完整蜕变过程。这种结构化呈现比纯文字描述更加清晰,也比视频教程更加精炼,适合在移动端快速浏览和学习。用户通过阅读工序列表,可以在几分钟内了解蜡染的完整工艺流程,这是数字化对传统工艺知识传播的效率提升。

6.6 BatikOrderItem —— 订单数据模型

@Observed
export class BatikOrderItem {
  name: string;
  buyer: string;
  amount: number;
  count: number;
  month: string;

  constructor(name: string, buyer: string, amount: number, count: number, month: string) {
    this.name = name;
    this.buyer = buyer;
    this.amount = amount;
    this.count = count;
    this.month = month;
  }
}

BatikOrderItem 是蜡染订单的数据模型类。五个属性分别描述了订单中的布品名称(name)、买方(buyer,如非遗展馆、文创品牌、旅游商店等)、金额(amount)、数量(count)和月份(month,格式如"2026-08")。这个数据模型将蜡染从工艺品扩展到了商业产品,体现了蜡染工艺不仅有文化价值,也有经济价值。

amount 属性是订单的总金额(以元为单位),在 UI 中被传给 orderBarH 函数计算柱状图中柱子的高度,实现金额的可视化对比。count 属性是订单的件数,与金额一起显示在订单行中。month 属性记录了订单的月份,用于时间维度的分析。值得注意的是,订单数据在应用中是可删除的——用户可以通过删除弹窗移除订单,这正是使用 @Observed 装饰器的关键原因之一。当订单被从数组中移除时,ForEach 会自动检测到数组变化并更新 UI,无需手动刷新。

订单数据的存在使这个应用不仅是一个蜡染知识展示工具,更是一个蜡染工坊的简易管理工具。从布品到订单的完整商业链条,体现了蜡染工艺从传统手艺到现代文创产业的转型路径。买方的多样性(非遗展馆、文创品牌、旅游商店、民宿连锁、民族剧团、电商平台、酒店、家居品牌、博物馆、家纺公司)也反映了蜡染产品在多个行业的应用,展现了传统工艺的现代商业价值。


七、状态管理:@Entry 组件与 @State 体系

7.1 组件声明与结构

@Entry
@Component
struct BatikPage {

BatikPage 是应用的根组件,使用 @Entry@Component 两个装饰器修饰。@Entry 装饰器标记该组件为页面的入口组件——每个页面有且只有一个 @Entry 组件,它是组件树的根节点,框架在渲染页面时从此处开始构建 UI 树。@Component 装饰器声明该结构体为一个自定义组件,使其可以拥有自己的状态(@State)、构建函数(build)和构建器函数(@Builder)。两个装饰器配合使用,使 BatikPage 成为应用的核心入口。

使用 struct 而非 class 来定义组件,是 ArkTS 的语法选择。struct 在 ArkTS 中是一种值类型(在某些实现中可能被特殊处理为引用类型),用于定义组件的结构和行为。与 class 不同,struct 不支持继承,这强制开发者使用组合而非继承来复用代码,符合组合优于继承的设计原则。每个 struct 组件都是独立的、自包含的,通过属性传递和事件回调与其他组件通信,降低了耦合度。这种"无继承"的设计选择,也是 ArkTS 区别于传统面向对象框架的特点之一。

7.2 交互状态变量

@State curTab: number = 0;
@State breath: boolean = false;
@State showAdd: boolean = false;
@State showEdit: boolean = false;
@State showDel: boolean = false;

这五个 @State 变量管理着应用的核心交互状态。curTab 记录当前激活的标签页索引(0 到 5),初始值为 0(蜡染布页)。当用户点击底部标签栏切换页面时,curTab 被更新,tabContent 构建器通过条件判断(if (this.curTab === 0) 等)渲染对应页面的内容。@State 的响应式特性确保 curTab 变化后 UI 自动更新,无需手动操作 DOM,这是声明式 UI 相比命令式 UI 的核心优势。

breath 是一个布尔"呼吸"状态变量,初始为 false。在 aboutToAppear 生命周期中通过 setInterval 每 500 毫秒翻转一次(this.breath = !this.breath),形成一个周期性的心跳信号。这个信号被头部装饰区域的多个动画元素引用——裂纹的透明度、气泡的透明度、圆环的缩放、纺织布条的旋转角度、金条的垂直位移等都根据 breath 的值在两个状态之间切换,配合 .animation() 属性实现循环往复的动画效果。这种以单一状态变量驱动多个动画元素的设计,是"单源多消费者"模式的体现——一个状态源头服务于多个 UI 消费者,避免了为每个动画元素设置独立的定时器,减少了资源消耗和同步复杂度。

showAddshowEditshowDel 三个布尔变量分别控制新增弹窗、编辑弹窗和删除弹窗的显示状态。当值为 true 时对应弹窗渲染,为 false 时弹窗消失。这种以布尔变量驱动弹窗显隐的方式,是声明式 UI 的典型模式——开发者只需声明"弹窗在什么条件下显示",框架自动处理渲染和过渡效果,无需手动操作弹窗的 DOM 节点。三个布尔变量实现了三种弹窗的独立控制,虽然在实际使用中三个弹窗互斥显示(同一时间最多一个弹窗打开),但使用三个独立变量而非一个枚举变量,使代码更灵活——如果未来需要同时显示多个弹窗(如嵌套弹窗),三个独立变量可以直接支持。

7.3 选中项状态变量

@State selPattern: BatikPatternItem | null = null;
@State selOrder: BatikOrderItem | null = null;

selPatternselOrder 两个状态变量使用联合类型(BatikPatternItem | nullBatikOrderItem | null),初始值为 null。它们分别记录当前被选中以待编辑的纹样和待删除的订单。使用联合类型而非单纯的类类型,是因为在弹窗未打开时没有选中项,需要用 null 表示"无选中"状态。联合类型是 TypeScript/ArkTS 类型系统的重要特性,它允许一个变量在多种类型之间变化,并通过类型窄化(type narrowing)在条件分支中自动收窄类型。

当用户在纹样列表中点击某个纹样的繁复度标签时,该纹样实例被赋值给 selPattern,同时 showEdit 被设为 true,编辑弹窗渲染并显示选中纹样的信息。编辑弹窗中"更繁复 +5"按钮的点击事件修改 selPattern.complex 属性,由于 BatikPatternItem@Observed 类,该修改会触发引用该实例的纹样行组件更新显示。这种"选中、编辑、自动更新"的流程,充分体现了 @State@Observed 配合的响应式能力——数据修改后 UI 自动同步,开发者无需编写任何手动刷新代码。

同理,当用户在订单列表中点击"删除"按钮时,对应订单实例被赋值给 selOrdershowDel 被设为 true。删除弹窗的确认按钮通过 this.orders.splice(this.orders.indexOf(this.selOrder), 1) 从数组中移除该订单。由于 orders@State 数组,数组变化后 ForEach 自动检测差异并更新列表,移除对应的订单行。整个删除流程从点击到 UI 更新完全自动化,开发者只需编写数据操作代码(赋值 selOrder、设置 showDel、执行 splice),UI 的响应式更新由框架处理。

7.4 表单状态变量

@State formName: string = '';
@State formRegion: string = '';
@State formPrice: string = '';

这三个 @State 变量用于新增蜡染布弹窗中的表单数据绑定。formName 存储用户输入的布名,formRegion 存储产地,formPrice 存储价格。值得注意的是,formPrice 的类型是 string 而非 number,这是因为 TextInput 组件的 onChange 回调返回的始终是字符串类型。在提交表单时,通过 Number(this.formPrice) 将字符串转换为数值。这种"输入时字符串、提交时转数值"的模式是表单处理中的常见做法,因为用户在输入框中可能输入非数字字符,使用字符串类型可以更灵活地处理输入。

使用独立的状态变量而非将表单数据封装为对象,在小型表单中是可接受的做法。每个变量独立管理,类型检查更直接,onChange 回调的赋值也更简洁。如果表单字段增多(超过五六个),封装为对象并使用 @State 对象会更合理,但当前三个字段的情况下,独立变量的方案足够简洁。三个变量的初始值都是空字符串 '',确保弹窗打开时输入框是空的,等待用户输入。

7.5 数据数组状态

六个 @State 数组分别管理蜡染布、纹样、蜡刀、染娘、工序和订单六个维度的数据。每个数组在声明时直接初始化为一组 @Observed 类实例,这些初始数据构成了应用的种子数据集。将数组声明为 @State,使数组本身的变化(增删元素)能触发 UI 更新。在 addModal 中,通过 this.batiks.push(new BatikItem(...)) 向数组末尾添加新元素;在 delModal 中,通过 this.orders.splice(...) 从数组中移除元素。这些数组操作会自动触发 ForEach 的 Diff 算法,计算出增删的元素并更新对应的 UI 节点。

@State 数组与 @Observed 元素的配合形成了一个两层响应式体系:数组层面,@State 追踪数组长度和元素顺序的变化(增删、排序);元素层面,@Observed 追踪元素属性值的变化(修改)。两者各司其职,协同实现了完整的响应式数据流。这种设计是 ArkTS 区别于传统命令式 UI 框架的核心优势——开发者只需操作数据,UI 自动跟随数据变化。例如,当用户通过编辑弹窗修改了某纹样的繁复度后,纹样列表中对应卡片的繁复度数字会自动更新为新的值,无需开发者编写任何查找和更新 UI 的代码。

初始数据的设计也值得一提。十二幅蜡染布涵盖了贵州丹寨、云南大理、湖南凤凰、四川珙县等主要产地,蓝度从 66 到 95 不等,价格从 128 元到 980 元不等,反映了蜡染产品的多样性。八种纹样涵盖了蝴蝶妈妈、太阳鼓、铜鼓、鱼、龙、花鸟、漩涡、蛙等经典图腾,各自附带了文化寓意和用途信息。八把蜡刀涵盖了不同材质和刀头形状,展示了工具的丰富性。八位染娘从大师到学徒构成了完整的技艺梯队。八道工序构成了完整的工艺流程。十个订单涵盖了多种买方类型和不同金额。这些初始数据不仅使应用在启动时就具有丰富的内容,也为用户展示了蜡染工艺的全貌。

7.6 aboutToAppear 生命周期

aboutToAppear(): void {
  setInterval(() => {
    this.breath = !this.breath;
  }, 500);
}

aboutToAppear 是 ArkTS 组件生命周期回调之一,在组件创建后、build 函数执行前被调用。这里在 aboutToAppear 中启动了一个定时器,每 500 毫秒翻转 breath 状态的值。这个定时器在组件的整个生命周期内持续运行,为头部装饰区域的动画效果提供驱动力。aboutToAppear 是执行初始化逻辑的理想位置——此时组件实例已创建,状态变量已初始化,但 UI 尚未渲染,因此在此设置定时器不会触发不必要的渲染。

setInterval 的回调使用了箭头函数 () => { ... },这是为了正确绑定 this 上下文。在普通函数中,this 的指向取决于调用方式,而在箭头函数中,this 继承自外层作用域(即组件实例),确保 this.breath 引用的是组件的状态变量。如果使用普通函数 function() { this.breath = ... }this 将指向 undefined(严格模式下)或全局对象(非严格模式下),导致运行时错误。这是 JavaScript 和 TypeScript 语言中 this 绑定的经典知识点,在 ArkTS 中同样适用。

值得注意的是,这里没有在 aboutToAppear 中做定时器的清理(没有 aboutToDisappear 中的 clearInterval)。在单页面应用中,根组件的生命周期与应用一致,定时器不会因组件销毁而泄漏。但如果在多页面应用中,页面被销毁时未清理定时器会造成内存泄漏,因此在更完整的实现中,应在 aboutToDisappear 中调用 clearInterval 来清理定时器。这种"有创建无销毁"的写法在快速原型开发中常见,但在生产环境中应当补全生命周期管理,避免资源泄漏。


八、UI 组件:@Builder 构建器体系

8.1 modalOverlay —— 模态遮罩层

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

modalOverlay 是一个参数化的 @Builder 构建器,用于生成弹窗的遮罩层。它接收一个 onClose 回调函数作为参数,当遮罩层被点击时调用该回调,使调用方可以执行关闭弹窗的逻辑。这种将回调函数作为参数传入的设计,使 modalOverlay 成为可复用的通用组件——无论是新增弹窗、编辑弹窗还是删除弹窗,都可以复用同一个遮罩层,只需传入不同的关闭回调即可。这种"以回调参数实现复用"的模式,是 ArkTS 中 @Builder 实现可配置性的标准手段。

遮罩层本身是一个占满全屏的 Column,背景色为 COLORS.mask(半透明黑色 rgba(0,0,0,0.45))。半透明遮罩层是移动端弹窗设计的标准模式——它覆盖在页面内容之上,使用户无法直接操作底层内容,同时通过半透明效果让用户隐约看到底层内容,传达"弹窗是临时覆盖层"的视觉语义。点击遮罩层关闭弹窗是移动端弹窗的常见交互模式,提供了除弹窗内按钮之外的另一种关闭途径,提升了用户体验。

@Builder 装饰器是 ArkTS 中定义可复用 UI 片段的机制。与 @Component 不同,@Builder 不创建独立的组件实例,而是在引用处内联展开。这意味着 @Builder 中的 this 指向引用它的组件实例,可以直接访问组件的 @State 变量和其他方法。@Builder 适合定义较短的、可复用的 UI 片段,而 @Component 适合定义复杂的、需要独立状态管理的组件。modalOverlay 作为一个短小的通用遮罩层,使用 @Builder 是恰当的选择——它不需要独立状态,只需接收一个回调参数即可工作。

8.2 pageHeader —— 页面头部装饰区(第一部分)

pageHeader 构建器是应用中最复杂的 UI 组件之一,它使用 Stack(层叠布局)将多个视觉元素叠加在一起,构建出一个富有层次感和动态效果的蜡染工坊头部装饰区域。由于此构建器内容极长,下面分段解析。

@Builder
pageHeader() {
  Column() {
    Stack() {
      Column()
        .width('100%')
        .height('100%')
        .borderRadius(22)
        .linearGradient({
          angle: 135,
          colors: [[COLORS.header1, 0], [COLORS.header2, 1]]
        })

Stack 的第一个子元素是一个占满整个区域的 Column,通过 linearGradient 属性设置了 135 度方向的线性渐变,从 header1#1B3B6F 深藏蓝)渐变到 header2#0A1B3A 墨蓝),模拟了靛蓝染缸中由浅至深的色彩层次。borderRadius(22) 为整个头部区域添加了 22vp 的圆角,使深色头部与浅色背景之间有柔和的过渡。渐变背景是整个头部装饰的"画布",后续所有装饰元素都将叠加在这块深蓝色画布之上。

linearGradient 属性接受一个对象参数,包含 angle(渐变角度)和 colors(颜色断点数组)两个字段。colors 数组中的每个元素是一个 [颜色值, 位置] 的元组,位置值从 0 到 1 表示从起点到终点的比例位置。135 度的渐变方向使色彩从左上向右下过渡,营造出光线从左上方照射的视觉暗示,增强了头部的立体感。这种对光线方向的暗示是视觉设计中的微妙手法,用户可能不会意识到,但会感受到画面更加"有深度"。

      ForEach(CRACK_IDX, (i: number) => {
        Column()
          .width(2)
          .height(16 + (i % 3) * 8)
          .backgroundColor(COLORS.waxW)
          .opacity(this.breath ? 0.5 : 0.85)
          .borderRadius(1)
          .position({ x: crackX(i), y: crackY(i) })
          .rotate({ angle: crackAngle(i) })
          .animation({ duration: 800, iterations: -1, playMode: PlayMode.Alternate })
      }, (i: number) => 'c' + i)

在渐变背景之上,第一个叠加层是六条裂纹(冰纹)效果。通过 ForEach 遍历 CRACK_IDX 数组,渲染六个 Column 元素作为裂纹。每个裂纹的宽度为 2vp,高度根据 16 + (i % 3) * 8 计算得出(16vp、24vp、32vp 三种高度循环),模拟了裂纹长短不一的自然形态。裂纹颜色为 waxW(蜡白色),在深蓝背景上呈现为细长的白色线条,模拟了蜡染布上冰裂纹的视觉效果。

裂纹的透明度通过 this.breath ? 0.5 : 0.85 与呼吸状态联动——当 breathtrue 时透明度 0.5(较淡),为 false 时透明度 0.85(较浓),配合 .animation({ duration: 800, iterations: -1, playMode: PlayMode.Alternate }) 实现了每 800 毫秒一次的透明度呼吸动画。iterations: -1 表示无限循环,PlayMode.Alternate 表示交替播放,使动画在两个状态之间平滑往返。裂纹的位置由 crackX(i)crackY(i) 计算,角度由 crackAngle(i) 决定,使六条裂纹以不同方向散布在头部区域,模拟了蜡染冰纹随机分布的自然效果。

      ForEach(BUBBLE_IDX, (i: number) => {
        Column()
          .width(4)
          .height(4)
          .borderRadius(2)
          .backgroundColor(COLORS.waxW)
          .opacity(this.breath ? 0.3 : 0.8)
          .position({ x: bubbleX(i), y: bubbleY(i) })
          .animation({ duration: 900, iterations: -1, playMode: PlayMode.Alternate })
      }, (i: number) => 'b' + i)

第二个叠加层是四个气泡效果。每个气泡是一个 4vp 乘以 4vp 的圆形 ColumnborderRadius(2) 使方形变为圆形),颜色同样为蜡白色,模拟染缸中上升的气泡。气泡的透明度变化幅度更大(0.3 到 0.8),配合 900 毫秒的动画周期,使气泡呈现出比裂纹更明显的"闪烁"效果,增强了染缸"沸腾"的视觉暗示。气泡和裂纹使用相同的蜡白色,在视觉上形成了统一的"蜡"的意象——裂纹是蜡层上的冰纹,气泡是染缸中的蜡液气泡,两者在概念上归属于"蜡"这一元素,在视觉上通过统一色彩得到了和谐统一。动画周期的差异(裂纹 800ms、气泡 900ms)也是有意为之——不同周期的动画不会同步,避免了机械感,使画面更加自然。

      Column()
        .width(120)
        .height(120)
        .borderRadius(60)
        .backgroundColor(COLORS.indigoA)
        .border({ width: 3, color: COLORS.gold })
        .position({ x: 110, y: 6 })

第三个叠加层是一个 120vp 乘以 120vp 的圆形色块,位于头部偏右中央位置。这个圆形以 indigoA(中等靛蓝)为填充色,以 3vp 宽的 gold(金色)为边框,构成了一个靛蓝底金边的圆形徽章效果。这个圆形是头部装饰的视觉焦点,模拟了蜡染中的铜鼓纹或太阳鼓纹的圆形图腾,金色边框赋予了它仪式感和庄重感,使其成为头部区域的"视觉锚点"。borderRadius(60) 恰好是宽度的一半,使方形 Column 变为完美的圆形。这种"宽度的一半作为圆角半径"的做法是创建圆形 UI 元素的标准技巧。

8.3 pageHeader —— 页面头部装饰区(第二部分)

      ForEach(RING_IDX, (i: number) => {
        Column()
          .width(ringSize(i))
          .height(ringSize(i))
          .borderRadius(ringSize(i) / 2)
          .border({ width: 1.5, color: COLORS.indigoB })
          .opacity(ringOpacity(i))
          .scale({ x: this.breath ? 1.1 : 1, y: this.breath ? 1.1 : 1 })
          .position({ x: 170 - ringSize(i) / 2 + 0, y: 66 - ringSize(i) / 2 })
          .animation({ duration: 1400 - i * 300, iterations: -1, playMode: PlayMode.Alternate })
      }, (i: number) => 'r' + i)

第四个叠加层是三个同心圆环,叠加在圆形徽章之上。每个圆环由 ringSize(i) 计算尺寸(60vp、94vp、128vp),borderRadius 设为尺寸的一半使其成为圆形,border 属性设为 1.5vp 宽的浅靛蓝色,无填充色(背景透明),仅显示边框线条。圆环通过 position 属性以同一中心点叠加——计算确保了三个圆环都以同一圆心由内向外扩散。

圆环的 scale 属性与呼吸状态联动——breathtrue 时缩放 1.1 倍,为 false 时缩放 1.0 倍,配合 .animation() 实现了圆环"呼吸式"放大缩小的动画效果。动画持续时间 1400 - i * 300 使三个圆环的动画周期不同(1400ms、1100ms、800ms),产生了波纹"错位扩散"的效果——最大的圆环最先开始扩散,最小的圆环最后跟随,模拟了染液在布面上从中心向外扩散的动态过程。这种不同周期的多元素动画组合,营造了丰富的动态层次感。

      Text('靛')
        .fontSize(26)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.waxW)
        .position({ x: 157, y: 52 })

第五个叠加层是一个单字"靛"的文本,位于圆形徽章的中心位置。fontSize(26)FontWeight.Bold 使其醒目而庄重,蜡白色的字体在靛蓝圆形背景上形成了鲜明的蓝白对比——这正是蜡染最经典的色彩搭配。"靛"字点明了整个应用的核心主题:靛蓝,蜡染的灵魂色彩。靛蓝是从蓝草(如马蓝、蓼蓝)中提取的天然染料,是蜡染色彩的根基,一个"靛"字概括了蜡染工艺的色彩本质。

      ForEach(TEXTILE_IDX, (i: number) => {
        Column() {
          Column()
            .width(14)
            .height(64)
            .borderRadius(4)
            .backgroundColor(i === 1 ? COLORS.indigoB : COLORS.indigoA)
            .rotate({ angle: this.breath ? 3 : -3 })
            .animation({ duration: 700, iterations: -1, playMode: PlayMode.Alternate })
        }
        .position({ x: textileX(i), y: 52 })
      }, (i: number) => 'x' + i)

第六个叠加层是三条纺织布条,位于头部左下区域。每条布条是一个 14vp 乘以 64vp 的圆角 Column,颜色交替为深靛蓝和浅靛蓝——中间一条为浅色,两侧为深色,模拟了不同浸染次数的布匹并列悬挂晾晒的场景。布条的 rotate 属性与呼吸状态联动,在正 3 度和负 3 度之间交替摆动,配合 700 毫秒的动画周期,模拟了布匹在微风中轻轻摇曳的动态效果。这种微幅旋转动画(仅 6 度的摆动幅度)是一种"微交互"设计——动画幅度小但持续不断,给静态界面注入了生命感,同时不会分散用户对主要内容的注意力。

      Column()
        .width(8)
        .height(44)
        .borderRadius(4)
        .backgroundColor(COLORS.gold)
        .position({ x: 254, y: 34 + (this.breath ? 4 : 0) })
        .animation({ duration: 800, iterations: -1, playMode: PlayMode.Alternate })

第七个叠加层是一根金色竖条,宽 8vp,高 44vp,位于头部右下区域。这根金色竖条模拟了蜡染工坊中的竹竿——晾晒布匹时用于撑展布面的工具。竖条的 y 位置与呼吸状态联动,在 y=34y=38 之间交替移动(位移 4vp),配合 800 毫秒动画周期,产生了轻微的上下"浮动"效果,与旁边的纺织布条摇曳动画相呼应,共同营造了"工坊晾晒"的场景感。

      Column() {
        Text('蜡染坊')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.title)
        Text('冰纹天成 · 蓝白之间有大美')
          .fontSize(11)
          .fontColor(COLORS.sub)
          .margin({ top: 4 })
      }
      .alignItems(HorizontalAlign.Start)
      .position({ x: 18, y: 12 })

第八个叠加层是应用标题区域,位于头部左上角。"蜡染坊"三字以 20 号粗体显示,副标题"冰纹天成 · 蓝白之间有大美"以 11 号字显示在标题下方,间距 4vp。主标题色为 title(浅蓝白),副标题色为 sub(中蓝),在深色背景上具有良好的可读性。"冰纹天成 · 蓝白之间有大美"这句副标题,凝练地概括了蜡染工艺的美学精髓——"冰纹天成"赞美了蜡染冰裂纹的自然之美和不可预测性,"蓝白之间有大美"道出了蜡染以蓝白二色构建出丰富视觉世界的艺术高度。这不仅仅是一句界面文案,更是一种文化宣言。

在标题下方排列着四个统计数字项:“12 染布”、“8 纹样”、“8 染娘”、“8 工序”,分别位于头部区域的四个象限,构成了一个统计信息矩阵。这些数字概括了应用的核心数据规模,使用户在进入应用的第一时间就了解到内容的丰富度。四个统计项分布在四个象限而非集中在一处,使头部区域的视觉重心均衡,避免了左侧过重右侧过轻的不平衡感。

整个 pageHeader 构建器通过 Stack 层叠布局将十余个视觉元素叠加在一个 128vp 高的区域内,构建了一个信息密度极高且动态效果丰富的头部装饰区。从渐变背景到裂纹气泡、从同心圆环到纺织布条、从金色竹竿到统计数字,每个元素都服务于"蜡染工坊"的主题表达,共同营造了一个充满工艺氛围的视觉场景。这是整个应用中最能体现"技术与艺术融合"的组件。

8.4 batikRow —— 蜡染布列表行

@Builder
batikRow(item: BatikItem) {
  Row() {
    Text(item.name)
      .fontSize(14)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.text1)
    Column() {
      Text(item.region + ' · ' + item.size)
        .fontSize(10)
        .fontColor(COLORS.text3)
      Row() {
        Text('蓝度')
          .fontSize(9)
          .fontColor(COLORS.text3)
        Column()
          .width(blueBarW(item.blue))
          .height(6)
          .borderRadius(3)
          .backgroundColor(blueColor(item.blue))
          .margin({ left: 6 })
        Text(item.blue + '%')
          .fontSize(9)
          .fontColor(COLORS.cool)
          .margin({ left: 6 })
      }
      .margin({ top: 4 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    .margin({ left: 12 })
    Text('¥' + item.price)
      .fontSize(12)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.accent)
  }
  .width('100%')
  .padding(12)
  .backgroundColor(COLORS.cardBg)
  .borderRadius(12)
  .margin({ top: 8 })
}

batikRow 构建器渲染蜡染布列表中的单行卡片。这是一个参数化的 @Builder,接收一个 BatikItem 类型的参数 item。卡片整体使用 Row 布局,从左到右依次排列:布名、产地尺寸信息和蓝度进度条、价格。这种"名称在左、信息居中、价格在右"的三段式布局,是商品列表卡片的标准设计模式。

布名以 14 号粗体、text1 色(深蓝)显示,是卡片的主要标题。布名右侧是一个 Column,上方显示"产地 · 尺寸"的组合文本(如"贵州丹寨 · 110x110"),以 10 号 text3 色(浅灰蓝)显示。下方是蓝度进度条区域,使用 Row 水平排列"蓝度"标签、进度条 Column 和蓝度百分比文本。进度条的宽度由 blueBarW(item.blue) 计算,背景色由 blueColor(item.blue) 根据蓝色深浅分级决定。蓝度进度条是这个卡片中最具特色的设计元素——在传统蜡染中,蓝度是衡量布匹品质的核心指标,通过将蓝度数值映射为进度条的宽度和颜色,使用户无需仔细阅读数字,仅通过条形长度和颜色深浅就能直观感知每幅布的蓝度高低。这种数据可视化手段,将专业知识以最直观的方式呈现给用户。

Column 设置了 layoutWeight(1),使其占据 Row 中除布名和价格之外的所有剩余空间,使布名和价格分别"靠左"和"靠右"排列。卡片的背景色为 cardBg(白色),圆角 12vp,内边距 12vp,上外边距 8vp,使卡片之间有适当的间距,视觉上清晰分离。

8.5 patternRow —— 纹样列表行

@Builder
patternRow(item: BatikPatternItem) {
  Row() {
    Text(item.name)
      .fontSize(14)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.text1)
    Text(item.style)
      .fontSize(9)
      .fontColor(COLORS.cardBg)
      .backgroundColor(styleColor(item.style))
      .borderRadius(8)
      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
      .margin({ left: 8 })
    Column() {
      Text(item.meaning)
        .fontSize(9)
        .fontColor(COLORS.text3)
      Text(item.use)
        .fontSize(9)
        .fontColor(COLORS.text3)
        .margin({ top: 1 })
    }
    .alignItems(HorizontalAlign.End)
    .layoutWeight(1)
    Text('繁复 ' + item.complex)
      .fontSize(10)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.hot)
      .onClick(() => {
        this.selPattern = item;
        this.showEdit = true;
      })
  }
  .width('100%')
  .padding(12)
  .backgroundColor(COLORS.cardBg)
  .borderRadius(12)
  .margin({ top: 8 })
}

patternRow 构建器渲染纹样列表中的单行卡片。卡片布局从左到右依次为:纹样名称、产地标签、寓意和用途信息、繁复度(可点击)。纹样名称以 14 号粗体显示。名称右侧紧跟一个产地标签,标签的背景色由 styleColor(item.style) 根据产地决定——不同产地的标签颜色不同,使纹样的地域属性在视觉上一目了然。标签使用白色字体在彩色背景上显示,borderRadius(8) 形成圆角药丸形标签。

标签右侧是一个 Column,上方显示纹样的文化寓意(如"万物之母"),下方显示用途(如"衣饰")。最右侧是"繁复"标签,显示繁复度数值,以 hot(金色)显示,并绑定了 onClick 事件——点击后将被选中的纹样实例赋值给 selPattern 状态变量,并设置 showEdit = true 打开编辑弹窗。这种"点击、选中、弹窗、编辑、自动更新"的交互流程,是 ArkTS 响应式状态管理的典型应用场景,展示了状态驱动 UI 的完整闭环:用户交互修改状态、状态变化自动更新 UI。

8.6 knifeRow —— 蜡刀列表行

@Builder
knifeRow(item: WaxKnifeItem) {
  Row() {
    Text(item.name)
      .fontSize(14)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.text1)
    Column() {
      Text(item.material + ' · ' + item.tip + ' · ' + item.len)
        .fontSize(10)
        .fontColor(COLORS.text3)
      Column()
        .width(complexBarW(item.price / 10))
        .height(5)
        .borderRadius(2)
        .backgroundColor(COLORS.indigoB)
        .margin({ top: 4 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    .margin({ left: 10 })
    Text('¥' + item.price)
      .fontSize(12)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.accent)
  }
  .width('100%')
  .padding(12)
  .backgroundColor(COLORS.cardBg)
  .borderRadius(12)
  .margin({ top: 8 })
}

knifeRow 构建器渲染蜡刀列表中的单行卡片。卡片布局与 batikRow 类似。蜡刀名称以 14 号粗体显示。名称右侧是一个 Column,上方显示"材质 · 刀头形状 · 长度"的组合文本(如"黄铜 · 圆头 · 12cm"),下方是一个进度条 Column,宽度由 complexBarW(item.price / 10) 计算——将价格除以 10 后传入函数,得到与价格成正比的进度条宽度。这里将价格除以 10 再传入 complexBarW,是因为蜡刀的价格范围(46 到 88 元)与函数的设计输入范围不同,需要通过除以 10 进行缩放调整,使进度条宽度落在合理的可视范围内。

8.7 dyerRow —— 染娘列表行

@Builder
dyerRow(item: DyerItem) {
  Row() {
    Text(item.name)
      .fontSize(14)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.text1)
    Text(item.title)
      .fontSize(10)
      .fontColor(COLORS.cardBg)
      .backgroundColor(levelColor(item.title))
      .borderRadius(8)
      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
      .margin({ left: 8 })
    Column() {
      Text('技艺 ' + item.skill + ' · ' + item.pieces + ' 幅')
        .fontSize(9)
        .fontColor(COLORS.text3)
      Column()
        .width(complexBarW(item.skill))
        .height(6)
        .borderRadius(3)
        .backgroundColor(COLORS.indigoA)
        .margin({ top: 3 })
    }
    .alignItems(HorizontalAlign.End)
    .layoutWeight(1)
    Text(item.age + '岁')
      .fontSize(10)
      .fontColor(COLORS.text3)
  }
  .width('100%')
  .padding(12)
  .backgroundColor(COLORS.cardBg)
  .borderRadius(12)
  .margin({ top: 8 })
}

dyerRow 构建器渲染染娘列表中的单行卡片。卡片布局从左到右依次为:染娘姓名、职称标签、技艺信息和进度条、年龄。染娘姓名以 14 号粗体显示。姓名右侧紧跟职称标签,标签背景色由 levelColor(item.title) 决定——大师级为深蓝、名师级为金色、其他为冷蓝。标签的样式与 patternRow 中的产地标签一致(白色字、圆角药丸形),保持了标签组件的视觉统一性。

技艺进度条将染娘的技艺评分(72-97)映射为视觉长度,使用户能直观比较不同染娘的技艺水平。与蜡刀列表中的价格进度条不同,这里的进度条使用了 indigoA(中等靛蓝)而非 indigoB(浅靛蓝),使染娘的技艺条在视觉上比蜡刀的价格条更加醒目,反映了"人比工具更重要"的信息优先级设计。

8.8 stepRow —— 工序列表行

@Builder
stepRow(item: WaxStepItem) {
  Row() {
    Text(item.seq + '')
      .fontSize(13)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.cardBg)
      .width(24)
      .height(24)
      .textAlign(TextAlign.Center)
      .backgroundColor(stepColor(item.seq))
      .borderRadius(12)
    Column() {
      Text(item.name)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text1)
      Text(item.tool + ' · ' + item.note)
        .fontSize(10)
        .fontColor(COLORS.text3)
        .margin({ top: 3 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    .margin({ left: 10 })
    Text(item.days + '天')
      .fontSize(11)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.hot)
  }
  .width('100%')
  .padding(12)
  .backgroundColor(COLORS.cardBg)
  .borderRadius(12)
  .margin({ top: 8 })
}

stepRow 构建器渲染工序列表中的单行卡片。与其他列表行不同,工序行的最左侧不是名称文本,而是一个序号圆形标记。序号被转换为字符串后显示在一个 24vp 乘以 24vp 的圆形色块中,背景色由 stepColor(item.seq) 根据序号分段决定(1-3 浅蓝、4-6 金色、7-8 深蓝),borderRadius(12) 使方形变为圆形。序号圆形的设计使八道工序在视觉上形成了一个有序的流程序列,用户通过序号颜色就能快速判断工序所处的阶段。

item.seq + '' 的写法是将数字转换为字符串的简写方式。在 ArkTS 模板中,Text 组件的内容参数需要是字符串类型,而 item.seqnumber 类型,因此需要转换。使用 + '' 是 JavaScript 中最常见的数字转字符串方式。序号右侧显示工序名称和"工具 · 说明"的组合文本。最右侧显示耗时天数,以 hot(金色)显示,强调时间信息。

8.9 orderChart —— 订单柱状图

@Builder
orderChart() {
  Row() {
    ForEach(this.orders, (item: BatikOrderItem) => {
      Column() {
        Column()
          .width(12)
          .height(orderBarH(item.amount))
          .borderRadius(3)
          .backgroundColor(this.breath ? COLORS.gold : COLORS.indigoA)
          .animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
        Text(item.amount / 1000 + 'k')
          .fontSize(8)
          .fontColor(COLORS.text3)
          .margin({ top: 3 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
    }, (item: BatikOrderItem) => item.name)
  }
  .width('100%')
  .height(92)
  .alignItems(VerticalAlign.Bottom)
  .padding({ left: 6, right: 6, top: 6, bottom: 6 })
  .backgroundColor(COLORS.cardBg)
  .borderRadius(12)
}

orderChart 构建器渲染订单金额的柱状图。通过 ForEach 遍历 this.orders 数组,为每个订单渲染一根柱子。柱体宽度固定为 12vp,高度由 orderBarH(item.amount) 根据订单金额计算。柱体的背景色与呼吸状态联动——breathtrue 时显示金色,为 false 时显示靛蓝色,配合 600 毫秒动画周期产生颜色闪烁效果。每根柱子下方显示金额标签,格式为"金额除以1000加上k"(如"20.4k"表示 20400 元),这是数据标签的常见简化策略。

外层 Row 设置了 alignItems(VerticalAlign.Bottom),使所有柱子底部对齐——这是柱状图的标准布局方式,柱子从底部向上生长,高度差直观反映数值差。layoutWeight(1) 使每根柱子等宽分配水平空间。柱状图的颜色动画为数据可视化增添了动态感,使原本静态的图表"活"了起来。

8.10 orderRow —— 订单列表行

@Builder
orderRow(item: BatikOrderItem) {
  Row() {
    Text(item.name)
      .fontSize(14)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.text1)
      .layoutWeight(1)
    Column() {
      Text('¥' + item.amount)
        .fontSize(12)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.accent)
      Text(item.count + ' 件 · ' + item.month)
        .fontSize(9)
        .fontColor(COLORS.text3)
        .margin({ top: 2 })
    }
    .alignItems(HorizontalAlign.End)
    Text('删除')
      .fontSize(11)
      .fontColor(COLORS.danger)
      .padding({ left: 10, right: 10, top: 4, bottom: 4 })
      .backgroundColor('#FDE8E8')
      .borderRadius(8)
      .margin({ left: 10 })
      .onClick(() => {
        this.selOrder = item;
        this.showDel = true;
      })
  }
  .width('100%')
  .padding(12)
  .backgroundColor(COLORS.cardBg)
  .borderRadius(12)
  .margin({ top: 8 })
}

orderRow 构建器渲染订单列表中的单行卡片。布品名称设置了 layoutWeight(1),占据弹性空间。最右侧是"删除"按钮,使用 danger(红色)字体色和浅红背景色,形成红色系的危险操作视觉提示。按钮的 onClick 事件将当前订单实例赋值给 selOrder,并设置 showDel = true 打开删除确认弹窗。这种"先选中再弹窗确认"的删除流程,避免了用户误操作直接删除数据,是移动端数据删除的最佳实践——通过二次确认降低误删风险。

8.11 tabContent —— 标签内容区

@Builder
tabContent() {
  if (this.curTab === 0) {
    Column() {
      Text('蜡染布总览 · 蓝得越深越珍贵')
        .fontSize(12)
        .fontColor(COLORS.text2)
        .width('100%')
      ForEach(this.batiks, (item: BatikItem) => {
        this.batikRow(item)
      }, (item: BatikItem) => item.name)
    }
    .width('100%')
    .margin({ top: 10 })
  }

tabContent 构建器是标签内容区的核心路由组件,通过六个 if 条件判断块,根据 this.curTab 的值渲染对应标签页的内容。当 curTab 等于 0 时,渲染蜡染布总览页面——顶部显示页面说明文字"蜡染布总览 · 蓝得越深越珍贵",下方通过 ForEach 遍历 this.batiks 数组渲染蜡染布列表。页面说明文字不仅是对页面内容的概述,更传达了蜡染的价值评判标准——蓝色越深,意味着浸染次数越多,工艺越精良,成品越珍贵。

类似地,当 curTab 等于 1 时渲染纹样页面(说明"传统纹样 · 点击可调整繁复度"),当 curTab 等于 2 时渲染蜡刀名录页面(说明"蜡刀名录 · 一把好刀走天下"),当 curTab 等于 3 时渲染染娘群像页面(说明"染娘群像 · 指尖上的蓝"),当 curTab 等于 4 时渲染工序页面(说明"染布八序 · 一缸靛蓝千般变"),当 curTab 等于 5 时渲染订单页面。每个页面的说明文字都不仅介绍内容,还嵌入了操作提示或文化阐释,是"在 UI 中嵌入领域知识"的设计手法。

curTab 等于 5 时,订单页面包含两个内容区块:上方是订单金额柱状图,展示各订单的金额对比;下方是全部订单列表,每行可通过删除按钮移除。值得注意的是,订单页面的 ForEach 键生成函数使用了 item.name + item.month(布品名加上月份)作为键,而非仅用 item.name。这是因为同一布品名可能在不同月份有多个订单,仅用 name 作为键会产生键冲突,导致 Diff 算法无法正确识别元素。通过组合键确保了每个订单元素的唯一性。

六个 if 块在 tabContent 中是平行的条件判断,而非 if-else if 链。在 ArkTS 的声明式 UI 中,条件渲染使用独立的 if 块使代码结构更清晰,每个标签页的内容块完全独立,便于维护和扩展。

8.12 bottomItem 与 bottomBar —— 底部标签栏

@Builder
bottomItem(i: number) {
  Row() {
    Text(TAB_LIST[i].icon)
      .fontSize(16)
    Text(TAB_LIST[i].label)
      .fontSize(11)
      .fontWeight(this.curTab === i ? FontWeight.Bold : FontWeight.Normal)
      .fontColor(this.curTab === i ? COLORS.tabOn : COLORS.sub)
      .margin({ left: 4 })
  }
  .layoutWeight(1)
  .justifyContent(FlexAlign.Center)
  .padding({ top: 8, bottom: 8 })
  .backgroundColor(this.curTab === i ? '#2A4E8A' : COLORS.tabBg)
  .borderRadius(10)
  .onClick(() => {
    this.curTab = i;
  })
}

bottomItem 构建器渲染底部标签栏中的单个标签项。标签项的样式根据 this.curTab === i 的判断结果动态变化:激活状态时标签文字为粗体、tabOn 色(金色),背景色为更浅的蓝色;非激活状态时标签文字为常规字重、sub 色,背景色为 tabBg(深蓝色)。onClick 事件将 this.curTab 设置为当前索引 i,触发标签切换。这种"标签项样式根据激活状态动态变化"的设计,是移动端标签栏的标准交互模式。

@Builder
bottomBar() {
  Column() {
    Row() {
      ForEach(ROW1_IDX, (i: number) => {
        this.bottomItem(i)
      }, (i: number) => 'r1' + i)
    }
    .width('100%')
    Row() {
      ForEach(ROW2_IDX, (i: number) => {
        this.bottomItem(i)
      }, (i: number) => 'r2' + i)
    }
    .width('100%')
    .margin({ top: 6 })
  }
  .width('100%')
  .padding(10)
  .backgroundColor(COLORS.tabBg)
  .borderRadius({ topLeft: 18, topRight: 18, bottomLeft: 0, bottomRight: 0 })
}

bottomBar 构建器渲染底部标签栏的容器。标签栏使用 Column 纵向排列两个 Row,将六个标签分为两行三列的布局。borderRadius 使用了分角圆角设置——左上角和右上角为 18vp 圆角,左下角和右下角为 0(直角)。这种"上圆下直"的圆角设计,使标签栏顶部与上方内容之间有柔和的弧线过渡,底部紧贴屏幕边缘保持直线,是移动端底部固定栏的标准圆角样式。两行三列的布局使每个标签项有更充裕的水平空间,提升了标签的辨识度和可点击性。

8.13 addModal —— 新增蜡染布弹窗

@Builder
addModal() {
  if (this.showAdd) {
    Stack() {
      this.modalOverlay(() => {
        this.showAdd = false;
      })
      Column() {
        Text('新增蜡染布')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.text1)
        Text('登记一款新染布')
          .fontSize(11)
          .fontColor(COLORS.text3)
          .margin({ top: 3 })

addModal 构建器渲染新增蜡染布的弹窗。弹窗整体被 if (this.showAdd) 条件包裹——只有当 showAddtrue 时才渲染弹窗内容。这种条件渲染方式比通过 visibility 属性控制显隐更加彻底——条件渲染的组件在不显示时不会占用任何渲染资源。弹窗使用 Stack 层叠布局,底层是 modalOverlay 遮罩层,上层是弹窗内容卡片。

弹窗中包含三个表单字段(布名、产地、价格),每个字段使用 TextInput 组件与对应的 @State 变量双向绑定。TextInputonChange 回调将输入值赋给状态变量,实现数据与 UI 的同步。弹窗底部是"取消"和"确认登记"两个按钮,确认按钮通过 this.batiks.push(new BatikItem(this.formName, this.formRegion, 75, '100x100', Number(this.formPrice))) 向蜡染布数组添加新元素。新蜡染布的蓝度被固定为 75(中等偏浅),尺寸被固定为"100x100",这是因为表单中没有提供这两个字段的输入——在简化版的表单中,非关键字段使用默认值。

8.14 editModal —— 编辑纹样弹窗

@Builder
editModal() {
  if (this.showEdit) {
    Stack() {
      this.modalOverlay(() => {
        this.showEdit = false;
      })
      Column() {
        Text('编辑纹样')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.text1)
        if (this.selPattern) {
          Text(this.selPattern.name + ' · 繁复度 ' + this.selPattern.complex)
            .fontSize(12)
            .fontColor(COLORS.text2)
            .margin({ top: 6 })
        }

editModal 构建器渲染编辑纹样繁复度的弹窗。弹窗通过 if (this.selPattern) 条件判断显示选中纹样的名称和当前繁复度。这个条件判断是必要的,因为 selPattern 的类型是 BatikPatternItem | null,需要先判断非空再访问。在 if 块内,TypeScript/ArkTS 的类型窄化(type narrowing)机制自动将 selPattern 的类型从联合类型窄化为 BatikPatternItem,无需额外的非空断言。

弹窗提供两个操作按钮:“更繁复 +5"和"简化为宜 -5”。"更繁复"按钮点击后将繁复度增加 5,通过 Math.min(100, ...) 限制不超过 100;"简化为宜"按钮点击后将繁复度减少 5,通过 Math.max(40, ...) 限制不低于 40。Math.minMath.max 的使用确保了繁复度始终在 40 到 100 的合理范围内。修改 selPattern.complex 后,由于 BatikPatternItem@Observed 类,该属性变化会通过响应式机制自动同步到纹样列表中对应卡片的显示。这种"修改即生效"的即时反馈,是响应式编程模型的核心优势。

8.15 delModal —— 删除订单弹窗

@Builder
delModal() {
  if (this.showDel) {
    Stack() {
      this.modalOverlay(() => {
        this.showDel = false;
      })
      Column() {
        Text('删除订单')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.text1)
        if (this.selOrder) {
          Text('确认删除「' + this.selOrder.name + '」订单?')
            .fontSize(12)
            .fontColor(COLORS.text2)
            .margin({ top: 8 })
        }
        Text('删除后不可恢复')
          .fontSize(10)
          .fontColor(COLORS.danger)
          .margin({ top: 4 })

delModal 构建器渲染删除订单的确认弹窗。弹窗标题"删除订单"以红色调的辅助文字"删除后不可恢复"作为警示,使用户意识到操作的不可逆性。通过 if (this.selOrder) 显示选中订单的名称,使用户能确认要删除的具体是哪个订单。这种"删除前确认"的设计模式,是移动端数据操作安全的基本保障。

确认按钮使用 danger(红色)背景,与警示文字的红色系一致,形成统一的"危险操作"视觉语言。确认按钮的 onClick 执行 this.orders.splice(this.orders.indexOf(this.selOrder), 1)——先通过 indexOf 找到选中订单在数组中的索引,再通过 splice 从该索引处移除一个元素。splice 操作会触发 @State 数组的响应式更新——ForEach 的 Diff 算法检测到数组长度减少,自动移除对应的订单行卡片。整个删除流程从点击到 UI 更新完全自动化。

8.16 build —— 根构建函数

build() {
  Stack() {
    Column() {
      Scroll() {
        Column() {
          this.pageHeader()
          this.tabContent()
        }
        .width('100%')
        .padding({ left: 14, right: 14, bottom: 12 })
      }
      .scrollable(ScrollDirection.Vertical)
      .layoutWeight(1)
      .backgroundColor(COLORS.bg)
      this.bottomBar()
    }
    .width('100%')
    .height('100%')
    .backgroundColor(COLORS.bg)
    if (this.showAdd) {
      this.addModal()
    }
    if (this.showEdit) {
      this.editModal()
    }
    if (this.showDel) {
      this.delModal()
    }
  }
  .width('100%')
  .height('100%')
  .backgroundColor(COLORS.bg)
}

build 函数是组件的根构建函数,定义了整个页面的 UI 结构。页面使用 Stack 作为最外层容器,内部分为两层:底层是主内容区(Column 包含可滚动的 Scroll 区域和底部标签栏),上层是三个条件渲染的弹窗。主内容区的 Column 纵向排列两个部分:上方是 Scroll 可滚动区域,下方是 bottomBar 底部标签栏。Scroll 区域设置了 scrollable(ScrollDirection.Vertical) 允许垂直滚动,layoutWeight(1) 使其占据除底部标签栏之外的所有剩余高度。

三个弹窗通过独立的 if 条件判断渲染。弹窗使用 Stack 叠加在主内容之上,而非使用页面路由跳转,是一种"模态弹窗"设计——弹窗不离开当前页面,覆盖在当前页面之上,关闭后回到原页面。这种设计的优势是保留了用户的浏览上下文——例如在订单列表中点击删除,确认弹窗关闭后用户仍在订单列表中,无需重新导航。这种"可滚动内容区加上固定底部栏再加上模态弹窗"的三层结构,是移动端应用页面的经典架构。


九、状态管理深度分析

9.1 @State 装饰器的响应式原理

在 ArkTS 框架中,@State 是最基础的状态装饰器,用于在组件内部声明可观察的局部状态。当 @State 变量的值发生变化时,框架会自动触发组件的重新渲染——具体来说,是重新执行 build 函数中引用了该状态变量的部分,更新对应的 UI 节点。这种"状态变化自动触发 UI 更新"的机制,是声明式 UI 范式的核心。

在本应用中,@State 的使用场景可以分为三类。第一类是简单类型状态,如 curTab(number)、breath(boolean)、showAddshowEditshowDel(boolean)、formNameformRegionformPrice(string)。这些状态的变化直接驱动条件渲染和属性绑定。第二类是引用类型状态,如 selPatternselOrder@Observed 类实例或 null)。这些状态的变化触发弹窗内容的更新。第三类是数组类型状态,如 batikspatternsknivesdyersstepsorders。数组的变化(pushsplice)触发 ForEach 的 Diff 更新。

@State 的响应式能力依赖于 ArkTS 框架的依赖追踪机制。在 build 函数执行期间,框架会记录每个 @State 变量被读取的位置(即 UI 节点与状态的依赖关系)。当某个 @State 变量被修改时,框架根据依赖关系找到对应的 UI 节点,重新执行那部分的渲染逻辑。这种"精确追踪、按需更新"的机制,确保了状态变化只更新真正受影响的 UI 部分,而非整个组件树,保障了渲染性能。

9.2 @Observed 与 @ObjectLink 的配合

@Observed 装饰器用于类定义,使类的实例成为可观察对象。当实例的属性被修改时,所有引用该实例的 @ObjectLink 组件都会收到通知并触发重新渲染。虽然本应用中没有直接使用 @ObjectLink(因为列表行使用 @Builder 而非独立的 @Component),但 @Observed 的作用仍然不可忽视。在 editModal 中,当用户点击"更繁复 +5"按钮时,this.selPattern.complex 被修改,由于 BatikPatternItem@Observed 类的实例,修改其 complex 属性会触发所有引用该实例的 UI 更新。

9.3 @Prop 与 @ObjectLink 的使用场景

虽然本应用没有使用 @Prop@ObjectLink,但理解它们的使用场景对于全面掌握 ArkTS 状态管理至关重要。@Prop 用于父子组件间的单向数据传递——父组件将数据通过 @Prop 传递给子组件,子组件可以读取该数据但不能修改。@ObjectLink 用于父子组件间传递 @Observed 类实例的引用——子组件对实例属性的修改会直接影响父组件中的同一实例。在本应用中,列表行使用 @Builder 而非 @Component,因此不使用 @Prop@ObjectLink@Builder 中的 this 指向引用它的组件实例,可以直接访问和修改组件的 @State 变量。这种方案在小型应用中足够简洁高效,但在大型应用中,将列表行提取为 @Component 并使用 @ObjectLink 可以实现更好的组件化和性能优化。


十、布局样式深度分析

10.1 Stack 层叠布局

本应用大量使用 Stack 层叠布局,尤其是在 pageHeader 和弹窗组件中。Stack 允许多个子元素在同一区域叠加,后声明的子元素覆盖在先声明的子元素之上。Stack 的子元素可以使用 position 属性进行绝对定位,不受文档流约束。在 pageHeader 中,十余个视觉元素通过 Stack 叠加在一起,每个元素都使用 position 精确定位,共同构成一个层次丰富的头部装饰场景。在弹窗组件中,Stack 用于叠加遮罩层和弹窗内容卡片。Stack 的两种用途——装饰性叠加和模态层叠——展示了它的灵活性。

10.2 Row 与 Column 的线性布局

RowColumn 是 ArkTS 中最基本的两种线性布局容器。本应用几乎所有列表行卡片都使用 Row 作为外层容器。layoutWeight 属性是线性布局中分配剩余空间的关键工具,多处使用 layoutWeight(1) 使某个子元素占据弹性空间。alignItemsjustifyContent 属性控制子元素在交叉轴和主轴上的对齐方式。这些属性在弹窗按钮、标签项等组件中被广泛使用,确保 UI 元素的对齐效果符合设计预期。

10.3 Scroll 可滚动区域与绝对定位动画

Scroll 组件提供了可滚动的内容区域,当内容超出容器大小时,用户可以通过手势滑动查看全部内容。这种"可滚动内容区加上固定底部栏"的布局模式,是移动端页面的标准结构。position 属性提供了绝对定位能力,在 pageHeader 中几乎所有装饰元素都使用 position 进行绝对定位。animation 属性为属性变化添加动画过渡效果,配合 breath 状态的周期性翻转,实现了裂纹透明度呼吸、气泡透明度闪烁、圆环缩放扩散、布条旋转摇曳、竹竿上下浮动、柱状图颜色变换等丰富的动画效果。rotatescale 变换属性与 animation 配合,创建了旋转、缩放等动态变换效果。


十一、Mermaid 流程图

11.1 应用整体架构流程图

curTab=0

curTab=1

curTab=2

curTab=3

curTab=4

curTab=5

true

true

true

应用启动

aboutToAppear 生命周期

启动 breath 定时器
每500ms翻转

build 函数执行

Stack 根容器

主内容区 Column

弹窗层 条件渲染

Scroll 可滚动区域

pageHeader 头部装饰

tabContent 标签内容

curTab 值判断

蜡染布列表
ForEach batiks

纹样列表
ForEach patterns

蜡刀列表
ForEach knives

染娘列表
ForEach dyers

工序列表
ForEach steps

订单柱状图+列表
ForEach orders

bottomBar 底部标签栏

bottomItem x6
点击切换 curTab

showAdd?

showEdit?

showDel?

addModal 新增弹窗

editModal 编辑弹窗

delModal 删除弹窗

这张流程图展示了应用从启动到渲染的完整流程。应用启动后首先执行 aboutToAppear 生命周期回调,启动呼吸定时器,然后执行 build 函数构建 UI 树。根容器 Stack 分为主内容区和弹窗层两条路径:主内容区包含可滚动的头部装饰和标签内容区,标签内容区根据 curTab 的值在六个页面之间切换;弹窗层根据三个布尔状态变量决定是否渲染对应的弹窗。底部标签栏是主内容区的固定底部部分,包含六个可点击的标签项,点击后切换 curTab 从而改变标签内容区的渲染内容。

11.2 蜡染工艺八序流程图

选布
1天
白坯布

画蜡
3天
热蜡绘纹

冷固
1天
蜡纹定型

入染
2天
多次浸染

煮蜡
1天
脱蜡显花

漂洗
1天
去浮色

冰纹
1天
自然裂纹

晾晒
1天
定型收布

这张流程图直观呈现了蜡染工艺从白坯布到成品的八道工序。前三道工序(选布、画蜡、冷固)以浅蓝色标识,属于准备阶段——选布是挑选合适的白坯布,画蜡是用蜡刀蘸取热蜡液在布上描绘纹样,冷固是让蜡液冷凝固化形成防染层。中间三道工序(入染、煮蜡、漂洗)以金色标识,属于核心工艺阶段——入染是将点好蜡的布浸入靛蓝染缸多次浸染,煮蜡是用沸水煮去蜂蜡显出蓝白花纹,漂洗是用清水和皂液洗去浮色。最后两道工序(冰纹、晾晒)以深蓝色标识,属于收尾阶段——冰纹是通过揉搓蜡层产生自然裂纹使染液渗入形成独特纹理,晾晒是将成品在竹竿上晾干定型。八道工序总耗时约 11 天,色彩从浅蓝到金色再到深蓝,暗合了工艺从轻到重再归于沉稳的节奏。

11.3 响应式状态管理流程图

数据操作层

响应式更新层

状态管理层

用户交互层

用户点击标签

用户点击繁复度

用户点击删除

用户填写表单

@State curTab 更新

@State selPattern 赋值
@State showEdit = true

@State selOrder 赋值
@State showDel = true

@State formName/Region/Price 更新

tabContent 条件渲染切换

editModal 弹窗渲染

delModal 弹窗渲染

addModal 弹窗渲染

selPattern.complex 修改
@Observed 触发更新

orders.splice 移除元素
@State 数组触发 Diff

batiks.push 添加元素
@State 数组触发 Diff

这张流程图展示了应用中响应式状态管理的完整数据流。用户交互层捕获四种用户操作(点击标签、点击繁复度、点击删除、填写表单),每种操作触发对应的状态管理层变量更新(curTabselPattern+showEditselOrder+showDelformName/formRegion/formPrice),状态更新触发响应式更新层的弹窗渲染或条件渲染切换,最终在数据操作层执行实际的数据修改(complex 属性修改、splice 移除元素、push 添加元素),数据修改又通过 @Observed@State 的响应式机制自动触发 UI 更新,形成"交互、状态、渲染、数据、更新"的完整闭环。这个闭环是 ArkTS 响应式编程的核心——开发者只需操作数据,UI 自动跟随,无需手动调用任何刷新方法。


十二、技术对比表格

12.1 ArkTS 状态装饰器对比

装饰器作用范围数据流向触发更新适用场景本应用使用情况
@State组件内部单向(自身变化触发 UI 更新)自动触发 build 中引用该状态的节点更新组件局部状态管理大量使用(curTab、breath、show系列、form系列、数据数组等)
@Prop父到子单向(父变化同步到子副本)父组件更新时同步子组件副本父子组件间只读数据传递未使用(列表行用 @Builder 替代 @Component)
@ObjectLink父到子双向(引用共享,子可修改)实例属性变化触发子组件更新父子组件间传递可观察对象引用未使用(同上原因)
@Observed类定义使类实例属性可观察属性变化通知所有引用方数据模型类全部 6 个数据模型类使用
@Entry页面入口组件标记页面根组件每个页面的入口根组件使用
@Component自定义组件声明自定义组件可复用的独立组件根组件使用
@Builder构建器函数定义可复用 UI 片段在引用处内联展开短小的可复用 UI 片段全部 16 个 UI 构建器

12.2 布局组件对比

布局组件排列方向子元素重叠定位方式适用场景本应用使用情况
Stack层叠允许重叠支持 position 绝对定位装饰性区域、弹窗层叠pageHeader、弹窗根容器、build 根容器
Column垂直不重叠文档流纵向排列纵向排列内容表单字段、统计数字、弹窗内容
Row水平不重叠文档流水平排列横向排列内容列表行卡片、按钮组、标签项
Scroll可滚动不重叠文档流加滚动超出容器的内容区域主内容可滚动区域
ForEach循环渲染不重叠按数组顺序排列列表渲染所有列表和装饰元素循环

12.3 动画属性对比

动画类型属性/方法效果循环方式本应用使用位置
透明度动画opacity 加 animation元素淡入淡出Alternate 交替裂纹、气泡透明度呼吸
缩放动画scale 加 animation元素放大缩小Alternate 交替圆环缩放扩散
旋转动画rotate 加 animation元素旋转摆动Alternate 交替裂纹角度、布条摇摆
位移动画position 加 animation元素位置移动Alternate 交替竹竿上下浮动
颜色动画backgroundColor 加 animation背景色切换Alternate 交替订单柱状图颜色变换
字重动画fontWeight 条件绑定字重切换跟随状态变化标签项激活态切换

12.4 数据可视化方式对比

可视化方式实现手段数据映射使用位置优势
进度条Column 宽度等于函数(数值)数值到宽度的线性映射蓝度条、技艺条、价格条直观比较数值大小
柱状图Column 高度等于函数(金额)金额到高度的截断映射订单金额柱状图多项数据横向对比
色彩分级函数返回颜色值数值到颜色的分档映射蓝度色、产地色、级别色、工序色通过色彩传达等级
标签药丸Text 加 backgroundColor 加 borderRadius分类到颜色的映射产地标签、职称标签视觉区分分类属性
序号圆形Text 加 backgroundColor 加 borderRadius序号到颜色的分段映射工序序号标记有序流程的阶段标识

12.5 弹窗组件对比

弹窗触发状态选中项操作类型数据操作关闭方式
addModalshowAdd新增batiks.push(new BatikItem)取消按钮、确认按钮、遮罩点击
editModalshowEditselPattern修改selPattern.complex 加减5取消按钮、操作按钮、遮罩点击
delModalshowDelselOrder删除orders.splice(indexOf, 1)取消按钮、确认按钮、遮罩点击

安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

interface ColorPalette {
  bg: string;
  cardBg: string;
  header1: string;
  header2: string;
  indigoA: string;
  indigoB: string;
  waxW: string;
  gold: string;
  title: string;
  sub: string;
  text1: string;
  text2: string;
  text3: string;
  accent: string;
  hot: string;
  cool: string;
  danger: string;
  tabBg: string;
  tabOn: string;
  mask: string;
}

const COLORS: ColorPalette = {
  bg: '#EEF3FA',
  cardBg: '#FFFFFF',
  header1: '#1B3B6F',
  header2: '#0A1B3A',
  indigoA: '#2E5FA3',
  indigoB: '#6A8FD0',
  waxW: '#F3EFE6',
  gold: '#D9B45B',
  title: '#E8F0FC',
  sub: '#A9C4E8',
  text1: '#1B2F52',
  text2: '#46608C',
  text3: '#8CA0BE',
  accent: '#1B3B6F',
  hot: '#D9B45B',
  cool: '#2E5FA3',
  danger: '#D9534F',
  tabBg: '#1B3B6F',
  tabOn: '#D9B45B',
  mask: 'rgba(0,0,0,0.45)'
};

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

const TAB_LIST: TabMeta[] = [
  { label: '蜡染布', icon: '🪁' },
  { label: '纹样', icon: '🌀' },
  { label: '蜡刀', icon: '🔪' },
  { label: '染娘', icon: '🧕' },
  { label: '工序', icon: '🫧' },
  { label: '订单', icon: '📦' }
];

const ROW1_IDX: number[] = [0, 1, 2];
const ROW2_IDX: number[] = [3, 4, 5];
const RING_IDX: number[] = [0, 1, 2];
const CRACK_IDX: number[] = [0, 1, 2, 3, 4, 5];
const TEXTILE_IDX: number[] = [0, 1, 2];
const BUBBLE_IDX: number[] = [0, 1, 2, 3];

function ringSize(i: number): number {
  return 60 + i * 34 + (i === 0 ? 0 : 0);
}

function ringOpacity(i: number): number {
  return 0.5 - i * 0.13;
}

function bubbleX(i: number): number {
  return 64 + i * 22;
}

function bubbleY(i: number): number {
  return 74 + (i % 2) * 20;
}

function crackX(i: number): number {
  return 264 + i * 14;
}

function crackY(i: number): number {
  return 30 + (i % 3) * 26;
}

function crackAngle(i: number): number {
  return -30 + i * 15;
}

function textileX(i: number): number {
  return 22 + i * 34;
}

function knifeY(i: number): number {
  return 60 + i * 26;
}

function blueBarW(b: number): number {
  return 18 + b * 2.2;
}

function complexBarW(c: number): number {
  return 16 + c * 1.2;
}

function craftBarW(d: number): number {
  return 16 + d * 1.6;
}

function orderBarH(a: number): number {
  return 24 + Math.min(a, 50000) / 500;
}

function blueColor(b: number): string {
  if (b >= 85) {
    return COLORS.header1;
  }
  if (b >= 60) {
    return COLORS.indigoA;
  }
  return COLORS.indigoB;
}

function styleColor(s: string): string {
  if (s === '贵州丹寨') {
    return COLORS.indigoA;
  }
  if (s === '云南大理') {
    return COLORS.hot;
  }
  if (s === '湖南凤凰') {
    return COLORS.cool;
  }
  if (s === '四川珙县') {
    return COLORS.accent;
  }
  return COLORS.text3;
}

function levelColor(l: string): string {
  if (l === '大师') {
    return COLORS.accent;
  }
  if (l === '名师') {
    return COLORS.hot;
  }
  return COLORS.cool;
}

function stepColor(s: number): string {
  if (s <= 3) {
    return COLORS.indigoB;
  }
  if (s <= 6) {
    return COLORS.hot;
  }
  return COLORS.accent;
}

@Observed
export class BatikItem {
  name: string;
  region: string;
  blue: number;
  size: string;
  price: number;

  constructor(name: string, region: string, blue: number, size: string, price: number) {
    this.name = name;
    this.region = region;
    this.blue = blue;
    this.size = size;
    this.price = price;
  }
}

@Observed
export class BatikPatternItem {
  name: string;
  meaning: string;
  complex: number;
  use: string;
  style: string;

  constructor(name: string, meaning: string, complex: number, use: string, style: string) {
    this.name = name;
    this.meaning = meaning;
    this.complex = complex;
    this.use = use;
    this.style = style;
  }
}

@Observed
export class WaxKnifeItem {
  name: string;
  material: string;
  tip: string;
  len: string;
  price: number;

  constructor(name: string, material: string, tip: string, len: string, price: number) {
    this.name = name;
    this.material = material;
    this.tip = tip;
    this.len = len;
    this.price = price;
  }
}

@Observed
export class DyerItem {
  name: string;
  title: string;
  age: number;
  pieces: number;
  skill: number;

  constructor(name: string, title: string, age: number, pieces: number, skill: number) {
    this.name = name;
    this.title = title;
    this.age = age;
    this.pieces = pieces;
    this.skill = skill;
  }
}

@Observed
export class WaxStepItem {
  name: string;
  days: number;
  tool: string;
  note: string;
  seq: number;

  constructor(name: string, days: number, tool: string, note: string, seq: number) {
    this.name = name;
    this.days = days;
    this.tool = tool;
    this.note = note;
    this.seq = seq;
  }
}

@Observed
export class BatikOrderItem {
  name: string;
  buyer: string;
  amount: number;
  count: number;
  month: string;

  constructor(name: string, buyer: string, amount: number, count: number, month: string) {
    this.name = name;
    this.buyer = buyer;
    this.amount = amount;
    this.count = count;
    this.month = month;
  }
}

@Entry
@Component
struct BatikPage {
  @State curTab: number = 0;
  @State breath: boolean = false;
  @State showAdd: boolean = false;
  @State showEdit: boolean = false;
  @State showDel: boolean = false;
  @State selPattern: BatikPatternItem | null = null;
  @State selOrder: BatikOrderItem | null = null;
  @State formName: string = '';
  @State formRegion: string = '';
  @State formPrice: string = '';
  @State batiks: BatikItem[] = [
    new BatikItem('丹寨花鸟蜡染', '贵州丹寨', 92, '110×110', 680),
    new BatikItem('蝴蝶妈妈纹', '贵州丹寨', 95, '90×90', 880),
    new BatikItem('大理扎染方巾', '云南大理', 72, '55×55', 128),
    new BatikItem('凤凰鱼纹蜡染', '湖南凤凰', 85, '120×60', 560),
    new BatikItem('珙县蜡染桌旗', '四川珙县', 78, '160×36', 320),
    new BatikItem('铜鼓纹蜡染', '贵州丹寨', 90, '100×100', 760),
    new BatikItem('云龙纹床单', '云南大理', 66, '200×230', 480),
    new BatikItem('苗家百褶裙料', '贵州榕江', 88, '140×90', 980),
    new BatikItem('蜡染靠垫套', '湖南凤凰', 74, '45×45', 158),
    new BatikItem('蜡染围巾', '云南大理', 68, '180×60', 218),
    new BatikItem('太阳鼓门帘', '贵州丹寨', 86, '150×90', 420),
    new BatikItem('鱼跃龙门蜡染', '四川珙县', 80, '100×70', 380)
  ];
  @State patterns: BatikPatternItem[] = [
    new BatikPatternItem('蝴蝶妈妈', '万物之母', 92, '衣饰', '贵州丹寨'),
    new BatikPatternItem('太阳鼓纹', '光明昌盛', 88, '门帘', '贵州丹寨'),
    new BatikPatternItem('铜鼓纹', '权力祥瑞', 85, '盛装', '广西'),
    new BatikPatternItem('鱼纹', '年年有余', 78, '枕巾', '湖南凤凰'),
    new BatikPatternItem('龙纹', '护佑子孙', 90, '背扇', '贵州榕江'),
    new BatikPatternItem('花鸟纹', '美满姻缘', 82, '嫁衣', '云南大理'),
    new BatikPatternItem('漩涡纹', '生生不息', 86, '裙摆', '四川珙县'),
    new BatikPatternItem('蛙纹', '多子多福', 74, '童装', '贵州丹寨')
  ];
  @State knives: WaxKnifeItem[] = [
    new WaxKnifeItem('圆蜡刀', '黄铜', '圆头', '12cm', 46),
    new WaxKnifeItem('尖蜡刀', '黄铜', '尖头', '14cm', 52),
    new WaxKnifeItem('斜口蜡刀', '白铜', '斜口', '13cm', 58),
    new WaxKnifeItem('排蜡刀', '黄铜', '排齿', '16cm', 76),
    new WaxKnifeItem('细线蜡刀', '白铜', '细尖', '11cm', 62),
    new WaxKnifeItem('竹柄蜡刀', '黄铜', '圆头', '15cm', 48),
    new WaxKnifeItem('双槽蜡刀', '红铜', '双槽', '14cm', 88),
    new WaxKnifeItem('点蜡笔刀', '不锈钢', '圆珠', '13cm', 66)
  ];
  @State dyers: DyerItem[] = [
    new DyerItem('杨光梅', '大师', 58, 420, 97),
    new DyerItem('王阿婆', '大师', 66, 380, 96),
    new DyerItem('张秀云', '名师', 44, 260, 92),
    new DyerItem('李染花', '名师', 38, 210, 90),
    new DyerItem('赵蜡娘', '名师', 32, 170, 87),
    new DyerItem('陈蓝衣', '匠娘', 26, 120, 82),
    new DyerItem('吴染春', '匠娘', 22, 90, 78),
    new DyerItem('周冰纹', '学徒', 18, 60, 72)
  ];
  @State steps: WaxStepItem[] = [
    new WaxStepItem('选布', 1, '棉布 · 尺', '白坯布', 1),
    new WaxStepItem('画蜡', 3, '蜡刀 · 蜂蜡', '热蜡绘纹', 2),
    new WaxStepItem('冷固', 1, '阴凉处', '蜡纹定型', 3),
    new WaxStepItem('入染', 2, '靛蓝缸', '多次浸染', 4),
    new WaxStepItem('煮蜡', 1, '沸水 · 锅', '脱蜡显花', 5),
    new WaxStepItem('漂洗', 1, '清水 · 皂', '去浮色', 6),
    new WaxStepItem('冰纹', 1, '揉搓 · 蜡层', '自然裂纹', 7),
    new WaxStepItem('晾晒', 1, '竹竿', '定型收布', 8)
  ];
  @State orders: BatikOrderItem[] = [
    new BatikOrderItem('丹寨花鸟蜡染', '非遗展馆', 20400, 30, '2026-08'),
    new BatikOrderItem('蝴蝶妈妈纹', '文创品牌', 26400, 30, '2026-07'),
    new BatikOrderItem('大理扎染方巾', '旅游商店', 12800, 100, '2026-08'),
    new BatikOrderItem('凤凰鱼纹蜡染', '民宿连锁', 16800, 30, '2026-06'),
    new BatikOrderItem('苗家百褶裙料', '民族剧团', 19600, 20, '2026-07'),
    new BatikOrderItem('蜡染围巾', '电商平台', 21800, 100, '2026-05'),
    new BatikOrderItem('太阳鼓门帘', '酒店', 12600, 30, '2026-08'),
    new BatikOrderItem('蜡染靠垫套', '家居品牌', 15800, 100, '2026-06'),
    new BatikOrderItem('铜鼓纹蜡染', '博物馆', 15200, 20, '2026-04'),
    new BatikOrderItem('云龙纹床单', '家纺公司', 19200, 40, '2026-05')
  ];

  aboutToAppear(): void {
    setInterval(() => {
      this.breath = !this.breath;
    }, 500);
  }

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

  @Builder
  pageHeader() {
    Column() {
      Stack() {
        Column()
          .width('100%')
          .height('100%')
          .borderRadius(22)
          .linearGradient({
            angle: 135,
            colors: [[COLORS.header1, 0], [COLORS.header2, 1]]
          })
        ForEach(CRACK_IDX, (i: number) => {
          Column()
            .width(2)
            .height(16 + (i % 3) * 8)
            .backgroundColor(COLORS.waxW)
            .opacity(this.breath ? 0.5 : 0.85)
            .borderRadius(1)
            .position({ x: crackX(i), y: crackY(i) })
            .rotate({ angle: crackAngle(i) })
            .animation({ duration: 800, iterations: -1, playMode: PlayMode.Alternate })
        }, (i: number) => 'c' + i)
        ForEach(BUBBLE_IDX, (i: number) => {
          Column()
            .width(4)
            .height(4)
            .borderRadius(2)
            .backgroundColor(COLORS.waxW)
            .opacity(this.breath ? 0.3 : 0.8)
            .position({ x: bubbleX(i), y: bubbleY(i) })
            .animation({ duration: 900, iterations: -1, playMode: PlayMode.Alternate })
        }, (i: number) => 'b' + i)
        Column()
          .width(120)
          .height(120)
          .borderRadius(60)
          .backgroundColor(COLORS.indigoA)
          .border({ width: 3, color: COLORS.gold })
          .position({ x: 110, y: 6 })
        ForEach(RING_IDX, (i: number) => {
          Column()
            .width(ringSize(i))
            .height(ringSize(i))
            .borderRadius(ringSize(i) / 2)
            .border({ width: 1.5, color: COLORS.indigoB })
            .opacity(ringOpacity(i))
            .scale({ x: this.breath ? 1.1 : 1, y: this.breath ? 1.1 : 1 })
            .position({ x: 170 - ringSize(i) / 2 + 0, y: 66 - ringSize(i) / 2 })
            .animation({ duration: 1400 - i * 300, iterations: -1, playMode: PlayMode.Alternate })
        }, (i: number) => 'r' + i)
        Text('靛')
          .fontSize(26)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.waxW)
          .position({ x: 157, y: 52 })
        ForEach(TEXTILE_IDX, (i: number) => {
          Column() {
            Column()
              .width(14)
              .height(64)
              .borderRadius(4)
              .backgroundColor(i === 1 ? COLORS.indigoB : COLORS.indigoA)
              .rotate({ angle: this.breath ? 3 : -3 })
              .animation({ duration: 700, iterations: -1, playMode: PlayMode.Alternate })
          }
          .position({ x: textileX(i), y: 52 })
        }, (i: number) => 'x' + i)
        Column()
          .width(8)
          .height(44)
          .borderRadius(4)
          .backgroundColor(COLORS.gold)
          .position({ x: 254, y: 34 + (this.breath ? 4 : 0) })
          .animation({ duration: 800, iterations: -1, playMode: PlayMode.Alternate })
        Column()
          .width(18)
          .height(3)
          .backgroundColor(COLORS.waxW)
          .position({ x: 249, y: 76 })
        Column() {
          Text('蜡染坊')
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.title)
          Text('冰纹天成 · 蓝白之间有大美')
            .fontSize(11)
            .fontColor(COLORS.sub)
            .margin({ top: 4 })
        }
        .alignItems(HorizontalAlign.Start)
        .position({ x: 18, y: 12 })
        Row() {
          Text('12')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.title)
          Text('染布')
            .fontSize(9)
            .fontColor(COLORS.sub)
            .margin({ left: 2 })
        }
        .position({ x: 18, y: 58 })
        Row() {
          Text('8')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.title)
          Text('纹样')
            .fontSize(9)
            .fontColor(COLORS.sub)
            .margin({ left: 2 })
        }
        .position({ x: 18, y: 80 })
        Row() {
          Text('8')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.title)
          Text('染娘')
            .fontSize(9)
            .fontColor(COLORS.sub)
            .margin({ left: 2 })
        }
        .position({ x: 118, y: 58 })
        Row() {
          Text('8')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.title)
          Text('工序')
            .fontSize(9)
            .fontColor(COLORS.sub)
            .margin({ left: 2 })
        }
        .position({ x: 118, y: 80 })
      }
      .width('100%')
      .height(128)
    }
    .width('100%')
  }

  @Builder
  batikRow(item: BatikItem) {
    Row() {
      Text(item.name)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text1)
      Column() {
        Text(item.region + ' · ' + item.size)
          .fontSize(10)
          .fontColor(COLORS.text3)
        Row() {
          Text('蓝度')
            .fontSize(9)
            .fontColor(COLORS.text3)
          Column()
            .width(blueBarW(item.blue))
            .height(6)
            .borderRadius(3)
            .backgroundColor(blueColor(item.blue))
            .margin({ left: 6 })
          Text(item.blue + '%')
            .fontSize(9)
            .fontColor(COLORS.cool)
            .margin({ left: 6 })
        }
        .margin({ top: 4 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      .margin({ left: 12 })
      Text('¥' + item.price)
        .fontSize(12)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.accent)
    }
    .width('100%')
    .padding(12)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(12)
    .margin({ top: 8 })
  }

  @Builder
  patternRow(item: BatikPatternItem) {
    Row() {
      Text(item.name)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text1)
      Text(item.style)
        .fontSize(9)
        .fontColor(COLORS.cardBg)
        .backgroundColor(styleColor(item.style))
        .borderRadius(8)
        .padding({ left: 6, right: 6, top: 2, bottom: 2 })
        .margin({ left: 8 })
      Column() {
        Text(item.meaning)
          .fontSize(9)
          .fontColor(COLORS.text3)
        Text(item.use)
          .fontSize(9)
          .fontColor(COLORS.text3)
          .margin({ top: 1 })
      }
      .alignItems(HorizontalAlign.End)
      .layoutWeight(1)
      Text('繁复 ' + item.complex)
        .fontSize(10)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.hot)
        .onClick(() => {
          this.selPattern = item;
          this.showEdit = true;
        })
    }
    .width('100%')
    .padding(12)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(12)
    .margin({ top: 8 })
  }

  @Builder
  knifeRow(item: WaxKnifeItem) {
    Row() {
      Text(item.name)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text1)
      Column() {
        Text(item.material + ' · ' + item.tip + ' · ' + item.len)
          .fontSize(10)
          .fontColor(COLORS.text3)
        Column()
          .width(complexBarW(item.price / 10))
          .height(5)
          .borderRadius(2)
          .backgroundColor(COLORS.indigoB)
          .margin({ top: 4 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      .margin({ left: 10 })
      Text('¥' + item.price)
        .fontSize(12)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.accent)
    }
    .width('100%')
    .padding(12)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(12)
    .margin({ top: 8 })
  }

  @Builder
  dyerRow(item: DyerItem) {
    Row() {
      Text(item.name)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text1)
      Text(item.title)
        .fontSize(10)
        .fontColor(COLORS.cardBg)
        .backgroundColor(levelColor(item.title))
        .borderRadius(8)
        .padding({ left: 6, right: 6, top: 2, bottom: 2 })
        .margin({ left: 8 })
      Column() {
        Text('技艺 ' + item.skill + ' · ' + item.pieces + ' 幅')
          .fontSize(9)
          .fontColor(COLORS.text3)
        Column()
          .width(complexBarW(item.skill))
          .height(6)
          .borderRadius(3)
          .backgroundColor(COLORS.indigoA)
          .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.End)
      .layoutWeight(1)
      Text(item.age + '岁')
        .fontSize(10)
        .fontColor(COLORS.text3)
    }
    .width('100%')
    .padding(12)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(12)
    .margin({ top: 8 })
  }

  @Builder
  stepRow(item: WaxStepItem) {
    Row() {
      Text(item.seq + '')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.cardBg)
        .width(24)
        .height(24)
        .textAlign(TextAlign.Center)
        .backgroundColor(stepColor(item.seq))
        .borderRadius(12)
      Column() {
        Text(item.name)
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.text1)
        Text(item.tool + ' · ' + item.note)
          .fontSize(10)
          .fontColor(COLORS.text3)
          .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      .margin({ left: 10 })
      Text(item.days + '天')
        .fontSize(11)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.hot)
    }
    .width('100%')
    .padding(12)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(12)
    .margin({ top: 8 })
  }

  @Builder
  orderChart() {
    Row() {
      ForEach(this.orders, (item: BatikOrderItem) => {
        Column() {
          Column()
            .width(12)
            .height(orderBarH(item.amount))
            .borderRadius(3)
            .backgroundColor(this.breath ? COLORS.gold : COLORS.indigoA)
            .animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
          Text(item.amount / 1000 + 'k')
            .fontSize(8)
            .fontColor(COLORS.text3)
            .margin({ top: 3 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
      }, (item: BatikOrderItem) => item.name)
    }
    .width('100%')
    .height(92)
    .alignItems(VerticalAlign.Bottom)
    .padding({ left: 6, right: 6, top: 6, bottom: 6 })
    .backgroundColor(COLORS.cardBg)
    .borderRadius(12)
  }

  @Builder
  orderRow(item: BatikOrderItem) {
    Row() {
      Text(item.name)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text1)
        .layoutWeight(1)
      Column() {
        Text('¥' + item.amount)
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.accent)
        Text(item.count + ' 件 · ' + item.month)
          .fontSize(9)
          .fontColor(COLORS.text3)
          .margin({ top: 2 })
      }
      .alignItems(HorizontalAlign.End)
      Text('删除')
        .fontSize(11)
        .fontColor(COLORS.danger)
        .padding({ left: 10, right: 10, top: 4, bottom: 4 })
        .backgroundColor('#FDE8E8')
        .borderRadius(8)
        .margin({ left: 10 })
        .onClick(() => {
          this.selOrder = item;
          this.showDel = true;
        })
    }
    .width('100%')
    .padding(12)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(12)
    .margin({ top: 8 })
  }

  @Builder
  tabContent() {
    if (this.curTab === 0) {
      Column() {
        Text('蜡染布总览 · 蓝得越深越珍贵')
          .fontSize(12)
          .fontColor(COLORS.text2)
          .width('100%')
        ForEach(this.batiks, (item: BatikItem) => {
          this.batikRow(item)
        }, (item: BatikItem) => item.name)
      }
      .width('100%')
      .margin({ top: 10 })
    }
    if (this.curTab === 1) {
      Column() {
        Text('传统纹样 · 点击可调整繁复度')
          .fontSize(12)
          .fontColor(COLORS.text2)
          .width('100%')
        ForEach(this.patterns, (item: BatikPatternItem) => {
          this.patternRow(item)
        }, (item: BatikPatternItem) => item.name)
      }
      .width('100%')
      .margin({ top: 10 })
    }
    if (this.curTab === 2) {
      Column() {
        Text('蜡刀名录 · 一把好刀走天下')
          .fontSize(12)
          .fontColor(COLORS.text2)
          .width('100%')
        ForEach(this.knives, (item: WaxKnifeItem) => {
          this.knifeRow(item)
        }, (item: WaxKnifeItem) => item.name)
      }
      .width('100%')
      .margin({ top: 10 })
    }
    if (this.curTab === 3) {
      Column() {
        Text('染娘群像 · 指尖上的蓝')
          .fontSize(12)
          .fontColor(COLORS.text2)
          .width('100%')
        ForEach(this.dyers, (item: DyerItem) => {
          this.dyerRow(item)
        }, (item: DyerItem) => item.name)
      }
      .width('100%')
      .margin({ top: 10 })
    }
    if (this.curTab === 4) {
      Column() {
        Text('染布八序 · 一缸靛蓝千般变')
          .fontSize(12)
          .fontColor(COLORS.text2)
          .width('100%')
        ForEach(this.steps, (item: WaxStepItem) => {
          this.stepRow(item)
        }, (item: WaxStepItem) => item.name)
      }
      .width('100%')
      .margin({ top: 10 })
    }
    if (this.curTab === 5) {
      Column() {
        Text('订单金额 · 月度走势')
          .fontSize(12)
          .fontColor(COLORS.text2)
          .width('100%')
        this.orderChart()
        Text('全部订单 · 点击可删除')
          .fontSize(12)
          .fontColor(COLORS.text2)
          .width('100%')
          .margin({ top: 12 })
        ForEach(this.orders, (item: BatikOrderItem) => {
          this.orderRow(item)
        }, (item: BatikOrderItem) => item.name + item.month)
      }
      .width('100%')
      .margin({ top: 10 })
    }
  }

  @Builder
  bottomItem(i: number) {
    Row() {
      Text(TAB_LIST[i].icon)
        .fontSize(16)
      Text(TAB_LIST[i].label)
        .fontSize(11)
        .fontWeight(this.curTab === i ? FontWeight.Bold : FontWeight.Normal)
        .fontColor(this.curTab === i ? COLORS.tabOn : COLORS.sub)
        .margin({ left: 4 })
    }
    .layoutWeight(1)
    .justifyContent(FlexAlign.Center)
    .padding({ top: 8, bottom: 8 })
    .backgroundColor(this.curTab === i ? '#2A4E8A' : COLORS.tabBg)
    .borderRadius(10)
    .onClick(() => {
      this.curTab = i;
    })
  }

  @Builder
  bottomBar() {
    Column() {
      Row() {
        ForEach(ROW1_IDX, (i: number) => {
          this.bottomItem(i)
        }, (i: number) => 'r1' + i)
      }
      .width('100%')
      Row() {
        ForEach(ROW2_IDX, (i: number) => {
          this.bottomItem(i)
        }, (i: number) => 'r2' + i)
      }
      .width('100%')
      .margin({ top: 6 })
    }
    .width('100%')
    .padding(10)
    .backgroundColor(COLORS.tabBg)
    .borderRadius({ topLeft: 18, topRight: 18, bottomLeft: 0, bottomRight: 0 })
  }

  @Builder
  addModal() {
    if (this.showAdd) {
      Stack() {
        this.modalOverlay(() => {
          this.showAdd = false;
        })
        Column() {
          Text('新增蜡染布')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.text1)
          Text('登记一款新染布')
            .fontSize(11)
            .fontColor(COLORS.text3)
            .margin({ top: 3 })
          Column() {
            Text('布名')
              .fontSize(12)
              .fontColor(COLORS.text2)
            TextInput({ text: this.formName, placeholder: '如:苗岭春色蜡染' })
              .height(38)
              .fontSize(13)
              .margin({ top: 5 })
              .onChange((v: string) => {
                this.formName = v;
              })
          }
          .alignItems(HorizontalAlign.Start)
          .width('100%')
          .margin({ top: 14 })
          Column() {
            Text('产地')
              .fontSize(12)
              .fontColor(COLORS.text2)
            TextInput({ text: this.formRegion, placeholder: '如:贵州丹寨' })
              .height(38)
              .fontSize(13)
              .margin({ top: 5 })
              .onChange((v: string) => {
                this.formRegion = v;
              })
          }
          .alignItems(HorizontalAlign.Start)
          .width('100%')
          .margin({ top: 12 })
          Column() {
            Text('价格(元)')
              .fontSize(12)
              .fontColor(COLORS.text2)
            TextInput({ text: this.formPrice, placeholder: '如:560' })
              .height(38)
              .fontSize(13)
              .margin({ top: 5 })
              .onChange((v: string) => {
                this.formPrice = v;
              })
          }
          .alignItems(HorizontalAlign.Start)
          .width('100%')
          .margin({ top: 12 })
          Row() {
            Text('取消')
              .fontSize(13)
              .fontColor(COLORS.text2)
              .layoutWeight(1)
              .textAlign(TextAlign.Center)
              .padding({ top: 10, bottom: 10 })
              .backgroundColor('#F2F2F2')
              .borderRadius(10)
              .onClick(() => {
                this.showAdd = false;
              })
            Text('确认登记')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.cardBg)
              .layoutWeight(1)
              .textAlign(TextAlign.Center)
              .padding({ top: 10, bottom: 10 })
              .backgroundColor(COLORS.cool)
              .borderRadius(10)
              .margin({ left: 10 })
              .onClick(() => {
                this.batiks.push(new BatikItem(this.formName, this.formRegion, 75, '100×100', Number(this.formPrice)));
                this.showAdd = false;
              })
          }
          .width('100%')
          .margin({ top: 16 })
        }
        .width('88%')
        .padding(18)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(16)
        .constraintSize({ maxHeight: '80%' })
        .position({ x: 0, y: 0 })
        .zIndex(999)
      }
      .width('100%')
      .height('100%')
    }
  }

  @Builder
  editModal() {
    if (this.showEdit) {
      Stack() {
        this.modalOverlay(() => {
          this.showEdit = false;
        })
        Column() {
          Text('编辑纹样')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.text1)
          if (this.selPattern) {
            Text(this.selPattern.name + ' · 繁复度 ' + this.selPattern.complex)
              .fontSize(12)
              .fontColor(COLORS.text2)
              .margin({ top: 6 })
          }
          Text('繁复度影响成品耗时与价值')
            .fontSize(11)
            .fontColor(COLORS.text3)
            .margin({ top: 3 })
          Row() {
            Text('更繁复 +5')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.cardBg)
              .layoutWeight(1)
              .textAlign(TextAlign.Center)
              .padding({ top: 10, bottom: 10 })
              .backgroundColor(COLORS.accent)
              .borderRadius(10)
              .onClick(() => {
                if (this.selPattern) {
                  this.selPattern.complex = Math.min(100, this.selPattern.complex + 5);
                }
                this.showEdit = false;
              })
            Text('简化为宜 -5')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.cardBg)
              .layoutWeight(1)
              .textAlign(TextAlign.Center)
              .padding({ top: 10, bottom: 10 })
              .backgroundColor(COLORS.cool)
              .borderRadius(10)
              .margin({ left: 10 })
              .onClick(() => {
                if (this.selPattern) {
                  this.selPattern.complex = Math.max(40, this.selPattern.complex - 5);
                }
                this.showEdit = false;
              })
          }
          .width('100%')
          .margin({ top: 16 })
          Text('取消')
            .fontSize(13)
            .fontColor(COLORS.text2)
            .width('100%')
            .textAlign(TextAlign.Center)
            .padding({ top: 10, bottom: 10 })
            .backgroundColor('#F2F2F2')
            .borderRadius(10)
            .margin({ top: 10 })
            .onClick(() => {
              this.showEdit = false;
            })
        }
        .width('88%')
        .padding(18)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(16)
        .constraintSize({ maxHeight: '80%' })
        .position({ x: 0, y: 0 })
        .zIndex(999)
      }
      .width('100%')
      .height('100%')
    }
  }

  @Builder
  delModal() {
    if (this.showDel) {
      Stack() {
        this.modalOverlay(() => {
          this.showDel = false;
        })
        Column() {
          Text('删除订单')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.text1)
          if (this.selOrder) {
            Text('确认删除「' + this.selOrder.name + '」订单?')
              .fontSize(12)
              .fontColor(COLORS.text2)
              .margin({ top: 8 })
          }
          Text('删除后不可恢复')
            .fontSize(10)
            .fontColor(COLORS.danger)
            .margin({ top: 4 })
          Row() {
            Text('取消')
              .fontSize(13)
              .fontColor(COLORS.text2)
              .layoutWeight(1)
              .textAlign(TextAlign.Center)
              .padding({ top: 10, bottom: 10 })
              .backgroundColor('#F2F2F2')
              .borderRadius(10)
              .onClick(() => {
                this.showDel = false;
              })
            Text('确认删除')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.cardBg)
              .layoutWeight(1)
              .textAlign(TextAlign.Center)
              .padding({ top: 10, bottom: 10 })
              .backgroundColor(COLORS.danger)
              .borderRadius(10)
              .margin({ left: 10 })
              .onClick(() => {
                if (this.selOrder) {
                  this.orders.splice(this.orders.indexOf(this.selOrder), 1);
                }
                this.showDel = false;
              })
          }
          .width('100%')
          .margin({ top: 16 })
        }
        .width('88%')
        .padding(18)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(16)
        .constraintSize({ maxHeight: '80%' })
        .position({ x: 0, y: 0 })
        .zIndex(999)
      }
      .width('100%')
      .height('100%')
    }
  }

  build() {
    Stack() {
      Column() {
        Scroll() {
          Column() {
            this.pageHeader()
            this.tabContent()
          }
          .width('100%')
          .padding({ left: 14, right: 14, bottom: 12 })
        }
        .scrollable(ScrollDirection.Vertical)
        .layoutWeight(1)
        .backgroundColor(COLORS.bg)
        this.bottomBar()
      }
      .width('100%')
      .height('100%')
      .backgroundColor(COLORS.bg)
      if (this.showAdd) {
        this.addModal()
      }
      if (this.showEdit) {
        this.editModal()
      }
      if (this.showDel) {
        this.delModal()
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor(COLORS.bg)
  }
}


十三、技术要点总结

在这里插入图片描述

本文以 HarmonyOS ArkTS 声明式开发框架为载体,深入解析了一个蜡染工艺主题的移动端应用。从蜡染工艺的历史文化背景出发,我们逐步分析了应用的色彩体系、标签系统、索引常量、辅助函数、数据模型、状态管理、UI 组件、布局样式等各个层面的代码实现。通过这一逐行逐段的深度解析,我们不仅理解了 ArkTS 的核心语法和开发范式,更体会到了如何用代码语言来描绘和传承一种古老的手工之美。

在色彩体系方面,应用通过 ColorPalette 接口和 COLORS 常量构建了一套完整的色彩管理系统,二十个语义化的色彩字段涵盖了从背景、卡片、头部到文字、强调、危险等全部 UI 色彩角色。色彩值的选择紧密围绕蜡染工艺的靛蓝主题,从浅蓝白到深藏蓝到墨蓝的渐变层次模拟了靛蓝染缸中由浅至深的浸染过程。三级文字色彩体系(text1、text2、text3)使界面信息层次分明,引导用户的视觉焦点有序流动。同色异名的设计(如 accent 与 header1 同值但语义不同)体现了语义与实现分离的工程思想,为未来的色彩调整预留了灵活性。

在数据模型方面,六个 @Observed 类(BatikItem、BatikPatternItem、WaxKnifeItem、DyerItem、WaxStepItem、BatikOrderItem)分别描述了蜡染布、纹样、蜡刀、染娘、工序、订单六个维度的数据。每个类的属性设计既包含了实用的商业信息(价格、金额),也融入了文化语义(纹样寓意、产地风格),使应用不仅是一个管理工具,更是一个文化数据库。@Observed 装饰器使类实例成为可观察对象,当属性被修改时自动触发 UI 更新,这是 ArkTS 响应式编程的基础。

在状态管理方面,@State 装饰器管理着交互状态(curTab、breath、showAdd/showEdit/showDel)、选中项(selPattern、selOrder)、表单数据(formName、formRegion、formPrice)和数据数组(batiks、patterns、knives、dyers、steps、orders)四大类状态。@State 数组与 @Observed 元素的配合形成了两层响应式体系:数组层面追踪增删,元素层面追踪属性修改,两者协同实现了完整的响应式数据流。aboutToAppear 生命周期中的 setInterval 驱动 breath 状态周期性翻转,为头部装饰区域的十余个动画元素提供统一的"心跳"信号,这种"单源多消费者"模式避免了为每个动画元素设置独立定时器的资源浪费。

在 UI 组件方面,十六个 @Builder 构建器覆盖了从遮罩层、头部装饰、列表行、柱状图、标签栏到弹窗的全部 UI 元素。pageHeader 是最复杂的组件,通过 Stack 层叠布局将十余个视觉元素(渐变背景、裂纹、气泡、圆形徽章、同心圆环、靛字、纺织布条、竹竿、蜡痕、标题、统计数字)叠加在一个 128vp 高的区域内,构建了一个信息密度极高且动态效果丰富的蜡染工坊场景。六个列表行构建器(batikRow、patternRow、knifeRow、dyerRow、stepRow、orderRow)各自将数据属性映射为视觉元素(进度条、标签、序号圆形等),实现了数据可视化的多种方式。三个弹窗构建器(addModal、editModal、delModal)通过条件渲染控制显隐,通过 modalOverlay 复用遮罩层,通过回调参数实现关闭逻辑的灵活配置。

在布局样式方面,Stack 层叠布局用于装饰性叠加和模态弹窗,RowColumn 线性布局用于列表行和表单,Scroll 可滚动区域用于适应不同长度的内容,layoutWeight 用于弹性空间分配,position 用于精确定位,animation 配合 breath 状态实现循环动画。这些布局和动画技术的组合运用,使应用在静态内容展示和动态视觉效果之间取得了良好的平衡——既有丰富的信息呈现,又有生动的动态氛围。

在数据可视化方面,应用采用了进度条、柱状图、色彩分级、标签药丸、序号圆形等多种可视化方式,将抽象的数值(蓝度、繁复度、技艺评分、金额)转化为直观的视觉元素(条形长度、柱子高度、颜色深浅、标签色彩)。每种可视化方式都根据数据特征选择了合适的映射函数和视觉形式,使用户无需仔细阅读数字就能通过视觉感知快速比较和理解数据。这种"一图胜千言"的可视化设计,是移动端信息呈现的重要原则。

在交互设计方面,应用实现了标签切换、列表浏览、点击编辑、表单填写、删除确认等多种交互模式。标签切换通过 curTab 的条件渲染实现六个页面的无缝切换;列表浏览通过 ForEach 动态渲染和 Scroll 可滚动区域实现大量数据的展示;点击编辑通过"选中、弹窗、修改、自动更新"的流程实现数据的即时编辑;表单填写通过 TextInput@State 的双向绑定实现数据的实时同步;删除确认通过二次确认弹窗降低误删风险。这些交互模式覆盖了移动端 CRUD(创建、读取、更新、删除)操作的完整生命周期。

总而言之,这个蜡染工艺主题的 ArkTS 应用展示了声明式 UI 开发的核心范式:通过接口和常量管理配置,通过 @Observed 类描述数据模型,通过 @State 管理交互状态,通过 @Builder 构建 UI 片段,通过 ForEach 渲染列表,通过条件渲染控制弹窗,通过 animation 实现动画,通过 layoutWeight 分配空间。这些技术手段的组合运用,构建了一个功能完整、视觉丰富、交互流畅的移动端应用,既展示了 ArkTS 的开发能力,也传承了蜡染工艺的文化内涵。在数字时代,用代码来描绘和传承传统文化,本身就是一种创新——它让古老的技艺以新的形式被看见、被理解、被传播,这正是技术与文化融合的意义所在。

Logo

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

更多推荐